diff --git a/.github/workflows/pr-automation-tests.yml b/.github/workflows/pr-automation-tests.yml new file mode 100644 index 0000000..4ea1dae --- /dev/null +++ b/.github/workflows/pr-automation-tests.yml @@ -0,0 +1,25 @@ +name: Validate PR automation + +on: + pull_request: + paths: + - .github/workflows/pull-request-review-labels.yml + - .github/workflows/stale-pull-requests.yml + - .github/workflows/pr-automation-tests.yml + - github-actions/pr-review-labeler/** + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out test source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Test review label decisions and reconciliation + run: node --test github-actions/pr-review-labeler/action.test.cjs diff --git a/.github/workflows/pull-request-review-labels.yml b/.github/workflows/pull-request-review-labels.yml new file mode 100644 index 0000000..647e8fa --- /dev/null +++ b/.github/workflows/pull-request-review-labels.yml @@ -0,0 +1,76 @@ +name: Pull request review labels + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, converted_to_draft, synchronize] + pull_request_review: + types: [submitted, dismissed] + schedule: + - cron: "43 5 * * *" + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +jobs: + targets: + # Fork and Dependabot review events have read-only tokens; daily refresh covers them. + if: >- + github.event_name != 'pull_request_review' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]') + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + numbers: ${{ steps.targets.outputs.result }} + steps: + - name: Select pull requests + id: targets + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + if (context.payload.pull_request) { + return [context.payload.pull_request.number]; + } + const pulls = await github.paginate(github.rest.pulls.list, { + ...context.repo, + state: 'open', + per_page: 100, + }); + return pulls.map(pull => pull.number); + + update-review-labels: + needs: targets + if: needs.targets.outputs.numbers != '[]' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + pull-requests: write + strategy: + fail-fast: false + max-parallel: 4 + matrix: + number: ${{ fromJSON(needs.targets.outputs.numbers) }} + concurrency: + group: pull-request-review-labels-pr-${{ matrix.number }} + cancel-in-progress: false + steps: + - name: Check out trusted action source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: | + github-actions/pr-review-labeler + + - name: Update review labels + # The local action is available only after the install PR reaches the default branch. + if: hashFiles('github-actions/pr-review-labeler/action.yml') != '' + uses: ./github-actions/pr-review-labeler + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + pull-request-number: ${{ matrix.number }} + required-approvals: "2" diff --git a/.github/workflows/stale-pull-requests.yml b/.github/workflows/stale-pull-requests.yml new file mode 100644 index 0000000..62f3cd9 --- /dev/null +++ b/.github/workflows/stale-pull-requests.yml @@ -0,0 +1,87 @@ +name: Close stale pull requests + +on: + schedule: + - cron: "17 1 * * *" + workflow_dispatch: + inputs: + dry_run: + description: Log candidates without changing pull requests + required: false + default: true + type: boolean + +permissions: + contents: read + issues: read + pull-requests: read + +concurrency: + group: stale-pull-requests + cancel-in-progress: false + +jobs: + stale: + name: Mark and close stale pull requests + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + pull-requests: write + # actions/stale saves scan progress in the Actions cache between bounded runs. + actions: write + steps: + - name: Ensure required labels exist + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + for (const label of [ + {name: 'stale', color: 'ededed', description: 'PR inactive for 7 days; closes after 7 more days.'}, + {name: 'keep-open', color: '0e8a16', description: 'Exempt this PR from automatic stale closure.'}, + ]) { + try { + await github.rest.issues.getLabel({...context.repo, name: label.name}); + } catch (error) { + if (error.status !== 404) throw error; + if (context.eventName === 'workflow_dispatch' && context.payload.inputs?.dry_run !== 'false') { + core.info(`Dry run: missing label ${label.name}; no label created.`); + continue; + } + try { + await github.rest.issues.createLabel({...context.repo, ...label}); + } catch (createError) { + if (createError.status !== 422) throw createError; + await github.rest.issues.getLabel({...context.repo, name: label.name}); + } + } + } + + - name: Process stale pull requests + uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + repo-token: ${{ github.token }} + days-before-issue-stale: -1 + days-before-issue-close: -1 + remove-issue-stale-when-updated: false + days-before-pr-stale: 7 + days-before-pr-close: 7 + stale-pr-label: "stale" + exempt-pr-labels: "keep-open" + exempt-draft-pr: false + remove-pr-stale-when-updated: true + stale-pr-message: >- + This pull request has had no activity for 7 days and is now marked + `stale`. It will be closed in 7 days unless there is new activity. + Comment or push an update to reset the timer. Add `keep-open` if it + must remain open without activity. + close-pr-message: >- + Closing this pull request after the 7-day stale grace period without + activity. This workflow does not delete the branch. Reopen the pull + request when work resumes. + delete-branch: false + operations-per-run: 200 + sort-by: updated + ascending: true + enable-statistics: true + debug-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} diff --git a/github-actions/pr-review-labeler/README.md b/github-actions/pr-review-labeler/README.md new file mode 100644 index 0000000..e7d13c1 --- /dev/null +++ b/github-actions/pr-review-labeler/README.md @@ -0,0 +1,44 @@ +# Pull request automation + +The review labeler is copied from [wallester/monorepo at `42aff24cf293`](https://github.com/wallester/monorepo/tree/42aff24cf2936452e740304d257bf1bbf5e4f9a4/github-actions/pr-review-labeler). +Keep future changes aligned with that source. This rollout pins the GitHub Script action, +removes two legacy review labels, and skips PRs closed while a refresh was queued. + +## Review labels + +- Draft PRs have managed review labels removed. +- Active changes requested by a merge-eligible reviewer produce `changes required`. +- No approvals on the current head produce `ready for review`. +- Some approvals below the configured threshold produce `ready for final review`. +- Enough approvals produce `ready for merge` only when GitHub's live review decision allows it. +- Only reviewers with write, maintain, or admin permission count. Approval counts use the latest opinionated review per reviewer; dismissed reviews clear that opinion. +- Review labels are advisory. Branch protection, required checks, code owners, conflicts, and other merge rules remain authoritative. + +The workflow refreshes labels on PR lifecycle and review events, daily, and through +**Actions → Pull request review labels → Run workflow**. Manual and daily runs cover +all open PRs, including existing PRs with no new activity. Fork and Dependabot review +events are refreshed by the daily/manual run because their review-event tokens are read-only. +Each run checks out the trusted default branch with persisted credentials disabled. +It never executes PR head code with label-write permissions. The install PR skips the +label step until this local action exists on the default branch. + +The configured approval threshold is **2**. It reflects the inspected +default-branch protection and rulesets at rollout time; keep it aligned with policy changes. + +## Stale pull requests + +After merge, the daily stale workflow marks PRs after **7 inactive days** and closes +them after a further **7-day grace period**. Drafts are included. Comments or updates +reset the timer. Add `keep-open` for an exemption. Branches are never deleted. +Issues are excluded. Missing `stale` and `keep-open` labels are created automatically. +The manual stale workflow defaults to `dry_run: true`; dry runs do not create labels +or change PRs. Review-label changes can update a PR's activity timestamp once during +initial reconciliation; unchanged labels are not written again on subsequent runs. + +## Validation and rollback + +Run `node --test github-actions/pr-review-labeler/action.test.cjs` locally. +The `Validate PR automation` workflow runs these scenarios for changes to this automation. +The tests exercise the JavaScript embedded in `action.yml` without credentials or network. +Disable the affected workflow or revert the rollout commit to stop automation. +Closed PRs can be reopened; removed labels can be reapplied. Reverting does not reopen PRs. diff --git a/github-actions/pr-review-labeler/action.test.cjs b/github-actions/pr-review-labeler/action.test.cjs new file mode 100644 index 0000000..c409da1 --- /dev/null +++ b/github-actions/pr-review-labeler/action.test.cjs @@ -0,0 +1,152 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const actionScript = new AsyncFunction('github', 'context', 'core', 'process', embeddedScript(path.join(__dirname, 'action.yml'))); +const core = {info() {}}; +const reviewLabels = ['ready for review', 'ready for final review', 'ready for merge', 'changes required']; +const env = { + PULL_REQUEST_NUMBER: '17', REQUIRED_APPROVALS: '2', CURRENT_HEAD_ONLY: 'true', CREATE_LABELS: 'true', + READY_FOR_REVIEW_LABEL: reviewLabels[0], READY_FOR_FINAL_REVIEW_LABEL: reviewLabels[1], + READY_FOR_MERGE_LABEL: reviewLabels[2], CHANGES_REQUIRED_LABEL: reviewLabels[3], + READY_FOR_REVIEW_COLOR: '0E8A16', READY_FOR_FINAL_REVIEW_COLOR: 'FBCA04', + READY_FOR_MERGE_COLOR: '1D76DB', CHANGES_REQUIRED_COLOR: 'D93F0B', +}; +const scenarios = [ + {name: 'unreviewed PR awaits its first review', expected: 'ready for review'}, + {name: 'one approval awaits final review', reviews: [review('alice')], expected: 'ready for final review'}, + {name: 'two eligible approvals are ready to merge', reviews: [review('alice'), review('bob')], expected: 'ready for merge'}, + {name: 'three-approval policy is respected', approvals: '3', reviews: [review('alice'), review('bob')], expected: 'ready for final review'}, + {name: 'three approvals satisfy the higher policy', approvals: '3', reviews: [review('alice'), review('bob'), review('carol')], expected: 'ready for merge'}, + {name: 'one-approval policy is respected', approvals: '1', reviews: [review('alice')], expected: 'ready for merge'}, + {name: 'GitHub review requirement overrides approval count', decision: 'REVIEW_REQUIRED', reviews: [review('alice'), review('bob')], expected: 'ready for final review'}, + {name: 'GitHub changes-requested decision takes precedence', decision: 'CHANGES_REQUESTED', expected: 'changes required'}, + {name: 'changes requested survive a new head', reviews: [review('alice', 'CHANGES_REQUESTED', 'old')], expected: 'changes required'}, + {name: 'non-current approvals do not count', reviews: [review('alice', 'APPROVED', 'old')], expected: 'ready for review'}, + {name: 'read-only reviewer does not count', permissions: {alice: 'read'}, reviews: [review('alice')], expected: 'ready for review'}, + {name: 'read-only changes requested do not block', permissions: {alice: 'read'}, reviews: [review('alice', 'CHANGES_REQUESTED')], expected: 'ready for review'}, + {name: 'author review does not count', reviews: [review('author')], expected: 'ready for review'}, + {name: 'multiple reviews by one person count once', reviews: [review('alice'), review('alice', 'APPROVED', 'head', 2)], expected: 'ready for final review'}, + {name: 'comment does not replace an approval', reviews: [review('alice'), review('alice', 'COMMENTED', 'head', 2)], expected: 'ready for final review'}, + {name: 'dismissal clears the previous opinion', reviews: [review('alice', 'CHANGES_REQUESTED'), review('alice', 'DISMISSED', 'head', 2)], expected: 'ready for review'}, + {name: 'later approval replaces requested changes', reviews: [review('alice', 'CHANGES_REQUESTED'), review('alice', 'APPROVED', 'head', 2)], expected: 'ready for final review'}, + {name: 'draft removes canonical and legacy review labels', draft: true, labels: [...reviewLabels, 'ready for 2nd review', 'review in progress', 'keep-open'], expected: null}, + {name: 'legacy review states are replaced', labels: ['review in progress', 'ready for 2nd review', 'keep-open'], expected: 'ready for review'}, + {name: 'unchanged labels cause no PR writes', labels: ['ready for review', 'keep-open'], expected: 'ready for review', writes: 0}, + {name: 'closed PR from a queued refresh is untouched', state: 'closed', labels: ['ready for merge', 'keep-open'], expected: 'ready for merge', writes: 0}, + {name: 'missing label definitions are created', missingLabels: true, expected: 'ready for review', created: 4}, + {name: 'stale event uses live head', eventHead: 'old', reviews: [review('alice', 'APPROVED', 'head')], expected: 'ready for final review'}, + {name: 'permission lookup failure does not relabel PR', permissionError: 503, reviews: [review('alice')], labels: ['ready for merge'], error: /permission lookup failed/}, + {name: 'invalid approval input fails before mutation', approvals: '0', error: /positive integer/}, +]; + +for (const scenario of scenarios) { + test(scenario.name, async () => { + // Arrange: keep the API boundary in memory so the production decision code runs unchanged. + const state = fixture(scenario); + const run = () => actionScript(state.github, state.context, core, {env: {...env, REQUIRED_APPROVALS: scenario.approvals || '2'}}); + // Act and assert. + if (scenario.error) { + await assert.rejects(run, scenario.error); + assert.equal(state.writes.length, 0); + return; + } + await run(); + const managed = [...state.labels].filter(label => reviewLabels.includes(label)); + assert.deepEqual(managed, scenario.expected ? [scenario.expected] : []); + if (scenario.labels?.includes('keep-open')) assert(state.labels.has('keep-open')); + if (scenario.state !== 'closed') { + assert(!state.labels.has('review in progress')); + assert(!state.labels.has('ready for 2nd review')); + } + if (scenario.writes !== undefined) assert.equal(state.writes.length, scenario.writes); + if (scenario.created !== undefined) assert.equal(state.created.length, scenario.created); + }); +} + +const workflowDir = path.resolve(__dirname, '../../.github/workflows'); +const targetScript = new AsyncFunction('github', 'context', embeddedScript(path.join(workflowDir, 'pull-request-review-labels.yml'))); +for (const scenario of [ + {name: 'PR event selects only its PR', payload: {pull_request: {number: 17}}, pulls: [], expected: [17], calls: 0}, + {name: 'manual refresh selects all paginated open PRs', payload: {}, pulls: [{number: 17}, {number: 21}], expected: [17, 21], calls: 1}, + {name: 'empty repository emits no matrix jobs', payload: {}, pulls: [], expected: [], calls: 1}, +]) { + test(scenario.name, async () => { + let calls = 0; + const github = {rest: {pulls: {list() {}}}, paginate: async (_method, args) => { + calls++; + assert.equal(args.state, 'open'); + assert.equal(args.per_page, 100); + return scenario.pulls; + }}; + const numbers = await targetScript(github, {repo: {owner: 'example', repo: 'example'}, payload: scenario.payload}); + assert.deepEqual(numbers, scenario.expected); + assert.equal(calls, scenario.calls); + }); +} + +const staleScript = new AsyncFunction('github', 'context', 'core', embeddedScript(path.join(workflowDir, 'stale-pull-requests.yml'))); +for (const scenario of [ + {name: 'stale dry run does not create missing labels', eventName: 'workflow_dispatch', inputs: {dry_run: 'true'}, expected: 0}, + {name: 'omitted stale dry-run input is non-mutating', eventName: 'workflow_dispatch', inputs: {}, expected: 0}, + {name: 'live stale run creates required labels', eventName: 'schedule', inputs: {}, expected: 2}, + {name: 'explicit live manual stale run creates labels', eventName: 'workflow_dispatch', inputs: {dry_run: 'false'}, expected: 2}, +]) { + test(scenario.name, async () => { + const created = []; + const github = {rest: {issues: { + getLabel: async () => {throw Object.assign(new Error('missing'), {status: 404});}, + createLabel: async label => {created.push(label.name);}, + }}}; + await staleScript(github, {repo: {owner: 'example', repo: 'example'}, eventName: scenario.eventName, payload: {inputs: scenario.inputs}}, core); + assert.equal(created.length, scenario.expected); + }); +} + +function embeddedScript(file) { + const lines = fs.readFileSync(file, 'utf8').split('\n'); + const start = lines.findIndex(line => /^\s+script: \|\s*$/.test(line)); + assert(start >= 0, `missing JavaScript script in ${file}`); + const indentation = lines[start].match(/^\s*/)[0].length; + const output = []; + for (const line of lines.slice(start + 1)) { + if (line.trim() && line.match(/^\s*/)[0].length <= indentation) break; + output.push(line.slice(indentation + 2)); + } + return output.join('\n'); +} + +function review(login, state = 'APPROVED', commit = 'head', sequence = 1) { + return {user: {login, id: login}, state, commit_id: commit, submitted_at: `2026-09-01T00:00:0${sequence}Z`}; +} + +function fixture(scenario) { + const labels = new Set(scenario.labels || []); + const writes = []; + const created = []; + const pull = {number: 17, state: scenario.state || 'open', draft: scenario.draft || false, user: {login: 'author'}, head: {sha: 'head'}}; + const issues = { + getLabel: async () => {if (scenario.missingLabels) throw Object.assign(new Error('missing'), {status: 404});}, + createLabel: async args => {created.push(args.name);}, + listLabelsOnIssue() {}, + removeLabel: async args => {labels.delete(args.name); writes.push(args);}, + addLabels: async args => {args.labels.forEach(label => labels.add(label)); writes.push(args);}, + }; + const pulls = {get: async () => ({data: pull}), listReviews() {}}; + const github = { + rest: {issues, pulls, repos: {getCollaboratorPermissionLevel: async args => { + if (scenario.permissionError) throw Object.assign(new Error('permission lookup failed'), {status: scenario.permissionError}); + return {data: {permission: scenario.permissions?.[args.username] || 'write'}}; + }}}, + graphql: async () => ({repository: {pullRequest: {reviewDecision: scenario.decision ?? 'APPROVED'}}}), + paginate: async method => { + if (method === pulls.listReviews) return scenario.reviews || []; + if (method === issues.listLabelsOnIssue) return [...labels].map(name => ({name})); + throw new Error('unexpected pagination method'); + }, + }; + const context = {repo: {owner: 'example', repo: 'example'}, payload: {pull_request: {number: 17, head: {sha: scenario.eventHead || 'head'}}}}; + return {github, context, labels, writes, created}; +} diff --git a/github-actions/pr-review-labeler/action.yml b/github-actions/pr-review-labeler/action.yml new file mode 100644 index 0000000..8eab45b --- /dev/null +++ b/github-actions/pr-review-labeler/action.yml @@ -0,0 +1,416 @@ +name: Pull request review labeler +description: Add exactly one review-state label to a pull request based on review status. + +inputs: + github-token: + description: GitHub token used to read pull request reviews and update labels. + required: true + pull-request-number: + description: Pull request number to update. Defaults to the pull request from the event payload. + required: false + required-approvals: + description: Number of approving reviewers required before the ready-for-merge label is applied. + required: false + default: "2" + current-head-only: + description: Only count reviews submitted against the current pull request head SHA. + required: false + default: "true" + create-labels: + description: Create configured labels when they do not already exist. + required: false + default: "true" + ready-for-review-label: + description: Label used when the pull request is ready for the first review. + required: false + default: ready for review + ready-for-final-review-label: + description: Label used after at least one approval but fewer than the required approvals. + required: false + default: ready for final review + ready-for-merge-label: + description: Label used when the pull request has the required approvals. + required: false + default: ready for merge + changes-required-label: + description: Label used when any active reviewer has requested changes. + required: false + default: changes required + ready-for-review-color: + description: Color for the ready-for-review label. + required: false + default: 0E8A16 + ready-for-final-review-color: + description: Color for the ready-for-final-review label. + required: false + default: FBCA04 + ready-for-merge-color: + description: Color for the ready-for-merge label. + required: false + default: 1D76DB + changes-required-color: + description: Color for the changes-required label. + required: false + default: D93F0B + ready-for-review-description: + description: Description for the ready-for-review label. + required: false + default: PR is waiting for the first mandatory review. + ready-for-final-review-description: + description: Description for the ready-for-final-review label. + required: false + default: PR has at least one approval and is waiting for final review. + ready-for-merge-description: + description: Description for the ready-for-merge label. + required: false + default: PR has the mandatory approvals and is ready to merge. + changes-required-description: + description: Description for the changes-required label. + required: false + default: PR has an active changes-requested review. + +runs: + using: composite + steps: + - name: Update review labels + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }} + REQUIRED_APPROVALS: ${{ inputs.required-approvals }} + CURRENT_HEAD_ONLY: ${{ inputs.current-head-only }} + CREATE_LABELS: ${{ inputs.create-labels }} + READY_FOR_REVIEW_LABEL: ${{ inputs.ready-for-review-label }} + READY_FOR_FINAL_REVIEW_LABEL: ${{ inputs.ready-for-final-review-label }} + READY_FOR_MERGE_LABEL: ${{ inputs.ready-for-merge-label }} + CHANGES_REQUIRED_LABEL: ${{ inputs.changes-required-label }} + READY_FOR_REVIEW_COLOR: ${{ inputs.ready-for-review-color }} + READY_FOR_FINAL_REVIEW_COLOR: ${{ inputs.ready-for-final-review-color }} + READY_FOR_MERGE_COLOR: ${{ inputs.ready-for-merge-color }} + CHANGES_REQUIRED_COLOR: ${{ inputs.changes-required-color }} + READY_FOR_REVIEW_DESCRIPTION: ${{ inputs.ready-for-review-description }} + READY_FOR_FINAL_REVIEW_DESCRIPTION: ${{ inputs.ready-for-final-review-description }} + READY_FOR_MERGE_DESCRIPTION: ${{ inputs.ready-for-merge-description }} + CHANGES_REQUIRED_DESCRIPTION: ${{ inputs.changes-required-description }} + with: + github-token: ${{ inputs.github-token }} + script: | + function requiredEnv(name) { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Missing required input: ${name}`); + } + return value; + } + + function optionalEnv(name) { + return process.env[name]?.trim() ?? ''; + } + + function booleanEnv(name) { + const value = requiredEnv(name).toLowerCase(); + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + throw new Error(`${name} must be true or false`); + } + + function colorEnv(name) { + const color = requiredEnv(name).replace(/^#/, ''); + if (!/^[0-9a-fA-F]{6}$/.test(color)) { + throw new Error(`${name} must be a 6-character hex color`); + } + return color; + } + + function parsePullRequestNumber(value) { + if (!value) { + return null; + } + + const trimmedValue = String(value).trim(); + if (!/^[1-9][0-9]*$/.test(trimmedValue)) { + throw new Error('PULL_REQUEST_NUMBER must be a positive integer'); + } + return Number.parseInt(trimmedValue, 10); + } + + const requiredApprovalsValue = requiredEnv('REQUIRED_APPROVALS'); + if (!/^[1-9][0-9]*$/.test(requiredApprovalsValue)) { + throw new Error('REQUIRED_APPROVALS must be a positive integer'); + } + const requiredApprovals = Number.parseInt(requiredApprovalsValue, 10); + + const currentHeadOnly = booleanEnv('CURRENT_HEAD_ONLY'); + const createLabels = booleanEnv('CREATE_LABELS'); + + const reviewLabels = { + readyForReview: { + name: requiredEnv('READY_FOR_REVIEW_LABEL'), + color: colorEnv('READY_FOR_REVIEW_COLOR'), + description: optionalEnv('READY_FOR_REVIEW_DESCRIPTION'), + }, + readyForFinalReview: { + name: requiredEnv('READY_FOR_FINAL_REVIEW_LABEL'), + color: colorEnv('READY_FOR_FINAL_REVIEW_COLOR'), + description: optionalEnv('READY_FOR_FINAL_REVIEW_DESCRIPTION'), + }, + readyForMerge: { + name: requiredEnv('READY_FOR_MERGE_LABEL'), + color: colorEnv('READY_FOR_MERGE_COLOR'), + description: optionalEnv('READY_FOR_MERGE_DESCRIPTION'), + }, + changesRequired: { + name: requiredEnv('CHANGES_REQUIRED_LABEL'), + color: colorEnv('CHANGES_REQUIRED_COLOR'), + description: optionalEnv('CHANGES_REQUIRED_DESCRIPTION'), + }, + }; + + const managedLabelNames = Object.values(reviewLabels).map((label) => label.name); + if (new Set(managedLabelNames).size !== managedLabelNames.length) { + throw new Error('Review-state label names must be unique'); + } + + // Remove labels owned by the replaced workflows so PRs retain one review state. + managedLabelNames.push('review in progress', 'ready for 2nd review'); + + const { owner, repo } = context.repo; + const payloadPullRequest = context.payload.pull_request; + const explicitPullRequestNumber = + parsePullRequestNumber(optionalEnv('PULL_REQUEST_NUMBER')) ?? + payloadPullRequest?.number; + + async function ensureLabel(label) { + if (!createLabels) { + return; + } + + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: label.name, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: label.name, + color: label.color, + description: label.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + + core.info(`Label already exists: ${label.name}`); + } + } + } + + function reviewStateForLog(reviewState) { + if (reviewState.state !== 'APPROVED' || reviewState.countsAsApproval) { + return reviewState.state; + } + return `${reviewState.state} (non-current)`; + } + + const countingPermissions = new Set(['admin', 'maintain', 'write']); + const reviewerPermissionCache = new Map(); + + async function reviewerCanAffectMerge(review) { + const login = review.user?.login; + if (!login) { + return false; + } + + if (reviewerPermissionCache.has(login)) { + return reviewerPermissionCache.get(login); + } + + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: login, + }); + const canAffectMerge = countingPermissions.has(data.permission); + if (!canAffectMerge) { + core.info(`Ignoring review from ${login}: permission is ${data.permission || 'none'}.`); + } + reviewerPermissionCache.set(login, canAffectMerge); + return canAffectMerge; + } catch (error) { + if (error.status === 404) { + core.info(`Ignoring review from ${login}: collaborator permission was not found.`); + reviewerPermissionCache.set(login, false); + return false; + } + throw error; + } + } + + async function latestOpinionatedReviewStates(reviews, pullRequest) { + const opinionatedStates = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']); + const latestByReviewer = new Map(); + + const candidateReviews = reviews + .filter((review) => review.user?.login !== pullRequest.user.login) + .filter((review) => opinionatedStates.has(review.state)) + .sort((left, right) => new Date(left.submitted_at) - new Date(right.submitted_at)); + + for (const review of candidateReviews) { + if (await reviewerCanAffectMerge(review)) { + const reviewerKey = review.user?.id ?? review.user?.login; + if (reviewerKey) { + if (review.state === 'DISMISSED') { + latestByReviewer.delete(reviewerKey); + continue; + } + + latestByReviewer.set(reviewerKey, { + state: review.state, + countsAsApproval: + review.state === 'APPROVED' && + (!currentHeadOnly || review.commit_id === pullRequest.head.sha), + }); + } + } + } + + return [...latestByReviewer.values()]; + } + + function labelForReviewState(reviewStates, reviewDecision) { + if ( + reviewDecision === 'CHANGES_REQUESTED' || + reviewStates.some((reviewState) => reviewState.state === 'CHANGES_REQUESTED') + ) { + return reviewLabels.changesRequired.name; + } + + const approvalCount = reviewStates.filter((reviewState) => reviewState.countsAsApproval).length; + if (approvalCount >= requiredApprovals && (!reviewDecision || reviewDecision === 'APPROVED')) { + return reviewLabels.readyForMerge.name; + } + + if (approvalCount > 0) { + return reviewLabels.readyForFinalReview.name; + } + + return reviewLabels.readyForReview.name; + } + + async function pullRequestReviewDecision(issue_number) { + const result = await github.graphql( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewDecision + } + } + }`, + { + owner, + repo, + number: issue_number, + }, + ); + + return result.repository.pullRequest.reviewDecision; + } + + async function removeLabelIfPresent(issue_number, name, currentLabelNames) { + if (!currentLabelNames.has(name)) { + return; + } + + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number, + name, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + await Promise.all(Object.values(reviewLabels).map(ensureLabel)); + + async function updatePullRequestLabels(issue_number) { + const { data: pullRequest } = await github.rest.pulls.get({ + owner, + repo, + pull_number: issue_number, + }); + + if (pullRequest.state !== 'open') { + core.info(`PR #${issue_number} is no longer open. Skipping review label update.`); + return; + } + + if (payloadPullRequest?.number === issue_number && payloadPullRequest?.head?.sha && payloadPullRequest.head.sha !== pullRequest.head.sha) { + core.info(`Event head ${payloadPullRequest.head.sha} is stale. Using current head ${pullRequest.head.sha}.`); + } + + let nextLabel = null; + if (pullRequest.draft) { + core.info(`Draft PR #${issue_number} detected. Removing managed review labels.`); + } else { + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: issue_number, + per_page: 100, + }); + const reviewStates = await latestOpinionatedReviewStates(reviews, pullRequest); + const reviewDecision = await pullRequestReviewDecision(issue_number); + nextLabel = labelForReviewState(reviewStates, reviewDecision); + core.info(`PR #${issue_number} review states: ${reviewStates.map(reviewStateForLog).join(', ') || 'none'}`); + core.info(`PR #${issue_number} GitHub review decision: ${reviewDecision || 'not set'}`); + core.info(`PR #${issue_number} next review label: ${nextLabel}`); + } + + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner, + repo, + issue_number, + per_page: 100, + }); + const currentLabelNames = new Set(currentLabels.map((label) => label.name)); + + await Promise.all( + managedLabelNames + .filter((name) => name !== nextLabel) + .map((name) => removeLabelIfPresent(issue_number, name, currentLabelNames)), + ); + + if (nextLabel && !currentLabelNames.has(nextLabel)) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number, + labels: [nextLabel], + }); + } + } + + if (!explicitPullRequestNumber) { + core.info('No pull request found. Skipping review label update.'); + return; + } + + await updatePullRequestLabels(explicitPullRequestNumber);