diff --git a/.changeset/cross-repo-issue-closer.md b/.changeset/cross-repo-issue-closer.md new file mode 100644 index 000000000..a0c959727 --- /dev/null +++ b/.changeset/cross-repo-issue-closer.md @@ -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. diff --git a/.github/workflows/cross-repo-issue-closer.yml b/.github/workflows/cross-repo-issue-closer.yml new file mode 100644 index 000000000..e5acd1d8d --- /dev/null +++ b/.github/workflows/cross-repo-issue-closer.yml @@ -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}`); + } + }