Skip to content

Commit a30f54f

Browse files
authored
workflows: add issue-led PR readiness gate (#11)
Closes #10.
1 parent e16db5d commit a30f54f

18 files changed

Lines changed: 2497 additions & 189 deletions
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import crypto from "node:crypto";
2+
3+
export const ISSUE_REVIEW_SCHEMA_VERSION = 1;
4+
export const REQUIRED_SECTIONS = Object.freeze([
5+
"Background",
6+
"Goal",
7+
"Code Changes Tree",
8+
"Design",
9+
"Test And Acceptance Criteria",
10+
]);
11+
export const PREFIXED_TITLE = /^[a-z][a-z0-9-]*(?:\/[a-z][a-z0-9-]*)*: \S.*$/;
12+
13+
export function sha256(value) {
14+
return crypto.createHash("sha256").update(value).digest("hex");
15+
}
16+
17+
function blocker(code, message) {
18+
return { source: "issue-format", code, message };
19+
}
20+
21+
function markdownStructure(body) {
22+
const lines = String(body).split(/\r?\n/);
23+
const headings = [];
24+
const visibleLines = [];
25+
let fence = "";
26+
for (let index = 0; index < lines.length; index += 1) {
27+
const line = lines[index];
28+
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
29+
if (fenceMatch) {
30+
if (!fence) fence = fenceMatch[1][0];
31+
else if (fence === fenceMatch[1][0]) fence = "";
32+
continue;
33+
}
34+
if (fence) continue;
35+
visibleLines.push({ index, line });
36+
const heading = line.match(/^## (.+?)\s*$/)?.[1];
37+
if (heading) headings.push({ index, heading });
38+
}
39+
return { lines, headings, visibleLines };
40+
}
41+
42+
export function issueSnapshot(issue) {
43+
const subIssueNumbers = [...new Set(
44+
(issue.sub_issue_numbers ?? []).map(Number).filter(Number.isSafeInteger),
45+
)].sort((left, right) => left - right);
46+
return {
47+
repository: String(issue.repository ?? ""),
48+
number: Number(issue.number),
49+
title: String(issue.title ?? ""),
50+
body: String(issue.body ?? ""),
51+
body_truncated: issue.body_truncated === true,
52+
issue_type: String(issue.issue_type ?? ""),
53+
parent_number: issue.parent_number == null ? null : Number(issue.parent_number),
54+
sub_issue_count: issue.sub_issue_count == null
55+
? subIssueNumbers.length
56+
: Number(issue.sub_issue_count),
57+
sub_issue_numbers: subIssueNumbers,
58+
};
59+
}
60+
61+
export function issueSnapshotSha256(issue) {
62+
return sha256(JSON.stringify(issueSnapshot(issue)));
63+
}
64+
65+
export function analyzeIssue(issue, { implementationIssue = true } = {}) {
66+
const snapshot = issueSnapshot(issue);
67+
const blockers = [];
68+
if (!PREFIXED_TITLE.test(snapshot.title)) {
69+
blockers.push(blocker(
70+
"invalid-title",
71+
"Issue title must use the lowercase `prefix: Subject` format.",
72+
));
73+
}
74+
if (!snapshot.issue_type) {
75+
blockers.push(blocker(
76+
"missing-issue-type",
77+
"Issue must have a GitHub Issue Type.",
78+
));
79+
}
80+
if (snapshot.body_truncated) {
81+
blockers.push(blocker(
82+
"issue-body-truncated",
83+
"The workflow could not snapshot the complete Issue body and must fail closed.",
84+
));
85+
}
86+
if (implementationIssue && snapshot.issue_type.toLowerCase() === "task") {
87+
blockers.push(blocker(
88+
"tracking-task",
89+
"A pull request must close a concrete implementation Issue, not only a Task container.",
90+
));
91+
}
92+
if (
93+
snapshot.issue_type.toLowerCase() === "task"
94+
&& snapshot.sub_issue_count === 0
95+
) {
96+
blockers.push(blocker(
97+
"task-without-sub-issues",
98+
"A Task Issue must be a tracking container with native sub-issues.",
99+
));
100+
}
101+
if (snapshot.sub_issue_count > snapshot.sub_issue_numbers.length) {
102+
blockers.push(blocker(
103+
"sub-issues-truncated",
104+
"The workflow could not snapshot every native sub-issue and must fail closed.",
105+
));
106+
}
107+
108+
const structure = markdownStructure(snapshot.body);
109+
const sections = structure.headings.map((item) => item.heading);
110+
if (
111+
snapshot.issue_type.toLowerCase() !== "task"
112+
&& (
113+
sections.length !== REQUIRED_SECTIONS.length
114+
|| sections.some((section, index) => section !== REQUIRED_SECTIONS[index])
115+
)
116+
) {
117+
blockers.push(blocker(
118+
"invalid-section-contract",
119+
`Issue must contain exactly these top-level sections in order: ${REQUIRED_SECTIONS.join(", ")}.`,
120+
));
121+
}
122+
123+
const backgroundHeading = structure.headings.find(
124+
(item) => item.heading === "Background",
125+
);
126+
const nextHeading = structure.headings.find(
127+
(item) => backgroundHeading && item.index > backgroundHeading.index,
128+
);
129+
const background = !backgroundHeading
130+
? ""
131+
: structure.visibleLines
132+
.filter((item) => (
133+
item.index > backgroundHeading.index
134+
&& (!nextHeading || item.index < nextHeading.index)
135+
))
136+
.map((item) => item.line)
137+
.join("\n");
138+
for (
139+
const label of snapshot.issue_type.toLowerCase() === "task"
140+
? []
141+
: ["Parent", "Prerequisite of", "Follow up to"]
142+
) {
143+
if (new RegExp(`^${label}:`, "m").test(background)) {
144+
blockers.push(blocker(
145+
"invalid-background-relationship",
146+
`${label} relationships must be Markdown list items.`,
147+
));
148+
}
149+
}
150+
if (
151+
snapshot.issue_type.toLowerCase() !== "task"
152+
&& /^- (?:Prerequisite of|Follow up to):\s+#\d+\s*$/m.test(background)
153+
) {
154+
blockers.push(blocker(
155+
"invalid-background-relationship",
156+
"Prerequisite of and Follow up to relationships must use nested Issue lists.",
157+
));
158+
}
159+
160+
return {
161+
schema_version: ISSUE_REVIEW_SCHEMA_VERSION,
162+
snapshot,
163+
snapshot_sha256: issueSnapshotSha256(snapshot),
164+
deterministic_blockers: blockers,
165+
};
166+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env node
2+
3+
import assert from "node:assert/strict";
4+
import {
5+
REQUIRED_SECTIONS,
6+
analyzeIssue,
7+
issueSnapshotSha256,
8+
} from "./common.mjs";
9+
10+
const validBody = REQUIRED_SECTIONS.map((section) => (
11+
`## ${section}\n\n${section} details.`
12+
)).join("\n\n");
13+
const valid = {
14+
repository: "GizClaw/example",
15+
number: 10,
16+
title: "ci: Add readiness gate",
17+
body: validBody,
18+
issue_type: "Feature",
19+
parent_number: null,
20+
sub_issue_numbers: [],
21+
};
22+
assert.deepEqual(analyzeIssue(valid).deterministic_blockers, []);
23+
assert.equal(issueSnapshotSha256(valid), issueSnapshotSha256({ ...valid }));
24+
assert.notEqual(
25+
issueSnapshotSha256(valid),
26+
issueSnapshotSha256({ ...valid, body: `${validBody}\nchanged` }),
27+
);
28+
assert.notEqual(
29+
issueSnapshotSha256(valid),
30+
issueSnapshotSha256({ ...valid, body_truncated: true }),
31+
);
32+
assert.ok(analyzeIssue({ ...valid, body_truncated: true })
33+
.deterministic_blockers.some((item) => item.code === "issue-body-truncated"));
34+
assert.ok(analyzeIssue({ ...valid, title: "Bad title" })
35+
.deterministic_blockers.some((item) => item.code === "invalid-title"));
36+
assert.ok(analyzeIssue({ ...valid, issue_type: "" })
37+
.deterministic_blockers.some((item) => item.code === "missing-issue-type"));
38+
assert.ok(analyzeIssue({ ...valid, issue_type: "Task" })
39+
.deterministic_blockers.some((item) => item.code === "tracking-task"));
40+
assert.ok(analyzeIssue({
41+
...valid,
42+
sub_issue_count: 101,
43+
sub_issue_numbers: Array.from({ length: 100 }, (_, index) => index + 1),
44+
}).deterministic_blockers.some(
45+
(item) => item.code === "sub-issues-truncated",
46+
));
47+
assert.deepEqual(analyzeIssue({
48+
...valid,
49+
issue_type: "Task",
50+
body: "",
51+
sub_issue_count: 1,
52+
sub_issue_numbers: [20],
53+
}, { implementationIssue: false }).deterministic_blockers, []);
54+
assert.ok(analyzeIssue({ ...valid, body: "## Goal\n\nToo little." })
55+
.deterministic_blockers.some((item) => item.code === "invalid-section-contract"));
56+
assert.ok(analyzeIssue({
57+
...valid,
58+
body: validBody.replace(
59+
"Background details.",
60+
"Parent: #1\n\n- Follow up to: #2",
61+
),
62+
}).deterministic_blockers.some(
63+
(item) => item.code === "invalid-background-relationship",
64+
));
65+
assert.deepEqual(analyzeIssue({
66+
...valid,
67+
body: validBody.replace(
68+
"Design details.",
69+
"```markdown\n## Not a real top-level section\n```\n\nDesign details.",
70+
),
71+
}).deterministic_blockers, []);
72+
73+
process.stdout.write("issue-review tests passed\n");

0 commit comments

Comments
 (0)