|
| 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 | +} |
0 commit comments