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
25 changes: 25 additions & 0 deletions .github/workflows/pr-automation-tests.yml
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions .github/workflows/pull-request-review-labels.yml
Original file line number Diff line number Diff line change
@@ -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"
87 changes: 87 additions & 0 deletions .github/workflows/stale-pull-requests.yml
Original file line number Diff line number Diff line change
@@ -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 }}
44 changes: 44 additions & 0 deletions github-actions/pr-review-labeler/README.md
Original file line number Diff line number Diff line change
@@ -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.
152 changes: 152 additions & 0 deletions github-actions/pr-review-labeler/action.test.cjs
Original file line number Diff line number Diff line change
@@ -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};
}
Loading