-
Notifications
You must be signed in to change notification settings - Fork 10
587 lines (564 loc) · 35.1 KB
/
Copy pathcross-repo-issue-closer.yml
File metadata and controls
587 lines (564 loc) · 35.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
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
# GitHub's closing keywords (`Fixes #123`) only work WITHIN a repository. A PR
# here that says `Fixes objectstack-ai/objectui#456` reads exactly like a
# same-repo close to a human, merges, and leaves that issue open forever — with
# no reference to the PR on the issue's own page either, so the next reader has
# no way to find the fix.
#
# That gap is why v17 verification (#4482) left #4475 and #4478 open after their
# fixes shipped in objectui; both had to be closed by hand.
#
# This job closes the loop. It deliberately has TWO modes and BOTH are visible:
#
# token present -> close the foreign issue and comment with the PR link
# token absent -> comment ON THIS PR naming what still needs closing by hand
#
# The second mode is the point. A workflow that quietly does nothing because a
# secret was never provisioned is the shape this repo keeps having to fix
# (#4449: written, tested, exported, called by nothing). Missing credentials
# must announce themselves.
#
# That second mode is a single `issues.createComment`, and its DELIVERY is
# retried, never assumed (#9575). Until #9575 a transient answer from the
# comments endpoint threw, github-script handed the throw to
# `main().catch(handleError)` -> `core.setFailed`, and the run lost BOTH the
# notice AND — because it sat after the `await` — the `core.warning` that named
# what had been left open. All that remained was `Unhandled error: HttpError` on
# a job nobody opens, and a set of foreign issues no longer on anyone's list. So
# now: the transient class is retried (declared in the step's `retries:` inputs,
# not hand-classified), the work order is announced BEFORE delivery is
# attempted, and a refusal that outlives the retries writes the notice into the
# run's job summary and then FAILS the job.
#
# The OTHER exit — the per-target close loop, the branch that runs when the
# token IS present — reached the same rule one card later (#9595). Its `catch`
# is unchanged and must stay so: it buys ISOLATION, and one unreachable target
# taking the rest down with it would be a worse failure than the one being
# fixed. What it did NOT buy is a verdict. `core.warning` leaves the job green,
# so a refused close left the foreign issue open, put nothing on the PR, and
# produced a conclusion identical to the ~2270 runs where there was nothing to
# do at all — while the catch's own comment said "a failure here must not read
# as success". Isolation and outcome are now separate: the loop records the
# keys it could not close, runs to the end, and the verdict is passed after it.
#
# The loop's LAST silent exit was the already-closed target (#9643). It was
# skipped whole, and the skip covered two calls of which only one is redundant:
# re-closing a closed issue is a no-op worth avoiding, but the BACKLINK is the
# half this file's own first paragraph calls the defect — "no way to find the
# fix" — and a wholesale skip dropped it and reported green.
#
# So the state is no longer the question; the CAUSE is, because "already
# closed" is three situations and they want three answers:
#
# closed as not_planned/duplicate -> post NOTHING and annotate. A comment
# saying this PR fixed it contradicts the triage decision recorded on the
# issue; two people have said opposite things and only a human settles it.
# `state_reason` is the one cause the API states outright — and it is
# nullable, so a null is read as "no objection recorded", i.e. completed.
# closed by an EARLIER run of this job -> post nothing, it is already there.
# The API cannot tell this from the next case: `closed_by` is a login and
# every seat in this org shares one identity. The per-PR comment marker is
# the only evidence, so the backlink now carries one on BOTH paths — a
# close performed by run 1 must be recognisable to run 2.
# closed BY HAND before the merge -> leave the backlink, change nothing else.
# This is the ordinary sequence and the reason the file exists.
#
# The marker is per-PR and therefore STABLE across runs, which is what fixes
# the degradation direction when the comment listing cannot be read: skip and
# say so (at-most-once). Posting blind would strand a permanent second backlink
# on another repo's closed issue, which nobody here can tidy up, and a miss is
# a convenience link that a re-run recovers. merge-queue-triage.yml reaches the
# OPPOSITE conclusion from the same trade because its marker carries a run id.
#
# Failing is deliberate on BOTH exits, and it is the opposite of what
# docs-drift-check.yml (#9373) chose for its advisory comment. The difference is
# a property of this job, measured rather than inherited — and re-measured for
# the loop on 2026-08-18 rather than carried over from the notice (#9595):
#
# - its conclusion is in no required set — the context name `Close issues
# referenced in other repositories` is absent from the registry in
# scripts/check-required-contexts.mjs, and could not be enrolled there
# anyway: that pin's assertions 6 and 7 want a `merge_group:` trigger and an
# unfiltered `pull_request:` trigger, and this file has neither;
# - it runs only AFTER the merge (`pull_request_target: [closed]` plus
# `merged == true`), so its conclusion gates nothing that has not already
# happened;
# - no `workflow_run:` listener in this repo watches it (the only two listen
# for `CI` and `Release`), so a red starts no fan-out.
#
# The first of those three was re-derived on 2026-08-18 against the LIVE ruleset
# rather than against the repo-side pin alone: `GET /repos/.../rulesets/12119582`
# lists six required contexts (`Lint & Repo Gates`, `TypeScript Type Check`,
# `Test Core`, `Dogfood Regression Gate`, `Build Core`, `Temporal Conformance
# (live PG + MySQL)`) and this job's name is not among them.
#
# A red therefore costs one X on an already-merged PR. A green that delivered
# nothing costs the foreign issues this workflow exists to stop losing — and
# looks exactly like the 2000+ green runs where there was simply nothing to
# report. That a red here is READ, meanwhile, is not an assumption either: this
# job's only two script-level failures (2026-08-02, a `SyntaxError` that stopped
# it running at all) were diagnosed and fixed 39 minutes after the first one,
# and the second of them landed on an unrelated author's merge.
#
# Same trade merge-queue-triage.yml (#9424) made, for the same reason;
# docs-drift-check.yml's opposite choice is right THERE because its conclusion
# is a check on a live PR and its comment is a courtesy, neither of which is
# true here.
name: Cross-repo Issue Closer
# `pull_request_target` (not `pull_request`) because the job needs repository
# secrets, which `pull_request` withholds from fork-originated runs. The usual
# hazard of `pull_request_target` — running untrusted PR code with write
# credentials — does not apply: this job never checks out the head ref and
# never executes anything from the PR. It reads the PR body and calls the
# issues API, nothing else.
on:
pull_request_target:
types: [closed]
permissions:
contents: read
pull-requests: write
jobs:
close-foreign-issues:
name: Close issues referenced in other repositories
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Close (or report) cross-repo closing keywords
uses: actions/github-script@v9
env:
# A fine-grained PAT or GitHub App token with `issues: write` on the
# sibling repositories. `GITHUB_TOKEN` cannot do this — it is scoped
# to the repository running the workflow, which is the whole problem.
CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_ISSUE_TOKEN }}
with:
# Hand the cross-repo token to the action itself, so `github` IS the
# cross-repo client. `require('@actions/github')` does NOT work here:
# github-script bundles its dependencies and the module is not
# resolvable from the script scope (`MODULE_NOT_FOUND`). Falling back
# to GITHUB_TOKEN keeps the report path able to comment on this PR.
github-token: ${{ secrets.CROSS_REPO_ISSUE_TOKEN || secrets.GITHUB_TOKEN }}
# The transient-retry policy, DECLARED rather than hand-written (#9575,
# and the point #9576 records): github-script has accepted these two
# inputs since v6 and every run of this job already echoes their
# defaults into its own log — `retries: 0`,
# `retry-exempt-status-codes: 400,401,403,404,422`. They drive octokit's
# retry plugin, which re-issues any request whose status is not exempt,
# plus network-level failures. Nothing is swallowed: a spent retry still
# throws. This widens how many times a request is ASKED and no verdict
# anywhere.
#
# It is a step input, so it covers the per-target loop below as well as
# the notice. The loop keeps its own `try`/`catch` unchanged — retries
# change how often a target is asked, never whether one unreachable
# target may take the rest down with it.
#
# 403 is dropped from the action's default exempt list on purpose:
# GitHub answers a SECONDARY rate limit with 403 as well as with 429, so
# the status alone cannot separate weather from a permission denial. The
# price is that a genuine denial — a cross-repo PAT without
# `issues: write` on a sibling repo — now takes four attempts per target
# to fail instead of one. It still fails, and the loop still names the
# target it failed on.
#
# 400/401/404/422 stay exempt: a malformed request, a wrong target, or a
# body past GitHub's 65536-character comment limit is this repo's own
# bug. It is answered correctly on the first try and asking again only
# spends runner minutes.
retries: 3
retry-exempt-status-codes: 400,401,404,422
script: |
const body = context.payload.pull_request.body || '';
const prUrl = context.payload.pull_request.html_url;
const thisRepo = `${context.repo.owner}/${context.repo.repo}`;
// ONE vocabulary for a refusal, used by both exits below: `HTTP 503`
// or `ECONNRESET` is what lets a reader separate platform weather
// from a 403/404 that means the credential is wrong. Hoisted rather
// than copied per call site — merge-queue-triage.yml declares the
// identical helper once, and a second hand-written copy here is the
// shape #9576 records as the thing to stop doing.
const describe = (error) => {
const kind = typeof error?.status === 'number'
? `HTTP ${error.status}`
: (error?.code || 'error');
// Octokit messages usually end in a full stop; ours supplies its own.
return `${kind}: ${String(error?.message || '').replace(/\s*\.\s*$/, '')}`;
};
// GitHub's own keyword set, restricted to the qualified
// `owner/repo#N` form — the bare `#N` form already works natively
// and must not be touched here.
//
// The `:?` is load-bearing, and MEASURED rather than inherited from
// the sibling file's comment (#9755). GitHub's own parser accepts
// the colon, so `Fixes: <ref>` is a real closing declaration and not
// a typo: PR #10241 merged 2026-08-20T15:10:06Z carrying the
// sentence `Filed, not fixed: #10240`, and GitHub closed #10240 as
// `completed` at 15:10:08Z — two seconds later, with that issue's
// own `closed_by_pull_requests` naming #10241 and nothing else. The
// negation in the prose changed nothing, which is the same property
// partof-closing-keyword-guard.yml is built on.
//
// Without the `:?` this job read `Fixes: objectstack-ai/objectui#456`
// as `No cross-repository closing keywords in this PR body.` and
// took exit path 1 — the quiet green that is indistinguishable from
// the ~2300 runs with genuinely nothing to do. A foreign issue is
// then lost with no notice on the PR and no annotation anywhere,
// which is the exact silent-path shape this file's card family
// (#9373 · #9424 · #9575 · #9595 · #9643 · #9711) has been closing
// one exit at a time.
//
// duplicate-fix-guard.yml spells this same keyword-and-separator
// prefix, and `scripts/check-closing-keyword-parity.mjs` goes red if
// the two ever stop agreeing. NEITHER workflow checks this repo out
// — this one deliberately (`pull_request_target`), the guard because
// it has no checkout step at all — so they cannot import a shared
// module. That gate is what stands in for the import.
const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';
const pattern = new RegExp(
`\\b(?:${KEYWORDS}):?\\s+([\\w.-]+)\\/([\\w.-]+)#(\\d+)\\b`,
'gi',
);
// Report credential state on EVERY run, before any early return.
// Otherwise a repository with the secret and one without look
// identical until a cross-repo reference happens to show up —
// which can be days — and "is it configured?" stays unanswerable.
// Presence only; the value is never read into the log.
const token = process.env.CROSS_REPO_TOKEN;
core.info(
`CROSS_REPO_ISSUE_TOKEN: ${token ? 'configured' : 'ABSENT — cross-repo closes will be reported, not performed'}`,
);
const targets = new Map();
for (const [, owner, repo, number] of body.matchAll(pattern)) {
const key = `${owner}/${repo}#${number}`;
// Skip same-repo references: GitHub already closed those, and
// closing them again would be a no-op comment on every merge.
if (`${owner}/${repo}`.toLowerCase() === thisRepo.toLowerCase()) continue;
targets.set(key, { owner, repo, number: Number(number) });
}
if (targets.size === 0) {
core.info('No cross-repository closing keywords in this PR body.');
return;
}
core.info(`Cross-repo targets: ${[...targets.keys()].join(', ')}`);
if (!token) {
// Degrade VISIBLY. Someone has to close these by hand, and this
// comment is the only thing that will tell them so.
const list = [...targets.keys()].map((k) => `- \`${k}\``).join('\n');
const keys = [...targets.keys()].join(', ');
// The work order is announced BEFORE it is delivered, and it names
// the TARGETS rather than only how many there are (#9575). This
// annotation used to sit after the `await` below, which is the same
// as not existing on the one run where it matters: a throw from the
// post skipped it, so a refused notice lost the list AND the count.
// It costs one line of log when delivery succeeds.
core.warning(
`CROSS_REPO_ISSUE_TOKEN is not configured — ${targets.size} issue(s) must be `
+ `closed BY HAND: ${keys}.`,
{ title: 'Cross-repo issues left open' },
);
const notice =
`### ⚠️ 跨仓库 issue 未被自动关闭\n\n` +
`本 PR 的正文声明了跨仓库关闭关键字,但 GitHub 的关闭关键字**只在同仓库内生效**,` +
`因此以下 issue 仍处于 open 状态,需要**手工关闭**:\n\n${list}\n\n` +
`自动关闭需要仓库 secret \`CROSS_REPO_ISSUE_TOKEN\`(对目标仓库具备 \`issues: write\` 的` +
` fine-grained PAT 或 GitHub App token)。\`GITHUB_TOKEN\` 只对当前仓库有写权限,无法胜任。\n\n` +
`配置该 secret 后本条提示会自动消失,改为直接关闭目标 issue。\n\n` +
`---\n_Generated by [Claude Code](https://claude.ai/code)_`;
// This comment IS the deliverable, not a courtesy about one — see
// the header. The step's `retries:` have already absorbed a blip by
// the time anything reaches the catch, so what is left there is a
// refusal that outlived them.
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: notice,
});
core.info(
`Reported ${targets.size} unclosed cross-repo issue(s) on this pull request.`,
);
} catch (error) {
const reason = describe(error);
try {
await core.summary.addRaw([
'## ⚠️ 跨仓库关闭提示没能发到 PR 上',
'',
`\`issues.createComment\` 最终被拒绝:\`${reason}\`。`,
'',
`- 下面就是本次运行算出的完整提示 —— PR #${context.payload.pull_request.number} 上没有它。`,
'- 可以直接复制到 PR 上;也可以在 API 恢复后 re-run 本 job。',
'- 真正要做的事情是上面列出的那几个 issue:它们仍是 open,需要手工关闭。',
'- 根治是配置仓库 secret `CROSS_REPO_ISSUE_TOKEN`,之后本分支不再运行。',
'',
'---',
'',
notice,
'',
].join('\n')).write();
} catch (summaryError) {
// The summary is the richer channel, the annotation the reliable
// one. Losing the richer one must not restore the silence this
// whole branch exists to break.
core.info(`Could not write the job summary: ${summaryError.message}`);
}
core.setFailed(
`${targets.size} cross-repo issue(s) were left open by this merge and the notice `
+ `saying so could NOT be posted on PR #${context.payload.pull_request.number} `
+ `(${reason}). Close these by hand: ${keys}. The full notice is reproduced in `
+ `this run's job summary; re-run this job to retry delivery.`,
);
}
return;
}
// ISOLATION and OUTCOME are separable, and the loop owns only the
// first (#9595). The `catch` below must keep swallowing — one
// unreachable target must not take the rest down — so the verdict
// is passed AFTER the loop, over the keys it collected. Until this
// split existed the two were fused into `core.warning` alone: a
// refused close left the foreign issue open, put no notice on the
// PR, and handed the run the same green conclusion as the ~2270
// runs that had nothing to do at all. The catch's own comment
// already stated the requirement; only the code was missing.
const failures = [];
// Targets that are not issues AT ALL. Kept apart from `failures`
// above because the two need opposite remedies: a failure is an API
// refusal a re-run can clear, this one is a defect in the merged
// PR's own body that no re-run will ever change. Fusing them would
// send the reader off to re-run a job that is going to refuse again.
const malformed = [];
// The backlink this PR leaves, identified so a SECOND run can see
// it. The marker is scoped to one pull request and is therefore
// STABLE across runs — one PR leaves one backlink, however many
// times the job runs. That is deliberate, and it decides the
// degradation direction below: merge-queue-triage.yml's marker
// carries a run id, so a duplicate there is self-evidently one
// comment per run and posting blind is safe; a stable marker
// strands a permanent second copy instead, which is the reason
// docs-drift-check.yml (#9423) chose the opposite. This one is in
// the second class.
const backlinkMarker =
`<!-- cross-repo-issue-closer:${thisRepo}#${context.payload.pull_request.number} -->`;
const backlink = (lead) =>
`${lead}\n\n` +
`(跨仓库的关闭关键字不会自动生效,本条由 \`cross-repo-issue-closer\` 工作流代为收口。)\n\n` +
`---\n_Generated by [Claude Code](https://claude.ai/code)_\n\n${backlinkMarker}`;
for (const [key, t] of targets) {
// What a refusal from here costs, for the verdict after the loop.
// Until the issue has been read there is no way to know it is
// already closed, so a refusal costs both halves.
let lost = 'close and backlink';
try {
const { data: issue } = await github.rest.issues.get({
owner: t.owner, repo: t.repo, issue_number: t.number,
});
// A pull request is ALSO an issue to this endpoint. Every PR
// answers `GET /repos/{owner}/{repo}/issues/{N}` with `state`,
// `state_reason` and the rest, plus a `pull_request` key that
// nothing here used to read (#9711) — the key is ABSENT on a
// real issue, present as an object on a PR, so its truthiness
// is the whole test. The target regex takes any `owner/repo#N`
// and PR and issue numbers share one sequence, so a body saying
// `Fixes owner/repo#4500` where 4500 is a pull request would
// have made this loop comment on that PR and CLOSE it.
//
// GitHub's own closing-keyword parser — the behaviour this
// workflow exists to carry across repository boundaries — never
// closes a pull request; `Fixes #N` aimed at a PR leaves a
// reference and nothing else. So the keyword is a MALFORMED
// instruction, and it is refused OUT LOUD rather than skipped:
// this file's ten exit paths were arrived at by fixing one
// quiet one after another, and a quiet `continue` would have
// been the eleventh. The refusal is also the conservative half
// of an asymmetry — closing a pull request drops its
// merge-queue membership and any armed auto-merge in the same
// step, and neither comes back by itself.
//
// Placed BEFORE the `state === 'closed'` branch on purpose: a
// merged pull request reads `state: 'closed'` with
// `state_reason: null` from this very endpoint (measured on
// objectstack-ai/objectstack#9143), which that branch reads as
// "no objection recorded" and answers with a backlink comment.
// A guard sitting after it would still write on somebody else's
// pull request.
if (issue.pull_request) {
malformed.push({ key, url: issue.pull_request.html_url });
core.warning(
`${key} is a PULL REQUEST, not an issue — ${prUrl} declares it FIXED, but GitHub's own `
+ `closing-keyword parser never closes a pull request, so this workflow does not either. `
+ `Nothing was commented on and nothing was closed. Re-running cannot fix a keyword that `
+ `can never fire: point the reference at the issue it meant.`,
{ title: 'Cross-repo closing keyword names a pull request' },
);
continue;
}
// "Already closed" is NOT one situation, and the three causes
// that reach here do not deserve the same treatment (#9643).
// Until this split existed all three were skipped whole, which
// dropped the backlink — the half of the defect the header
// names — on every one of them, and reported green.
if (issue.state === 'closed') {
// (a) Closed as not-planned or as a duplicate. A comment
// saying this PR fixed it would CONTRADICT the triage
// decision on the issue, so nothing is posted. Two people
// have said opposite things and only a human can settle
// it, hence an annotation rather than a silent skip.
// `state_reason` is the only cause the API states
// outright; it is nullable (a real closed issue answers
// `null` here), and null is deliberately read as "no
// objection recorded", i.e. as `completed`.
if (issue.state_reason === 'not_planned' || issue.state_reason === 'duplicate') {
core.warning(
`${key} is closed as \`${issue.state_reason}\`, but this PR's body declares it FIXED. `
+ `No backlink was left: a comment saying ${prUrl} closed it would contradict that `
+ `triage decision. Someone has to decide which of the two is right.`,
{ title: 'Cross-repo close contradicts the issue triage' },
);
continue;
}
// (b) Closed by an EARLIER run of this job — a re-run, which
// this workflow's own job summary invites. The marker is
// the only evidence that separates it from (c): the API
// cannot, because `closed_by` is a login and every seat
// here shares one identity, so "the workflow did it" and
// "a human did it" read the same.
let alreadyLinked = false;
try {
// One page is the bound. Overflowing it costs a DUPLICATE
// backlink, never a lost one, and the stable marker makes
// the pair self-evident.
const existing = await github.rest.issues.listComments({
owner: t.owner, repo: t.repo, issue_number: t.number, per_page: 100,
});
alreadyLinked = existing.data.some((c) => (c.body ?? '').includes(backlinkMarker));
} catch (error) {
// AT-MOST-ONCE, stated: the marker is stable, so posting
// without knowing strands a second permanent backlink on
// somebody ELSE's repo, which nobody here can tidy up. A
// miss costs a convenience link on an already-closed issue
// and a re-run recovers it. Loud either way — this is a
// skip with a reason, not a silent one.
core.warning(
`${key} is already closed, but its comments could not be read `
+ `(${describe(error)}), so this run could not tell whether ${prUrl} is already `
+ `linked there. No backlink was posted — re-run this job to leave it.`,
{ title: 'Cross-repo backlink skipped, not confirmed' },
);
continue;
}
if (alreadyLinked) {
core.info(`${key} is already closed and already carries this PR's backlink — nothing to do.`);
continue;
}
// (c) Closed by a human before this PR merged — the ordinary
// sequence the header is about (v17 verification, #4482).
// The close is genuinely redundant; the backlink is the
// whole remaining value, so leave it and touch nothing
// else.
lost = 'backlink';
await github.rest.issues.createComment({
owner: t.owner, repo: t.repo, issue_number: t.number,
body: backlink(
`本 issue 在本次合并之前就已经是 closed 状态,这里只补上修复它的 PR:` +
`${thisRepo} 的 ${prUrl}。本工作流没有改动它的状态。`,
),
});
core.info(`${key} was already closed — left the backlink to ${prUrl}, did not re-close it.`);
continue;
}
await github.rest.issues.createComment({
owner: t.owner, repo: t.repo, issue_number: t.number,
body: backlink(`已由 ${thisRepo} 的 ${prUrl} 修复并合并。`),
});
await github.rest.issues.update({
owner: t.owner, repo: t.repo, issue_number: t.number,
state: 'closed', state_reason: 'completed',
});
core.info(`Closed ${key}.`);
} catch (error) {
// One unreachable target must not swallow the rest, and a
// failure here must not read as success. This half buys the
// first: the loop continues. The failure is RECORDED instead of
// dropped, and the second half is passed below.
const reason = describe(error);
failures.push({ key, reason, lost });
core.warning(`Could not finish ${key} — ${lost} not delivered: ${reason}`, {
title: 'Cross-repo issue left unfinished',
});
}
}
if (failures.length === 0 && malformed.length === 0) {
core.info(`All ${targets.size} cross-repo target(s) handled: closed, or already closed and linked.`);
return;
}
// A spent failure IS the report. Announce it in both channels the
// notice branch uses, for the same reasons: the summary carries the
// full list a human needs, `setFailed` carries the conclusion that
// makes anyone open the run at all.
//
// Both classes are red, and each says what to do about ITSELF: a
// refusal is retried, a malformed reference is rewritten. Green
// with an annotation was the alternative for the malformed class,
// and this file's own history rejects it — 2334 runs, every one of
// them `run_attempt` 1 and 99 of the last 100 green, so a green run
// of this job has never been opened by anybody. An annotation on a
// green post-merge run is the silent path with extra steps, and a
// declared close that can never happen is exactly what #9595 made
// red for.
const failedKeys = failures.map((f) => f.key).join(', ');
const failedList = failures.map((f) => `- \`${f.key}\` —— ${f.lost} 未完成:${f.reason}`).join('\n');
const malformedKeys = malformed.map((m) => m.key).join(', ');
const malformedList = malformed.map((m) => `- \`${m.key}\` —— 这个编号是一个 pull request:${m.url}`).join('\n');
const summaryLines = [];
if (failures.length > 0) {
summaryLines.push(
'## ⚠️ 跨仓库 issue 没能收口',
'',
`本次合并声明了 ${targets.size} 个跨仓库关闭目标,其中 ${failures.length} 个没有完成:`,
'',
failedList,
'',
`- 修复它们的是 ${prUrl} —— 需要手工收口:还是 open 的要关掉,已经关掉的要把这条链接留在上面。`,
'- 原因排除后可以直接 re-run 本 job:已经关闭的目标不会被重复关闭,已经留下过本 PR 反链的目标也不会被重复评论。',
'- 401/404 通常意味着 `CROSS_REPO_ISSUE_TOKEN` 对目标仓库没有 `issues: write`,而不是目标不存在 —— GitHub 对无权访问的仓库回 404。',
'',
);
}
if (malformed.length > 0) {
summaryLines.push(
'## ⛔ 关闭关键字指向的是 pull request,不是 issue',
'',
`本次合并声明的 ${targets.size} 个跨仓库关闭目标里,有 ${malformed.length} 个的编号是 pull request:`,
'',
malformedList,
'',
'- 它们没有被评论,也没有被关闭。GitHub 自己的关闭关键字解析器从不关闭 pull request,本工作流也不会 —— 关掉一个 PR 会在同一步里丢掉它的 merge queue 成员资格和已经武装的 auto-merge,两者都不会自己回来。',
`- 这不是 re-run 能解决的故障:写错的是 ${prUrl} 的正文。把引用改成它真正想关的那个 issue 编号,或者手工关掉那个 issue。`,
'',
);
}
try {
await core.summary.addRaw(summaryLines.join('\n')).write();
} catch (summaryError) {
// Same asymmetry as the notice branch: the summary is the richer
// channel, the conclusion the reliable one. Losing the richer one
// must not restore the silence.
core.info(`Could not write the job summary: ${summaryError.message}`);
}
const verdict = [];
if (failures.length > 0) {
verdict.push(
`${failures.length} of ${targets.size} cross-repo target(s) were NOT finished by this merge: `
+ `${failedKeys}. Finish them by hand (fixed by ${prUrl}) — an open one needs closing, an `
+ `already-closed one needs this PR's link on it — or re-run this job once the cause is `
+ `cleared. The full list, with what each one is missing, is in this run's job summary.`,
);
}
if (malformed.length > 0) {
verdict.push(
`${malformed.length} of ${targets.size} cross-repo closing keyword(s) in this merged pull `
+ `request name a PULL REQUEST rather than an issue: ${malformedKeys}. GitHub's own keyword `
+ `parser never closes a pull request and this workflow does not either, so nothing was `
+ `commented on and nothing was closed. Re-running this job will refuse them again — what `
+ `has to change is the reference in ${prUrl}'s body.`,
);
}
core.setFailed(verdict.join(' '));