|
| 1 | +# GitHub happily lets any number of open PRs declare `Fixes #N` for the same |
| 2 | +# issue. On 2026-08-02 that cost a full duplicate implementation: #4555 and |
| 3 | +# #4559 both declared `Fixes #4551`, both ran the whole gate suite green, and |
| 4 | +# the duplication sat machine-detectable from the moment the second PR opened |
| 5 | +# (03:08) until a human noticed it (08:52). All agents here share one GitHub |
| 6 | +# identity, so the issue's assignee could not warn the second author either — |
| 7 | +# "assigned to os-zhuang" reads the same whether it is you or another session. |
| 8 | +# Full post-mortem: #4588. |
| 9 | +# |
| 10 | +# This gate makes the second PR red at open time. First come, first served — |
| 11 | +# the EARLIER open PR (lower number) keeps its claim and stays green; the |
| 12 | +# later one fails with a pointer to it. That matches the pm-dispatch claim |
| 13 | +# convention ("first claim comment wins") already in .claude/skills/. |
| 14 | +# |
| 15 | +# Scope: same-repo references only (bare `#N` and qualified `<this repo>#N`). |
| 16 | +# Cross-repo references are the cross-repo-issue-closer's territory, and a |
| 17 | +# duplicate across repos cannot be resolved by failing one side's CI anyway. |
| 18 | +name: Duplicate Fix Guard |
| 19 | + |
| 20 | +# `edited` matters as much as `opened`: a PR that adds `Fixes #N` to its body |
| 21 | +# after the fact must re-run this check, and one that drops the line must be |
| 22 | +# able to go green again. |
| 23 | +on: |
| 24 | + pull_request: |
| 25 | + types: [opened, edited, reopened, synchronize] |
| 26 | + |
| 27 | +permissions: |
| 28 | + pull-requests: read |
| 29 | + |
| 30 | +jobs: |
| 31 | + duplicate-fix-guard: |
| 32 | + name: No other open PR may claim the same issue |
| 33 | + runs-on: ubuntu-latest |
| 34 | + steps: |
| 35 | + - name: Check declared issues against other open PRs |
| 36 | + uses: actions/github-script@v9 |
| 37 | + with: |
| 38 | + script: | |
| 39 | + const pr = context.payload.pull_request; |
| 40 | + const thisRepo = `${context.repo.owner}/${context.repo.repo}`; |
| 41 | +
|
| 42 | + // GitHub's own closing-keyword set. The optional colon is part of |
| 43 | + // GitHub's accepted syntax (`Fixes: #123`). Two same-repo forms: |
| 44 | + // bare `#N` and qualified `owner/repo#N` naming THIS repo. |
| 45 | + const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved'; |
| 46 | + const pattern = new RegExp( |
| 47 | + `\\b(?:${KEYWORDS}):?\\s+(?:([\\w.-]+)\\/([\\w.-]+))?#(\\d+)\\b`, |
| 48 | + 'gi', |
| 49 | + ); |
| 50 | +
|
| 51 | + const declaredIssues = (body) => { |
| 52 | + const found = new Set(); |
| 53 | + for (const [, owner, repo, number] of (body || '').matchAll(pattern)) { |
| 54 | + // A qualified reference to ANOTHER repo is not ours to judge. |
| 55 | + if (owner && `${owner}/${repo}`.toLowerCase() !== thisRepo.toLowerCase()) continue; |
| 56 | + found.add(Number(number)); |
| 57 | + } |
| 58 | + return found; |
| 59 | + }; |
| 60 | +
|
| 61 | + const mine = declaredIssues(pr.body); |
| 62 | + if (mine.size === 0) { |
| 63 | + core.info('This PR declares no same-repo closing keywords — nothing to guard.'); |
| 64 | + return; |
| 65 | + } |
| 66 | + core.info(`This PR declares: ${[...mine].map((n) => `#${n}`).join(', ')}`); |
| 67 | +
|
| 68 | + // Branch-name convention (advisory, never red): a fix branch named |
| 69 | + // `claude/issue-<n>-<slug>` is discoverable by the next session |
| 70 | + // with one `git ls-remote | grep issue-<n>`. #4555 vs #4559 |
| 71 | + // happened partly because the branches shared no token to grep. |
| 72 | + // Warning only — existing branches must not go red retroactively. |
| 73 | + const branch = pr.head.ref; |
| 74 | + if (![...mine].some((n) => branch.includes(`issue-${n}`))) { |
| 75 | + core.warning( |
| 76 | + `Branch \`${branch}\` does not name any declared issue. ` + |
| 77 | + `Convention: claude/issue-<n>-<slug> (e.g. claude/issue-${[...mine][0]}-short-slug) ` + |
| 78 | + `so parallel sessions can discover in-flight work with git ls-remote.`, |
| 79 | + ); |
| 80 | + } |
| 81 | +
|
| 82 | + // Drafts count: a draft PR is work in flight, which is exactly |
| 83 | + // what the second session needs to see. |
| 84 | + const openPrs = await github.paginate(github.rest.pulls.list, { |
| 85 | + owner: context.repo.owner, |
| 86 | + repo: context.repo.repo, |
| 87 | + state: 'open', |
| 88 | + per_page: 100, |
| 89 | + }); |
| 90 | +
|
| 91 | + const conflicts = []; |
| 92 | + for (const other of openPrs) { |
| 93 | + if (other.number === pr.number) continue; |
| 94 | + const theirs = declaredIssues(other.body); |
| 95 | + const shared = [...mine].filter((n) => theirs.has(n)); |
| 96 | + if (shared.length > 0) conflicts.push({ other, shared }); |
| 97 | + } |
| 98 | +
|
| 99 | + if (conflicts.length === 0) { |
| 100 | + core.info('No other open PR declares these issues.'); |
| 101 | + return; |
| 102 | + } |
| 103 | +
|
| 104 | + // First come, first served: only the LATER PR goes red. Failing |
| 105 | + // both would leave the original author red through no action of |
| 106 | + // their own; failing the earlier one would reward racing. |
| 107 | + const older = conflicts.filter((c) => c.other.number < pr.number); |
| 108 | + const newer = conflicts.filter((c) => c.other.number > pr.number); |
| 109 | +
|
| 110 | + for (const { other, shared } of newer) { |
| 111 | + core.info( |
| 112 | + `#${other.number} (newer) also declares ${shared.map((n) => `#${n}`).join(', ')} — ` + |
| 113 | + `it will fail its own run of this guard; this PR keeps its claim.`, |
| 114 | + ); |
| 115 | + } |
| 116 | +
|
| 117 | + if (older.length > 0) { |
| 118 | + const lines = older.map(({ other, shared }) => |
| 119 | + ` - ${shared.map((n) => `#${n}`).join(', ')} is already claimed by #${other.number} ` + |
| 120 | + `(${other.html_url}, branch \`${other.head.ref}\`${other.draft ? ', draft' : ''})`, |
| 121 | + ); |
| 122 | + core.setFailed( |
| 123 | + `Another open PR already declares a fix for the same issue(s):\n${lines.join('\n')}\n` + |
| 124 | + `If this PR is the duplicate, close it and add anything it uniquely covers to the ` + |
| 125 | + `earlier PR or a follow-up issue (that is what saved #4560 when #4559 was closed). ` + |
| 126 | + `If the EARLIER one is abandoned, close it first — this check re-runs on 'edited' ` + |
| 127 | + `and 'synchronize', and goes green once the conflict is gone.`, |
| 128 | + ); |
| 129 | + } |
0 commit comments