|
| 1 | +# GitHub's closing keywords (`Fixes #123`) only work WITHIN a repository. A PR |
| 2 | +# here that says `Fixes objectstack-ai/objectstack#4475` 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 | +# This is not hypothetical for this repository: defects are routinely FOUND in |
| 8 | +# objectstack (where verification runs) and FIXED here. During v17 verification |
| 9 | +# that happened twice in one day — #3150 fixed objectstack#4475 and #3163 fixed |
| 10 | +# objectstack#4478 — and both framework issues had to be closed by hand. |
| 11 | +# |
| 12 | +# This job closes the loop. It deliberately has TWO modes and BOTH are visible: |
| 13 | +# |
| 14 | +# token present -> close the foreign issue and comment with the PR link |
| 15 | +# token absent -> comment ON THIS PR naming what still needs closing by hand |
| 16 | +# |
| 17 | +# The second mode is the point. A workflow that quietly does nothing because a |
| 18 | +# secret was never provisioned is exactly the "declared but never enforced" |
| 19 | +# shape both repositories keep having to fix. Missing credentials must announce |
| 20 | +# themselves. |
| 21 | +name: Cross-repo Issue Closer |
| 22 | + |
| 23 | +# `pull_request_target` (not `pull_request`) because the job needs repository |
| 24 | +# secrets, which `pull_request` withholds from fork-originated runs. The usual |
| 25 | +# hazard of `pull_request_target` — running untrusted PR code with write |
| 26 | +# credentials — does not apply: this job never checks out the head ref and |
| 27 | +# never executes anything from the PR. It reads the PR body and calls the |
| 28 | +# issues API, nothing else. |
| 29 | +on: |
| 30 | + pull_request_target: |
| 31 | + types: [closed] |
| 32 | + |
| 33 | +permissions: |
| 34 | + contents: read |
| 35 | + pull-requests: write |
| 36 | + |
| 37 | +jobs: |
| 38 | + close-foreign-issues: |
| 39 | + name: Close issues referenced in other repositories |
| 40 | + if: github.event.pull_request.merged == true |
| 41 | + runs-on: ubuntu-latest |
| 42 | + steps: |
| 43 | + - name: Close (or report) cross-repo closing keywords |
| 44 | + uses: actions/github-script@v9 |
| 45 | + env: |
| 46 | + # A fine-grained PAT or GitHub App token with `issues: write` on the |
| 47 | + # sibling repositories. `GITHUB_TOKEN` cannot do this — it is scoped |
| 48 | + # to the repository running the workflow, which is the whole problem. |
| 49 | + CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_ISSUE_TOKEN }} |
| 50 | + with: |
| 51 | + script: | |
| 52 | + const body = context.payload.pull_request.body || ''; |
| 53 | + const prUrl = context.payload.pull_request.html_url; |
| 54 | + const thisRepo = `${context.repo.owner}/${context.repo.repo}`; |
| 55 | +
|
| 56 | + // GitHub's own keyword set, restricted to the qualified |
| 57 | + // `owner/repo#N` form — the bare `#N` form already works natively |
| 58 | + // and must not be touched here. |
| 59 | + const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved'; |
| 60 | + const pattern = new RegExp( |
| 61 | + `\\b(?:${KEYWORDS})\\s+([\\w.-]+)\\/([\\w.-]+)#(\\d+)\\b`, |
| 62 | + 'gi', |
| 63 | + ); |
| 64 | +
|
| 65 | + // Report credential state on EVERY run, before any early return. |
| 66 | + // Otherwise a repository with the secret and one without look |
| 67 | + // identical until a cross-repo reference happens to show up — |
| 68 | + // which can be days — and "is it configured?" stays unanswerable. |
| 69 | + // Presence only; the value is never read into the log. |
| 70 | + const token = process.env.CROSS_REPO_TOKEN; |
| 71 | + core.info( |
| 72 | + `CROSS_REPO_ISSUE_TOKEN: ${token ? 'configured' : 'ABSENT — cross-repo closes will be reported, not performed'}`, |
| 73 | + ); |
| 74 | +
|
| 75 | + const targets = new Map(); |
| 76 | + for (const [, owner, repo, number] of body.matchAll(pattern)) { |
| 77 | + const key = `${owner}/${repo}#${number}`; |
| 78 | + // Skip same-repo references: GitHub already closed those, and |
| 79 | + // closing them again would be a no-op comment on every merge. |
| 80 | + if (`${owner}/${repo}`.toLowerCase() === thisRepo.toLowerCase()) continue; |
| 81 | + targets.set(key, { owner, repo, number: Number(number) }); |
| 82 | + } |
| 83 | +
|
| 84 | + if (targets.size === 0) { |
| 85 | + core.info('No cross-repository closing keywords in this PR body.'); |
| 86 | + return; |
| 87 | + } |
| 88 | + core.info(`Cross-repo targets: ${[...targets.keys()].join(', ')}`); |
| 89 | +
|
| 90 | + if (!token) { |
| 91 | + // Degrade VISIBLY. Someone has to close these by hand, and this |
| 92 | + // comment is the only thing that will tell them so. |
| 93 | + const list = [...targets.keys()].map((k) => `- \`${k}\``).join('\n'); |
| 94 | + await github.rest.issues.createComment({ |
| 95 | + owner: context.repo.owner, |
| 96 | + repo: context.repo.repo, |
| 97 | + issue_number: context.payload.pull_request.number, |
| 98 | + body: |
| 99 | + `### ⚠️ 跨仓库 issue 未被自动关闭\n\n` + |
| 100 | + `本 PR 的正文声明了跨仓库关闭关键字,但 GitHub 的关闭关键字**只在同仓库内生效**,` + |
| 101 | + `因此以下 issue 仍处于 open 状态,需要**手工关闭**:\n\n${list}\n\n` + |
| 102 | + `自动关闭需要仓库 secret \`CROSS_REPO_ISSUE_TOKEN\`(对目标仓库具备 \`issues: write\` 的` + |
| 103 | + ` fine-grained PAT 或 GitHub App token)。\`GITHUB_TOKEN\` 只对当前仓库有写权限,无法胜任。\n\n` + |
| 104 | + `配置该 secret 后本条提示会自动消失,改为直接关闭目标 issue。\n\n` + |
| 105 | + `---\n_Generated by [Claude Code](https://claude.ai/code)_`, |
| 106 | + }); |
| 107 | + core.warning( |
| 108 | + `CROSS_REPO_ISSUE_TOKEN is not configured — ${targets.size} issue(s) left open. ` + |
| 109 | + `Reported on the pull request instead.`, |
| 110 | + ); |
| 111 | + return; |
| 112 | + } |
| 113 | +
|
| 114 | + // A second client: `github` is bound to GITHUB_TOKEN, which has no |
| 115 | + // write access outside this repository. |
| 116 | + const crossRepo = require('@actions/github').getOctokit(token); |
| 117 | +
|
| 118 | + for (const [key, t] of targets) { |
| 119 | + try { |
| 120 | + const { data: issue } = await crossRepo.rest.issues.get({ |
| 121 | + owner: t.owner, repo: t.repo, issue_number: t.number, |
| 122 | + }); |
| 123 | + if (issue.state === 'closed') { |
| 124 | + core.info(`${key} is already closed — skipping.`); |
| 125 | + continue; |
| 126 | + } |
| 127 | + await crossRepo.rest.issues.createComment({ |
| 128 | + owner: t.owner, repo: t.repo, issue_number: t.number, |
| 129 | + body: |
| 130 | + `已由 ${thisRepo} 的 ${prUrl} 修复并合并。\n\n` + |
| 131 | + `(跨仓库的关闭关键字不会自动生效,本条由 \`cross-repo-issue-closer\` 工作流代为收口。)\n\n` + |
| 132 | + `---\n_Generated by [Claude Code](https://claude.ai/code)_`, |
| 133 | + }); |
| 134 | + await crossRepo.rest.issues.update({ |
| 135 | + owner: t.owner, repo: t.repo, issue_number: t.number, |
| 136 | + state: 'closed', state_reason: 'completed', |
| 137 | + }); |
| 138 | + core.info(`Closed ${key}.`); |
| 139 | + } catch (error) { |
| 140 | + // One unreachable target must not swallow the rest, and a |
| 141 | + // failure here must not read as success. |
| 142 | + core.warning(`Could not close ${key}: ${error.message}`); |
| 143 | + } |
| 144 | + } |
0 commit comments