Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions .github/workflows/issue-state.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
name: Issue state

# The one place that enforces docs/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;
# - a reporter's reply moves bug:needs-info back to bug:triage;
# - a feature whose last sub-issue closed gets a completion-conditions note;
# - 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]
issue_comment:
types: [created]
pull_request:
types: [opened, edited, synchronize]
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):"

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 task:draft to task:ready
if: >-
github.event_name == 'issues' &&
(github.event.action == 'closed' ||
(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") {
if (hasLabel(context.payload.issue, "task:draft")) candidates.push(context.payload.issue);
} else {
const closed = context.payload.issue.number;
const drafts = await github.paginate(github.rest.issues.listForRepo, {
...context.repo, state: "open", labels: "task:draft", per_page: 100,
});
for (const issue of drafts) {
if (dependencies(issue.body ?? "").includes(closed)) 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(", ")}`);
if (why.length) {
core.info(`#${issue.number} stays task:draft: ${why.join("; ")}`);
continue;
}
await github.rest.issues.addLabels({
...context.repo, issue_number: issue.number, labels: ["task:ready"],
});
await github.rest.issues.removeLabel({
...context.repo, issue_number: issue.number, name: "task:draft",
});
const deps = dependencies(body).map((n) => `#${n}`);
await github.rest.issues.createComment({
...context.repo, issue_number: issue.number,
body: `Now \`task:ready\`: approved, technical spec present${deps.length ? `, ${deps.join(", ")} closed` : ""}.`,
});
core.info(`#${issue.number}: task:draft -> task:ready`);
}

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.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;
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-verify:
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. 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*.`,
});
core.info(`#${parent.number}: completion conditions due`);

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'
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.`);
}
3 changes: 2 additions & 1 deletion docs/internal/DELIVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ that are mechanical: adding a state label removes the previous one, so every
transition is a single add; `task:draft` becomes `task:ready` when approved,
specified and unblocked; a reporter's reply moves `bug:needs-info` back to
`bug:triage`; a silent `bug:needs-info` closes after two weeks; a feature
whose last sub-issue closed gets the completion-conditions note. The repo's
whose last sub-issue closed gets the completion-conditions note; a PR from a
`<kind>/<n>` branch must close `#<n>` and nothing else. The repo's
skills — `spec-session`, `triage-bug`, `deliver` — handle the transitions an
agent makes as part of its own procedure.

Expand Down
23 changes: 23 additions & 0 deletions docs/internal/agent-rules/delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,26 @@ gh issue edit <n> --add-label bug:needs-info --remove-label bug:triage

The repo's own skills (`spec-session`, `triage-bug`, `deliver`) encode these
procedures; use them rather than retyping the steps.

## Automation

`.github/workflows/issue-state.yml` is the one place that enforces the label
rules mechanically, so nobody has to remember them:

- Adding a `<kind>:<state>` label removes any other one on the issue. Every
transition is therefore a single add.
- A `task:draft` issue becomes `task:ready` on its own when its approval box
is ticked, its Technical spec section is filled in, and every issue under
Depends on is closed. It is re-evaluated whenever its body changes and
whenever an issue it depends on closes.
- A comment by the reporter on a `bug:needs-info` issue moves it back to
`bug:triage`. Two weeks of silence closes it as not planned; a later
comment does not reopen it automatically, the maintainer does.
- When the last sub-issue of a `feature:planned` issue closes, the workflow
comments that completion conditions are due.
- A pull request from a `<kind>/<n>` branch fails its check unless its body
closes `#<n>` and closes nothing else.

The judgment calls stay manual by design: `bug:new` → `bug:triage`,
`bug:triage` → `bug:ready`, `feature:spec` → `feature:ready`, and the
approval box on each task.
Loading