PR Triage Apply #1215
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| name: PR Triage Apply | |
| # Comment-driven PR triage and lifecycle labels. Inspired by | |
| # rust-lang/triagebot UX. | |
| # | |
| # Two-stage design to defeat the fork-PR token restriction: | |
| # pr-triage-collect.yml runs on issue_comment / pull_request_review with | |
| # a read-only GITHUB_TOKEN (forced for cross-fork events) and uploads | |
| # the raw event payload as artifact `triage-event`. This workflow then | |
| # fires via workflow_run on the base ref, where the GITHUB_TOKEN has | |
| # write perms, downloads the artifact, and applies the labels. | |
| # pull_request_target lifecycle is handled directly here without going | |
| # through the collector — it already gets a write token by design. | |
| # | |
| # Comment commands (parsed line-by-line in PR comments and review bodies): | |
| # /request-review @u [@u2 ...] — request review from one or more | |
| # @users or @org/teams; the command may | |
| # also repeat across lines | |
| # /ready — flip state to S-waiting-on-review | |
| # /author — flip state to S-waiting-on-author | |
| # | |
| # Auth gate: | |
| # /request-review, /ready -> org MEMBER / repo COLLABORATOR/OWNER, or PR author | |
| # /author -> CONTRIBUTOR / MEMBER / COLLABORATOR / OWNER | |
| # (anyone with author_association=CONTRIBUTOR has | |
| # at least one merged commit and is trusted to | |
| # flip any PR back to the author queue, matching | |
| # the implicit /author from a changes_requested | |
| # review) | |
| # changes_requested review -> CONTRIBUTOR / MEMBER / COLLABORATOR / OWNER | |
| # | |
| # Review state (pull_request_review.submitted): | |
| # A changes_requested review by a prior contributor (CONTRIBUTOR or | |
| # above, never a bot) acts as an implicit /author. An explicit command | |
| # in the review body overrides it. | |
| # | |
| # Feedback: | |
| # On issue_comment commands the workflow reacts on the comment: +1 when | |
| # something was applied, "confused" when a recognized command was | |
| # rejected for lack of permission. A failed /request-review posts a | |
| # one-line reply naming the rejected handles. Review-triggered actions | |
| # have no comment to react to and stay log-only. | |
| # | |
| # Lifecycle (pull_request_target): | |
| # opened (non-draft) -> add S-waiting-on-review (only if no S-* present); | |
| # unless the author has repo write access, post | |
| # a one-time welcome comment listing the triage | |
| # commands (marker-guarded, best-effort) | |
| # ready_for_review -> add S-waiting-on-review (only if no S-* present) | |
| # converted_to_draft -> remove both S-* labels | |
| # closed (merged or not) -> remove both S-* labels | |
| # | |
| # SECURITY: | |
| # - workflow_run, pull_request_target are the ONLY triggers here. | |
| # - No actions/checkout of any ref. PR-controlled code is never executed. | |
| # - The default GITHUB_TOKEN is never written to outputs, env files, or | |
| # passed to user-supplied programs. The workflow only calls the GitHub | |
| # REST API via actions/github-script. | |
| # - pull_request_target runs the base-repo workflow with a write token so | |
| # fork-PR lifecycle labels can be applied. workflow_run also runs against | |
| # base ref. Both are safe because we never run fork code: no checkout, | |
| # no exec of PR contents, no token export. The review/comment body | |
| # reaches us via the artifact uploaded by pr-triage-collect.yml; it is | |
| # treated as untrusted text, parsed only by the command regex. See | |
| # https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ | |
| on: | |
| pull_request_target: | |
| types: [opened, ready_for_review, converted_to_draft, closed] | |
| workflow_run: | |
| workflows: ["PR Triage Collect"] | |
| types: [completed] | |
| permissions: | |
| pull-requests: write | |
| issues: write | |
| contents: read | |
| # Required for actions/download-artifact@v8 to fetch the triage-event | |
| # artifact uploaded by the upstream PR Triage Collect run via run-id. | |
| actions: read | |
| concurrency: | |
| # cancel-in-progress: false keeps the active run, but GH still replaces | |
| # any pending run with the latest arrival on burst commands. Net effect | |
| # under N rapid commands: the active run plus the latest arrival land, | |
| # everything queued in between is dropped. Acceptable because /ready | |
| # and /author are idempotent (state is reflected in the final label) | |
| # and /request-review is the only non-idempotent command; a 3-command | |
| # /request-review burst in <1s lands at most 2 reviewers, larger bursts | |
| # lose proportionally more. Pending-queue retention key (queue:) is not | |
| # yet in the GH Actions schema; revisit when available. | |
| # | |
| # workflow_run leg cannot key on PR number until after artifact parse, | |
| # so it falls back to the upstream run id. Each collect run is unique, | |
| # so this widens the group to one-run-per-upstream-collect and same-PR | |
| # bursts on this leg do NOT serialize. Correctness does not depend on | |
| # serialization: the setLabels helper performs an atomic replace-all | |
| # (list current labels, drop the S-* sibling, push target, single | |
| # setLabels call), so two interleaved label flips converge on the | |
| # last-finishing run's target instead of fabricating a both-labels | |
| # state. Per-PR serialization on this leg is a noise/cost | |
| # optimization, blocked on GH adding job-level concurrency so the | |
| # parsed PR number can re-key. | |
| group: pr-triage-apply-${{ github.event.pull_request.number || github.event.workflow_run.id }} | |
| cancel-in-progress: false | |
| jobs: | |
| apply: | |
| if: | | |
| github.event_name == 'pull_request_target' | |
| || (github.event_name == 'workflow_run' | |
| && github.event.workflow_run.conclusion == 'success') | |
| runs-on: ubuntu-24.04-arm | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Download triage event artifact | |
| if: github.event_name == 'workflow_run' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: triage-event | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| path: payload/ | |
| - name: Parse triage event payload | |
| if: github.event_name == 'workflow_run' | |
| env: | |
| UPSTREAM_EVENT: ${{ github.event.workflow_run.event }} | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| # The artifact contains exactly one file: the JSON event payload | |
| # uploaded as `${{ github.event_path }}`. Its on-disk name is | |
| # whatever the runner used (e.g. event.json); resolve it dynamically. | |
| payload=( payload/*.json payload/event.json ) | |
| f="" | |
| for candidate in "${payload[@]}"; do | |
| [[ -f "$candidate" ]] && f="$candidate" && break | |
| done | |
| if [[ -z "$f" ]]; then | |
| echo "triage payload not found under payload/" >&2 | |
| ls -la payload/ >&2 || true | |
| exit 1 | |
| fi | |
| # Body is attacker-controlled. Route it via a file rather than | |
| # $GITHUB_ENV: heredoc terminators can be forged inside the body | |
| # to close the block early and inject arbitrary env vars (e.g. | |
| # NODE_OPTIONS, GITHUB_PATH) into the subsequent github-script | |
| # step, which holds a write GITHUB_TOKEN. The other fields below | |
| # are constrained single-line values (numeric ids, enum states, | |
| # GH login charset) and can stay in $GITHUB_ENV. | |
| jq -r '.comment.body // .review.body // ""' "$f" > payload/body.txt | |
| { | |
| echo "COMMENT_AUTHOR=$(jq -r '.comment.user.login // .review.user.login // ""' "$f")" | |
| echo "COMMENT_ASSOC=$(jq -r '.comment.author_association // .review.author_association // ""' "$f")" | |
| echo "COMMENT_ID=$(jq -r '.comment.id // ""' "$f")" | |
| echo "PR_NUMBER=$(jq -r '.pull_request.number // .issue.number // ""' "$f")" | |
| echo "REVIEW_STATE=$(jq -r '.review.state // ""' "$f")" | |
| echo "TRIAGE_EVENT_NAME=${UPSTREAM_EVENT}" | |
| } >> "$GITHUB_ENV" | |
| - name: Dispatch | |
| uses: actions/github-script@v9 | |
| env: | |
| COMMENT_AUTHOR: ${{ env.COMMENT_AUTHOR }} | |
| COMMENT_ASSOC: ${{ env.COMMENT_ASSOC }} | |
| COMMENT_ID: ${{ env.COMMENT_ID }} | |
| PR_NUMBER: ${{ env.PR_NUMBER || github.event.pull_request.number }} | |
| REVIEW_STATE: ${{ env.REVIEW_STATE }} | |
| TRIAGE_EVENT_NAME: ${{ env.TRIAGE_EVENT_NAME || github.event_name }} | |
| with: | |
| script: | | |
| const LABEL_REVIEW = 'S-waiting-on-review'; | |
| const LABEL_AUTHOR = 'S-waiting-on-author'; | |
| const COMMITTER_ASSOCS = new Set(['MEMBER', 'COLLABORATOR', 'OWNER']); | |
| // Wider gate for the implicit changes_requested -> author | |
| // flip: a formal "Request changes" review is a deliberate, | |
| // attributable action, so any prior contributor can drive | |
| // it, not just committers. Derived from COMMITTER_ASSOCS so | |
| // the two cannot drift. Bots are excluded separately. | |
| const REVIEW_AUTHOR_ASSOCS = new Set(['CONTRIBUTOR', ...COMMITTER_ASSOCS]); | |
| const prNumber = Number(process.env.PR_NUMBER); | |
| // Number('') === 0 and an unknown jq path also yields ''. | |
| // Without this gate the run would silently issue API calls | |
| // against issue_number: 0, get a 404 swallowed by the | |
| // pulls.get catch below, and complete green with no labels | |
| // applied. Fail loudly instead so the misconfiguration shows | |
| // up as a red Actions run. | |
| if (!Number.isInteger(prNumber) || prNumber <= 0) { | |
| core.setFailed(`invalid PR_NUMBER: ${process.env.PR_NUMBER}`); | |
| return; | |
| } | |
| // Effective event name. On the workflow_run leg the artifact | |
| // payload was produced by issue_comment / pull_request_review, | |
| // and the upstream event name was preserved into the env in | |
| // the parse step. On the pull_request_target leg we read the | |
| // GH-provided context.eventName directly. | |
| const eventName = process.env.TRIAGE_EVENT_NAME || context.eventName; | |
| // Welcome comment posted once when a non-draft PR is opened. | |
| // The leading HTML marker is invisible in rendered Markdown | |
| // and is what postWelcomeOnce scans for to stay idempotent. | |
| const WELCOME_MARKER = '<!-- iggy-pr-triage-welcome -->'; | |
| const WELCOME_BODY = [ | |
| WELCOME_MARKER, | |
| 'Thanks for the PR. It is labeled `S-waiting-on-review` and queued for review.', | |
| '', | |
| 'Slash commands (own line, regular comment) move it around the queue:', | |
| '', | |
| '- `/ready` - back to `S-waiting-on-review` after addressing feedback', | |
| '- `/author` - flip to `S-waiting-on-author` while you finish changes', | |
| '- `/request-review @user-or-team` - request a reviewer', | |
| '', | |
| 'See [CONTRIBUTING.md](https://github.com/apache/iggy/blob/master/CONTRIBUTING.md#pr-triage-commands) for details.', | |
| ].join('\n'); | |
| // Retry transient GitHub API failures so a single 502 cannot | |
| // abort a command. Transient = 5xx, 429, or a network error | |
| // with no HTTP status. Anything else (404, 403, 422) is not | |
| // retried and surfaces to the caller unchanged. | |
| const withRetry = async (fn, label) => { | |
| const delays = [500, 1500, 4000]; | |
| for (let attempt = 0; ; attempt++) { | |
| try { | |
| return await fn(); | |
| } catch (e) { | |
| const s = e.status; | |
| const transient = s === undefined || s === 429 | |
| || (s >= 500 && s < 600); | |
| if (!transient || attempt >= delays.length) throw e; | |
| core.warning(`${label}: transient failure ` + | |
| `(${s ?? e.code ?? 'network'}), retry ` + | |
| `${attempt + 1}/${delays.length} in ${delays[attempt]}ms`); | |
| await new Promise(r => setTimeout(r, delays[attempt])); | |
| } | |
| } | |
| }; | |
| // Atomic replace-all on the S-* slot: list current labels, | |
| // drop both S-* siblings, optionally push the target, single | |
| // setLabels PUT. Used by both the command leg (add = the | |
| // target state) and the lifecycle close/converted_to_draft | |
| // path (add = null -> just clear both S-*). One primitive | |
| // for both legs so the lifecycle DELETE pair cannot race the | |
| // command-leg PUT and resurrect a cleared S-* on a closed PR. | |
| // | |
| // Per-PR concurrency is NOT guaranteed on the workflow_run | |
| // leg (see concurrency: comment above), so two command runs | |
| // can land in parallel. The PUT-style setLabels makes | |
| // interleaved flips converge on the last-finishing run's | |
| // target instead of fabricating a both-labels state (which | |
| // the lifecycle hasState gate would then treat as terminal | |
| // and never clean up). | |
| // | |
| // The list+setLabels pair is not atomic against unrelated | |
| // mutators, so a racing /label add by another workflow | |
| // between our list and our setLabels would be overwritten. | |
| // Acceptable: iggy's other workflows do not touch labels. | |
| // | |
| // Pagination is mandatory: an unpaginated per_page: 100 fetch | |
| // on a PR carrying more than 100 labels would silently drop | |
| // every label beyond page 1 on the setLabels PUT. | |
| const replaceStateLabel = async (add) => { | |
| const live = await withRetry(() => github.paginate( | |
| github.rest.issues.listLabelsOnIssue, | |
| { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| }, | |
| ), 'listLabelsOnIssue (replaceStateLabel)'); | |
| const next = live | |
| .map(l => l.name) | |
| .filter(n => n !== LABEL_REVIEW && n !== LABEL_AUTHOR); | |
| if (add) next.push(add); | |
| await withRetry(() => github.rest.issues.setLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| labels: next, | |
| }), `setLabels ${add ?? '(clear S-*)'}`); | |
| }; | |
| // Best-effort commenter feedback. A failed reaction or reply | |
| // must never fail the run, so each swallows its own errors. | |
| // Reactions exist only for issue comments — review-triggered | |
| // commands have no comment to react to, so commentId is null | |
| // and react() is a no-op. | |
| const commentId = Number(process.env.COMMENT_ID) || null; | |
| const react = async (content) => { | |
| if (!commentId) return; | |
| try { | |
| await withRetry(() => github.rest.reactions.createForIssueComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: commentId, | |
| content, | |
| }), `react ${content}`); | |
| } catch (e) { | |
| core.warning(`reaction ${content} failed: ${e.message}`); | |
| } | |
| }; | |
| const reply = async (text) => { | |
| try { | |
| await withRetry(() => github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: text, | |
| }), 'createComment'); | |
| } catch (e) { | |
| core.warning(`reply failed: ${e.message}`); | |
| } | |
| }; | |
| // True when `login` has push (write) access to this repo -- | |
| // the core team, by repo/team/org grant combined. On an org | |
| // repo, author_association cannot answer this: it reports | |
| // MEMBER for every org member, not just repo writers, so the | |
| // collaborator-permission endpoint is the only accurate | |
| // signal. On failure return false -- a stray welcome on a | |
| // maintainer PR is harmless, a missing one on a contributor | |
| // PR defeats the feature. | |
| const hasWriteAccess = async (login) => { | |
| try { | |
| const { data } = await withRetry(() => | |
| github.rest.repos.getCollaboratorPermissionLevel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| username: login, | |
| }), `getCollaboratorPermissionLevel ${login}`); | |
| return data.permission === 'write' || data.permission === 'admin'; | |
| } catch (e) { | |
| core.warning(`write-access check failed for ${login}: ${e.message}`); | |
| return false; | |
| } | |
| }; | |
| // Post WELCOME_BODY once on PR open. Best-effort: a failed | |
| // post must never fail the run or block labeling, the same | |
| // contract as react()/reply(). Idempotent via a listComments | |
| // scan for WELCOME_MARKER, so a manual workflow re-run does | |
| // not double-post. | |
| const postWelcomeOnce = async () => { | |
| try { | |
| // Paginated so the marker is still detected if an Actions | |
| // re-run fires after the PR has accumulated more than 100 | |
| // comments. Without it the welcome would double-post. | |
| const existing = await withRetry(() => github.paginate( | |
| github.rest.issues.listComments, | |
| { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| }, | |
| ), 'listComments'); | |
| if (existing.some(c => c.body && c.body.includes(WELCOME_MARKER))) { | |
| core.info('welcome: already posted, skipping'); | |
| return; | |
| } | |
| await withRetry(() => github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: WELCOME_BODY, | |
| }), 'welcome createComment'); | |
| core.info('welcome: posted'); | |
| } catch (e) { | |
| core.warning(`welcome comment failed: ${e.message}`); | |
| } | |
| }; | |
| // ----- pull_request_target lifecycle ----- | |
| if (eventName === 'pull_request_target') { | |
| const action = context.payload.action; | |
| const pr = context.payload.pull_request; | |
| core.info(`lifecycle event=${action} draft=${pr.draft}`); | |
| if (action === 'closed' || action === 'converted_to_draft') { | |
| // Atomic clear via replaceStateLabel(null): a single | |
| // setLabels PUT that drops both S-* in one call. Using | |
| // the same primitive as the command leg closes the | |
| // cross-leg TOCTOU where two independent removeLabel | |
| // calls here could be interleaved with a command-leg | |
| // list+PUT and let S-waiting-on-review resurrect on a | |
| // closed PR. | |
| await replaceStateLabel(null); | |
| core.info(`lifecycle: cleared S-* labels (${action})`); | |
| return; | |
| } | |
| if (action === 'opened' || action === 'ready_for_review') { | |
| if (pr.draft) { | |
| core.info('lifecycle: draft PR, no label'); | |
| return; | |
| } | |
| // Welcome comment only on the initial non-draft open, | |
| // and only for authors without repo write access -- the | |
| // core team already knows the triage commands, the | |
| // comment is contributor onboarding. ready_for_review is | |
| // excluded: a PR opened as a draft and later marked ready | |
| // gets no welcome, keeping scope at "non-draft open". | |
| // Bots (dependabot, renovate, ...) are skipped: the | |
| // welcome targets human contributor onboarding and bots | |
| // cannot drive triage commands anyway (gated by isBot). | |
| if (action === 'opened') { | |
| const author = pr.user && pr.user.login; | |
| const authorIsBot = (pr.user && pr.user.type === 'Bot') | |
| || (author && /\[bot\]$/.test(author)); | |
| if (!author) { | |
| core.info('welcome: skipped, PR has no author'); | |
| } else if (authorIsBot) { | |
| core.info(`welcome: skipped, ${author} is a bot`); | |
| } else if (await hasWriteAccess(author)) { | |
| core.info(`welcome: skipped, ${author} has write access`); | |
| } else { | |
| await postWelcomeOnce(); | |
| } | |
| } | |
| // Read live labels rather than the webhook-frozen | |
| // pr.labels — a comment-driven /author or /ready may have | |
| // landed between the lifecycle event and this run. Paginated | |
| // so a PR with more than 100 labels still reports hasState | |
| // correctly. | |
| const live = await withRetry(() => github.paginate( | |
| github.rest.issues.listLabelsOnIssue, | |
| { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| }, | |
| ), 'listLabelsOnIssue'); | |
| const liveNames = live.map(l => l.name); | |
| const hasState = liveNames.includes(LABEL_REVIEW) | |
| || liveNames.includes(LABEL_AUTHOR); | |
| core.info(`lifecycle: has_state=${hasState}`); | |
| if (hasState) { | |
| core.info('lifecycle: S-* label already present, no change'); | |
| return; | |
| } | |
| await withRetry(() => github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| labels: [LABEL_REVIEW], | |
| }), `lifecycle addLabels ${LABEL_REVIEW}`); | |
| core.info(`lifecycle: added ${LABEL_REVIEW}`); | |
| return; | |
| } | |
| core.info(`lifecycle: unhandled action ${action}`); | |
| return; | |
| } | |
| // ----- issue_comment / pull_request_review commands ----- | |
| // Both event types feed the same path: COMMENT_AUTHOR/_ASSOC/ | |
| // _ID, PR_NUMBER and REVIEW_STATE resolve from the artifact | |
| // uploaded by pr-triage-collect.yml via the parse step above. | |
| // body is routed through a file (payload/body.txt) rather | |
| // than $GITHUB_ENV -- see the parse step's comment for the | |
| // injection rationale. The file is absent on the | |
| // pull_request_target leg (no parse step), and lifecycle | |
| // returned above, so falling back to '' is safe. | |
| const fs = require('fs'); | |
| const body = fs.existsSync('payload/body.txt') | |
| ? fs.readFileSync('payload/body.txt', 'utf8') | |
| : ''; | |
| // A changes_requested review is an implicit /author: the ball | |
| // is back with the author. Gated by REVIEW_AUTHOR_ASSOCS | |
| // (checked below) -- wider than the /author command. An | |
| // explicit command in the review body still takes precedence. | |
| // REVIEW_STATE is empty for issue_comment. | |
| const reviewState = process.env.REVIEW_STATE || ''; | |
| const reviewWantsAuthor = reviewState === 'changes_requested'; | |
| // Skip everything if there is neither a command line nor an | |
| // actionable review state. Avoids a pulls.get on every | |
| // drive-by comment and every approve/comment-only review. | |
| // Leading whitespace is tolerated so that " /ready" matches | |
| // the same way the line-by-line dispatch (which trims) does. | |
| // The trailing `(?:\s|$)` rejects suffixed prose like | |
| // `/ready-to-merge` — `\b` fires at hyphen/slash and would | |
| // silently flip state. | |
| const COMMAND_RE = /^[ \t]*\/(request-review|ready|author)(?:\s|$)/m; | |
| if (!COMMAND_RE.test(body) && !reviewWantsAuthor) { | |
| core.info('no command in body and no actionable review state, skipping'); | |
| return; | |
| } | |
| const commentAuthor = process.env.COMMENT_AUTHOR; | |
| const commentAssoc = process.env.COMMENT_ASSOC; | |
| // Bot-suffixed accounts (e.g. dependabot[bot]) cannot drive | |
| // commands as committer or PR author — would let bots | |
| // self-flip review state. | |
| const isBot = /\[bot\]$/.test(commentAuthor); | |
| const isCommitter = COMMITTER_ASSOCS.has(commentAssoc) && !isBot; | |
| // Permitted to drive the implicit changes_requested flip; | |
| // see REVIEW_AUTHOR_ASSOCS. | |
| const isReviewFlipper = REVIEW_AUTHOR_ASSOCS.has(commentAssoc) && !isBot; | |
| // Live state read. The artifact carries the webhook-frozen | |
| // payload at delivery time, plus we now have ~30s of | |
| // artifact-handoff latency on top; comment-while-open + | |
| // close-arriving-later would otherwise re-label a now-closed | |
| // PR. Run on both committer and non-committer paths -- | |
| // correctness over the single API call saved on the | |
| // committer fast-path. | |
| let prData; | |
| try { | |
| prData = await withRetry(() => github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }), 'pulls.get'); | |
| } catch (e) { | |
| if (e.status === 404) { | |
| core.info('PR not found (deleted or transferred), skipping'); | |
| return; | |
| } | |
| throw e; | |
| } | |
| if (prData.data.state === 'closed') { | |
| core.info('PR is closed, skipping'); | |
| return; | |
| } | |
| // Reject command runs against a PR that turned draft after | |
| // the comment was posted. The pull_request_target lifecycle | |
| // clears S-* on converted_to_draft; without this gate the | |
| // ~30-90s artifact-handoff window lets a stale command | |
| // re-add S-waiting-on-review onto a now-draft PR. | |
| if (prData.data.draft) { | |
| core.info('PR is draft, skipping'); | |
| return; | |
| } | |
| // pr.user can be null for deleted GitHub accounts; treat the | |
| // PR as having no recognized author rather than crashing. | |
| const prAuthor = (prData.data.user && prData.data.user.login) || null; | |
| const isPrAuthor = !!prAuthor && commentAuthor === prAuthor && !isBot; | |
| core.info(`comment_author=${commentAuthor} assoc=${commentAssoc} ` + | |
| `committer=${isCommitter} pr_author=${isPrAuthor} bot=${isBot}`); | |
| const lines = body.split(/\r?\n/).map(l => l.trim()); | |
| let sawReassign = false; | |
| let sawReady = false; | |
| let sawAuthor = false; | |
| // Outcome tracking for commenter feedback (see react() above). | |
| // applied: at least one command took effect. denied: at least | |
| // one recognized command was rejected for lack of permission. | |
| let applied = false; | |
| let denied = false; | |
| // /request-review handles accumulate across every matching line | |
| // (and every handle on a line); the requestReviewers call is | |
| // deferred until after the scan so N handles cost one API call, | |
| // not N. Sets dedupe handles repeated within the same comment. | |
| const reviewers = new Set(); | |
| const teamReviewers = new Set(); | |
| // GH username: alnum + hyphen, no leading/trailing hyphen. | |
| // Optional team slug: '/' then alnum + underscore + hyphen. | |
| // Fully anchored so a malformed handle (empty slug @foo/, extra | |
| // segment @foo/bar/baz) is rejected per-token, not silently | |
| // truncated. | |
| const HANDLE_RE = /^@([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\/[A-Za-z0-9_-]+)?)$/; | |
| for (const line of lines) { | |
| // Unlike /ready and /author, /request-review may legitimately | |
| // repeat across lines, so no first-match-wins guard and no | |
| // early loop break. | |
| const rr = line.match(/^\/request-review(?:\s+(.*))?$/); | |
| if (rr) { | |
| sawReassign = true; | |
| if (!(isCommitter || isPrAuthor)) { | |
| core.info(`/request-review: ignored, ${commentAuthor} lacks permission`); | |
| denied = true; | |
| continue; | |
| } | |
| const rest = (rr[1] || '').trim(); | |
| if (!rest) { | |
| core.info('/request-review: no reviewer specified'); | |
| continue; | |
| } | |
| for (const token of rest.split(/\s+/)) { | |
| const hm = token.match(HANDLE_RE); | |
| if (!hm) { | |
| core.warning(`/request-review: ignoring malformed handle "${token}"`); | |
| continue; | |
| } | |
| const handle = hm[1]; | |
| // Team slugs go through team_reviewers as bare slug, no org. | |
| if (handle.includes('/')) { | |
| teamReviewers.add(handle.split('/')[1]); | |
| } else { | |
| reviewers.add(handle); | |
| } | |
| } | |
| continue; | |
| } | |
| if (!sawReady && /^\/ready(?:\s|$)/.test(line)) { | |
| sawReady = true; | |
| if (!(isCommitter || isPrAuthor)) { | |
| core.info(`/ready: ignored, ${commentAuthor} lacks permission`); | |
| denied = true; | |
| } else { | |
| await replaceStateLabel(LABEL_REVIEW); | |
| core.info(`/ready: ${LABEL_REVIEW} <- ${LABEL_AUTHOR}`); | |
| applied = true; | |
| } | |
| continue; | |
| } | |
| if (!sawAuthor && /^\/author(?:\s|$)/.test(line)) { | |
| sawAuthor = true; | |
| // Wider than /ready: anyone GitHub recognizes as a returning | |
| // contributor (>=1 merged commit) can flip any PR to the | |
| // author queue. Same gate as the implicit /author from a | |
| // changes_requested review. | |
| if (!isReviewFlipper) { | |
| core.info(`/author: ignored, ${commentAuthor} not a committer or returning contributor`); | |
| denied = true; | |
| } else { | |
| await replaceStateLabel(LABEL_AUTHOR); | |
| core.info(`/author: ${LABEL_AUTHOR} <- ${LABEL_REVIEW}`); | |
| applied = true; | |
| } | |
| continue; | |
| } | |
| } | |
| if (reviewers.size > 0 || teamReviewers.size > 0) { | |
| const params = { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }; | |
| if (reviewers.size > 0) params.reviewers = [...reviewers]; | |
| if (teamReviewers.size > 0) params.team_reviewers = [...teamReviewers]; | |
| const requested = [...reviewers, ...teamReviewers].join(', '); | |
| try { | |
| await withRetry( | |
| () => github.rest.pulls.requestReviewers(params), | |
| 'requestReviewers', | |
| ); | |
| core.info(`/request-review: requested ${requested}`); | |
| applied = true; | |
| } catch (e) { | |
| // Transient 5xx is retried by withRetry before reaching | |
| // here. What lands in this catch is a non-transient | |
| // failure — typically the 422 GitHub returns for the | |
| // whole batch on a single bad handle (non-collaborator, | |
| // unknown team). Surface it to the commenter; the run log | |
| // also names the rejected set. | |
| core.warning(`/request-review: failed to request [${requested}]: ${e.message}`); | |
| const why = e.status === 422 | |
| ? 'a handle is not a repo collaborator, or the team is unknown' | |
| : `GitHub API error (${e.status ?? 'network'})`; | |
| await reply(`\`/request-review\`: could not request ${requested} - ${why}.`); | |
| } | |
| } | |
| // Implicit /author from a changes_requested review. Skipped | |
| // when the review body already carried an explicit /ready or | |
| // /author -- the command wins. Wider gate than the /author | |
| // command: any prior contributor, not just committers (see | |
| // REVIEW_AUTHOR_ASSOCS). | |
| if (reviewWantsAuthor && !sawReady && !sawAuthor) { | |
| if (!isReviewFlipper) { | |
| core.info(`review changes_requested: ignored, ` + | |
| `${commentAuthor} (${commentAssoc}) not permitted`); | |
| } else { | |
| await replaceStateLabel(LABEL_AUTHOR); | |
| core.info(`review changes_requested: ${LABEL_AUTHOR} <- ${LABEL_REVIEW}`); | |
| applied = true; | |
| } | |
| } | |
| if (!sawReassign && !sawReady && !sawAuthor && !reviewWantsAuthor) { | |
| core.info('no command matched'); | |
| } | |
| // Commenter feedback. denied wins over applied: if any part of | |
| // the comment was rejected the commenter should see it, even | |
| // when another command in the same comment worked. react() is | |
| // a no-op for review-triggered runs (no comment to react to). | |
| if (denied) { | |
| await react('confused'); | |
| } else if (applied) { | |
| await react('+1'); | |
| } |