Repository navigation
350 lines (333 loc) · 16.5 KB
/
Copy pathissue-state.yml
File metadata and controls
350 lines (333 loc) · 16.5 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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
name: Issue state
# The one place that enforces docs/internal/agent-rules/delivery.md
# mechanically:
# - one <kind>:<state> label per issue (adding the next label drops the old);
# - task:draft becomes task:ready when approved, specified, and unblocked, and
# task:ready goes back to task:draft when that stops being true;
# - a reporter's reply moves bug:needs-info back to bug:triage, reopening the
# issue if the stale job closed it (agents post under a maintainer's
# account, so comments carrying the agent footer from rule 12 do not count);
# - a feature whose last sub-issue closed gets a note to check completion
# conditions and close;
# - a feature closed as completed closes the request it came from;
# - a PR closed without merge releases the claim on the issue it would close;
# - a claim with no activity for three days is released;
# - bug:needs-info with no activity for two weeks closes as not planned;
# - a PR from a <kind>/<n> branch must close issue <n> and no other.
# Actions taken with the workflow token do not trigger this workflow again,
# so every job removes the label it replaces itself.
on:
issues:
types: [labeled, edited, closed, reopened]
issue_comment:
types: [created]
pull_request:
types: [opened, edited, synchronize, closed]
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
permissions: {}
concurrency:
group: issue-state-${{ github.event.issue.number || github.event.pull_request.number || 'schedule' }}
cancel-in-progress: false
env:
STATE_LABEL: "^(request|bug|feature|task):"
AGENT_FOOTER: "Written by an agent."
jobs:
exclusive-label:
name: One state label per issue
if: github.event_name == 'issues' && github.event.action == 'labeled'
runs-on: ubuntu-24.04
permissions:
issues: write
timeout-minutes: 5
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const re = new RegExp(process.env.STATE_LABEL);
const added = context.payload.label.name;
if (!re.test(added)) return;
const issue = context.payload.issue;
for (const label of issue.labels.map((l) => l.name)) {
if (label === added || !re.test(label)) continue;
await github.rest.issues.removeLabel({
...context.repo, issue_number: issue.number, name: label,
});
core.info(`#${issue.number}: ${label} replaced by ${added}`);
}
task-ready:
name: Promote or demote between task:draft and task:ready
if: >-
github.event_name == 'issues' &&
(github.event.action == 'closed' || github.event.action == 'reopened' ||
(github.event.action == 'edited' && github.event.changes.body != null))
runs-on: ubuntu-24.04
permissions:
issues: write
timeout-minutes: 5
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const hasLabel = (issue, name) => issue.labels.some((l) => l.name === name);
const section = (body, heading) => {
const m = body.match(new RegExp(`^## ${heading}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, "m"));
return m ? m[1] : null;
};
const dependencies = (body) =>
[...(section(body, "Depends on") ?? "").matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
const specified = (body) => {
const spec = section(body, "Technical spec");
if (spec === null) return false;
return spec
.replace(/<!--[\s\S]*?-->/g, "")
.split("\n")
.some((line) => line.trim() !== "" && !line.startsWith("#") && line.trim() !== "- ...");
};
const approved = (body) => /^- \[x\] Approved for delivery/im.test(body);
const candidates = [];
if (context.payload.action === "edited") {
const issue = context.payload.issue;
if (hasLabel(issue, "task:draft") || hasLabel(issue, "task:ready")) candidates.push(issue);
} else {
const changed = context.payload.issue.number;
for (const labels of ["task:draft", "task:ready"]) {
const issues = await github.paginate(github.rest.issues.listForRepo, {
...context.repo, state: "open", labels, per_page: 100,
});
for (const issue of issues) {
if (dependencies(issue.body ?? "").includes(changed)) candidates.push(issue);
}
}
}
for (const issue of candidates) {
const body = issue.body ?? "";
const why = [];
if (!approved(body)) why.push("approval box not ticked");
if (!specified(body)) why.push("Technical spec section empty");
const open = [];
for (const n of dependencies(body)) {
const dep = await github.rest.issues.get({ ...context.repo, issue_number: n });
if (dep.data.state !== "closed") open.push(`#${n}`);
}
if (open.length) why.push(`waiting on ${open.join(", ")}`);
const ready = why.length === 0;
const isReady = hasLabel(issue, "task:ready");
const issue_number = issue.number;
if (ready && !isReady) {
await github.rest.issues.addLabels({ ...context.repo, issue_number, labels: ["task:ready"] });
await github.rest.issues.removeLabel({ ...context.repo, issue_number, name: "task:draft" });
const deps = dependencies(body).map((n) => `#${n}`);
await github.rest.issues.createComment({
...context.repo, issue_number,
body: `Now \`task:ready\`: approved, technical spec present${deps.length ? `, ${deps.join(", ")} closed` : ""}.`,
});
core.info(`#${issue_number}: task:draft -> task:ready`);
} else if (!ready && isReady) {
if (issue.assignees?.length) {
core.info(`#${issue_number} no longer ready (${why.join("; ")}) but is claimed; leaving it`);
continue;
}
await github.rest.issues.addLabels({ ...context.repo, issue_number, labels: ["task:draft"] });
await github.rest.issues.removeLabel({ ...context.repo, issue_number, name: "task:ready" });
await github.rest.issues.createComment({
...context.repo, issue_number, body: `Back to \`task:draft\`: ${why.join("; ")}.`,
});
core.info(`#${issue_number}: task:ready -> task:draft`);
} else {
core.info(`#${issue_number} unchanged${why.length ? ` (${why.join("; ")})` : ""}`);
}
}
needs-info-reply:
name: Reporter replied on bug:needs-info
if: >-
github.event_name == 'issue_comment' &&
github.event.issue.pull_request == null &&
github.event.comment.user.login == github.event.issue.user.login &&
!contains(github.event.comment.body, 'Written by an agent.') &&
contains(github.event.issue.labels.*.name, 'bug:needs-info')
runs-on: ubuntu-24.04
permissions:
issues: write
timeout-minutes: 5
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const issue_number = context.payload.issue.number;
if (context.payload.issue.state === "closed") {
await github.rest.issues.update({ ...context.repo, issue_number, state: "open" });
core.info(`#${issue_number}: reopened`);
}
await github.rest.issues.addLabels({ ...context.repo, issue_number, labels: ["bug:triage"] });
await github.rest.issues.removeLabel({ ...context.repo, issue_number, name: "bug:needs-info" });
core.info(`#${issue_number}: bug:needs-info -> bug:triage`);
feature-complete:
name: Last sub-issue closed
if: github.event_name == 'issues' && github.event.action == 'closed'
runs-on: ubuntu-24.04
permissions:
issues: write
timeout-minutes: 5
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { repository } = await github.graphql(`
query($owner:String!,$repo:String!,$number:Int!){
repository(owner:$owner,name:$repo){ issue(number:$number){
parent{ number state labels(first:10){nodes{name}} subIssuesSummary{ total completed } }
}}}`, { ...context.repo, number: context.payload.issue.number });
const parent = repository.issue.parent;
if (!parent || parent.state !== "OPEN") return;
if (!parent.labels.nodes.some((l) => l.name === "feature:planned")) return;
const { total, completed } = parent.subIssuesSummary;
if (completed < total) return;
await github.rest.issues.createComment({
...context.repo, issue_number: parent.number,
body: `All ${total} sub-issues are closed. If the completion conditions hold on \`main\`, close this feature and flip its ADRs to *Accepted*.`,
});
core.info(`#${parent.number}: all sub-issues closed`);
request-close:
name: Feature completed, close its request
if: >-
github.event_name == 'issues' && github.event.action == 'closed' &&
github.event.issue.state_reason == 'completed' &&
(contains(github.event.issue.labels.*.name, 'feature:planned') ||
contains(github.event.issue.labels.*.name, 'feature:ready') ||
contains(github.event.issue.labels.*.name, 'feature:spec'))
runs-on: ubuntu-24.04
permissions:
issues: write
timeout-minutes: 5
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const feature = context.payload.issue;
const m = (feature.body ?? "").match(/^Request:\s*#(\d+)/im);
if (!m) return;
const issue_number = Number(m[1]);
const request = await github.rest.issues.get({ ...context.repo, issue_number });
if (request.data.state !== "open") return;
await github.rest.issues.createComment({
...context.repo, issue_number, body: `Shipped as #${feature.number}.`,
});
await github.rest.issues.update({
...context.repo, issue_number, state: "closed", state_reason: "completed",
});
core.info(`#${issue_number}: closed, shipped as #${feature.number}`);
pr-closed-unmerged:
name: PR closed without merge releases the claim
if: >-
github.event_name == 'pull_request' && github.event.action == 'closed' &&
github.event.pull_request.merged == false
runs-on: ubuntu-24.04
permissions:
issues: write
timeout-minutes: 5
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
const closes = [...(pr.body ?? "").matchAll(/\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)/gi)]
.map((x) => Number(x[1]));
for (const issue_number of new Set(closes)) {
const issue = await github.rest.issues.get({ ...context.repo, issue_number });
if (issue.data.state !== "open") continue;
const assignees = issue.data.assignees.map((a) => a.login);
if (assignees.length) {
await github.rest.issues.removeAssignees({ ...context.repo, issue_number, assignees });
}
await github.rest.issues.createComment({
...context.repo, issue_number,
body: `#${pr.number} was closed without merging. Claim released; the label is unchanged, so the next agent starts from the branch and the last handoff.`,
});
core.info(`#${issue_number}: claim released after #${pr.number} closed unmerged`);
}
stale-claims:
name: Release claims with no activity
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
timeout-minutes: 5
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const days = 3;
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const q = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open -no:assignee label:bug:ready,feature:ready,task:ready`;
const found = await github.paginate(github.rest.search.issuesAndPullRequests, { q, per_page: 100 });
for (const issue of found) {
let last = new Date(issue.updated_at).getTime();
const kind = issue.labels.map((l) => l.name).find((n) => /:ready$/.test(n))?.split(":")[0];
try {
const branch = await github.rest.repos.getBranch({ ...context.repo, branch: `${kind}/${issue.number}` });
last = Math.max(last, new Date(branch.data.commit.commit.committer.date).getTime());
} catch (e) {
if (e.status !== 404) throw e;
}
if (last > cutoff) continue;
const assignees = issue.assignees.map((a) => a.login);
await github.rest.issues.removeAssignees({ ...context.repo, issue_number: issue.number, assignees });
await github.rest.issues.createComment({
...context.repo, issue_number: issue.number,
body: `Claim released: no activity for ${days} days. The label is unchanged; the next agent starts from the branch and the last handoff.`,
});
core.info(`#${issue.number}: stale claim released`);
}
stale-needs-info:
name: Close silent bug:needs-info
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
permissions:
issues: write
timeout-minutes: 5
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const cutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const q = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open label:bug:needs-info updated:<${cutoff}`;
const found = await github.paginate(github.rest.search.issuesAndPullRequests, { q, per_page: 100 });
for (const issue of found) {
await github.rest.issues.createComment({
...context.repo, issue_number: issue.number,
body: "Closing: no reply in two weeks. Comment with the missing information and it reopens for triage.",
});
await github.rest.issues.update({
...context.repo, issue_number: issue.number, state: "closed", state_reason: "not_planned",
});
core.info(`#${issue.number}: closed as not planned (stale bug:needs-info)`);
}
branch-matches-issue:
name: Branch name closes its issue
if: github.event_name == 'pull_request' && github.event.action != 'closed'
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
const m = pr.head.ref.match(/^(bug|feature|task)\/(\d+)$/);
if (!m) {
core.info(`${pr.head.ref} is not a <kind>/<n> branch; nothing to check`);
return;
}
const expected = Number(m[2]);
const closes = [...(pr.body ?? "").matchAll(/\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)/gi)]
.map((x) => Number(x[1]));
if (!closes.includes(expected)) {
core.setFailed(`Branch ${pr.head.ref} must close #${expected}: add "Closes #${expected}" to the PR body.`);
return;
}
const others = closes.filter((n) => n !== expected);
if (others.length) {
core.setFailed(`Branch ${pr.head.ref} closes #${expected} but also ${others.map((n) => `#${n}`).join(", ")}; one branch closes one issue.`);
}