Skip to content

Commit c2517c8

Browse files
committed
ci(issues): enforce the delivery label rules — exclusive state label, task:ready promotion, needs-info reply, feature verify, stale close
One workflow, five jobs, all through actions/github-script so no third-party action is needed: adding a <kind>:<state> label drops the old one; task:draft becomes task:ready once approved, specified, and unblocked; a reporter's reply moves bug:needs-info back to bug:triage; the last sub-issue closing on a feature:planned issue posts a completion-conditions note; bug:needs-info silent for two weeks closes as not planned. Documents the automation in docs/agent-rules/delivery.md.
1 parent abb7238 commit c2517c8

2 files changed

Lines changed: 218 additions & 0 deletions

File tree

.github/workflows/issue-state.yml

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
name: Issue state
2+
3+
# The one place that enforces docs/agent-rules/delivery.md mechanically:
4+
# - one <kind>:<state> label per issue (adding the next label drops the old);
5+
# - task:draft becomes task:ready when approved, specified, and unblocked;
6+
# - a reporter's reply moves bug:needs-info back to bug:triage;
7+
# - a feature whose last sub-issue closed gets a completion-conditions note;
8+
# - bug:needs-info with no activity for two weeks closes as not planned.
9+
# Actions taken with the workflow token do not trigger this workflow again,
10+
# so every job removes the label it replaces itself.
11+
12+
on:
13+
issues:
14+
types: [labeled, edited, closed]
15+
issue_comment:
16+
types: [created]
17+
schedule:
18+
- cron: "17 6 * * *"
19+
workflow_dispatch:
20+
21+
permissions: {}
22+
23+
concurrency:
24+
group: issue-state-${{ github.event.issue.number || 'schedule' }}
25+
cancel-in-progress: false
26+
27+
env:
28+
STATE_LABEL: "^(request|bug|feature|task):"
29+
30+
jobs:
31+
exclusive-label:
32+
name: One state label per issue
33+
if: github.event_name == 'issues' && github.event.action == 'labeled'
34+
runs-on: ubuntu-24.04
35+
permissions:
36+
issues: write
37+
timeout-minutes: 5
38+
steps:
39+
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
40+
with:
41+
script: |
42+
const re = new RegExp(process.env.STATE_LABEL);
43+
const added = context.payload.label.name;
44+
if (!re.test(added)) return;
45+
const issue = context.payload.issue;
46+
for (const label of issue.labels.map((l) => l.name)) {
47+
if (label === added || !re.test(label)) continue;
48+
await github.rest.issues.removeLabel({
49+
...context.repo, issue_number: issue.number, name: label,
50+
});
51+
core.info(`#${issue.number}: ${label} replaced by ${added}`);
52+
}
53+
54+
task-ready:
55+
name: Promote task:draft to task:ready
56+
if: >-
57+
github.event_name == 'issues' &&
58+
(github.event.action == 'closed' ||
59+
(github.event.action == 'edited' && github.event.changes.body != null))
60+
runs-on: ubuntu-24.04
61+
permissions:
62+
issues: write
63+
timeout-minutes: 5
64+
steps:
65+
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
66+
with:
67+
script: |
68+
const hasLabel = (issue, name) => issue.labels.some((l) => l.name === name);
69+
const section = (body, heading) => {
70+
const m = body.match(new RegExp(`^## ${heading}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, "m"));
71+
return m ? m[1] : null;
72+
};
73+
const dependencies = (body) =>
74+
[...(section(body, "Depends on") ?? "").matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
75+
const specified = (body) => {
76+
const spec = section(body, "Technical spec");
77+
if (spec === null) return false;
78+
return spec
79+
.replace(/<!--[\s\S]*?-->/g, "")
80+
.split("\n")
81+
.some((line) => line.trim() !== "" && !line.startsWith("#") && line.trim() !== "- ...");
82+
};
83+
const approved = (body) => /^- \[x\] Approved for delivery/im.test(body);
84+
85+
const candidates = [];
86+
if (context.payload.action === "edited") {
87+
if (hasLabel(context.payload.issue, "task:draft")) candidates.push(context.payload.issue);
88+
} else {
89+
const closed = context.payload.issue.number;
90+
const drafts = await github.paginate(github.rest.issues.listForRepo, {
91+
...context.repo, state: "open", labels: "task:draft", per_page: 100,
92+
});
93+
for (const issue of drafts) {
94+
if (dependencies(issue.body ?? "").includes(closed)) candidates.push(issue);
95+
}
96+
}
97+
98+
for (const issue of candidates) {
99+
const body = issue.body ?? "";
100+
const why = [];
101+
if (!approved(body)) why.push("approval box not ticked");
102+
if (!specified(body)) why.push("Technical spec section empty");
103+
const open = [];
104+
for (const n of dependencies(body)) {
105+
const dep = await github.rest.issues.get({ ...context.repo, issue_number: n });
106+
if (dep.data.state !== "closed") open.push(`#${n}`);
107+
}
108+
if (open.length) why.push(`waiting on ${open.join(", ")}`);
109+
if (why.length) {
110+
core.info(`#${issue.number} stays task:draft: ${why.join("; ")}`);
111+
continue;
112+
}
113+
await github.rest.issues.addLabels({
114+
...context.repo, issue_number: issue.number, labels: ["task:ready"],
115+
});
116+
await github.rest.issues.removeLabel({
117+
...context.repo, issue_number: issue.number, name: "task:draft",
118+
});
119+
const deps = dependencies(body).map((n) => `#${n}`);
120+
await github.rest.issues.createComment({
121+
...context.repo, issue_number: issue.number,
122+
body: `Now \`task:ready\`: approved, technical spec present${deps.length ? `, ${deps.join(", ")} closed` : ""}.`,
123+
});
124+
core.info(`#${issue.number}: task:draft -> task:ready`);
125+
}
126+
127+
needs-info-reply:
128+
name: Reporter replied on bug:needs-info
129+
if: >-
130+
github.event_name == 'issue_comment' &&
131+
github.event.issue.pull_request == null &&
132+
github.event.comment.user.login == github.event.issue.user.login &&
133+
contains(github.event.issue.labels.*.name, 'bug:needs-info')
134+
runs-on: ubuntu-24.04
135+
permissions:
136+
issues: write
137+
timeout-minutes: 5
138+
steps:
139+
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
140+
with:
141+
script: |
142+
const issue_number = context.payload.issue.number;
143+
await github.rest.issues.addLabels({ ...context.repo, issue_number, labels: ["bug:triage"] });
144+
await github.rest.issues.removeLabel({ ...context.repo, issue_number, name: "bug:needs-info" });
145+
core.info(`#${issue_number}: bug:needs-info -> bug:triage`);
146+
147+
feature-verify:
148+
name: Last sub-issue closed
149+
if: github.event_name == 'issues' && github.event.action == 'closed'
150+
runs-on: ubuntu-24.04
151+
permissions:
152+
issues: write
153+
timeout-minutes: 5
154+
steps:
155+
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
156+
with:
157+
script: |
158+
const { repository } = await github.graphql(`
159+
query($owner:String!,$repo:String!,$number:Int!){
160+
repository(owner:$owner,name:$repo){ issue(number:$number){
161+
parent{ number state labels(first:10){nodes{name}} subIssuesSummary{ total completed } }
162+
}}}`, { ...context.repo, number: context.payload.issue.number });
163+
const parent = repository.issue.parent;
164+
if (!parent || parent.state !== "OPEN") return;
165+
if (!parent.labels.nodes.some((l) => l.name === "feature:planned")) return;
166+
const { total, completed } = parent.subIssuesSummary;
167+
if (completed < total) return;
168+
await github.rest.issues.createComment({
169+
...context.repo, issue_number: parent.number,
170+
body: `All ${total} sub-issues are closed. Completion conditions are now due: an agent proves each one against \`main\` and reports here, then the maintainer closes this feature and flips its ADRs to *Accepted*.`,
171+
});
172+
core.info(`#${parent.number}: completion conditions due`);
173+
174+
stale-needs-info:
175+
name: Close silent bug:needs-info
176+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
177+
runs-on: ubuntu-24.04
178+
permissions:
179+
issues: write
180+
timeout-minutes: 5
181+
steps:
182+
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
183+
with:
184+
script: |
185+
const cutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
186+
const q = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open label:bug:needs-info updated:<${cutoff}`;
187+
const found = await github.paginate(github.rest.search.issuesAndPullRequests, { q, per_page: 100 });
188+
for (const issue of found) {
189+
await github.rest.issues.createComment({
190+
...context.repo, issue_number: issue.number,
191+
body: "Closing: no reply in two weeks. Comment with the missing information and it reopens for triage.",
192+
});
193+
await github.rest.issues.update({
194+
...context.repo, issue_number: issue.number, state: "closed", state_reason: "not_planned",
195+
});
196+
core.info(`#${issue.number}: closed as not planned (stale bug:needs-info)`);
197+
}

docs/agent-rules/delivery.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,24 @@ gh issue edit <n> --add-label bug:needs-info --remove-label bug:triage
145145

146146
The repo's own skills (`spec-session`, `triage-bug`, `deliver`) encode these
147147
procedures; use them rather than retyping the steps.
148+
149+
## Automation
150+
151+
`.github/workflows/issue-state.yml` is the one place that enforces the label
152+
rules mechanically, so nobody has to remember them:
153+
154+
- Adding a `<kind>:<state>` label removes any other one on the issue. Every
155+
transition is therefore a single add.
156+
- A `task:draft` issue becomes `task:ready` on its own when its approval box
157+
is ticked, its Technical spec section is filled in, and every issue under
158+
Depends on is closed. It is re-evaluated whenever its body changes and
159+
whenever an issue it depends on closes.
160+
- A comment by the reporter on a `bug:needs-info` issue moves it back to
161+
`bug:triage`. Two weeks of silence closes it as not planned; a later
162+
comment does not reopen it automatically, the maintainer does.
163+
- When the last sub-issue of a `feature:planned` issue closes, the workflow
164+
comments that completion conditions are due.
165+
166+
The judgment calls stay manual by design: `bug:new``bug:triage`,
167+
`bug:triage``bug:ready`, `feature:spec``feature:ready`, and the
168+
approval box on each task.

0 commit comments

Comments
 (0)