@gobing-ai/spur 0.3.53 → 0.3.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/corpus-baseline.json +2783 -2783
- package/config/pipeline-budgets.json +42 -0
- package/config/plugin-scripts.json +73 -0
- package/config/workflow-composition-baseline.json +561 -0
- package/config/workflows/docs-pipeline.yaml +29 -13
- package/config/workflows/feature-dev.yaml +54 -2
- package/config/workflows/idea-pipeline.yaml +15 -0
- package/config/workflows/task-pipeline.yaml +77 -65
- package/config/workflows/wrapup-pipeline.yaml +40 -45
- package/package.json +9 -9
- package/plugins/README.md +1 -1
- package/plugins/sp/README.md +3 -3
- package/plugins/sp/agents/super-planner.md +1 -1
- package/plugins/sp/commands/dev-daily.md +1 -1
- package/plugins/sp/commands/dev-history-load.md +1 -1
- package/plugins/sp/commands/dev-idea.md +10 -2
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/batch-preflight.mjs +160 -0
- package/plugins/sp/scripts/batch-preflight.ts +1 -1
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +479 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +75 -37
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +204 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +1 -1
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +141 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +1 -1
- package/plugins/sp/scripts/feature-sync-bounded.mjs +290 -0
- package/plugins/sp/scripts/feature-sync-bounded.ts +9 -8
- package/plugins/sp/scripts/history-load.mjs +268 -0
- package/plugins/sp/scripts/pr-reviewing.mjs +685 -0
- package/plugins/sp/scripts/pr-reviewing.ts +1 -1
- package/plugins/sp/scripts/script-contract-check.ts +319 -0
- package/plugins/sp/skills/daily-summary/SKILL.md +3 -3
- package/plugins/sp/skills/dogfood-testing/SKILL.md +5 -5
- package/plugins/sp/skills/dogfood-testing/references/report-template.md +1 -1
- package/plugins/sp/skills/pr-reviewing/SKILL.md +3 -3
- package/plugins/sp/skills/spur-dev/SKILL.md +2 -1
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +3 -4
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +1 -1
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +3 -3
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +4 -4
- package/spur.js +4270 -1323
- package/web/_astro/{BoardApp.plVXYkKo.js → BoardApp.DCLSB3Zs.js} +63 -63
- package/web/_astro/BoardApp.Dx5gzAhb.js +1 -0
- package/web/_astro/{TaskDetail.5DIyVQ_N.js → TaskDetail.DN_RxS-2.js} +1 -1
- package/web/_astro/{arc.BnUgL7ho.js → arc.CldTHRg-.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.B9YMJau-.js → architectureDiagram-3BPJPVTR.DQ4T9oeU.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.Dw2qgvWJ.js → blockDiagram-GPEHLZMM.CGDaXH8J.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.jTbU0-Nk.js → c4Diagram-AAUBKEIU.Cqsc2iub.js} +1 -1
- package/web/_astro/channel.CrBJYpxo.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.Di46EtkU.js → chunk-2J33WTMH.CQSKsRmq.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.DweHBwqQ.js → chunk-4BX2VUAB.Pv1HCtp8.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.rRjTKaa7.js → chunk-55IACEB6.-9MseOIk.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.BkEQIkSV.js → chunk-727SXJPM.M3OI1DF7.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.Cfe9IvAd.js → chunk-AQP2D5EJ.NfHka5Ca.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.fI6tLbeN.js → chunk-FMBD7UC4.B2suYe3s.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.BIjgpq0O.js → chunk-ND2GUHAM.BfJ7aucP.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.ZwLnVxyz.js → chunk-QZHKN3VN._582hZVc.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.BJAGYidE.js → classDiagram-4FO5ZUOK._tttU_jk.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.BJAGYidE.js → classDiagram-v2-Q7XG4LA2._tttU_jk.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.B5y3nz26.js → cose-bilkent-S5V4N54A.C4rVATLQ.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.DHmw0ZBC.js → dagre-BM42HDAG.8BLRi7f9.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.10gapJfY.js → diagram-2AECGRRQ.D6lwUZos.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.Gh33_drM.js → diagram-5GNKFQAL.Cl_rMiVT.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.8ROWlfcX.js → diagram-KO2AKTUF.DhMExHLp.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.BsP3tKmg.js → diagram-LMA3HP47.3OAxzH_o.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.DM2WFDhU.js → diagram-OG6HWLK6.Uh9RLucG.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.royaC_lH.js → erDiagram-TEJ5UH35.izzElG0c.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.C9GEvIie.js → flowDiagram-I6XJVG4X.BlGwp5qM.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.CXO4T2U0.js → ganttDiagram-6RSMTGT7.je7Vf8dN.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.Q02KzYH7.js → gitGraphDiagram-PVQCEYII.DNk0Ycop.js} +1 -1
- package/web/_astro/{index.DAxu50UF.css → index.V6Q7nhed.css} +1 -1
- package/web/_astro/{infoDiagram-5YYISTIA.DNfSDKJt.js → infoDiagram-5YYISTIA.K2HhGyWH.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.BFttvtgJ.js → ishikawaDiagram-YF4QCWOH.BbbOW4IN.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.Cfm-2SbG.js → journeyDiagram-JHISSGLW.B1UaZKqN.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.BWGNW6fV.js → kanban-definition-UN3LZRKU.9tsw5QFd.js} +1 -1
- package/web/_astro/{linear.BprdUk2Y.js → linear.I9dvtu-j.js} +1 -1
- package/web/_astro/{mermaid.core.B2zRNxtX.js → mermaid.core.5bKJsMgf.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.g9jij8sw.js → mindmap-definition-RKZ34NQL.BC5MqSn6.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.CbzR4n5b.js → pieDiagram-4H26LBE5.B8GIMyxD.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.DxV3viWt.js → quadrantDiagram-W4KKPZXB.K_aZSM_u.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.DvAb90JS.js → requirementDiagram-4Y6WPE33.B_V5kzfT.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.CIFz5t4D.js → sankeyDiagram-5OEKKPKP.DyV2quLS.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.DzlEQynd.js → sequenceDiagram-3UESZ5HK.BlKvPnBv.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.Dph2V-ts.js → stateDiagram-AJRCARHV.C1lYUMdS.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.wJhfrLAy.js → stateDiagram-v2-BHNVJYJU.DJWjr3QV.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.BrSiyubX.js → timeline-definition-PNZ67QCA.CYYiXVlv.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.COobPrUX.js → vennDiagram-CIIHVFJN.zYjQKa_S.js} +1 -1
- package/web/_astro/{wardley-L42UT6IY.CrjeTx8A.js → wardley-L42UT6IY.BGrWTY3D.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.B8_fTQ8E.js → wardleyDiagram-YWT4CUSO.CkXxtcI1.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.BPpokgVv.js → xychartDiagram-2RQKCTM6.BhKH3KG3.js} +1 -1
- package/web/index.html +2 -2
- package/config/workflows/planning-pipeline.yaml +0 -249
- package/config/workflows/task-pipeline2.yaml +0 -778
- package/web/_astro/BoardApp.pdtYu-yW.js +0 -1
- package/web/_astro/channel.DEqvaz-I.js +0 -1
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// plugins/sp/scripts/pr-reviewing.ts
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
var spawnRunner = (cmd) => {
|
|
9
|
+
const proc = spawnSync(cmd[0] ?? "", [...cmd.slice(1)], { encoding: "utf8", env: process.env });
|
|
10
|
+
return {
|
|
11
|
+
code: proc.status ?? 1,
|
|
12
|
+
stdout: proc.stdout ?? "",
|
|
13
|
+
stderr: proc.stderr ?? "",
|
|
14
|
+
error: proc.error?.message
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
var runner = spawnRunner;
|
|
18
|
+
function setCommandRunner(next) {
|
|
19
|
+
runner = next ?? spawnRunner;
|
|
20
|
+
}
|
|
21
|
+
function run(cmd) {
|
|
22
|
+
return runner(cmd);
|
|
23
|
+
}
|
|
24
|
+
function runOk(cmd, what) {
|
|
25
|
+
const res = run(cmd);
|
|
26
|
+
if (res.code !== 0) {
|
|
27
|
+
throw new Error(`${what} failed: ${res.stderr.trim() || res.stdout.trim() || res.error || `exit ${res.code}`}`);
|
|
28
|
+
}
|
|
29
|
+
return res.stdout.trim();
|
|
30
|
+
}
|
|
31
|
+
function parseJson(raw, what) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(raw);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`${what}: unparseable JSON output`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
var VALUE_FLAGS = new Set(["--base", "--focus", "--since", "--head", "--timeout", "--interval", "--status-file"]);
|
|
39
|
+
var BOOL_FLAGS = new Set(["--json", "--force"]);
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
const [subcommand = "", ...rest] = argv;
|
|
42
|
+
const flags = new Map;
|
|
43
|
+
const booleans = new Set;
|
|
44
|
+
for (let i = 0;i < rest.length; i++) {
|
|
45
|
+
const tok = rest[i];
|
|
46
|
+
if (BOOL_FLAGS.has(tok)) {
|
|
47
|
+
booleans.add(tok);
|
|
48
|
+
} else if (VALUE_FLAGS.has(tok)) {
|
|
49
|
+
const value = rest[i + 1];
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
throw new Error(`flag ${tok} requires a value`);
|
|
52
|
+
flags.set(tok, value);
|
|
53
|
+
i++;
|
|
54
|
+
} else {
|
|
55
|
+
throw new Error(`unknown argument: ${tok}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { subcommand, flags, booleans };
|
|
59
|
+
}
|
|
60
|
+
function emit(args, payload, human) {
|
|
61
|
+
if (args.booleans.has("--json")) {
|
|
62
|
+
console.log(JSON.stringify(payload));
|
|
63
|
+
} else {
|
|
64
|
+
console.log(human);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class ScriptExit extends Error {
|
|
69
|
+
code;
|
|
70
|
+
json;
|
|
71
|
+
constructor(message, code, json) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.code = code;
|
|
74
|
+
this.json = json;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function fail(args, message, code) {
|
|
78
|
+
throw new ScriptExit(message, code, args?.booleans.has("--json") ?? false);
|
|
79
|
+
}
|
|
80
|
+
function writeStatus(args, verdict) {
|
|
81
|
+
const file = args.flags.get("--status-file");
|
|
82
|
+
if (file)
|
|
83
|
+
writeFileSync(file, `${verdict}
|
|
84
|
+
`);
|
|
85
|
+
}
|
|
86
|
+
function writeFailureStatus(args) {
|
|
87
|
+
const file = args?.flags.get("--status-file");
|
|
88
|
+
if (!file)
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
writeFileSync(file, `FAIL
|
|
92
|
+
`);
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
function isCodexAuthor(login, type) {
|
|
96
|
+
return /codex/i.test(login ?? "") && (type === "Bot" || /\[bot\]$/i.test(login ?? ""));
|
|
97
|
+
}
|
|
98
|
+
function isHeadReviewed(reviews, head) {
|
|
99
|
+
return reviews.some((review) => isCodexAuthor(review.user?.login, review.user?.type) && review.commit_id === head && isCompletedReview(review));
|
|
100
|
+
}
|
|
101
|
+
function isCompletedReview(review) {
|
|
102
|
+
const state = (review.state ?? "").toUpperCase();
|
|
103
|
+
return review.submitted_at !== undefined && state !== "DISMISSED" && state !== "PENDING";
|
|
104
|
+
}
|
|
105
|
+
var CLEAN_REVIEW_RE = /\b(?:no|zero)\s+(?:actionable\s+)?(?:findings?|issues?|problems?)\b|\b(?:looks|seems)\s+good\b|\blgtm\b|\bno\s+concerns\b/i;
|
|
106
|
+
function isExplicitCleanReview(review, head) {
|
|
107
|
+
if (!isCodexAuthor(review.user?.login, review.user?.type) || review.commit_id !== head || !isCompletedReview(review)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const body = (review.body ?? "").trim();
|
|
111
|
+
if (/\bP[0-3]\b/i.test(body))
|
|
112
|
+
return false;
|
|
113
|
+
const state = (review.state ?? "").toUpperCase();
|
|
114
|
+
if (!["APPROVED", "CLEAN", "COMMENTED"].includes(state))
|
|
115
|
+
return false;
|
|
116
|
+
return state === "CLEAN" || state === "APPROVED" && body === "" || CLEAN_REVIEW_RE.test(body);
|
|
117
|
+
}
|
|
118
|
+
function isFresh(at, since) {
|
|
119
|
+
if (at === undefined)
|
|
120
|
+
return false;
|
|
121
|
+
if (since === "")
|
|
122
|
+
return true;
|
|
123
|
+
const atMs = Date.parse(at);
|
|
124
|
+
const sinceMs = Date.parse(since);
|
|
125
|
+
return Number.isFinite(atMs) && Number.isFinite(sinceMs) && atMs >= Math.floor(sinceMs / 1000) * 1000;
|
|
126
|
+
}
|
|
127
|
+
function hasCurrentCleanReview(reviews, since, head) {
|
|
128
|
+
return reviews.some((review) => isFresh(review.submitted_at, since) && isExplicitCleanReview(review, head));
|
|
129
|
+
}
|
|
130
|
+
function extractSeverity(body) {
|
|
131
|
+
const match = body.match(/\bP([0-3])\b/);
|
|
132
|
+
return match ? `P${match[1]}` : "unrated";
|
|
133
|
+
}
|
|
134
|
+
var DEFAULT_FOCUS = "for correctness and regressions, security boundaries, data-loss risks, concurrency or race conditions, " + "API/backward compatibility, migration safety, error handling, and missing high-value tests. " + "Prioritize actionable issues over style or nits.";
|
|
135
|
+
function hasCodeReviewRules(repoRoot) {
|
|
136
|
+
const agentsPath = join(repoRoot, "AGENTS.md");
|
|
137
|
+
if (!existsSync(agentsPath))
|
|
138
|
+
return false;
|
|
139
|
+
return /^## Code Review Rules\s*$/m.test(readFileSync(agentsPath, "utf8"));
|
|
140
|
+
}
|
|
141
|
+
function buildRequestBody(rulesPresent, focus) {
|
|
142
|
+
const base = rulesPresent ? "@codex review" : `@codex review ${DEFAULT_FOCUS}`;
|
|
143
|
+
const trimmed = focus.trim();
|
|
144
|
+
return trimmed === "" ? base : `${base} Focus especially on ${trimmed}.`;
|
|
145
|
+
}
|
|
146
|
+
var BLOCK_LINE_PATTERNS = [
|
|
147
|
+
[/^(<{7}|={7}|>{7})(\s|$)/, "merge-conflict marker"],
|
|
148
|
+
[/^-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/, "private key material"],
|
|
149
|
+
[/\bAKIA[0-9A-Z]{16}\b/, "AWS access key id"],
|
|
150
|
+
[/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/, "GitHub token"],
|
|
151
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}\b/, "GitHub fine-grained PAT"],
|
|
152
|
+
[/\bsk-[A-Za-z0-9_-]{20,}\b/, "API secret key"],
|
|
153
|
+
[/\bxox[bapors]-[A-Za-z0-9-]{10,}\b/, "Slack token"]
|
|
154
|
+
];
|
|
155
|
+
var WARN_LINE_PATTERNS = [
|
|
156
|
+
[/\bdebugger\b/, "debugger statement"],
|
|
157
|
+
[/\bconsole\.(log|debug|trace)\(/, "debug logging"]
|
|
158
|
+
];
|
|
159
|
+
function scanHygiene(diffText, changedFiles) {
|
|
160
|
+
const blockers = [];
|
|
161
|
+
const warnings = [];
|
|
162
|
+
for (const file of changedFiles) {
|
|
163
|
+
if (/(^|\/)\.env(\.[^/]+)?$/.test(file) && !/\.(example|sample|template)$/.test(file)) {
|
|
164
|
+
blockers.push(`${file}: committed .env file`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const line of diffText.split(`
|
|
168
|
+
`)) {
|
|
169
|
+
if (!line.startsWith("+") || line.startsWith("+++"))
|
|
170
|
+
continue;
|
|
171
|
+
const added = line.slice(1);
|
|
172
|
+
let blocked = false;
|
|
173
|
+
for (const [pattern, label] of BLOCK_LINE_PATTERNS) {
|
|
174
|
+
if (pattern.test(added)) {
|
|
175
|
+
blockers.push(`${label}: redacted`);
|
|
176
|
+
blocked = true;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (blocked)
|
|
181
|
+
continue;
|
|
182
|
+
for (const [pattern, label] of WARN_LINE_PATTERNS) {
|
|
183
|
+
if (pattern.test(added)) {
|
|
184
|
+
warnings.push(`${label}: ${added.trim().slice(0, 80)}`);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const verdict = blockers.length > 0 ? "BLOCK" : warnings.length > 0 ? "WARN" : "PASS";
|
|
190
|
+
return { verdict, blockers, warnings };
|
|
191
|
+
}
|
|
192
|
+
function normalizeFindings(reviews, inline, comments, since, head) {
|
|
193
|
+
const findings = [];
|
|
194
|
+
const fresh = (at) => isFresh(at, since);
|
|
195
|
+
const onHead = (commitId) => commitId === head;
|
|
196
|
+
for (const r of reviews) {
|
|
197
|
+
if (!isCodexAuthor(r.user?.login, r.user?.type) || !isCompletedReview(r) || !fresh(r.submitted_at) || !onHead(r.commit_id))
|
|
198
|
+
continue;
|
|
199
|
+
if (isExplicitCleanReview(r, head))
|
|
200
|
+
continue;
|
|
201
|
+
if ((r.body ?? "").trim() === "")
|
|
202
|
+
continue;
|
|
203
|
+
findings.push({
|
|
204
|
+
kind: "review",
|
|
205
|
+
severity: extractSeverity(r.body ?? ""),
|
|
206
|
+
path: null,
|
|
207
|
+
line: null,
|
|
208
|
+
body: r.body ?? "",
|
|
209
|
+
url: r.html_url ?? "",
|
|
210
|
+
at: r.submitted_at ?? ""
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
for (const c of inline) {
|
|
214
|
+
if (!isCodexAuthor(c.user?.login, c.user?.type) || !fresh(c.created_at) || !onHead(c.commit_id))
|
|
215
|
+
continue;
|
|
216
|
+
findings.push({
|
|
217
|
+
kind: "inline",
|
|
218
|
+
severity: extractSeverity(c.body ?? ""),
|
|
219
|
+
path: c.path ?? null,
|
|
220
|
+
line: c.line ?? null,
|
|
221
|
+
body: c.body ?? "",
|
|
222
|
+
url: c.html_url ?? "",
|
|
223
|
+
at: c.created_at ?? ""
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
for (const c of comments) {
|
|
227
|
+
if (!isCodexAuthor(c.user?.login, c.user?.type) || !fresh(c.created_at) || !onHead(c.commit_id))
|
|
228
|
+
continue;
|
|
229
|
+
findings.push({
|
|
230
|
+
kind: "comment",
|
|
231
|
+
severity: extractSeverity(c.body ?? ""),
|
|
232
|
+
path: null,
|
|
233
|
+
line: null,
|
|
234
|
+
body: c.body ?? "",
|
|
235
|
+
url: c.html_url ?? "",
|
|
236
|
+
at: c.created_at ?? ""
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return findings.sort((a, b) => a.at.localeCompare(b.at));
|
|
240
|
+
}
|
|
241
|
+
function renderFindings(findings) {
|
|
242
|
+
if (findings.length === 0)
|
|
243
|
+
return "Codex review completed without actionable findings.";
|
|
244
|
+
const lines = [`Findings (${findings.length})`, ""];
|
|
245
|
+
findings.forEach((f, i) => {
|
|
246
|
+
const where = f.path ? `${f.path}${f.line ? `:${f.line}` : ""}` : f.kind;
|
|
247
|
+
lines.push(`${i + 1}. [${f.severity}] ${where}`);
|
|
248
|
+
lines.push(` ${f.body.split(`
|
|
249
|
+
`)[0]}`);
|
|
250
|
+
if (f.url)
|
|
251
|
+
lines.push(` ${f.url}`);
|
|
252
|
+
});
|
|
253
|
+
return lines.join(`
|
|
254
|
+
`);
|
|
255
|
+
}
|
|
256
|
+
function preflightContext() {
|
|
257
|
+
const repoRoot = runOk(["git", "rev-parse", "--show-toplevel"], "git rev-parse --show-toplevel");
|
|
258
|
+
const branch = runOk(["git", "branch", "--show-current"], "git branch --show-current");
|
|
259
|
+
if (branch === "")
|
|
260
|
+
throw new Error("HEAD is detached \u2014 check out a branch before requesting a PR review");
|
|
261
|
+
const head = runOk(["git", "rev-parse", "HEAD"], "git rev-parse HEAD");
|
|
262
|
+
const auth = run(["gh", "auth", "status"]);
|
|
263
|
+
if (auth.code !== 0) {
|
|
264
|
+
throw new Error("gh CLI missing or unauthenticated \u2014 install gh and run `gh auth login` (no browser fallback)");
|
|
265
|
+
}
|
|
266
|
+
const repo = parseJson(runOk(["gh", "repo", "view", "--json", "nameWithOwner,defaultBranchRef"], "gh repo view"), "gh repo view");
|
|
267
|
+
return {
|
|
268
|
+
repoRoot,
|
|
269
|
+
nameWithOwner: repo.nameWithOwner,
|
|
270
|
+
branch,
|
|
271
|
+
head,
|
|
272
|
+
shortHead: head.slice(0, 7),
|
|
273
|
+
defaultBranch: repo.defaultBranchRef.name
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function resolveUpstream() {
|
|
277
|
+
const refRes = run(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
278
|
+
if (refRes.code !== 0)
|
|
279
|
+
return null;
|
|
280
|
+
const ref = refRes.stdout.trim();
|
|
281
|
+
const count = (res) => {
|
|
282
|
+
const n = Number(res.stdout.trim());
|
|
283
|
+
return res.code === 0 && Number.isFinite(n) ? n : 0;
|
|
284
|
+
};
|
|
285
|
+
return {
|
|
286
|
+
ref,
|
|
287
|
+
ahead: count(run(["git", "rev-list", "--count", "@{u}..HEAD"])),
|
|
288
|
+
behind: count(run(["git", "rev-list", "--count", "HEAD..@{u}"]))
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function viewPr() {
|
|
292
|
+
const res = run([
|
|
293
|
+
"gh",
|
|
294
|
+
"pr",
|
|
295
|
+
"view",
|
|
296
|
+
"--json",
|
|
297
|
+
"number,url,state,isDraft,headRefName,baseRefName,title,headRefOid"
|
|
298
|
+
]);
|
|
299
|
+
if (res.code !== 0) {
|
|
300
|
+
const detail = `${res.stderr}
|
|
301
|
+
${res.stdout}`;
|
|
302
|
+
if (/no pull requests? found|no pull request associated|could not find pull request/i.test(detail))
|
|
303
|
+
return null;
|
|
304
|
+
throw new Error(`gh pr view failed: ${res.stderr.trim() || res.stdout.trim() || res.error || `exit ${res.code}`}`);
|
|
305
|
+
}
|
|
306
|
+
return parseJson(res.stdout, "gh pr view");
|
|
307
|
+
}
|
|
308
|
+
function fetchReviews(ctx, pr) {
|
|
309
|
+
return fetchPaginated(`repos/${ctx.nameWithOwner}/pulls/${pr}/reviews`, "gh api reviews");
|
|
310
|
+
}
|
|
311
|
+
function fetchInlineComments(ctx, pr) {
|
|
312
|
+
return fetchPaginated(`repos/${ctx.nameWithOwner}/pulls/${pr}/comments`, "gh api review comments");
|
|
313
|
+
}
|
|
314
|
+
function fetchIssueComments(ctx, pr) {
|
|
315
|
+
return fetchPaginated(`repos/${ctx.nameWithOwner}/issues/${pr}/comments`, "gh api issue comments");
|
|
316
|
+
}
|
|
317
|
+
function fetchPaginated(endpoint, what) {
|
|
318
|
+
const pages = parseJson(runOk(["gh", "api", "--method", "GET", endpoint, "--paginate", "--slurp"], what), what);
|
|
319
|
+
return pages.flat();
|
|
320
|
+
}
|
|
321
|
+
var requestMarker = (head) => `<!-- spur-pr-review head:${head} -->`;
|
|
322
|
+
function hasPendingRequest(comments, head, login) {
|
|
323
|
+
const marker = requestMarker(head);
|
|
324
|
+
return comments.some((comment) => comment.user?.login === login && (comment.body ?? "").includes(marker));
|
|
325
|
+
}
|
|
326
|
+
function requireExpectedHead(args, pr) {
|
|
327
|
+
const expected = args.flags.get("--head");
|
|
328
|
+
if (expected && expected !== pr.headRefOid) {
|
|
329
|
+
writeStatus(args, "FAIL");
|
|
330
|
+
fail(args, `PR HEAD moved from ${expected.slice(0, 7)} to ${pr.headRefOid.slice(0, 7)} \u2014 request a new review`, 2);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function cmdPreflight(args) {
|
|
334
|
+
const ctx = preflightContext();
|
|
335
|
+
const dirty = run(["git", "status", "--porcelain"]);
|
|
336
|
+
if (dirty.code !== 0)
|
|
337
|
+
fail(args, `git status failed: ${dirty.stderr.trim() || dirty.error || `exit ${dirty.code}`}`, 2);
|
|
338
|
+
const dirtyFiles = dirty.stdout.split(`
|
|
339
|
+
`).filter((l) => l.trim() !== "");
|
|
340
|
+
if (dirtyFiles.length > 0) {
|
|
341
|
+
writeStatus(args, "FAIL");
|
|
342
|
+
fail(args, `working tree is dirty (${dirtyFiles.length} entries) \u2014 a PR only reviews pushed commits; ` + "commit or stash first, or let the skill triage the changes interactively", 2);
|
|
343
|
+
}
|
|
344
|
+
const base = (args.flags.get("--base") ?? "").trim() || ctx.defaultBranch;
|
|
345
|
+
const upstream = resolveUpstream();
|
|
346
|
+
if (ctx.branch === base) {
|
|
347
|
+
writeStatus(args, "FAIL");
|
|
348
|
+
fail(args, `current branch is the base branch (${base}) \u2014 a PR reviews a feature branch against it; ` + "check out a feature branch (nothing on the base branch is reviewable)", 2);
|
|
349
|
+
}
|
|
350
|
+
writeStatus(args, "PASS");
|
|
351
|
+
emit(args, { ok: true, ...ctx, upstream }, [
|
|
352
|
+
`Repository: ${ctx.nameWithOwner}`,
|
|
353
|
+
`Branch: ${ctx.branch}`,
|
|
354
|
+
`HEAD: ${ctx.shortHead}`,
|
|
355
|
+
`Default: ${ctx.defaultBranch}`,
|
|
356
|
+
`Upstream: ${upstream ? `${upstream.ref} (ahead ${upstream.ahead}, behind ${upstream.behind})` : `none (publishing would create origin/${ctx.branch})`}`,
|
|
357
|
+
"Local: clean"
|
|
358
|
+
].join(`
|
|
359
|
+
`));
|
|
360
|
+
}
|
|
361
|
+
function cmdPush(args) {
|
|
362
|
+
const ctx = preflightContext();
|
|
363
|
+
const upstream = run(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
364
|
+
if (upstream.code !== 0) {
|
|
365
|
+
runOk(["git", "push", "-u", "origin", "HEAD"], "git push -u origin HEAD");
|
|
366
|
+
writeStatus(args, "PUSHED");
|
|
367
|
+
emit(args, { ok: true, pushed: true, head: ctx.head }, `Pushed ${ctx.branch} and set upstream to origin/HEAD.`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const remoteHead = runOk(["git", "rev-parse", "@{u}"], "git rev-parse @{u}");
|
|
371
|
+
if (remoteHead === ctx.head) {
|
|
372
|
+
writeStatus(args, "UP_TO_DATE");
|
|
373
|
+
emit(args, { ok: true, pushed: false, head: ctx.head }, `Remote already at ${ctx.shortHead}.`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
runOk(["git", "push"], "git push (fast-forward only \u2014 never force)");
|
|
377
|
+
writeStatus(args, "PUSHED");
|
|
378
|
+
emit(args, { ok: true, pushed: true, head: ctx.head }, `Pushed ${ctx.branch} -> ${ctx.shortHead}.`);
|
|
379
|
+
}
|
|
380
|
+
function cmdEnsurePr(args) {
|
|
381
|
+
const ctx = preflightContext();
|
|
382
|
+
const existing = viewPr();
|
|
383
|
+
if (existing) {
|
|
384
|
+
writeStatus(args, "FOUND");
|
|
385
|
+
emit(args, { ok: true, created: false, pr: existing }, `PR #${existing.number} ${existing.url}
|
|
386
|
+
Base: ${existing.baseRefName} State: ${existing.state}`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const base = (args.flags.get("--base") ?? "").trim() || ctx.defaultBranch;
|
|
390
|
+
if (ctx.branch === base) {
|
|
391
|
+
writeStatus(args, "FAIL");
|
|
392
|
+
fail(args, `current branch is the base branch (${base}) \u2014 nothing to review`, 2);
|
|
393
|
+
}
|
|
394
|
+
const commits = run(["git", "log", "--oneline", `${base}..HEAD`]);
|
|
395
|
+
if (commits.code !== 0)
|
|
396
|
+
fail(args, `git log failed: ${commits.stderr.trim() || commits.error || `exit ${commits.code}`}`, 2);
|
|
397
|
+
if (commits.stdout.trim() === "") {
|
|
398
|
+
writeStatus(args, "FAIL");
|
|
399
|
+
fail(args, `no commits on ${ctx.branch} beyond ${base} \u2014 nothing to review`, 2);
|
|
400
|
+
}
|
|
401
|
+
runOk(["gh", "pr", "create", "--fill", "--base", base], "gh pr create");
|
|
402
|
+
const pr = viewPr();
|
|
403
|
+
if (!pr) {
|
|
404
|
+
writeStatus(args, "FAIL");
|
|
405
|
+
fail(args, "gh pr create reported success but the PR is not visible", 2);
|
|
406
|
+
}
|
|
407
|
+
writeStatus(args, "CREATED");
|
|
408
|
+
emit(args, { ok: true, created: true, pr }, `Created PR #${pr.number} ${pr.url}
|
|
409
|
+
Base: ${pr.baseRefName}`);
|
|
410
|
+
}
|
|
411
|
+
function cmdHygiene(args) {
|
|
412
|
+
const ctx = preflightContext();
|
|
413
|
+
const pr = viewPr();
|
|
414
|
+
const base = (args.flags.get("--base") ?? "").trim() || pr?.baseRefName || ctx.defaultBranch;
|
|
415
|
+
const filesRaw = run(["git", "diff", "--name-only", "--diff-filter=AMCR", `${base}...HEAD`]);
|
|
416
|
+
if (filesRaw.code !== 0) {
|
|
417
|
+
writeStatus(args, "FAIL");
|
|
418
|
+
fail(args, `git diff --name-only failed (exit ${filesRaw.code})`, 2);
|
|
419
|
+
}
|
|
420
|
+
const changedFiles = filesRaw.stdout.split(`
|
|
421
|
+
`).filter((l) => l.trim() !== "");
|
|
422
|
+
const diffRaw = run(["git", "diff", `${base}...HEAD`]);
|
|
423
|
+
if (diffRaw.code !== 0) {
|
|
424
|
+
writeStatus(args, "FAIL");
|
|
425
|
+
fail(args, `git diff failed (exit ${diffRaw.code})`, 2);
|
|
426
|
+
}
|
|
427
|
+
const diff = diffRaw.stdout;
|
|
428
|
+
const result = scanHygiene(diff, changedFiles);
|
|
429
|
+
writeStatus(args, result.verdict);
|
|
430
|
+
const human = [
|
|
431
|
+
`Hygiene (${base}...HEAD): ${result.verdict}`,
|
|
432
|
+
...result.blockers.map((b) => ` BLOCK ${b}`),
|
|
433
|
+
...result.warnings.map((w) => ` WARN ${w}`)
|
|
434
|
+
].join(`
|
|
435
|
+
`);
|
|
436
|
+
emit(args, { ok: result.verdict !== "BLOCK", ...result }, human);
|
|
437
|
+
if (result.verdict === "BLOCK")
|
|
438
|
+
throw new ScriptExit("", 2, args.booleans.has("--json"));
|
|
439
|
+
}
|
|
440
|
+
function cmdRequest(args) {
|
|
441
|
+
const ctx = preflightContext();
|
|
442
|
+
const pr = viewPr();
|
|
443
|
+
if (!pr) {
|
|
444
|
+
writeStatus(args, "FAIL");
|
|
445
|
+
fail(args, "no PR for the current branch \u2014 run ensure-pr first", 2);
|
|
446
|
+
}
|
|
447
|
+
const force = args.booleans.has("--force");
|
|
448
|
+
const reviews = fetchReviews(ctx, pr.number);
|
|
449
|
+
if (!force && isHeadReviewed(reviews, pr.headRefOid)) {
|
|
450
|
+
writeStatus(args, "ALREADY_REVIEWED");
|
|
451
|
+
emit(args, { ok: true, requested: false, alreadyReviewed: true, pr: pr.number, url: pr.url, head: pr.headRefOid }, `PR #${pr.number}: current HEAD ${pr.headRefOid.slice(0, 7)} already has a Codex review \u2014 not requesting a duplicate.`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (!force) {
|
|
455
|
+
const login = runOk(["gh", "api", "user", "--jq", ".login"], "gh api user");
|
|
456
|
+
if (hasPendingRequest(fetchIssueComments(ctx, pr.number), pr.headRefOid, login)) {
|
|
457
|
+
writeStatus(args, "ALREADY_REQUESTED");
|
|
458
|
+
emit(args, { ok: true, requested: false, pending: true, pr: pr.number, url: pr.url, head: pr.headRefOid }, `PR #${pr.number}: current HEAD ${pr.headRefOid.slice(0, 7)} already has a pending Codex request.`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const body = `${buildRequestBody(hasCodeReviewRules(ctx.repoRoot), args.flags.get("--focus") ?? "")}
|
|
463
|
+
|
|
464
|
+
${requestMarker(pr.headRefOid)}`;
|
|
465
|
+
const requestedAt = new Date().toISOString();
|
|
466
|
+
runOk(["gh", "pr", "comment", String(pr.number), "--body", body], "gh pr comment (@codex review)");
|
|
467
|
+
writeStatus(args, "REQUESTED");
|
|
468
|
+
emit(args, { ok: true, requested: true, pr: pr.number, url: pr.url, head: pr.headRefOid, requestedAt, body }, `Requested GitHub Codex review on PR #${pr.number} (${pr.url}) at HEAD ${pr.headRefOid.slice(0, 7)}.`);
|
|
469
|
+
}
|
|
470
|
+
function cmdWait(args) {
|
|
471
|
+
const since = args.flags.get("--since") ?? new Date().toISOString();
|
|
472
|
+
const timeoutSec = Number(args.flags.get("--timeout") ?? "600");
|
|
473
|
+
const intervalSec = Number(args.flags.get("--interval") ?? "30");
|
|
474
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec < 0)
|
|
475
|
+
fail(args, "--timeout must be a non-negative number", 1);
|
|
476
|
+
if (!Number.isFinite(intervalSec) || intervalSec <= 0)
|
|
477
|
+
fail(args, "--interval must be a positive number", 1);
|
|
478
|
+
const ctx = preflightContext();
|
|
479
|
+
const pr = viewPr();
|
|
480
|
+
if (!pr) {
|
|
481
|
+
writeStatus(args, "FAIL");
|
|
482
|
+
fail(args, "no PR for the current branch", 2);
|
|
483
|
+
}
|
|
484
|
+
requireExpectedHead(args, pr);
|
|
485
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
486
|
+
for (;; ) {
|
|
487
|
+
const reviews = fetchReviews(ctx, pr.number);
|
|
488
|
+
const findings = normalizeFindings(reviews, fetchInlineComments(ctx, pr.number), fetchIssueComments(ctx, pr.number), since, pr.headRefOid);
|
|
489
|
+
if (findings.length > 0) {
|
|
490
|
+
writeStatus(args, "FOUND");
|
|
491
|
+
emit(args, { ok: true, verdict: "FOUND", findings }, renderFindings(findings));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (hasCurrentCleanReview(reviews, since, pr.headRefOid)) {
|
|
495
|
+
writeStatus(args, "CLEAN");
|
|
496
|
+
emit(args, { ok: true, verdict: "CLEAN", pr: pr.number, url: pr.url, head: pr.headRefOid, findings: [] }, `Codex review completed cleanly for HEAD ${pr.headRefOid.slice(0, 7)}.`);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (Date.now() >= deadline) {
|
|
500
|
+
writeStatus(args, "TIMEOUT");
|
|
501
|
+
emit(args, { ok: true, verdict: "TIMEOUT", pr: pr.number, url: pr.url }, `No Codex review within ${timeoutSec}s \u2014 still pending. Collect later with /sp:dev-pr-review collect.
|
|
502
|
+
PR: ${pr.url}`);
|
|
503
|
+
throw new ScriptExit("", 3, args.booleans.has("--json"));
|
|
504
|
+
}
|
|
505
|
+
const sleepMs = intervalSec * 1000;
|
|
506
|
+
if (sleepMs > 0)
|
|
507
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, sleepMs);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function cmdCollect(args) {
|
|
511
|
+
const ctx = preflightContext();
|
|
512
|
+
const pr = viewPr();
|
|
513
|
+
if (!pr) {
|
|
514
|
+
fail(args, "no PR for the current branch", 2);
|
|
515
|
+
}
|
|
516
|
+
requireExpectedHead(args, pr);
|
|
517
|
+
const since = args.flags.get("--since") ?? "";
|
|
518
|
+
const reviews = fetchReviews(ctx, pr.number);
|
|
519
|
+
const findings = normalizeFindings(reviews, fetchInlineComments(ctx, pr.number), fetchIssueComments(ctx, pr.number), since, pr.headRefOid);
|
|
520
|
+
const verdict = findings.length > 0 ? "FINDINGS" : hasCurrentCleanReview(reviews, since, pr.headRefOid) ? "CLEAN" : "PENDING";
|
|
521
|
+
writeStatus(args, verdict);
|
|
522
|
+
const header = `PR #${pr.number} ${pr.url}
|
|
523
|
+
HEAD ${pr.headRefOid.slice(0, 7)} \u2014 Codex: ${verdict === "FINDINGS" ? "findings" : verdict.toLowerCase()}`;
|
|
524
|
+
const summary = verdict === "PENDING" ? "No current-HEAD Codex review result yet \u2014 still pending." : renderFindings(findings);
|
|
525
|
+
emit(args, { ok: true, verdict, pr: pr.number, url: pr.url, head: pr.headRefOid, findings }, `${header}
|
|
526
|
+
|
|
527
|
+
${summary}`);
|
|
528
|
+
}
|
|
529
|
+
function cmdStatus(args) {
|
|
530
|
+
const ctx = preflightContext();
|
|
531
|
+
const pr = viewPr();
|
|
532
|
+
if (pr)
|
|
533
|
+
requireExpectedHead(args, pr);
|
|
534
|
+
const dirtyResult = run(["git", "status", "--porcelain"]);
|
|
535
|
+
if (dirtyResult.code !== 0) {
|
|
536
|
+
fail(args, `git status failed: ${dirtyResult.stderr.trim() || dirtyResult.error || `exit ${dirtyResult.code}`}`, 2);
|
|
537
|
+
}
|
|
538
|
+
const dirty = dirtyResult.stdout.trim();
|
|
539
|
+
let ci = "unavailable";
|
|
540
|
+
let codex = "not requested";
|
|
541
|
+
const since = args.flags.get("--since") ?? "";
|
|
542
|
+
if (pr) {
|
|
543
|
+
const checks = run(["gh", "pr", "checks", String(pr.number), "--json", "bucket"]);
|
|
544
|
+
if ([0, 1, 8].includes(checks.code) && checks.stdout.trim() !== "") {
|
|
545
|
+
const buckets = parseJson(checks.stdout, "gh pr checks").map((check) => check.bucket ?? "");
|
|
546
|
+
if (buckets.some((bucket) => bucket === "fail" || bucket === "cancel"))
|
|
547
|
+
ci = "failing";
|
|
548
|
+
else if (buckets.some((bucket) => bucket === "pending"))
|
|
549
|
+
ci = "pending";
|
|
550
|
+
else if (buckets.length > 0 && buckets.every((bucket) => bucket === "pass"))
|
|
551
|
+
ci = "passing";
|
|
552
|
+
}
|
|
553
|
+
const reviews = fetchReviews(ctx, pr.number);
|
|
554
|
+
const codexReviews = reviews.filter((r) => isCodexAuthor(r.user?.login, r.user?.type));
|
|
555
|
+
if (codexReviews.length > 0) {
|
|
556
|
+
const inline = fetchInlineComments(ctx, pr.number);
|
|
557
|
+
const findings = normalizeFindings(reviews, inline, [], since, pr.headRefOid);
|
|
558
|
+
if (findings.length > 0)
|
|
559
|
+
codex = "findings";
|
|
560
|
+
else if (hasCurrentCleanReview(reviews, since, pr.headRefOid))
|
|
561
|
+
codex = "clean";
|
|
562
|
+
else if (codexReviews.some((review) => review.commit_id === pr.headRefOid))
|
|
563
|
+
codex = "pending";
|
|
564
|
+
else
|
|
565
|
+
codex = "stale \u2014 HEAD moved";
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const payload = {
|
|
569
|
+
ok: true,
|
|
570
|
+
repo: ctx.nameWithOwner,
|
|
571
|
+
branch: ctx.branch,
|
|
572
|
+
head: ctx.shortHead,
|
|
573
|
+
base: pr?.baseRefName ?? ctx.defaultBranch,
|
|
574
|
+
pr: pr ? { number: pr.number, url: pr.url, state: pr.state } : null,
|
|
575
|
+
local: dirty === "" ? "clean" : "modified",
|
|
576
|
+
ci,
|
|
577
|
+
codex
|
|
578
|
+
};
|
|
579
|
+
emit(args, payload, [
|
|
580
|
+
`Repository: ${payload.repo}`,
|
|
581
|
+
`PR: ${pr ? `#${pr.number} ${pr.url}` : "none"}`,
|
|
582
|
+
`Branch: ${payload.branch}`,
|
|
583
|
+
`HEAD: ${payload.head}`,
|
|
584
|
+
`Base: ${payload.base}`,
|
|
585
|
+
`Local: ${payload.local}`,
|
|
586
|
+
`CI: ${payload.ci}`,
|
|
587
|
+
`Codex: ${payload.codex}`
|
|
588
|
+
].join(`
|
|
589
|
+
`));
|
|
590
|
+
}
|
|
591
|
+
var HELP = `pr-reviewing.ts \u2014 deterministic spine for /sp:dev-pr-review (sp:pr-reviewing)
|
|
592
|
+
|
|
593
|
+
Installed usage: node "$(superskill script path sp pr-reviewing.mjs)" <subcommand> [flags]
|
|
594
|
+
Source-tree usage: bun plugins/sp/scripts/pr-reviewing.ts <subcommand> [flags]
|
|
595
|
+
|
|
596
|
+
Subcommands:
|
|
597
|
+
preflight git/gh/repo checks; FAILs on detached HEAD or a dirty tree
|
|
598
|
+
push push the branch (never force); sets upstream when missing
|
|
599
|
+
ensure-pr [--base b] reuse the branch's PR or create one with gh pr create --fill
|
|
600
|
+
hygiene [--base b] scan base...HEAD for secrets/.env/conflict markers (BLOCK) and debug residue (WARN)
|
|
601
|
+
request [--force] [--focus text]
|
|
602
|
+
post @codex review (dedupes reviewed/in-flight HEAD unless --force)
|
|
603
|
+
wait [--since iso] [--head sha] [--timeout 600] [--interval 30]
|
|
604
|
+
poll for Codex output; exit 3 on timeout (pending, not failed)
|
|
605
|
+
collect [--since iso] [--head sha]
|
|
606
|
+
fetch the current-HEAD Codex result (FINDINGS, CLEAN, or PENDING)
|
|
607
|
+
status [--since iso] [--head sha] composite repo/PR/CI/Codex status (read-only)
|
|
608
|
+
|
|
609
|
+
Global flags: --json (single JSON object) --status-file <path> (one-word verdict)
|
|
610
|
+
Exit codes: 0 ok \xB7 1 usage \xB7 2 hard failure \xB7 3 wait timeout`;
|
|
611
|
+
function main(argv) {
|
|
612
|
+
let args = null;
|
|
613
|
+
try {
|
|
614
|
+
args = parseArgs(argv);
|
|
615
|
+
switch (args.subcommand) {
|
|
616
|
+
case "preflight":
|
|
617
|
+
cmdPreflight(args);
|
|
618
|
+
return 0;
|
|
619
|
+
case "push":
|
|
620
|
+
cmdPush(args);
|
|
621
|
+
return 0;
|
|
622
|
+
case "ensure-pr":
|
|
623
|
+
cmdEnsurePr(args);
|
|
624
|
+
return 0;
|
|
625
|
+
case "hygiene":
|
|
626
|
+
cmdHygiene(args);
|
|
627
|
+
return 0;
|
|
628
|
+
case "request":
|
|
629
|
+
cmdRequest(args);
|
|
630
|
+
return 0;
|
|
631
|
+
case "wait":
|
|
632
|
+
cmdWait(args);
|
|
633
|
+
return 0;
|
|
634
|
+
case "collect":
|
|
635
|
+
cmdCollect(args);
|
|
636
|
+
return 0;
|
|
637
|
+
case "status":
|
|
638
|
+
cmdStatus(args);
|
|
639
|
+
return 0;
|
|
640
|
+
case "":
|
|
641
|
+
case "--help":
|
|
642
|
+
case "help":
|
|
643
|
+
console.log(HELP);
|
|
644
|
+
return 0;
|
|
645
|
+
default:
|
|
646
|
+
fail(args, `unknown subcommand: ${args.subcommand}`, 1);
|
|
647
|
+
}
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (error instanceof ScriptExit) {
|
|
650
|
+
if (error.code === 2 && error.message !== "")
|
|
651
|
+
writeFailureStatus(args);
|
|
652
|
+
if (error.message !== "") {
|
|
653
|
+
if (error.json)
|
|
654
|
+
console.log(JSON.stringify({ ok: false, error: error.message }));
|
|
655
|
+
console.error(`error: ${error.message}`);
|
|
656
|
+
}
|
|
657
|
+
return error.code;
|
|
658
|
+
}
|
|
659
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
660
|
+
writeFailureStatus(args);
|
|
661
|
+
if (args?.booleans.has("--json"))
|
|
662
|
+
console.log(JSON.stringify({ ok: false, error: message }));
|
|
663
|
+
console.error(`error: ${message}`);
|
|
664
|
+
return 2;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
{
|
|
668
|
+
process.exit(main(process.argv.slice(2)));
|
|
669
|
+
}
|
|
670
|
+
export {
|
|
671
|
+
spawnRunner,
|
|
672
|
+
setCommandRunner,
|
|
673
|
+
scanHygiene,
|
|
674
|
+
renderFindings,
|
|
675
|
+
parseArgs,
|
|
676
|
+
normalizeFindings,
|
|
677
|
+
main,
|
|
678
|
+
isHeadReviewed,
|
|
679
|
+
isExplicitCleanReview,
|
|
680
|
+
isCodexAuthor,
|
|
681
|
+
hasCurrentCleanReview,
|
|
682
|
+
hasCodeReviewRules,
|
|
683
|
+
extractSeverity,
|
|
684
|
+
buildRequestBody
|
|
685
|
+
};
|
|
@@ -841,7 +841,7 @@ function cmdStatus(args: ParsedArgs): void {
|
|
|
841
841
|
|
|
842
842
|
const HELP = `pr-reviewing.ts — deterministic spine for /sp:dev-pr-review (sp:pr-reviewing)
|
|
843
843
|
|
|
844
|
-
Installed usage:
|
|
844
|
+
Installed usage: node "$(superskill script path sp pr-reviewing.mjs)" <subcommand> [flags]
|
|
845
845
|
Source-tree usage: bun plugins/sp/scripts/pr-reviewing.ts <subcommand> [flags]
|
|
846
846
|
|
|
847
847
|
Subcommands:
|