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