forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
394 lines (368 loc) · 19.1 KB
/
Copy pathpr-issue-link.yml
File metadata and controls
394 lines (368 loc) · 19.1 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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
# Fails a pull request that closes no issue, and warns about one that closes
# an issue somebody else is already closing.
#
# CONTRIBUTING.md has asked for `Closes #42` since the beginning, on the
# grounds that a merge closing its own issue is what keeps the tracker honest
# rather than someone remembering to tick a box. Asking was all it did. This
# is the part that checks.
#
# THE SECOND QUESTION, AND WHY IT LIVES HERE (#597 / #608 / #610). Two sessions
# implemented the same issue on the same night. The second opened its pull
# request twelve minutes after the first, both were green, and one whole
# implementation was thrown away. It was the second time it had happened;
# CLAUDE.md records the first, "caught only because a human noticed two
# branches doing the same work".
#
# The claim protocol did not catch it and could not have. A session claims an
# issue, then checks `scripts/threads.sh` for a branch carrying the same
# subject - but the other work was STACKED onto a branch already open for two
# unrelated pipeline issues (BRANCHING.md section 3 allows exactly that), so
# there was no new branch to see and the ledger read `suites: pipeline` right
# up until the client commit landed.
#
# What both sessions did do is write `Closes #597` in a pull request body. That
# is the one signal the collision reliably produces, this workflow already
# reads it, and asking "is anyone else closing this?" is the same query one
# level down. So the check is here rather than in a new file.
#
# IT WARNS RATHER THAN FAILS, deliberately. Two pull requests closing one issue
# is usually a mistake and is occasionally the point - a change split for
# review, a replacement opened while the original stays up for comparison. A
# red check would block the legitimate case to catch the common one, and this
# is the check most likely to become required. Being told is enough: the whole
# failure was that nobody knew.
#
# AND IT CANNOT TURN THIS CHECK RED BY FAILING, which matters more than the
# feature does. The duplicate lookup is wrapped, and an error in it reports
# "could not be determined" and moves on. A pull request that correctly closes
# an issue must never fail this check because a secondary query broke - that
# would be a gate rejecting good work for a reason unrelated to its rule.
#
# WHAT COUNTS IS THE LINK, NOT THE WORDING. The query below reads
# `closingIssuesReferences`, which is GitHub's own record of what this PR
# will close - the same set the merge button acts on. That means a closing
# keyword in the description counts, and so does an issue attached by hand
# through the sidebar's Development panel. A bare `#42` mention does not,
# and should not: mentioning an issue and resolving it are different claims,
# and only the second one is a promise the merge can keep.
#
# One consequence worth knowing before it surprises someone: attaching an
# issue through the sidebar fires no webhook, so this check will not notice
# on its own. Re-run it, or put the keyword in the description - editing the
# description does fire `edited`, and the check re-runs within seconds.
#
# THIS FILE ALONE BLOCKS NOTHING. A red check is a suggestion until "PR has a
# linked issue" is a required status check on `main` (Settings -> Rules, or
# branch protection). That switch is deliberately not in the repository -
# nothing in `.github/` can grant itself the power to stop a merge.
#
# It is, though, the check most likely to be the first one required, which is
# why it triggers on `merge_group` below despite having nothing to say there.
# A required check that never reports hangs a queue entry rather than failing
# it, so the cost of leaving the trigger off is not a weaker rule - it is a
# queue that stops merging anything the day somebody ticks the box.
name: PR issue link
on:
pull_request:
branches: [main]
# `edited` is the one that matters: it is what fires when someone adds
# the missing `Closes #42` to the description, and without it the check
# would stay red until an unrelated commit happened to shake it loose.
# `synchronize` is not redundant either - required checks are matched
# against the head commit, so every push needs its own run or the PR
# sits waiting on a check that already passed for an older SHA.
types:
- opened
- edited
- reopened
- synchronize
- ready_for_review
- labeled
- unlabeled
# Reports green without asking anything - see the job below. `types:` is not
# repeated because `merge_group` has only the one activity type.
merge_group:
# `issues: read` is what lets the query name the issue it found instead of
# just counting it.
#
# `pull-requests: write` is the one that is not a read, and it is here for one
# thing only: the sticky comment when another open pull request closes the same
# issue. This file used to say "reads three things and writes nothing", which
# was a nicer sentence than the one it can honestly make now.
#
# It is worth being clear about what that does and does not buy anybody. The
# token can comment on a pull request; it cannot merge, push, or change a
# setting. The check's power to gate a merge comes from being in the required
# list on `main`, which is a repository setting no workflow can grant itself
# (see the note above) - and that is unchanged by this line.
#
# The alternative was to leave the finding in the job summary only, which needs
# no write at all. It was rejected because a summary is a page somebody has to
# go and open, and the failure this exists to catch is precisely nobody
# noticing. The annotation and the comment are the two places a finding is seen
# without being looked for.
permissions:
contents: read
pull-requests: write
issues: read
# The fallback is load-bearing, not defensive tidiness. A merge_group run has
# no `pull_request` in its payload, so the interpolation is empty and every
# queue entry would land in the single group `pr-issue-link-` - where
# cancel-in-progress means each entry cancels the one before it. A cancelled
# run reports no status, which is the one outcome that hangs a queue instead of
# failing it, and it would happen only once two entries were queued at once:
# exactly the case a merge queue exists to create.
concurrency:
group: pr-issue-link-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
linked:
name: PR has a linked issue
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# No `if:` on this job, and there should never be one. A job skipped by
# a job-level condition reports no status at all, and a required check
# that reports no status blocks the PR forever rather than passing it -
# so every exemption below is decided inside the script, where it can
# still finish green.
- id: check
uses: actions/github-script@v7
env:
ESCAPE_LABEL: no-issue
with:
script: |
const { owner, repo } = context.repo
const pr = context.payload.pull_request
const escapeLabel = process.env.ESCAPE_LABEL
const summary = lines => core.summary.addRaw(lines.join('\n')).write()
// A merge queue entry is not a pull request, and this is a question
// only a pull request can be asked - `closingIssuesReferences` is a
// property of one, and the temporary branch a queue builds names at
// most the last of the several pull requests it may contain. So
// this reports green here having checked nothing, and says so.
//
// Reporting green is the point rather than a concession. Every pull
// request in a merge group answered this on its own runs before it
// was queued, so there is nothing left to catch; what the status
// buys is that this check can sit in the required list without
// leaving queue entries waiting on a report that was never coming.
if (!pr) {
await summary([
'## PR issue link',
'',
'Not checked here — this is a merge queue entry, not a pull request.',
'',
'Each pull request in the group answered this on its own runs before',
'it was queued. This reports green so that requiring the check cannot',
'leave a queue entry waiting on a status it would never receive.',
])
core.info('merge_group: nothing to ask, reporting green so the queue is not left waiting.')
return
}
// The escape hatch exists so the rule does not manufacture the
// exact thing it is meant to prevent. A typo fix, a revert, a
// dependency bump - without a way out, those get an issue opened
// for the sole purpose of closing it, and the tracker fills with
// paperwork nobody wrote for a reader. Label it and move on.
if (pr.labels.some(label => label.name === escapeLabel)) {
await summary([
'## PR issue link',
'',
`Exempt: labelled \`${escapeLabel}\`.`,
'',
'Nothing here says an issue was unnecessary — only that a human said so on purpose.',
])
core.info(`Labelled ${escapeLabel}; not requiring a linked issue.`)
return
}
const { repository } = await github.graphql(
`query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 20) {
nodes { number title url }
}
}
}
}`,
{ owner, repo, number: pr.number },
)
const linked = repository.pullRequest.closingIssuesReferences.nodes
// Who else is closing these issues.
//
// Asked per issue rather than by searching open pull requests for
// the text `Closes #N`, because this reads the same record the
// merge button acts on - so an issue attached through the sidebar's
// Development panel counts here exactly as it does above, and a
// bare `#42` mention still does not.
//
// `state` is filtered here rather than by an argument on purpose:
// whether the field defaults to open-only is not a thing this file
// should depend on, and filtering the answer works either way.
//
// Returns null - meaning "could not be determined" - rather than
// throwing. See the header: nothing in this lookup may fail a pull
// request that satisfied the rule above.
const otherPullRequestsClosing = async issue => {
try {
const { repository: r } = await github.graphql(
`query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
closedByPullRequestsReferences(first: 20, includeClosedPrs: false) {
nodes { number title url state isDraft author { login } }
}
}
}
}`,
{ owner, repo, number: issue.number },
)
const nodes = r.issue?.closedByPullRequestsReferences?.nodes ?? []
return nodes.filter(other => other.state === 'OPEN' && other.number !== pr.number)
} catch (error) {
core.info(`Could not read who else closes #${issue.number}: ${error.message}`)
return null
}
}
if (linked.length > 0) {
const collisions = []
let undetermined = false
for (const issue of linked) {
const others = await otherPullRequestsClosing(issue)
if (others === null) {
undetermined = true
continue
}
for (const other of others) collisions.push({ issue, other })
}
const lines = [
'## PR issue link',
'',
`Merging this closes ${linked.length === 1 ? 'an issue' : `${linked.length} issues`}:`,
'',
...linked.map(issue => `- [#${issue.number}](${issue.url}) — ${issue.title}`),
]
if (collisions.length > 0) {
lines.push(
'',
'### Somebody else is closing this too',
'',
'Not a failure — two pull requests closing one issue is sometimes',
'deliberate. But it is usually two people doing the same work without',
'knowing, so it is worth a look before either merges:',
'',
...collisions.map(({ issue, other }) =>
`- **#${issue.number}** is also closed by [#${other.number}](${other.url})` +
`${other.isDraft ? ' (draft)' : ''} — ${other.title}`,
),
'',
'If one supersedes the other, say so on both and close the one that loses.',
)
// An annotation as well as the summary, because a summary is a
// page somebody has to open and this needs to reach whoever is
// watching the checks.
for (const { issue, other } of collisions) {
core.warning(
`#${other.number} also closes #${issue.number}. Check whether these are the same work.`,
{ title: 'Another open pull request closes this issue' },
)
}
}
if (undetermined) {
lines.push(
'',
'_Whether another pull request closes the same issue could not be',
'determined — the lookup failed. The linked-issue rule above is',
'unaffected._',
)
}
await summary(lines)
core.info(`Linked: ${linked.map(i => `#${i.number}`).join(', ')}`)
// Two outputs from one finding: the JSON is what the steps below
// branch on, the markdown is what a reader sees. Built here rather
// than in YAML because a bulleted list assembled out of `join`
// inside a template expression is unreadable in both languages.
core.setOutput('collisions', JSON.stringify(collisions))
core.setOutput(
'collisions_markdown',
collisions
.map(({ issue, other }) =>
`- **#${issue.number}** — ${issue.title}\n` +
` is also closed by [#${other.number}](${other.url})` +
`${other.isDraft ? ' (draft)' : ''}, ${other.title}`,
)
.join('\n'),
)
return
}
await summary([
'## PR issue link',
'',
'This pull request closes no issue.',
'',
'Add a closing keyword to the description — `Closes #42`, `Fixes #42`,',
'`Resolves #42` — and this check re-runs on its own within seconds.',
'Attaching the issue through the sidebar\'s **Development** panel works',
'too, but fires no event, so re-run this check by hand afterwards.',
'',
`If the change genuinely has no issue behind it, label the PR \`${escapeLabel}\`.`,
'That is a deliberate exemption, not a workaround — prefer it over',
'opening an issue whose only purpose is to be closed by this PR.',
'',
'A plain `#42` mention is not enough. Referring to an issue and',
'resolving it are different claims, and only the second one survives',
'the merge.',
])
core.setFailed('No linked issue. Add `Closes #<n>` to the description, or label the PR `no-issue`.')
# The comment half of the duplicate warning. Everything about this step is
# arranged so it cannot affect the check's conclusion.
#
# - `if:` skips it unless there is something to say, on a real pull
# request, from a branch in this repository. A fork's token is
# read-only whatever the block above asks for, so attempting it there
# would be a guaranteed failure on somebody else's contribution.
# - `continue-on-error` covers the rest. A comment that cannot be posted
# is a lost warning; a comment that cannot be posted and reddens the
# check is a pull request blocked over a courtesy. The annotation and
# the summary already carry the finding either way.
#
# Sticky, so a pull request that pushes six times has one comment saying
# the current answer rather than six saying the same thing - and so the
# comment disappears on its own once the collision is resolved, instead of
# sitting there as a permanent accusation.
- name: Say so on the pull request
if: >-
steps.check.outputs.collisions != ''
&& steps.check.outputs.collisions != '[]'
&& github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: marocchino/sticky-pull-request-comment@v3
with:
header: pr-issue-link-duplicate
message: |
**Another open pull request closes the same issue.**
${{ steps.check.outputs.collisions_markdown }}
Not a failure, and sometimes deliberate — a change split for review, or a
replacement opened while the original stays up for comparison. But it is
more often two people doing the same work without knowing, which has
happened twice here and cost a whole implementation each time.
Worth settling before either merges: if one supersedes the other, say so on
both and close the one that loses.
# And take it down again once it stops being true, which is most of what
# makes the comment above tolerable rather than noise. The other pull
# request gets closed, this one re-runs on its next push, and the warning
# removes itself - instead of sitting at the top of the thread accusing a
# branch of a collision that was settled hours ago.
#
# Same `if:` shape inverted, same reasons, same continue-on-error: a
# tidy-up that cannot be performed must not fail a pull request either.
- name: Take the duplicate warning down once it is settled
if: >-
(steps.check.outputs.collisions == '' || steps.check.outputs.collisions == '[]')
&& github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: marocchino/sticky-pull-request-comment@v3
with:
header: pr-issue-link-duplicate
delete: true