-
Notifications
You must be signed in to change notification settings - Fork 212
277 lines (247 loc) · 13.4 KB
/
Copy pathlabel-pr-review-state.yml
File metadata and controls
277 lines (247 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
name: Label PR review state
on:
schedule:
- cron: '0 * * * *' # hourly fallback
workflow_dispatch:
pull_request:
types: [opened, reopened, ready_for_review, synchronize, review_requested]
pull_request_review:
types: [submitted, dismissed]
permissions:
pull-requests: write
checks: read
concurrency:
group: label-pr-review-state
cancel-in-progress: false
jobs:
reconcile:
runs-on: ubuntu-latest
steps:
- name: Reconcile PR review state labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { owner, repo } = context.repo;
const stateLabels = ['awaiting-author', 'awaiting-review', 'has-conflicts'];
// When triggered by a single PR event, only reconcile that PR.
// The hourly schedule and workflow_dispatch reconcile all open PRs.
let prs;
const prNumber = context.payload.pull_request?.number;
if (prNumber) {
const { data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: prNumber,
});
prs = [pr];
} else {
prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
}
// Only a `pull_request`/`pull_request_review` run that was itself triggered
// FROM a fork gets a read-only GITHUB_TOKEN — label mutations there 403 with
// "Resource not accessible by integration". `schedule` and `workflow_dispatch`
// runs always execute in the base repo's context with a read/write token, even
// when the PR they're reconciling happens to come from a fork, so they must NOT
// be skipped or fork PRs would never get stale labels cleaned up.
// See: https://docs.github.com/en/actions/concepts/security/github_token
const isReadOnlyRun = Boolean(context.payload.pull_request) &&
context.payload.pull_request.head?.repo?.owner?.login !== owner;
function isForkPR(pr) {
return pr.head?.repo?.owner?.login && pr.head.repo.owner.login !== owner;
}
// Strips stateLabels from a PR, optionally keeping one.
// Also removes stale-awaiting-author when not keeping awaiting-author.
// Only skipped when this run's own token is read-only (see isReadOnlyRun) —
// schedule/workflow_dispatch runs reconcile fork PRs normally.
async function reconcileLabels(pr, desiredLabel) {
if (isReadOnlyRun && isForkPR(pr)) {
core.info(`PR #${pr.number}: fork PR on a read-only run — skipping label mutation`);
return;
}
const currentLabels = new Set(pr.labels.map(l => l.name));
for (const label of stateLabels) {
if (label !== desiredLabel && currentLabels.has(label)) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr.number, name: label,
});
} catch (err) {
if (err.status !== 404) throw err; // 404 = already gone, benign
}
}
}
if (desiredLabel && !currentLabels.has(desiredLabel)) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.number, labels: [desiredLabel],
});
}
if (desiredLabel !== 'awaiting-author' && currentLabels.has('stale-awaiting-author')) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr.number, name: 'stale-awaiting-author',
});
} catch (err) {
if (err.status !== 404) throw err;
}
}
}
// Fetch required status check names from the branch ruleset.
// Uses the public /rules/branches endpoint — no admin token needed.
// Falls back to blocking on all checks if the endpoint is unavailable.
let requiredCheckNames = null;
try {
const { data: rules } = await github.request(
'GET /repos/{owner}/{repo}/rules/branches/{branch}',
{ owner, repo, branch: 'main' },
);
const statusRule = rules.find(r => r.type === 'required_status_checks');
if (statusRule) {
requiredCheckNames = new Set(
statusRule.parameters.required_status_checks.map(c => c.context),
);
core.info(`Required checks: ${[...requiredCheckNames].join(', ')}`);
}
} catch (err) {
core.warning(`Could not fetch branch rules, falling back to all checks: ${err.message}`);
}
const failures = [];
for (const pr of prs) {
try {
// Draft PRs never get a state label.
if (pr.draft) {
core.info(`PR #${pr.number}: draft — stripping state labels`);
await reconcileLabels(pr, null);
continue;
}
// `mergeable`/`mergeable_state` are only returned by the single-PR GET
// endpoint, and are computed asynchronously by GitHub — a PR fetched via
// pulls.list (schedule/workflow_dispatch runs) never has them, and even a
// single-PR fetch can return `null`/"unknown" if the merge check hasn't
// finished yet. Re-fetch the single PR to get a fresh value, and treat
// "unknown" as not-yet-computed rather than as conflicting.
const prDetail = prNumber
? pr
: (await github.rest.pulls.get({ owner, repo, pull_number: pr.number })).data;
if (prDetail.mergeable === false && prDetail.mergeable_state === 'dirty') {
core.info(`PR #${pr.number}: has merge conflicts — labeling has-conflicts`);
await reconcileLabels(pr, 'has-conflicts');
continue;
}
// Check CI status for required checks on the PR's head commit only.
// Scoping to required checks avoids advisory checks (e.g. codecov/patch)
// incorrectly blocking label assignment on otherwise-ready PRs.
const [checkRuns, commitStatusRes] = await Promise.all([
github.paginate(github.rest.checks.listForRef, {
owner, repo, ref: pr.head.sha, per_page: 100,
}),
github.rest.repos.getCombinedStatusForRef({
owner, repo, ref: pr.head.sha,
}),
]);
// listForRef returns every check run ever recorded on the ref, including
// stale superseded ones (e.g. a failed run later re-run green). Branch
// protection and the PR UI only consider the latest run per check name, so
// reduce to that before evaluating — otherwise a single stale failure makes
// ciFailed true forever and state labels never come back. See issue #884.
//
// Unlike listReviews (which documents oldest-first order), listForRef's
// ordering is unspecified, so we pick the latest by run.id — GitHub assigns
// monotonically increasing IDs, and id is never null (a freshly re-queued
// run can have started_at: null, which would lose a string comparison
// against an older completed run's timestamp).
const latestByName = new Map();
for (const run of checkRuns) {
const prev = latestByName.get(run.name);
if (!prev || run.id > prev.id) {
latestByName.set(run.name, run);
}
}
// Filter to required checks only (or all checks if rules unavailable).
// Always exclude this workflow's own run to avoid self-referential loops.
const relevantRuns = [...latestByName.values()].filter(run => {
if (run.name === 'Reconcile PR review state labels') return false;
return requiredCheckNames ? requiredCheckNames.has(run.name) : true;
});
// For commit statuses (external CIs), there's no per-status name filtering
// available from getCombinedStatusForRef — it aggregates all statuses.
// If required checks are known, we only use commitStatus as a signal when
// no required check runs exist for this ref (i.e. pure status-based CI).
const useCommitStatus = !requiredCheckNames || relevantRuns.length === 0;
core.debug(`PR #${pr.number}: ${relevantRuns.length} required check run(s), commit status=${commitStatusRes.data.state} (used=${useCommitStatus})`);
for (const run of relevantRuns) {
core.debug(` check: "${run.name}" status=${run.status} conclusion=${run.conclusion}`);
}
const ciPending = relevantRuns.some(
run => run.status === 'queued' || run.status === 'in_progress',
) || (useCommitStatus && commitStatusRes.data.state === 'pending');
const ciFailed = !ciPending && (
relevantRuns.some(
run => run.status === 'completed' &&
run.conclusion !== 'success' &&
run.conclusion !== 'skipped' &&
run.conclusion !== 'neutral',
) || (useCommitStatus && (
commitStatusRes.data.state === 'failure' ||
commitStatusRes.data.state === 'error'
))
);
// While CI is running or has failed, remove state labels and move on.
// CI failure is its own signal; the label would add noise, not clarity.
if (ciPending || ciFailed) {
core.info(`PR #${pr.number}: CI ${ciPending ? 'pending' : 'failed'} — stripping state labels`);
await reconcileLabels(pr, null);
continue;
}
// CI is passing. Now determine review state.
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner, repo, pull_number: pr.number, per_page: 100,
});
// Reduce to each reviewer's latest meaningful state.
// Reviews are returned oldest-first, so last-write-wins yields the latest state.
// COMMENTED and DISMISSED are treated as neutral — they do not
// block the PR or indicate the author needs to act.
const latest = new Map();
for (const r of reviews) {
if (r.state !== 'COMMENTED' && r.state !== 'DISMISSED') {
latest.set(r.user.login, r);
}
}
const requestedReviewers = new Set(
pr.requested_reviewers.map(r => r.login),
);
const changeRequesters = [...latest.entries()]
.filter(([, r]) => r.state === 'CHANGES_REQUESTED')
.map(([login]) => login);
let desiredLabel;
if (changeRequesters.length > 0) {
// If every change-requester has been re-requested for review,
// the author has addressed feedback and re-opened it for review.
desiredLabel = changeRequesters.every(login => requestedReviewers.has(login))
? 'awaiting-review'
: 'awaiting-author';
} else {
// No outstanding change requests: awaiting first review, or all approved.
// awaiting-review if: there are pending requested reviewers, or nobody
// has given a meaningful review yet. null (approved) if everyone approved.
const allApproved = latest.size > 0 &&
[...latest.values()].every(r => r.state === 'APPROVED') &&
requestedReviewers.size === 0;
desiredLabel = allApproved ? null : 'awaiting-review';
}
core.info(
`PR #${pr.number}: CI passing, reviews=${latest.size}, ` +
`changeRequesters=[${changeRequesters.join(',')}], ` +
`requestedReviewers=[${[...requestedReviewers].join(',')}] → ${desiredLabel ?? '(none)'}`
);
await reconcileLabels(pr, desiredLabel);
} catch (error) {
const detail = error.status
? `${error.message} (HTTP ${error.status}${error.response?.data?.message ? `: ${error.response.data.message}` : ''})`
: error.message;
failures.push(`#${pr.number}: ${detail}`);
core.error(`Failed to reconcile PR #${pr.number}: ${detail}`);
}
}
if (failures.length > 0) {
core.setFailed(`Failed to reconcile ${failures.length} PR(s): ${failures.join('; ')}`);
}