Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .github/workflows/label-pr-review-state-fork-review-followup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: Label PR review state fork review follow-up

on:
# Fork pull_request_review runs of "Label PR review state" get a read-only
# token and skip their label writes (issue #2046). When such a run
# completes, this privileged follow-up resolves the fork PRs and dispatches
# a workflow_dispatch run of "Label PR review state", which reconciles them
# and writes the computed labels. The follow-up lives in its own workflow
# because GitHub rejects a workflow that lists itself in
# workflow_run.workflows (issue #2086). It takes no artifact input and
# never checks out code, so no untrusted PR code or artifacts are touched.
workflow_run:
workflows: ["Label PR review state"]
types: [completed]

permissions:
# This privileged workflow only reads PR metadata and dispatches the
# reconciliation workflow. All unspecified permissions, including contents,
# are none; no fork code or configuration is checked out or executed.
actions: write
pull-requests: read

# Each completed run gets its own group: one run can resolve several PRs, and
# a shared key would let an unrelated completion supersede queued dispatches.
concurrency:
group: label-pr-review-state-fork-review-followup-${{ github.event.workflow_run.id }}
cancel-in-progress: false

jobs:
dispatch:
name: Zoo Code / dispatch fork review reconciliation
# Loop guard (issue #2046): follow up only on runs triggered by
# pull_request_review. The runs this workflow dispatches have event
# 'workflow_dispatch', so their completion is filtered out here and
# the chain stops after one hop.
if: github.event.workflow_run.event == 'pull_request_review'
runs-on: ubuntu-latest
steps:
- name: Resolve fork PRs and dispatch reconciliation
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
retries: 3
script: |
const { owner, repo } = context.repo;
const run = context.payload.workflow_run;

// Mirrors the job-level `if`: only runs triggered by
// pull_request_review need a follow-up, so the chain
// stops after one hop.
if (run?.event !== 'pull_request_review') {
core.info(`Ignoring workflow_run with event '${run?.event}'`);
return;
}

// Same-repository review runs already ran with a privileged
// token and wrote their labels; only fork runs need the
// follow-up.
if (run.head_repository && run.head_repository.fork === false) {
core.info('Ignoring same-repository pull_request_review run');
return;
}

// Resolve which PRs the completed run targeted. Fork runs
// backfill pull_requests empty, so fall back to the exact head
// owner, branch, and SHA lookup used by the resolve job of
// label-pr-review-state.yml. Keep both copies in sync.
let prNumbers = [];
if (run.pull_requests?.length > 0) {
prNumbers = run.pull_requests.map(pr => pr.number);
} else {
const headOwner = run.head_repository?.owner?.login;
if (headOwner && run.head_branch && run.head_sha) {
const candidates = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
head: `${headOwner}:${run.head_branch}`,
per_page: 100,
});
const baseRepository = `${owner}/${repo}`.toLowerCase();
prNumbers = candidates
.filter(pr =>
pr.head?.sha === run.head_sha &&
pr.base?.repo?.full_name?.toLowerCase() === baseRepository
)
.map(pr => pr.number);
core.info(
`Resolved workflow_run ${run.id ?? '(unknown)'} to ` +
`${prNumbers.length} PR(s) by exact head owner, branch, and SHA`
);
}
}

// Each dispatch starts a privileged workflow_dispatch run of the
// reconciliation workflow for one PR. Its completion re-triggers
// this workflow, but the loop guard above filters it out.
// A failed dispatch must not skip the PRs after it: collect the
// failures and fail the step once, naming each PR.
const failures = [];
for (const prNumber of prNumbers) {
try {
await github.rest.actions.createWorkflowDispatch({
owner,
repo,
workflow_id: 'label-pr-review-state.yml',
ref: 'main',
inputs: { pull_request_number: String(prNumber) },
});
core.info(`Dispatched label-pr-review-state.yml for PR #${prNumber}`);
} catch (error) {
failures.push(`#${prNumber}: ${error.message}`);
}
}
if (failures.length > 0) {
core.setFailed(`Failed to dispatch ${failures.length} PR(s): ${failures.join('; ')}`);
}
52 changes: 8 additions & 44 deletions .github/workflows/label-pr-review-state.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,11 @@ on:
issue_comment:
types: [created, edited]
# Fork pull_request_review runs get a read-only token and skip their label
# writes. This workflow lists itself so such a run's completion re-triggers
# it with a privileged token that writes the computed labels (issue #2046).
# The resolve job accepts a workflow_run of this workflow only when the
# completed run was itself a pull_request_review run, so the chain stops
# after one hop. The privileged follow-up takes no artifact input and never
# checks out code, so no untrusted PR code or artifacts are touched.
# writes (issue #2046). The privileged follow-up for those runs lives in
# label-pr-review-state-fork-review-followup.yml: GitHub rejects a workflow
# that lists itself in workflow_run.workflows (issue #2086).
workflow_run:
workflows:
[
"Code QA Roo Code",
"E2E Tests (Mocked)",
"Webview Visual Regression",
"CodeQL Advanced",
"Label PR review state",
]
workflows: ["Code QA Roo Code", "E2E Tests (Mocked)", "Webview Visual Regression", "CodeQL Advanced"]
types: [completed]

permissions:
Expand Down Expand Up @@ -104,17 +94,9 @@ concurrency:
jobs:
resolve:
name: Zoo Code / resolve PRs to reconcile
# The second clause is the loop guard for the self workflow_run
# follow-up (issue #2046): accept a workflow_run of this workflow only
# when the completed run was triggered by pull_request_review. A run
# triggered by workflow_run has event 'workflow_run', so the next level
# is filtered out and the chain stops after one hop.
if: >-
(github.event_name != 'issue_comment' ||
(github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]')) &&
(github.event_name != 'workflow_run' ||
github.event.workflow_run.name != 'Label PR review state' ||
github.event.workflow_run.event == 'pull_request_review')
github.event_name != 'issue_comment' ||
(github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]')
runs-on: ubuntu-latest
outputs:
pr_numbers: ${{ steps.resolve.outputs.pr_numbers }}
Expand Down Expand Up @@ -152,27 +134,12 @@ jobs:
return;
}

// Loop guard for the self workflow_run follow-up (issue #2046),
// mirroring the job-level `if`: accept a workflow_run of this
// workflow only when the completed run was triggered by
// pull_request_review. A run triggered by workflow_run has event
// 'workflow_run', so the chain stops after one hop.
if (context.eventName === 'workflow_run' &&
context.payload.workflow_run?.name === 'Label PR review state' &&
context.payload.workflow_run?.event !== 'pull_request_review') {
core.info(
'Ignoring workflow_run of this workflow not triggered by a ' +
'pull_request_review run'
);
core.setOutput('pr_numbers', '[]');
return;
}

// Resolve which PRs this event targets. Single-PR events carry the
// number in the payload; sweep triggers (hourly schedule, pushes to
// main) list every open PR; workflow_run events use the run's
// associated PRs, with a fork-safe backfill by exact head owner,
// branch, and SHA when GitHub left pull_requests empty.
// branch, and SHA when GitHub left pull_requests empty. Keep the
// backfill in sync with label-pr-review-state-fork-review-followup.yml.
let prNumbers = [];
if (context.payload.pull_request?.number) {
prNumbers = [context.payload.pull_request.number];
Expand Down Expand Up @@ -238,9 +205,6 @@ jobs:
if: >-
(github.event_name != 'issue_comment' ||
(github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]')) &&
(github.event_name != 'workflow_run' ||
github.event.workflow_run.name != 'Label PR review state' ||
github.event.workflow_run.event == 'pull_request_review') &&
needs.resolve.outputs.pr_numbers != '[]'
strategy:
fail-fast: false
Expand Down
7 changes: 4 additions & 3 deletions src/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,14 @@ export default [
// Tests inside __tests__/ directories must not traverse 3+ levels above
// the test file. That escapes the src/ package root and makes Turbo
// invisible to cache changes in those files.
// pr-review-state-workflow.test.ts and stale-pr-clock-workflow.test.ts are the
// intentional exceptions: they read root-level config and run in a dedicated
// non-Turbo CI step.
// The workflow test files below are the intentional exceptions: they read
// root-level config and run in a dedicated non-Turbo CI step.
files: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.spec.ts"],
ignores: [
"**/services/__tests__/pr-review-state-workflow.test.ts",
"**/services/__tests__/stale-pr-clock-workflow.test.ts",
"**/services/__tests__/fork-review-followup-workflow.test.ts",
"**/services/__tests__/workflow-self-listen.test.ts",
],
rules: {
"no-restricted-syntax": [
Expand Down
Loading
Loading