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
156 changes: 139 additions & 17 deletions .github/workflows/cross-repo-issue-closer.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,35 @@
# 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
Expand DownExpand Up@@ -278,21 +307,113 @@ jobs:
// already stated the requirement; only the code was missing.
const failures = [];

// 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,
});

// "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') {
core.info(`${key} is already closed — skipping.`);
// (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:
`已由 ${thisRepo} 的 ${prUrl} 修复并合并。\n\n` +
`(跨仓库的关闭关键字不会自动生效,本条由 \`cross-repo-issue-closer\` 工作流代为收口。)\n\n` +
`---\n_Generated by [Claude Code](https://claude.ai/code)_`,
body: backlink(`已由 ${thisRepo} 的 ${prUrl} 修复并合并。`),
});
await github.rest.issues.update({
owner: t.owner, repo: t.repo, issue_number: t.number,
Expand All@@ -305,15 +426,15 @@ jobs:
// 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 });
core.warning(`Could not close ${key}: ${reason}`, {
title: 'Cross-repo issue left open',
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) {
core.info(`All ${targets.size} cross-repo target(s) closed or already closed.`);
core.info(`All ${targets.size} cross-repo target(s) handled: closed, or already closed and linked.`);
return;
}

Expand All@@ -322,17 +443,17 @@ jobs:
// full list a human needs, `setFailed` carries the conclusion that
// makes anyone open the run at all.
const failedKeys = failures.map((f) => f.key).join(', ');
const failedList = failures.map((f) => `- \`${f.key}\` —— ${f.reason}`).join('\n');
const failedList = failures.map((f) => `- \`${f.key}\` —— ${f.lost} 未完成:${f.reason}`).join('\n');
try {
await core.summary.addRaw([
'## ⚠️ 跨仓库 issue 没能自动关闭',
'## ⚠️ 跨仓库 issue 没能收口',
'',
`本次合并声明了 ${targets.size} 个跨仓库关闭目标,其中 ${failures.length} 个被拒绝,仍是 open:`,
`本次合并声明了 ${targets.size} 个跨仓库关闭目标,其中 ${failures.length} 个没有完成:`,
'',
failedList,
'',
`- 修复它们的是 ${prUrl} —— 需要手工关闭,并把这条链接留在目标 issue 上。`,
'- 原因排除后可以直接 re-run 本 job:已经关闭的目标会被跳过,不会重复评论。',
`- 修复它们的是 ${prUrl} —— 需要手工收口:还是 open 的要关掉,已经关掉的要把这条链接留在上面。`,
'- 原因排除后可以直接 re-run 本 job:已经关闭的目标不会被重复关闭,已经留下过本 PR 反链的目标也不会被重复评论。',
'- 401/404 通常意味着 `CROSS_REPO_ISSUE_TOKEN` 对目标仓库没有 `issues: write`,而不是目标不存在 —— GitHub 对无权访问的仓库回 404。',
'',
].join('\n')).write();
Expand All@@ -343,7 +464,8 @@ jobs:
core.info(`Could not write the job summary: ${summaryError.message}`);
}
core.setFailed(
`${failures.length} of ${targets.size} cross-repo issue(s) could NOT be closed by this merge and `
+ `are still open: ${failedKeys}. Close them by hand (fixed by ${prUrl}), or re-run this job once `
+ `the cause is cleared. The full list with reasons is in this run's job summary.`,
`${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.`,
);
Loading
Loading