@unifan/pi-review-zh 1.0.0
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/CHANGELOG.md +540 -0
- package/LICENSE +15 -0
- package/README.md +55 -0
- package/agents/bugbot.md +46 -0
- package/agents/claude-md-compliance.md +46 -0
- package/agents/code-comments.md +43 -0
- package/agents/conventions.md +41 -0
- package/agents/gate.md +70 -0
- package/agents/history-context.md +45 -0
- package/agents/lite-review.md +51 -0
- package/agents/security-review.md +45 -0
- package/index.ts +205 -0
- package/package.json +38 -0
- package/reference/README.md +20 -0
- package/reference/claude-code-review.md +133 -0
- package/reference/cursor-review-skills.md +72 -0
- package/reference/pi-review-roadmap.md +183 -0
- package/reference/structured-output.md +26 -0
- package/reference/v0.2-plan.md +268 -0
- package/src/cli-args.ts +105 -0
- package/src/config.ts +340 -0
- package/src/directive.ts +481 -0
- package/src/gate-enforce.ts +151 -0
- package/src/lean-agents.ts +105 -0
- package/src/pr-ref.ts +39 -0
- package/src/report-tool.ts +394 -0
- package/src/report.ts +399 -0
- package/src/review-report.ts +279 -0
- package/src/review-run.ts +568 -0
- package/src/target-workspace.ts +239 -0
- package/src/tool-wrapper.ts +75 -0
- package/src/tui-renderer.ts +92 -0
- package/src/types.ts +207 -0
- package/src/workflow-schemas.ts +172 -0
package/src/report.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GateDisposition,
|
|
3
|
+
GateRunResult,
|
|
4
|
+
Issue,
|
|
5
|
+
IssueSeverity,
|
|
6
|
+
ReviewReport,
|
|
7
|
+
ReviewerRunResult,
|
|
8
|
+
Verdict,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
const EMPTY_SEVERITY: Record<IssueSeverity, number> = {
|
|
12
|
+
blocker: 0,
|
|
13
|
+
major: 0,
|
|
14
|
+
minor: 0,
|
|
15
|
+
nit: 0,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export interface ReviewerWorkflowResult {
|
|
19
|
+
key: string;
|
|
20
|
+
ok: boolean;
|
|
21
|
+
error?: string;
|
|
22
|
+
output?: string;
|
|
23
|
+
structuredOutput?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WorkflowReturnValue {
|
|
27
|
+
reviewers?: ReviewerWorkflowResult[];
|
|
28
|
+
gate?: { ok: boolean; error?: string; output?: string; structuredOutput?: unknown } | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function coerceReviewerOutput(r: ReviewerWorkflowResult): {
|
|
32
|
+
status: "ok" | "limited" | "skipped" | "failed";
|
|
33
|
+
issues: Issue[];
|
|
34
|
+
summary: string;
|
|
35
|
+
coverage: { filesChecked: string[]; commandsRun: string[]; limitations: string[] };
|
|
36
|
+
} {
|
|
37
|
+
if (!r.ok) {
|
|
38
|
+
return {
|
|
39
|
+
status: "failed",
|
|
40
|
+
issues: [],
|
|
41
|
+
summary: r.error ?? "审查专家执行失败",
|
|
42
|
+
coverage: { filesChecked: [], commandsRun: [], limitations: [r.error ?? "审查专家执行失败"] },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const md = r.output ?? "";
|
|
46
|
+
const skipped = /^\s*##\s*Summary\s*\n+\s*SKIPPED:/im.test(md) || /^\s*SKIPPED:/m.test(md);
|
|
47
|
+
return {
|
|
48
|
+
status: skipped ? "skipped" : "ok",
|
|
49
|
+
issues: [],
|
|
50
|
+
summary: md,
|
|
51
|
+
coverage: { filesChecked: [], commandsRun: [], limitations: [] },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function coerceGateOutput(gate: WorkflowReturnValue["gate"]): {
|
|
56
|
+
status: "ok" | "limited" | "skipped" | "failed";
|
|
57
|
+
verdict?: Verdict;
|
|
58
|
+
issues: Issue[];
|
|
59
|
+
dispositions: GateDisposition[];
|
|
60
|
+
reason: string;
|
|
61
|
+
} {
|
|
62
|
+
if (!gate || !gate.ok) {
|
|
63
|
+
return {
|
|
64
|
+
status: "failed",
|
|
65
|
+
issues: [],
|
|
66
|
+
dispositions: [],
|
|
67
|
+
reason: gate?.error ?? "门禁裁判执行失败",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const so = gate.structuredOutput;
|
|
71
|
+
if (!so || typeof so !== "object") {
|
|
72
|
+
return {
|
|
73
|
+
status: "limited",
|
|
74
|
+
issues: [],
|
|
75
|
+
dispositions: [],
|
|
76
|
+
reason: "门禁裁判未返回结构化输出",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const obj = so as {
|
|
80
|
+
status?: string;
|
|
81
|
+
verdict?: Verdict;
|
|
82
|
+
issues?: Issue[];
|
|
83
|
+
dispositions?: GateDisposition[];
|
|
84
|
+
reason?: string;
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
status: obj.status === "ok" || obj.status === "limited" || obj.status === "skipped" ? obj.status : "ok",
|
|
88
|
+
verdict: obj.verdict,
|
|
89
|
+
issues: Array.isArray(obj.issues) ? obj.issues : [],
|
|
90
|
+
dispositions: Array.isArray(obj.dispositions) ? obj.dispositions : [],
|
|
91
|
+
reason: typeof obj.reason === "string" ? obj.reason : "",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function reviewerRow(
|
|
96
|
+
id: string,
|
|
97
|
+
label: string,
|
|
98
|
+
res: ReviewerWorkflowResult,
|
|
99
|
+
durationMs: number,
|
|
100
|
+
): ReviewerRunResult {
|
|
101
|
+
const coerced = coerceReviewerOutput(res);
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
label,
|
|
105
|
+
model: "(见工作流配置)",
|
|
106
|
+
ok: res.ok,
|
|
107
|
+
output: {
|
|
108
|
+
status: coerced.status,
|
|
109
|
+
issues: coerced.issues,
|
|
110
|
+
summary: coerced.summary,
|
|
111
|
+
coverage: coerced.coverage,
|
|
112
|
+
},
|
|
113
|
+
error: res.error,
|
|
114
|
+
durationMs,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function computeTotals(issues: Issue[]): {
|
|
119
|
+
issues: number;
|
|
120
|
+
bySeverity: Record<IssueSeverity, number>;
|
|
121
|
+
} {
|
|
122
|
+
const bySeverity = { ...EMPTY_SEVERITY };
|
|
123
|
+
for (const i of issues) bySeverity[i.severity]++;
|
|
124
|
+
return { issues: issues.length, bySeverity };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function reportVerdict(
|
|
128
|
+
reviewers: ReviewerWorkflowResult[],
|
|
129
|
+
gate: { ok: boolean; structuredOutput?: unknown; error?: string } | null | undefined,
|
|
130
|
+
enforcedVerdict?: Verdict,
|
|
131
|
+
): Verdict | "no-gate" | "error" | "partial" {
|
|
132
|
+
if (reviewers.length > 0 && reviewers.every((r) => coerceReviewerOutput(r).status === "failed")) return "error";
|
|
133
|
+
if (!gate || !gate.ok) return "no-gate";
|
|
134
|
+
const so = gate.structuredOutput as { status?: string } | null;
|
|
135
|
+
if (so?.status && so.status !== "ok") return "partial";
|
|
136
|
+
if (reviewers.length > 0 && reviewers.every((r) => coerceReviewerOutput(r).status === "skipped")) {
|
|
137
|
+
return enforcedVerdict === "approve" ? "comment" : enforcedVerdict ?? "comment";
|
|
138
|
+
}
|
|
139
|
+
return enforcedVerdict ?? "comment";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type ReportVerdictKind = Verdict | "no-gate" | "error" | "partial";
|
|
143
|
+
|
|
144
|
+
export interface ReportInput {
|
|
145
|
+
startedAt: number;
|
|
146
|
+
manifest: {
|
|
147
|
+
runId: string;
|
|
148
|
+
targetLabel: string;
|
|
149
|
+
targetKind: string;
|
|
150
|
+
prRef?: string;
|
|
151
|
+
diffSha256: string;
|
|
152
|
+
workspacePath: string;
|
|
153
|
+
workspaceHeadSha?: string;
|
|
154
|
+
workspaceWarning?: string;
|
|
155
|
+
diffWarning?: string;
|
|
156
|
+
mode?: string;
|
|
157
|
+
docsOnly: boolean;
|
|
158
|
+
rulePaths: string[];
|
|
159
|
+
historyAvailable: boolean;
|
|
160
|
+
changedFiles: string[];
|
|
161
|
+
baseSha?: string;
|
|
162
|
+
headSha?: string;
|
|
163
|
+
skippedReviewers?: Array<{ id: string; reason: string }>;
|
|
164
|
+
};
|
|
165
|
+
workflowReturn: WorkflowReturnValue;
|
|
166
|
+
threshold: number;
|
|
167
|
+
policy: "strict" | "legacy";
|
|
168
|
+
enforcedVerdict: Verdict;
|
|
169
|
+
enforcedIssues: Issue[];
|
|
170
|
+
enforcedDispositions: GateDisposition[];
|
|
171
|
+
enforcedReason: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface BuiltReport extends Omit<ReviewReport, "verdict"> {
|
|
175
|
+
manifest: ReportInput["manifest"];
|
|
176
|
+
dispositions: GateDisposition[];
|
|
177
|
+
reviewerStatus: Array<{ id: string; status: string; limitations: string[] }>;
|
|
178
|
+
gateStatus: "ok" | "limited" | "skipped" | "failed";
|
|
179
|
+
verdict: Verdict | "no-gate" | "error" | "partial";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function buildReportFromWorkflow(input: ReportInput): BuiltReport {
|
|
183
|
+
const durationMs = Date.now() - input.startedAt;
|
|
184
|
+
const reviewerResults = (input.workflowReturn.reviewers ?? []).map((r) =>
|
|
185
|
+
reviewerRow(r.key, r.key, r, 0),
|
|
186
|
+
);
|
|
187
|
+
const reviewerStatus = (input.workflowReturn.reviewers ?? []).map((r) => {
|
|
188
|
+
const c = coerceReviewerOutput(r);
|
|
189
|
+
return { id: r.key, status: c.status, limitations: c.coverage.limitations };
|
|
190
|
+
});
|
|
191
|
+
const gateRaw = input.workflowReturn.gate ?? null;
|
|
192
|
+
const coercedGate = coerceGateOutput(gateRaw);
|
|
193
|
+
const gateResult: GateRunResult = {
|
|
194
|
+
ok: !!gateRaw?.ok,
|
|
195
|
+
verdict: {
|
|
196
|
+
verdict: input.enforcedVerdict,
|
|
197
|
+
issues: input.enforcedIssues,
|
|
198
|
+
dispositions: input.enforcedDispositions,
|
|
199
|
+
status: coercedGate.status,
|
|
200
|
+
reason: input.enforcedReason,
|
|
201
|
+
},
|
|
202
|
+
error: gateRaw?.error,
|
|
203
|
+
durationMs: 0,
|
|
204
|
+
model: "(见工作流配置)",
|
|
205
|
+
};
|
|
206
|
+
const totals = computeTotals(input.enforcedIssues);
|
|
207
|
+
const verdict = reportVerdict(input.workflowReturn.reviewers ?? [], gateRaw, input.enforcedVerdict);
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
startedAt: input.startedAt,
|
|
211
|
+
durationMs,
|
|
212
|
+
input: {
|
|
213
|
+
kind: input.manifest.targetKind as ReviewReport["input"]["kind"],
|
|
214
|
+
label: input.manifest.targetLabel,
|
|
215
|
+
prRef: input.manifest.prRef,
|
|
216
|
+
},
|
|
217
|
+
reviewers: reviewerResults,
|
|
218
|
+
gate: gateResult,
|
|
219
|
+
totals,
|
|
220
|
+
verdict,
|
|
221
|
+
manifest: input.manifest,
|
|
222
|
+
dispositions: input.enforcedDispositions,
|
|
223
|
+
reviewerStatus,
|
|
224
|
+
gateStatus: coercedGate.status,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function renderReport(report: BuiltReport): string {
|
|
229
|
+
const lines: string[] = [];
|
|
230
|
+
lines.push(`## 🔍 AI 代码审查报告 — ${report.input.label}`);
|
|
231
|
+
lines.push("");
|
|
232
|
+
lines.push(renderVerdictLine(report));
|
|
233
|
+
lines.push(renderSummaryLine(report));
|
|
234
|
+
lines.push("");
|
|
235
|
+
lines.push(renderRunLine(report));
|
|
236
|
+
if (report.manifest.prRef) {
|
|
237
|
+
lines.push(`- PR: ${report.manifest.prRef}`);
|
|
238
|
+
}
|
|
239
|
+
if (report.manifest.mode) {
|
|
240
|
+
lines.push(`- Diff 模式: ${report.manifest.mode}`);
|
|
241
|
+
}
|
|
242
|
+
if (report.manifest.baseSha && report.manifest.headSha) {
|
|
243
|
+
lines.push(`- 基础版本 Base: ${report.manifest.baseSha.slice(0, 12)} · 目标版本 Head: ${report.manifest.headSha.slice(0, 12)}`);
|
|
244
|
+
}
|
|
245
|
+
if (report.manifest.workspaceHeadSha) {
|
|
246
|
+
if (report.manifest.headSha) {
|
|
247
|
+
const matched = report.manifest.workspaceHeadSha === report.manifest.headSha;
|
|
248
|
+
lines.push(
|
|
249
|
+
`- 工作区 HEAD: ${report.manifest.workspaceHeadSha.slice(0, 12)}${matched ? " (与 Diff HEAD 匹配)" : " (与 Diff HEAD 不一致)"}`,
|
|
250
|
+
);
|
|
251
|
+
} else {
|
|
252
|
+
lines.push(`- 工作区 HEAD: ${report.manifest.workspaceHeadSha.slice(0, 12)}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (report.manifest.workspaceWarning) {
|
|
256
|
+
lines.push(`- 工作区提示: ${report.manifest.workspaceWarning}`);
|
|
257
|
+
}
|
|
258
|
+
if (report.manifest.diffWarning) {
|
|
259
|
+
lines.push(`- Diff 提示: ${report.manifest.diffWarning}`);
|
|
260
|
+
}
|
|
261
|
+
lines.push(`- Diff 哈希: ${report.manifest.diffSha256.slice(0, 16)}…`);
|
|
262
|
+
lines.push(`- 工作区路径: ${report.manifest.workspacePath}`);
|
|
263
|
+
lines.push(`- Git 历史可用: ${report.manifest.historyAvailable ? "是" : "否"}`);
|
|
264
|
+
lines.push(`- 纯文档变更: ${report.manifest.docsOnly ? "是" : "否"}`);
|
|
265
|
+
if (report.manifest.rulePaths.length > 0) {
|
|
266
|
+
lines.push(`- 项目规范文件: ${report.manifest.rulePaths.join(", ")}`);
|
|
267
|
+
} else {
|
|
268
|
+
lines.push(`- 项目规范文件: (无)`);
|
|
269
|
+
}
|
|
270
|
+
lines.push("");
|
|
271
|
+
|
|
272
|
+
if (report.manifest.skippedReviewers && report.manifest.skippedReviewers.length > 0) {
|
|
273
|
+
lines.push("### ⚡ 自适应跳过的专家");
|
|
274
|
+
for (const s of report.manifest.skippedReviewers) {
|
|
275
|
+
lines.push(`- ${s.id}: ${s.reason}`);
|
|
276
|
+
}
|
|
277
|
+
lines.push("");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (report.reviewerStatus.length > 0) {
|
|
281
|
+
lines.push("### 📊 专家审查覆盖率");
|
|
282
|
+
for (const s of report.reviewerStatus) {
|
|
283
|
+
const limit = s.limitations.length > 0 ? ` (${s.limitations.join("; ")})` : "";
|
|
284
|
+
lines.push(`- ${s.id}: ${s.status}${limit}`);
|
|
285
|
+
}
|
|
286
|
+
lines.push("");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (report.reviewers.length > 0) {
|
|
290
|
+
lines.push("### 🕵️ 专家详细发现");
|
|
291
|
+
for (const r of report.reviewers) {
|
|
292
|
+
lines.push(renderReviewerSection(r));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (report.gate) {
|
|
297
|
+
lines.push(renderGateSection(report.gate));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (report.dispositions.length > 0) {
|
|
301
|
+
lines.push(renderDispositions(report.dispositions));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function renderVerdictLine(report: BuiltReport): string {
|
|
308
|
+
const v = report.verdict;
|
|
309
|
+
const label =
|
|
310
|
+
v === "no-gate"
|
|
311
|
+
? "NO GATE (无门禁)"
|
|
312
|
+
: v === "error"
|
|
313
|
+
? "ERROR (异常)"
|
|
314
|
+
: v === "partial"
|
|
315
|
+
? "PARTIAL (部分完成)"
|
|
316
|
+
: v === "approve"
|
|
317
|
+
? "APPROVE (审核通过)"
|
|
318
|
+
: v === "request_changes"
|
|
319
|
+
? "REQUEST_CHANGES (需要修改)"
|
|
320
|
+
: "COMMENT (普通建议)";
|
|
321
|
+
const t = report.totals.bySeverity;
|
|
322
|
+
return `**审查裁决: ${label}** (${t.blocker} 致命阻断 · ${t.major} 严重 · ${t.minor} 次要 · ${t.nit} 细节优化)`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function renderSummaryLine(report: BuiltReport): string {
|
|
326
|
+
const dur = (report.durationMs / 1000).toFixed(1);
|
|
327
|
+
const reviewerCount = report.reviewers.length;
|
|
328
|
+
const gateCount = report.gate ? 1 : 0;
|
|
329
|
+
return `审查耗时 ${dur}s · ${reviewerCount} 个审查专家 · ${gateCount} 个门禁裁判`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function renderRunLine(report: BuiltReport): string {
|
|
333
|
+
return `- 审查编号: ${report.manifest.runId}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function renderReviewerSection(r: ReviewerRunResult): string {
|
|
337
|
+
const status = r.ok ? "已完成" : "失败";
|
|
338
|
+
const dur = (r.durationMs / 1000).toFixed(1);
|
|
339
|
+
const head = `#### ${r.id} — ${status} · 耗时 ${dur}s`;
|
|
340
|
+
if (!r.ok) {
|
|
341
|
+
return [head, "", `- ${r.error ?? "未知错误"}`, ""].join("\n");
|
|
342
|
+
}
|
|
343
|
+
const md = r.output?.summary ?? "";
|
|
344
|
+
const body: string[] = [head, ""];
|
|
345
|
+
if (md.trim()) {
|
|
346
|
+
body.push(md.trim());
|
|
347
|
+
} else {
|
|
348
|
+
body.push("- (无输出内容)");
|
|
349
|
+
}
|
|
350
|
+
body.push("");
|
|
351
|
+
return body.join("\n");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function renderGateSection(g: GateRunResult): string {
|
|
355
|
+
if (!g.ok) {
|
|
356
|
+
return [`### ⚖️ 门禁裁判长 (Gate) — 执行失败`, "", `- ${g.error ?? "未知错误"}`, ""].join("\n");
|
|
357
|
+
}
|
|
358
|
+
const v = g.verdict;
|
|
359
|
+
if (!v) {
|
|
360
|
+
return [`### ⚖️ 门禁裁判长 (Gate) — 完成 · 未产生裁决`, ""].join("\n");
|
|
361
|
+
}
|
|
362
|
+
const issues = v.issues;
|
|
363
|
+
return [
|
|
364
|
+
`### ⚖️ 门禁裁判长 (Gate) 综合裁决 · 耗时 ${(g.durationMs / 1000).toFixed(1)}s`,
|
|
365
|
+
"",
|
|
366
|
+
`- 最终裁决: ${v.verdict}`,
|
|
367
|
+
`- 裁决理由: ${v.reason}`,
|
|
368
|
+
`- 去重与置信度过滤后共计: ${issues.length} 个关键问题`,
|
|
369
|
+
"",
|
|
370
|
+
]
|
|
371
|
+
.concat(
|
|
372
|
+
issues.map((issue) => {
|
|
373
|
+
const loc = issue.line !== undefined ? `${issue.file}:${issue.line}` : issue.file;
|
|
374
|
+
return `- [${issue.severity.toUpperCase()} · ${issue.category} · 置信度 ${issue.confidence}] \`${loc}\` — ${issue.evidence}`;
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
.concat([""])
|
|
378
|
+
.join("\n");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function renderDispositions(dispositions: GateDisposition[]): string {
|
|
382
|
+
const lines: string[] = ["### 🎯 高严重级别问题判定清单", ""];
|
|
383
|
+
const sorted = [...dispositions].sort((a, b) => {
|
|
384
|
+
if (a.decision === b.decision) return b.originalConfidence - a.originalConfidence;
|
|
385
|
+
const order = { dropped: 0, merged: 1, kept: 2 } as const;
|
|
386
|
+
return order[a.decision] - order[b.decision];
|
|
387
|
+
});
|
|
388
|
+
for (const d of sorted.slice(0, 25)) {
|
|
389
|
+
const action = d.decision === "kept" ? "保留" : d.decision === "merged" ? "合并" : "过滤/丢弃";
|
|
390
|
+
lines.push(
|
|
391
|
+
`- ${action} \`${d.fingerprint}\` · 置信度 ${d.originalConfidence}→${d.finalConfidence} · ${d.reason}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
if (dispositions.length > 25) {
|
|
395
|
+
lines.push(`- … 另有 ${dispositions.length - 25} 条判定记录`);
|
|
396
|
+
}
|
|
397
|
+
lines.push("");
|
|
398
|
+
return lines.join("\n");
|
|
399
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target workspace + run manifest prep.
|
|
3
|
+
*
|
|
4
|
+
* The extension owns diff acquisition + target repo checkout so the
|
|
5
|
+
* main-agent directive stays a single `subagent` call. Reviewer children
|
|
6
|
+
* share the prepared workspace as their cwd; git history, file lookups, and
|
|
7
|
+
* `gh pr diff` all work without the LLM re-fetching anything.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
statSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { PR_REF_REGEX } from "./pr-ref.js";
|
|
24
|
+
import type { ReviewTarget } from "./types.js";
|
|
25
|
+
|
|
26
|
+
/** Hard cap on prepared workspace age before pruning (ms). */
|
|
27
|
+
export const WORKSPACE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
export interface RunManifest {
|
|
30
|
+
/** Stable id used in filenames, log lines, and the report entry. */
|
|
31
|
+
runId: string;
|
|
32
|
+
/** Target label (e.g. "PR 18689 (agent-fetch)"). */
|
|
33
|
+
targetLabel: string;
|
|
34
|
+
targetKind: ReviewTarget["kind"];
|
|
35
|
+
prRef?: string;
|
|
36
|
+
/** Absolute path to the reviewer-facing diff (already captured). */
|
|
37
|
+
diffPath: string;
|
|
38
|
+
/** SHA-256 of `diffPath` contents (lowercase hex). */
|
|
39
|
+
diffSha256: string;
|
|
40
|
+
/** Repo-relative changed paths (parsed from `diffPath`, not from cwd). */
|
|
41
|
+
changedFiles: string[];
|
|
42
|
+
/** Whether the diff is exclusively docs/markdown. */
|
|
43
|
+
docsOnly: boolean;
|
|
44
|
+
/** Repo-relative rule file paths (AGENTS.md / CLAUDE.md / .pi/rules/*). */
|
|
45
|
+
rulePaths: string[];
|
|
46
|
+
/** True when reviewer `git log`/`blame` will work in the workspace. */
|
|
47
|
+
historyAvailable: boolean;
|
|
48
|
+
/** Detection mode: which path produced the diff. `git-pr-fallback` is
|
|
49
|
+
* legacy-only (pre-0.7.1) — PR diffs now come exclusively from gh. */
|
|
50
|
+
mode: "gh-pr-diff" | "git-pr-fallback" | "local-uncommitted" | "local-vs-default";
|
|
51
|
+
/** Base + head SHA when known (PR mode or local-vs-default). */
|
|
52
|
+
baseSha?: string;
|
|
53
|
+
headSha?: string;
|
|
54
|
+
mergeBase?: string;
|
|
55
|
+
/** Absolute path to the prepared target workspace. */
|
|
56
|
+
workspacePath: string;
|
|
57
|
+
/** HEAD SHA actually checked out in the workspace, when determinable. */
|
|
58
|
+
workspaceHeadSha?: string;
|
|
59
|
+
/** Non-fatal workspace prep note surfaced in the report. */
|
|
60
|
+
workspaceWarning?: string;
|
|
61
|
+
/** True when the workspace is a plugin-owned tmpdir clone (safe to
|
|
62
|
+
* reclaim after the report renders); false = the user's cwd. */
|
|
63
|
+
workspaceCloned?: boolean;
|
|
64
|
+
/** Absolute path to the run directory (manifest + diff + history live here). */
|
|
65
|
+
runDir: string;
|
|
66
|
+
createdAt: number;
|
|
67
|
+
/** Lanes adaptive routing dropped up front (id + reason), for report coverage. */
|
|
68
|
+
skippedReviewers?: Array<{ id: string; reason: string }>;
|
|
69
|
+
/** Reviewer ids fanned out by THIS run — the report tool rejects findings from anything else. */
|
|
70
|
+
reviewerIds?: string[];
|
|
71
|
+
/** Non-fatal note about the acquired diff (e.g. stale-base fallback). */
|
|
72
|
+
diffWarning?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PrepInput {
|
|
76
|
+
cwd: string;
|
|
77
|
+
target: ReviewTarget;
|
|
78
|
+
prRepo?: { owner: string; repo: string };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Per-reviewer path inside `.pi/pi-review/runs/<runId>/`. */
|
|
82
|
+
export function runDirFor(cwd: string, runId: string): string {
|
|
83
|
+
return join(cwd, ".pi", "pi-review", "runs", runId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Create the prepared run dir + workspace. */
|
|
87
|
+
export function ensureRunDir(cwd: string, runId: string): string {
|
|
88
|
+
const runDir = runDirFor(cwd, runId);
|
|
89
|
+
mkdirSync(runDir, { recursive: true });
|
|
90
|
+
return runDir;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Prune run dirs older than `WORKSPACE_TTL_MS`. */
|
|
94
|
+
export function pruneStaleRuns(cwd: string, now = Date.now()): string[] {
|
|
95
|
+
const runsRoot = join(cwd, ".pi", "pi-review", "runs");
|
|
96
|
+
if (!existsSync(runsRoot)) return [];
|
|
97
|
+
const removed: string[] = [];
|
|
98
|
+
for (const entry of safeListDir(runsRoot)) {
|
|
99
|
+
const path = join(runsRoot, entry);
|
|
100
|
+
const stat = safeStat(path);
|
|
101
|
+
if (!stat) continue;
|
|
102
|
+
if (now - stat.mtimeMs > WORKSPACE_TTL_MS) {
|
|
103
|
+
try {
|
|
104
|
+
rmSync(path, { recursive: true, force: true });
|
|
105
|
+
removed.push(path);
|
|
106
|
+
} catch {
|
|
107
|
+
/* ignore */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return removed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function safeListDir(p: string): string[] {
|
|
115
|
+
try {
|
|
116
|
+
return readdirSync(p);
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
void safeListDir; // keep tree-shaker honest
|
|
122
|
+
|
|
123
|
+
function safeStat(p: string): { mtimeMs: number } | null {
|
|
124
|
+
try {
|
|
125
|
+
return statSync(p);
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
void safeStat;
|
|
131
|
+
|
|
132
|
+
/** Stable, URL-safe run id (timestamp + 6 random hex chars). */
|
|
133
|
+
export function generateRunId(now = Date.now()): string {
|
|
134
|
+
const rand = Math.random().toString(16).slice(2, 8).padEnd(6, "0");
|
|
135
|
+
return `${now.toString(36)}-${rand}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface GhExec {
|
|
139
|
+
stdout: string;
|
|
140
|
+
stderr: string;
|
|
141
|
+
exitCode: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Spawn `gh` (or `git`) and capture output. Tests inject a fake. */
|
|
145
|
+
export type RunCmd = (
|
|
146
|
+
cmd: string,
|
|
147
|
+
args: string[],
|
|
148
|
+
opts: { cwd: string },
|
|
149
|
+
) => Promise<GhExec>;
|
|
150
|
+
|
|
151
|
+
let _runCmd: RunCmd = defaultRunCmd;
|
|
152
|
+
export function setRunCmd(fn: RunCmd): void {
|
|
153
|
+
_runCmd = fn;
|
|
154
|
+
}
|
|
155
|
+
export function resetRunCmd(): void {
|
|
156
|
+
_runCmd = defaultRunCmd;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function defaultRunCmd(cmd: string, args: string[], opts: { cwd: string }): Promise<GhExec> {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
try {
|
|
162
|
+
const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
163
|
+
let stdout = "";
|
|
164
|
+
let stderr = "";
|
|
165
|
+
child.stdout?.setEncoding("utf-8");
|
|
166
|
+
child.stderr?.setEncoding("utf-8");
|
|
167
|
+
child.stdout?.on("data", (d: string) => (stdout += d));
|
|
168
|
+
child.stderr?.on("data", (d: string) => (stderr += d));
|
|
169
|
+
child.on("error", () => resolve({ stdout, stderr, exitCode: 1 }));
|
|
170
|
+
child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }));
|
|
171
|
+
} catch (err) {
|
|
172
|
+
resolve({ stdout: "", stderr: err instanceof Error ? err.message : "spawn failed", exitCode: 1 });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function sha256Hex(buf: string | Buffer): string {
|
|
178
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parse changed paths + docsOnly flag from a captured diff. */
|
|
182
|
+
export function parseChangedFilesFromDiff(diff: string): {
|
|
183
|
+
files: string[];
|
|
184
|
+
docsOnly: boolean;
|
|
185
|
+
additions: number;
|
|
186
|
+
deletions: number;
|
|
187
|
+
} {
|
|
188
|
+
const files: string[] = [];
|
|
189
|
+
const seen = new Set<string>();
|
|
190
|
+
let additions = 0;
|
|
191
|
+
let deletions = 0;
|
|
192
|
+
for (const line of diff.split("\n")) {
|
|
193
|
+
if (line.startsWith("diff --git ")) {
|
|
194
|
+
const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
195
|
+
const file = m?.[2] ?? "";
|
|
196
|
+
if (file && !seen.has(file)) {
|
|
197
|
+
seen.add(file);
|
|
198
|
+
files.push(file);
|
|
199
|
+
}
|
|
200
|
+
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
201
|
+
additions++;
|
|
202
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
203
|
+
deletions++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const docsOnly =
|
|
207
|
+
files.length > 0 &&
|
|
208
|
+
files.every((f) =>
|
|
209
|
+
/\.(md|mdx|txt|rst)$/i.test(f) ||
|
|
210
|
+
f.startsWith("docs/") ||
|
|
211
|
+
f.startsWith(".agents/") ||
|
|
212
|
+
f.startsWith(".pi/") ||
|
|
213
|
+
f.includes("/docs/") ||
|
|
214
|
+
/CHANGELOG|LICENSE|README/i.test(f),
|
|
215
|
+
);
|
|
216
|
+
return { files, docsOnly, additions, deletions };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function discoverRulePathsLocal(cwd: string): string[] {
|
|
220
|
+
const candidates = ["AGENTS.md", "CLAUDE.md", "CONVENTIONS.md", ".pi/conventions.md"];
|
|
221
|
+
const found: string[] = [];
|
|
222
|
+
for (const name of candidates) {
|
|
223
|
+
if (existsSync(join(cwd, name))) found.push(name);
|
|
224
|
+
}
|
|
225
|
+
for (const rulesDir of [join(cwd, ".pi", "rules"), join(cwd, ".agents", "rules")]) {
|
|
226
|
+
if (!existsSync(rulesDir)) continue;
|
|
227
|
+
try {
|
|
228
|
+
const list = readdirSync(rulesDir) as string[];
|
|
229
|
+
for (const entry of list) {
|
|
230
|
+
if (typeof entry === "string" && entry.endsWith(".md")) {
|
|
231
|
+
found.push(
|
|
232
|
+
rulesDir.includes(".agents/rules") ? join(".agents/rules", entry) : join(".pi/rules", entry),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
/* ignore */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return [...new Set(found)];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Parse a GitHub PR URL or "owner/repo#number" into structured pieces. */
|
|
244
|
+
export function parsePrRepo(prRef: string): { owner: string; repo: string; number: string } | null {
|
|
245
|
+
const m = prRef.match(PR_REF_REGEX);
|
|
246
|
+
if (!m) return null;
|
|
247
|
+
return { owner: m[1]!, repo: m[2]!, number: m[3]! };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
void PR_REF_REGEX;
|
|
251
|
+
|
|
252
|
+
/** Read a manifest from disk (paranoia: corrupted files throw). */
|
|
253
|
+
export function readManifest(runDir: string): RunManifest {
|
|
254
|
+
const path = join(runDir, "manifest.json");
|
|
255
|
+
const raw = readFileSync(path, "utf-8");
|
|
256
|
+
return JSON.parse(raw) as RunManifest;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function writeManifest(runDir: string, manifest: RunManifest): void {
|
|
260
|
+
writeFileSync(join(runDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Write the diff body to a deterministic path inside the run dir. */
|
|
264
|
+
export function writeDiff(runDir: string, diff: string): string {
|
|
265
|
+
const path = join(runDir, "change.diff");
|
|
266
|
+
writeFileSync(path, diff, "utf-8");
|
|
267
|
+
return path;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Cheap scratch path under tmp/ — never used by reviewers. */
|
|
271
|
+
export function localScratchDir(prefix: string): string {
|
|
272
|
+
const dir = join(tmpdir(), `pi-review-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
273
|
+
mkdirSync(dir, { recursive: true });
|
|
274
|
+
return dir;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const _INTERNAL = {
|
|
278
|
+
WORKSPACE_TTL_MS,
|
|
279
|
+
};
|