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
31 changes: 31 additions & 0 deletions .changeset/cross-repo-issue-closer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
---

ci: close issues that a merged PR fixes in another repository

Release-nothing: adds `.github/workflows/cross-repo-issue-closer.yml` and no
package code.

Defects are routinely found in objectstack, where verification runs, and fixed
here. But GitHub's closing keywords only act within a repository, so a PR here
saying `Fixes objectstack-ai/objectstack#4475` merges and leaves that framework
issue open — with no reference to the PR on the issue's own page either. v17
verification hit this twice in one day: #3150 fixed objectstack#4475 and #3163
fixed objectstack#4478, and 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 by hand — this repository's secrets are
`GITHUB_TOKEN` (scoped to the repository running the workflow, which is the
whole problem), `NPM_TOKEN` and `CODECOV_TOKEN`, so until an admin provisions
`CROSS_REPO_ISSUE_TOKEN` the job cannot perform the close at all.

That second mode is deliberate, not a fallback. A workflow that quietly does
nothing because a secret was never provisioned is the "declared but never
enforced" shape both repositories keep having to fix. A missing credential has
to announce itself.

Matched references are restricted to the qualified `owner/repo#N` form; the
bare `#N` form already works natively and is left alone. Same-repo qualified
references are filtered out, already-closed targets are skipped, and one
unreachable target cannot swallow the rest or read as success.
144 changes: 144 additions & 0 deletions .github/workflows/cross-repo-issue-closer.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# GitHub's closing keywords (`Fixes #123`) only work WITHIN a repository. A PR
# here that says `Fixes objectstack-ai/objectstack#4475` 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.
#
# This is not hypothetical for this repository: defects are routinely FOUND in
# objectstack (where verification runs) and FIXED here. During v17 verification
# that happened twice in one day — #3150 fixed objectstack#4475 and #3163 fixed
# objectstack#4478 — and both framework issues 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 exactly the "declared but never enforced"
# shape both repositories keep having to fix. Missing credentials must announce
# themselves.
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:
script: |
const body = context.payload.pull_request.body || '';
const prUrl = context.payload.pull_request.html_url;
const thisRepo = `${context.repo.owner}/${context.repo.repo}`;

// 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.
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');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body:
`### ⚠️ 跨仓库 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)_`,
});
core.warning(
`CROSS_REPO_ISSUE_TOKEN is not configured — ${targets.size} issue(s) left open. ` +
`Reported on the pull request instead.`,
);
return;
}

// A second client: `github` is bound to GITHUB_TOKEN, which has no
// write access outside this repository.
const crossRepo = require('@actions/github').getOctokit(token);

for (const [key, t] of targets) {
try {
const { data: issue } = await crossRepo.rest.issues.get({
owner: t.owner, repo: t.repo, issue_number: t.number,
});
if (issue.state === 'closed') {
core.info(`${key} is already closed — skipping.`);
continue;
}
await crossRepo.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)_`,
});
await crossRepo.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.
core.warning(`Could not close ${key}: ${error.message}`);
}
}
Loading