Skip to content

Commit dab35e0

Browse files
os-zhuangclaude
andauthored
ci: 合并时收口另一个仓库里被 Fixes 声明的 issue(缺凭据时改为出声,不静默) (#4553)
* ci: close issues that a merged PR fixes in another repository GitHub's closing keywords only act within a repository, so a PR here saying `Fixes objectstack-ai/objectui#456` merges and leaves that issue open — with no reference to the PR on the issue either. v17 verification hit this twice (#4475, #4478); both were closed by hand. The job has two modes and both are visible: with a cross-repo token it closes the foreign issue and links the PR; without one it comments on the merged PR naming what still needs closing. Silent no-op on a missing secret is the shape this repo keeps having to fix (#4449), so the absent credential announces itself instead. * chore: add release-nothing changeset for the cross-repo issue closer --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9503465 commit dab35e0

2 files changed

Lines changed: 165 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
---
3+
4+
ci: close issues that a merged PR fixes in another repository (#4482 follow-up)
5+
6+
Release-nothing: adds `.github/workflows/cross-repo-issue-closer.yml` and no
7+
package code.
8+
9+
GitHub's closing keywords only act within a repository, so a PR here saying
10+
`Fixes objectstack-ai/objectui#456` merges and leaves that issue open — and the
11+
issue's own page carries no reference to the PR that fixed it, so the next
12+
reader cannot find the fix either. v17 verification (#4482) hit this twice in
13+
one day; #4475 and #4478 were both closed by hand.
14+
15+
The job has two modes and both are visible. With a cross-repo token it closes
16+
the foreign issue and links the PR. Without one it comments on the merged PR
17+
naming what still needs closing by hand — because the repository's only secrets
18+
are `GITHUB_TOKEN` (scoped to the repository running the workflow, which is the
19+
whole problem) and `NPM_TOKEN`, so until an admin provisions
20+
`CROSS_REPO_ISSUE_TOKEN` the job cannot perform the close at all.
21+
22+
That second mode is deliberate, not a fallback. A workflow that quietly does
23+
nothing because a secret was never provisioned is the shape this repo keeps
24+
having to fix — #4449's `validateFormLayout` was written, tested, exported, and
25+
called by nothing, running on zero stacks for as long as it existed. A missing
26+
credential has to announce itself.
27+
28+
Matched references are restricted to the qualified `owner/repo#N` form; the
29+
bare `#N` form already works natively and is left alone. Same-repo qualified
30+
references are filtered out, already-closed targets are skipped, and one
31+
unreachable target cannot swallow the rest or read as success.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# GitHub's closing keywords (`Fixes #123`) only work WITHIN a repository. A PR
2+
# here that says `Fixes objectstack-ai/objectui#456` reads exactly like a
3+
# same-repo close to a human, merges, and leaves that issue open forever — with
4+
# no reference to the PR on the issue's own page either, so the next reader has
5+
# no way to find the fix.
6+
#
7+
# That gap is why v17 verification (#4482) left #4475 and #4478 open after their
8+
# fixes shipped in objectui; both had to be closed by hand.
9+
#
10+
# This job closes the loop. It deliberately has TWO modes and BOTH are visible:
11+
#
12+
# token present -> close the foreign issue and comment with the PR link
13+
# token absent -> comment ON THIS PR naming what still needs closing by hand
14+
#
15+
# The second mode is the point. A workflow that quietly does nothing because a
16+
# secret was never provisioned is the shape this repo keeps having to fix
17+
# (#4449: written, tested, exported, called by nothing). Missing credentials
18+
# must announce themselves.
19+
name: Cross-repo Issue Closer
20+
21+
# `pull_request_target` (not `pull_request`) because the job needs repository
22+
# secrets, which `pull_request` withholds from fork-originated runs. The usual
23+
# hazard of `pull_request_target` — running untrusted PR code with write
24+
# credentials — does not apply: this job never checks out the head ref and
25+
# never executes anything from the PR. It reads the PR body and calls the
26+
# issues API, nothing else.
27+
on:
28+
pull_request_target:
29+
types: [closed]
30+
31+
permissions:
32+
contents: read
33+
pull-requests: write
34+
35+
jobs:
36+
close-foreign-issues:
37+
name: Close issues referenced in other repositories
38+
if: github.event.pull_request.merged == true
39+
runs-on: ubuntu-latest
40+
steps:
41+
- name: Close (or report) cross-repo closing keywords
42+
uses: actions/github-script@v9
43+
env:
44+
# A fine-grained PAT or GitHub App token with `issues: write` on the
45+
# sibling repositories. `GITHUB_TOKEN` cannot do this — it is scoped
46+
# to the repository running the workflow, which is the whole problem.
47+
CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_ISSUE_TOKEN }}
48+
with:
49+
script: |
50+
const body = context.payload.pull_request.body || '';
51+
const prUrl = context.payload.pull_request.html_url;
52+
const thisRepo = `${context.repo.owner}/${context.repo.repo}`;
53+
54+
// GitHub's own keyword set, restricted to the qualified
55+
// `owner/repo#N` form — the bare `#N` form already works natively
56+
// and must not be touched here.
57+
const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';
58+
const pattern = new RegExp(
59+
`\\b(?:${KEYWORDS})\\s+([\\w.-]+)\\/([\\w.-]+)#(\\d+)\\b`,
60+
'gi',
61+
);
62+
63+
const targets = new Map();
64+
for (const [, owner, repo, number] of body.matchAll(pattern)) {
65+
const key = `${owner}/${repo}#${number}`;
66+
// Skip same-repo references: GitHub already closed those, and
67+
// closing them again would be a no-op comment on every merge.
68+
if (`${owner}/${repo}`.toLowerCase() === thisRepo.toLowerCase()) continue;
69+
targets.set(key, { owner, repo, number: Number(number) });
70+
}
71+
72+
if (targets.size === 0) {
73+
core.info('No cross-repository closing keywords in this PR body.');
74+
return;
75+
}
76+
core.info(`Cross-repo targets: ${[...targets.keys()].join(', ')}`);
77+
78+
const token = process.env.CROSS_REPO_TOKEN;
79+
80+
if (!token) {
81+
// Degrade VISIBLY. Someone has to close these by hand, and this
82+
// comment is the only thing that will tell them so.
83+
const list = [...targets.keys()].map((k) => `- \`${k}\``).join('\n');
84+
await github.rest.issues.createComment({
85+
owner: context.repo.owner,
86+
repo: context.repo.repo,
87+
issue_number: context.payload.pull_request.number,
88+
body:
89+
`### ⚠️ 跨仓库 issue 未被自动关闭\n\n` +
90+
`本 PR 的正文声明了跨仓库关闭关键字,但 GitHub 的关闭关键字**只在同仓库内生效**,` +
91+
`因此以下 issue 仍处于 open 状态,需要**手工关闭**:\n\n${list}\n\n` +
92+
`自动关闭需要仓库 secret \`CROSS_REPO_ISSUE_TOKEN\`(对目标仓库具备 \`issues: write\` 的` +
93+
` fine-grained PAT 或 GitHub App token)。\`GITHUB_TOKEN\` 只对当前仓库有写权限,无法胜任。\n\n` +
94+
`配置该 secret 后本条提示会自动消失,改为直接关闭目标 issue。\n\n` +
95+
`---\n_Generated by [Claude Code](https://claude.ai/code)_`,
96+
});
97+
core.warning(
98+
`CROSS_REPO_ISSUE_TOKEN is not configured — ${targets.size} issue(s) left open. ` +
99+
`Reported on the pull request instead.`,
100+
);
101+
return;
102+
}
103+
104+
// A second client: `github` is bound to GITHUB_TOKEN, which has no
105+
// write access outside this repository.
106+
const octokit = require('@actions/github').getOctokit(token);
107+
108+
for (const [key, t] of targets) {
109+
try {
110+
const { data: issue } = await octokit.rest.issues.get({
111+
owner: t.owner, repo: t.repo, issue_number: t.number,
112+
});
113+
if (issue.state === 'closed') {
114+
core.info(`${key} is already closed — skipping.`);
115+
continue;
116+
}
117+
await octokit.rest.issues.createComment({
118+
owner: t.owner, repo: t.repo, issue_number: t.number,
119+
body:
120+
`已由 ${thisRepo} 的 ${prUrl} 修复并合并。\n\n` +
121+
`(跨仓库的关闭关键字不会自动生效,本条由 \`cross-repo-issue-closer\` 工作流代为收口。)\n\n` +
122+
`---\n_Generated by [Claude Code](https://claude.ai/code)_`,
123+
});
124+
await octokit.rest.issues.update({
125+
owner: t.owner, repo: t.repo, issue_number: t.number,
126+
state: 'closed', state_reason: 'completed',
127+
});
128+
core.info(`Closed ${key}.`);
129+
} catch (error) {
130+
// One unreachable target must not swallow the rest, and a
131+
// failure here must not read as success.
132+
core.warning(`Could not close ${key}: ${error.message}`);
133+
}
134+
}

0 commit comments

Comments
 (0)