|
| 1 | +name: Reviewer Bot PR Comment Router |
| 2 | + |
| 3 | +on: |
| 4 | + issue_comment: |
| 5 | + types: [created] |
| 6 | + |
| 7 | +permissions: |
| 8 | + contents: read |
| 9 | + |
| 10 | +env: |
| 11 | + STATE_ISSUE_NUMBER: '314' |
| 12 | + |
| 13 | +jobs: |
| 14 | + route-pr-comment: |
| 15 | + if: ${{ github.event.issue.pull_request != null }} |
| 16 | + runs-on: ubuntu-latest |
| 17 | + permissions: |
| 18 | + contents: read |
| 19 | + actions: read |
| 20 | + outputs: |
| 21 | + route_outcome: ${{ steps.route.outputs.route_outcome }} |
| 22 | + pr_head_full_name: ${{ steps.route.outputs.pr_head_full_name }} |
| 23 | + pr_author: ${{ steps.route.outputs.pr_author }} |
| 24 | + issue_state: ${{ steps.route.outputs.issue_state }} |
| 25 | + issue_labels: ${{ steps.route.outputs.issue_labels }} |
| 26 | + comment_author_id: ${{ steps.route.outputs.comment_author_id }} |
| 27 | + reviewer_bot_trust_class: ${{ steps.route.outputs.reviewer_bot_trust_class }} |
| 28 | + steps: |
| 29 | + - name: Route PR comment |
| 30 | + id: route |
| 31 | + env: |
| 32 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 33 | + PAYLOAD_PATH: ${{ runner.temp }}/deferred-comment.json |
| 34 | + run: | |
| 35 | + python - <<'PY' |
| 36 | + import json |
| 37 | + import os |
| 38 | + import urllib.error |
| 39 | + import urllib.request |
| 40 | + from pathlib import Path |
| 41 | +
|
| 42 | + event_path = Path(os.environ['GITHUB_EVENT_PATH']) |
| 43 | + event = json.loads(event_path.read_text(encoding='utf-8')) |
| 44 | + repo = os.environ['GITHUB_REPOSITORY'] |
| 45 | + issue = event['issue'] |
| 46 | + comment = event['comment'] |
| 47 | + sender = event.get('sender') or {} |
| 48 | + installation = event.get('installation') or {} |
| 49 | + labels = [str(item.get('name') or '') for item in issue.get('labels') or []] |
| 50 | + issue_labels_json = json.dumps([label for label in labels if label]) |
| 51 | + issue_state = str(issue.get('state') or '').strip() |
| 52 | + comment_author = str((comment.get('user') or {}).get('login') or '').strip() |
| 53 | + comment_user_type = str((comment.get('user') or {}).get('type') or '').strip() |
| 54 | + comment_sender_type = str(sender.get('type') or '').strip() |
| 55 | + comment_author_association = str(comment.get('author_association') or '').strip() |
| 56 | + performed_via_github_app = bool(comment.get('performed_via_github_app')) |
| 57 | + installation_id = installation.get('id') |
| 58 | + route_outcome = 'trusted_direct' |
| 59 | + pr_head_full_name = '' |
| 60 | + pr_author = '' |
| 61 | +
|
| 62 | + if ( |
| 63 | + comment_user_type != 'User' |
| 64 | + or comment_author.endswith('[bot]') |
| 65 | + or performed_via_github_app |
| 66 | + or str(installation_id or '').strip() |
| 67 | + or not comment_author |
| 68 | + ): |
| 69 | + route_outcome = 'safe_noop' |
| 70 | + else: |
| 71 | + pr_number = issue['number'] |
| 72 | + req = urllib.request.Request( |
| 73 | + f"https://api.github.com/repos/{repo}/pulls/{pr_number}", |
| 74 | + headers={ |
| 75 | + 'Authorization': f"Bearer {os.environ['GITHUB_TOKEN']}", |
| 76 | + 'Accept': 'application/vnd.github+json', |
| 77 | + }, |
| 78 | + ) |
| 79 | + try: |
| 80 | + with urllib.request.urlopen(req) as response: |
| 81 | + pull_request = json.load(response) |
| 82 | + except urllib.error.URLError: |
| 83 | + route_outcome = 'deferred_reconcile' |
| 84 | + else: |
| 85 | + head_repo = pull_request.get('head', {}).get('repo') or {} |
| 86 | + pr_head_full_name = str(head_repo.get('full_name') or '').strip() |
| 87 | + pr_author = str((pull_request.get('user') or {}).get('login') or '').strip() |
| 88 | + if ( |
| 89 | + not pr_head_full_name |
| 90 | + or not pr_author |
| 91 | + or pr_head_full_name != repo |
| 92 | + or pr_author == 'dependabot[bot]' |
| 93 | + or comment_author_association not in {'OWNER', 'MEMBER', 'COLLABORATOR'} |
| 94 | + ): |
| 95 | + route_outcome = 'deferred_reconcile' |
| 96 | +
|
| 97 | + with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as handle: |
| 98 | + print(f'route_outcome={route_outcome}', file=handle) |
| 99 | + print(f'pr_head_full_name={pr_head_full_name}', file=handle) |
| 100 | + print(f'pr_author={pr_author}', file=handle) |
| 101 | + print(f'issue_state={issue_state}', file=handle) |
| 102 | + print(f'issue_labels={issue_labels_json}', file=handle) |
| 103 | + print(f'comment_author_id={int((comment.get("user") or {}).get("id") or 0)}', file=handle) |
| 104 | + print('reviewer_bot_trust_class=pr_trusted_direct', file=handle) |
| 105 | +
|
| 106 | + if route_outcome == 'deferred_reconcile': |
| 107 | + payload = { |
| 108 | + 'payload_kind': 'deferred_comment', |
| 109 | + 'schema_version': 3, |
| 110 | + 'source_workflow_name': 'Reviewer Bot PR Comment Router', |
| 111 | + 'source_workflow_file': '.github/workflows/reviewer-bot-pr-comment-router.yml', |
| 112 | + 'source_run_id': int(os.environ['GITHUB_RUN_ID']), |
| 113 | + 'source_run_attempt': int(os.environ['GITHUB_RUN_ATTEMPT']), |
| 114 | + 'source_event_name': 'issue_comment', |
| 115 | + 'source_event_action': 'created', |
| 116 | + 'source_event_key': f"issue_comment:{comment['id']}", |
| 117 | + 'pr_number': int(issue['number']), |
| 118 | + 'comment_id': int(comment['id']), |
| 119 | + 'comment_body': str(comment.get('body') or ''), |
| 120 | + 'comment_created_at': str(comment.get('created_at') or ''), |
| 121 | + 'comment_author': comment_author, |
| 122 | + 'comment_author_id': int((comment.get('user') or {}).get('id') or 0), |
| 123 | + 'comment_user_type': comment_user_type, |
| 124 | + 'comment_sender_type': comment_sender_type, |
| 125 | + 'comment_installation_id': str(installation_id) if installation_id is not None else None, |
| 126 | + 'comment_performed_via_github_app': performed_via_github_app, |
| 127 | + 'issue_author': str((issue.get('user') or {}).get('login') or ''), |
| 128 | + 'issue_state': issue_state, |
| 129 | + 'issue_labels': [label for label in labels if label], |
| 130 | + } |
| 131 | + Path(os.environ['PAYLOAD_PATH']).write_text(json.dumps(payload), encoding='utf-8') |
| 132 | + PY |
| 133 | + - name: Upload deferred comment artifact |
| 134 | + if: ${{ steps.route.outputs.route_outcome == 'deferred_reconcile' }} |
| 135 | + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 |
| 136 | + with: |
| 137 | + name: reviewer-bot-comment-context-${{ github.run_id }}-attempt-${{ github.run_attempt }} |
| 138 | + path: ${{ runner.temp }}/deferred-comment.json |
| 139 | + retention-days: 7 |
| 140 | + if-no-files-found: error |
| 141 | + |
| 142 | + trusted-direct: |
| 143 | + if: ${{ needs.route-pr-comment.outputs.route_outcome == 'trusted_direct' }} |
| 144 | + needs: [route-pr-comment] |
| 145 | + runs-on: ubuntu-latest |
| 146 | + permissions: |
| 147 | + contents: write |
| 148 | + issues: write |
| 149 | + pull-requests: write |
| 150 | + actions: read |
| 151 | + steps: |
| 152 | + - name: Install uv |
| 153 | + run: python -m pip install uv |
| 154 | + - name: Fetch trusted bot source tarball |
| 155 | + env: |
| 156 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 157 | + run: | |
| 158 | + python - <<'PY' |
| 159 | + import io, os, tarfile, urllib.request |
| 160 | + from pathlib import Path |
| 161 | + req = urllib.request.Request( |
| 162 | + f"https://api.github.com/repos/{os.environ['GITHUB_REPOSITORY']}/tarball/{os.environ['GITHUB_SHA']}", |
| 163 | + headers={'Authorization': f"Bearer {os.environ['GITHUB_TOKEN']}", 'Accept': 'application/vnd.github+json'}, |
| 164 | + ) |
| 165 | + target = Path(os.environ['RUNNER_TEMP']) / 'reviewer-bot-src' |
| 166 | + target.mkdir(parents=True, exist_ok=True) |
| 167 | + with urllib.request.urlopen(req) as response: |
| 168 | + data = response.read() |
| 169 | + with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive: |
| 170 | + archive.extractall(target) |
| 171 | + roots = list(target.iterdir()) |
| 172 | + print(f'BOT_SRC_ROOT={roots[0]}', file=open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8')) |
| 173 | + PY |
| 174 | + - name: Run reviewer bot |
| 175 | + env: |
| 176 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 177 | + EVENT_NAME: issue_comment |
| 178 | + EVENT_ACTION: created |
| 179 | + ISSUE_NUMBER: ${{ github.event.issue.number }} |
| 180 | + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} |
| 181 | + ISSUE_STATE: ${{ needs.route-pr-comment.outputs.issue_state }} |
| 182 | + ISSUE_LABELS: ${{ needs.route-pr-comment.outputs.issue_labels }} |
| 183 | + IS_PULL_REQUEST: 'true' |
| 184 | + COMMENT_BODY: ${{ github.event.comment.body }} |
| 185 | + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} |
| 186 | + COMMENT_AUTHOR_ID: ${{ needs.route-pr-comment.outputs.comment_author_id }} |
| 187 | + COMMENT_ID: ${{ github.event.comment.id }} |
| 188 | + COMMENT_CREATED_AT: ${{ github.event.comment.created_at }} |
| 189 | + COMMENT_USER_TYPE: ${{ github.event.comment.user.type }} |
| 190 | + COMMENT_SENDER_TYPE: ${{ github.event.sender.type }} |
| 191 | + COMMENT_INSTALLATION_ID: ${{ github.event.installation.id }} |
| 192 | + COMMENT_PERFORMED_VIA_GITHUB_APP: ${{ github.event.comment.performed_via_github_app != null }} |
| 193 | + GITHUB_REPOSITORY: ${{ github.repository }} |
| 194 | + PR_HEAD_FULL_NAME: ${{ needs.route-pr-comment.outputs.pr_head_full_name }} |
| 195 | + PR_AUTHOR: ${{ needs.route-pr-comment.outputs.pr_author }} |
| 196 | + REVIEWER_BOT_ROUTE_OUTCOME: ${{ needs.route-pr-comment.outputs.route_outcome }} |
| 197 | + REVIEWER_BOT_TRUST_CLASS: ${{ needs.route-pr-comment.outputs.reviewer_bot_trust_class }} |
| 198 | + GITHUB_RUN_ID: ${{ github.run_id }} |
| 199 | + GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }} |
| 200 | + WORKFLOW_NAME: ${{ github.workflow }} |
| 201 | + WORKFLOW_JOB_NAME: ${{ github.job }} |
| 202 | + run: uv run --project "$BOT_SRC_ROOT" reviewer-bot |
0 commit comments