| # 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'); |
| } |