@unifan/pi-review-zh 1.0.11 → 1.0.13
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/index.ts +1250 -235
- package/package.json +2 -2
package/index.ts
CHANGED
|
@@ -1,288 +1,1303 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Pi 交互式 AI 代码审查扩展 (中文增强版)
|
|
3
|
+
*
|
|
4
|
+
* 参考并融合了 Codex 与 pi-agent-extensions (Armin Ronacher @mitsuhiko) 的经典架构:
|
|
5
|
+
* 采用原生会话与会话分支隔离树技术,支持灵活的双执行引擎:
|
|
6
|
+
* 1. 【单模型原生审查】(默认推荐):0 依赖、0 网络断流、极速 100% 稳定。
|
|
7
|
+
* 2. 【多 Subagent 并发审查】:支持自由设置并发 2、3、4、5、6 个专家子代理协同会诊。
|
|
8
|
+
*
|
|
9
|
+
* 支持审查模式:
|
|
10
|
+
* - 审查当前未提交的改动 (工作区 + 暂存区)
|
|
11
|
+
* - 审查 GitHub PR (自动通过 gh 本地检出 PR 分支)
|
|
12
|
+
* - 与基准分支 (如 main / master / dev) 进行差分审查
|
|
13
|
+
* - 审查指定的历史 Commit 提交
|
|
14
|
+
* - 审查指定目录或文件快照
|
|
15
|
+
* - 自定义审查要求与重点说明
|
|
16
|
+
*
|
|
17
|
+
* 会话分支隔离:
|
|
18
|
+
* - 默认支持在新分支 (Empty branch) 中开启审查,保持主会话干净
|
|
19
|
+
* - 审查过程中常驻黄色横幅提醒,完成后敲 /end-review
|
|
20
|
+
* - /end-review 自动将审查发现 (P0~P3) 结构化汇总并一键跳回原会话位置,自动填入修复指令
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { DynamicBorder, BorderedLoader } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import { promises as fs } from "node:fs";
|
|
29
|
+
|
|
30
|
+
// 跟踪审查会话来源分支节点(保证单次仅一个活跃审查会话)
|
|
31
|
+
let reviewOriginId: string | undefined = undefined;
|
|
32
|
+
|
|
33
|
+
const REVIEW_STATE_TYPE = "review-session";
|
|
34
|
+
|
|
35
|
+
type ReviewSessionState = {
|
|
36
|
+
active: boolean;
|
|
37
|
+
originId?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface ReviewSettings {
|
|
41
|
+
/** 模式: "single" (单模型原生审查) | "subagents" (多 Subagent 并发审查) */
|
|
42
|
+
mode: "single" | "subagents";
|
|
43
|
+
/** 并发子代理专家数量 (支持 2, 3, 4, 5, 6) */
|
|
44
|
+
concurrency: number;
|
|
45
|
+
/** 是否启用门禁裁判长总结去重 */
|
|
46
|
+
gateEnabled: boolean;
|
|
22
47
|
}
|
|
23
48
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
const DEFAULT_SETTINGS: ReviewSettings = {
|
|
50
|
+
mode: "single",
|
|
51
|
+
concurrency: 3,
|
|
52
|
+
gateEnabled: true,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "pi-review.json");
|
|
56
|
+
|
|
57
|
+
async function loadSettings(): Promise<ReviewSettings> {
|
|
58
|
+
try {
|
|
59
|
+
const raw = await fs.readFile(CONFIG_PATH, "utf8");
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
return {
|
|
62
|
+
mode: parsed.mode === "subagents" ? "subagents" : "single",
|
|
63
|
+
concurrency: Math.min(6, Math.max(2, typeof parsed.concurrency === "number" ? parsed.concurrency : 3)),
|
|
64
|
+
gateEnabled: parsed.gateEnabled !== false,
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
return { ...DEFAULT_SETTINGS };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function saveSettings(settings: ReviewSettings): Promise<void> {
|
|
72
|
+
try {
|
|
73
|
+
await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
|
|
74
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(settings, null, 2), "utf8");
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error("保存审查设置失败:", err);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ReviewExpert {
|
|
81
|
+
id: string;
|
|
82
|
+
label: string;
|
|
83
|
+
desc: string;
|
|
84
|
+
task: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ALL_EXPERTS: ReviewExpert[] = [
|
|
88
|
+
{
|
|
89
|
+
id: "pi-review.bugbot",
|
|
90
|
+
label: "Bug 猎手 (Bugbot)",
|
|
91
|
+
desc: "逻辑缺陷、空指针、越界越权、死锁与运行时崩溃",
|
|
92
|
+
task: "深入排查本次代码改动中的业务逻辑缺陷、空指针、越界、并发竞态与未捕获的运行时异常",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "pi-review.security-review",
|
|
96
|
+
label: "安全专家 (Security)",
|
|
97
|
+
desc: "未受信任外部输入、SQL注入、路径穿越与越权漏洞",
|
|
98
|
+
task: "深入排查本次代码改动中的安全隐患、外部输入未严格校验、未参数化语句与注入风险",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "pi-review.perf-review",
|
|
102
|
+
label: "性能探针 (Perf)",
|
|
103
|
+
desc: "循环内GC内存分配、CPU热点消耗、算法复杂度与资源泄露",
|
|
104
|
+
task: "深入排查本次代码改动中的性能退化、高频循环内无谓内存分配 (GC压力) 与算法复杂度",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "pi-review.claude-md-compliance",
|
|
108
|
+
label: "契约合规 (Compliance)",
|
|
109
|
+
desc: "架构契约、设计模式、模块边界与规范遵循",
|
|
110
|
+
task: "排查本次代码改动是否违反项目既有架构契约、模块封装规范与规范指南",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "pi-review.code-comments",
|
|
114
|
+
label: "注释与可读性 (Comments)",
|
|
115
|
+
desc: "注释与代码逻辑倒挂、误导性命名与维护性隐患",
|
|
116
|
+
task: "排查本次代码改动中的可读性隐患、注释与逻辑不符、误导性命名与维护风险",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "pi-review.history-context",
|
|
120
|
+
label: "历史脉络 (History)",
|
|
121
|
+
desc: "结合 Git 历史演进判断意图,防止历史问题回归",
|
|
122
|
+
task: "结合代码演变历史,排查本次改动是否破坏既有历史契约或重现已知缺陷",
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
function buildSubagentOrchestrationPrompt(concurrency: number, gateEnabled: boolean): string {
|
|
127
|
+
const count = Math.min(6, Math.max(2, concurrency));
|
|
128
|
+
const selected = ALL_EXPERTS.slice(0, count);
|
|
129
|
+
|
|
130
|
+
const listText = selected.map((exp, idx) => `${idx + 1}. **${exp.label}** (\`${exp.id}\`):${exp.desc}`).join("\n");
|
|
131
|
+
const callsExample = selected
|
|
132
|
+
.map((exp) => ` - \`subagent({ agent: "${exp.id}", task: "${exp.task}" })\``)
|
|
133
|
+
.join("\n");
|
|
134
|
+
|
|
135
|
+
return `## 🚀 执行方式:多 Subagent 并发专家审查 (当前配置并发数: ${count} 个专家)
|
|
136
|
+
|
|
137
|
+
当前已配置并行启动以下 ${count} 个专家子代理进行分工审查:
|
|
138
|
+
|
|
139
|
+
${listText}
|
|
140
|
+
|
|
141
|
+
### 协作审查执行规范:
|
|
142
|
+
1. **并发调用子代理**:请在当前回合使用 \`subagent\` 工具**同时并行唤起**上述 ${count} 个专家子代理(单回合发起 ${count} 个并发 tool_call,严禁串行逐个调用):
|
|
143
|
+
${callsExample}
|
|
144
|
+
2. **主审裁判长汇总整理**:当所有专家子代理执行完毕返回发现后,请你作为主审裁判长${gateEnabled ? "(门禁裁决)" : ""}:
|
|
145
|
+
- 全面综合各专家的审查意见,对相同问题进行去重,剔除误报和低置信度内容。
|
|
146
|
+
- 严格按照《核心代码审查准则》的 **[P0~P3]** 等级标准排布审查清单。
|
|
147
|
+
- 给出最终综合裁决与一句话中文总评。
|
|
148
|
+
3. **语言要求**:所有输出必须为纯正中文。`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function setReviewWidget(ctx: ExtensionContext, active: boolean) {
|
|
152
|
+
if (!ctx.hasUI) return;
|
|
153
|
+
if (!active) {
|
|
154
|
+
ctx.ui.setWidget("review", undefined);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
ctx.ui.setWidget("review", (_tui, theme) => {
|
|
159
|
+
const text = new Text(theme.fg("warning", "🔍 代码审查分支进行中,审查完毕后输入 /end-review 返回主对话"), 0, 0);
|
|
160
|
+
return {
|
|
161
|
+
render(width: number) {
|
|
162
|
+
return text.render(width);
|
|
163
|
+
},
|
|
164
|
+
invalidate() {
|
|
165
|
+
text.invalidate();
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getReviewState(ctx: ExtensionContext): ReviewSessionState | undefined {
|
|
172
|
+
let state: ReviewSessionState | undefined;
|
|
173
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
174
|
+
if (entry.type === "custom" && entry.customType === REVIEW_STATE_TYPE) {
|
|
175
|
+
state = entry.data as ReviewSessionState | undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return state;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function applyReviewState(ctx: ExtensionContext) {
|
|
182
|
+
const state = getReviewState(ctx);
|
|
183
|
+
if (state?.active && state.originId) {
|
|
184
|
+
reviewOriginId = state.originId;
|
|
185
|
+
setReviewWidget(ctx, true);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
reviewOriginId = undefined;
|
|
190
|
+
setReviewWidget(ctx, false);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 审查目标类型
|
|
194
|
+
type ReviewTarget =
|
|
195
|
+
| { type: "uncommitted" }
|
|
196
|
+
| { type: "baseBranch"; branch: string }
|
|
197
|
+
| { type: "commit"; sha: string; title?: string }
|
|
198
|
+
| { type: "custom"; instructions: string }
|
|
199
|
+
| { type: "pullRequest"; prNumber: number; baseBranch: string; title: string }
|
|
200
|
+
| { type: "folder"; paths: string[] };
|
|
201
|
+
|
|
202
|
+
// 针对不同审查目标的中文提示词
|
|
203
|
+
const UNCOMMITTED_PROMPT =
|
|
204
|
+
"请审查当前代码的所有改动(包含暂存区、未暂存区以及新增文件)。请直接运行 `git diff`、`git status` 及必要的文件读取工具深入审查,并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。";
|
|
205
|
+
|
|
206
|
+
const BASE_BRANCH_PROMPT_WITH_MERGE_BASE =
|
|
207
|
+
"请审查当前分支相对于基准分支 '{baseBranch}' 的改动代码。两者的合并基准 commit 为 {mergeBaseSha}。请直接运行 `git diff {mergeBaseSha}` 检查本次改动并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。";
|
|
208
|
+
|
|
209
|
+
const BASE_BRANCH_PROMPT_FALLBACK =
|
|
210
|
+
"请审查当前分支相对于基准分支 '{branch}' 的改动代码。首先使用 `git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{upstream}\")\"` 找到合并基准,再通过 `git diff` 检查改动并给出具体审查发现。所有输出必须使用纯正中文。";
|
|
211
|
+
|
|
212
|
+
const COMMIT_PROMPT_WITH_TITLE =
|
|
213
|
+
'请审查提交 commit {sha} ("{title}") 引入的代码改动。直接运行 `git show {sha}` 检查差异并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。';
|
|
214
|
+
|
|
215
|
+
const COMMIT_PROMPT =
|
|
216
|
+
"请审查提交 commit {sha} 引入的代码改动。直接运行 `git show {sha}` 检查差异并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。";
|
|
217
|
+
|
|
218
|
+
const PULL_REQUEST_PROMPT =
|
|
219
|
+
'请审查 Pull Request #{prNumber} ("{title}") 相对于基准分支 \'{baseBranch}\' 的改动代码。两者的合并基准 commit 为 {mergeBaseSha}。请直接运行 `git diff {mergeBaseSha}` 检查改动并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。';
|
|
220
|
+
|
|
221
|
+
const PULL_REQUEST_PROMPT_FALLBACK =
|
|
222
|
+
'请审查 Pull Request #{prNumber} ("{title}") 相对于基准分支 \'{baseBranch}\' 的改动代码。首先寻找当前分支与 {baseBranch} 的合并基准 (例如 `git merge-base HEAD {baseBranch}`),再通过 `git diff` 检查改动并给出具体审查发现。所有输出必须使用纯正中文。';
|
|
223
|
+
|
|
224
|
+
const FOLDER_REVIEW_PROMPT =
|
|
225
|
+
"请对以下目录/文件路径的代码进行快照审查:{paths}。注意这是全量快照审查(非 diff 对比)。请直接读取这些文件并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。";
|
|
226
|
+
|
|
227
|
+
// 权威的中文代码审查准则 (基于 Codex 准则精炼与本土化)
|
|
228
|
+
const REVIEW_RUBRIC = `# 核心代码审查准则(资深工程师视角)
|
|
229
|
+
|
|
230
|
+
你正在作为一名资深技术专家对另一位工程师提交的代码改动进行严格的代码审查。你的目标是帮作者把关并拦截真实风险,给出清晰、可落地、带事实证据的中文审查意见。
|
|
231
|
+
|
|
232
|
+
## 重点排查范围(排查什么)
|
|
233
|
+
1. **代码正确性与边界处理**:逻辑缺陷、空指针/未定义引用、边界越界、生命周期异常、未捕获的运行时异常。
|
|
234
|
+
2. **并发与状态安全**:竞态条件、死锁隐患、异步缺少等待、未处理的取消或中断、脏状态残留。
|
|
235
|
+
3. **性能与内存开销**:高频主循环内的大量内存分配 (GC 压力)、不必要的深拷贝、高复杂度算法、资源句柄或网络连接未释放。
|
|
236
|
+
4. **代码健壮性与外部合规**:
|
|
237
|
+
- 严禁信任外部用户输入(必须检查未参数化的 SQL、路径穿越、未校验的 URL 重定向、危险的反序列化等)。
|
|
238
|
+
- 报错与异常必须检查稳定的错误码或类型,严禁用脆弱的错误文本字符串做业务分支判断。
|
|
239
|
+
5. **本次改动引入的缺陷**:只审查本次改动实际引入的问题,严禁把改动前既有的历史代码或未改动代码归咎为缺陷。
|
|
240
|
+
|
|
241
|
+
## 严格过滤误报(不排查什么)
|
|
242
|
+
- ❌ 严禁提出吹毛求疵、纯属个人审美的废话风格建议(若无明确项目规范强制要求)。
|
|
243
|
+
- ❌ 严禁提出 Linter、类型检查器、编译构建会自动捕获的浅层格式建议。
|
|
244
|
+
- ❌ 严禁基于未证实的纯主观假设进行无端猜测,每一条问题必须有明确的代码事实证据。
|
|
245
|
+
|
|
246
|
+
## 缺陷严重等级标记
|
|
247
|
+
每一条审查发现必须在标题中清晰标注严重等级:
|
|
248
|
+
- **[P0 - 致命阻塞]** 导致系统崩溃、死锁、数据损坏、关键功能完全不可用或高危漏洞,必须立即修复,阻断合并。
|
|
249
|
+
- **[P1 - 紧急待修]** 明确的逻辑缺陷、高概率边界异常、严重性能退化或破坏公共接口契约,应在本次合并前修复。
|
|
250
|
+
- **[P2 - 普通建议]** 局部的健壮性隐患、轻度可读性或次要设计问题,建议在后续优化。
|
|
251
|
+
- **[P3 - 细节优化]** 极轻微的细节优化建议,不影响业务。
|
|
252
|
+
|
|
253
|
+
## 输出格式(所有内容必须使用纯正中文)
|
|
254
|
+
请按以下规范输出审查报告:
|
|
255
|
+
|
|
256
|
+
### 审查发现清单
|
|
257
|
+
每个问题按以下格式列出(若完全无缺陷,请明确输出:\`未发现存活的代码缺陷,代码质量良好,建议合并。\`):
|
|
258
|
+
- **[P0|P1|P2|P3] 简短标题**:\`文件路径:行号\`
|
|
259
|
+
- **缺陷说明**:简明扼要说明该问题会导致什么后果以及在何种场景下被触发。
|
|
260
|
+
- **代码证据**:引用 1~3 行具体代码或调用链路。
|
|
261
|
+
- **修改建议**:给出最小化的修复思路或直接附带精准的代码替换块(可使用 \`\`\`suggestion 代码块)。
|
|
262
|
+
|
|
263
|
+
### 综合裁决
|
|
264
|
+
- **最终结论**:\`通过 (Approved)\` 或 \`需要修改 (Request Changes - 存在 P0/P1 阻塞问题)\`
|
|
265
|
+
- **总结说明**:一句话中文总评。`;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 尝试加载项目本地的专属审查准则文件 (REVIEW_GUIDELINES.md 或 AGENTS.md / CLAUDE.md)
|
|
269
|
+
*/
|
|
270
|
+
async function loadProjectReviewGuidelines(cwd: string): Promise<string | null> {
|
|
271
|
+
let currentDir = path.resolve(cwd);
|
|
272
|
+
|
|
273
|
+
while (true) {
|
|
274
|
+
const piDir = path.join(currentDir, ".pi");
|
|
275
|
+
const candidates = [
|
|
276
|
+
path.join(currentDir, "REVIEW_GUIDELINES.md"),
|
|
277
|
+
path.join(currentDir, "AGENTS.md"),
|
|
278
|
+
path.join(currentDir, "CLAUDE.md"),
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
const piStats = await fs.stat(piDir).catch(() => null);
|
|
282
|
+
if (piStats?.isDirectory()) {
|
|
283
|
+
for (const guidelinePath of candidates) {
|
|
284
|
+
const stat = await fs.stat(guidelinePath).catch(() => null);
|
|
285
|
+
if (stat?.isFile()) {
|
|
286
|
+
try {
|
|
287
|
+
const content = await fs.readFile(guidelinePath, "utf8");
|
|
288
|
+
const trimmed = content.trim();
|
|
289
|
+
if (trimmed) return trimmed.slice(0, 3000);
|
|
290
|
+
} catch {
|
|
291
|
+
/* ignore */
|
|
292
|
+
}
|
|
293
|
+
}
|
|
42
294
|
}
|
|
43
|
-
return
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const parentDir = path.dirname(currentDir);
|
|
299
|
+
if (parentDir === currentDir) return null;
|
|
300
|
+
currentDir = parentDir;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 获取 HEAD 与指定分支的 merge base
|
|
306
|
+
*/
|
|
307
|
+
async function getMergeBase(pi: ExtensionAPI, branch: string): Promise<string | null> {
|
|
308
|
+
try {
|
|
309
|
+
const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
|
|
310
|
+
"rev-parse",
|
|
311
|
+
"--abbrev-ref",
|
|
312
|
+
`${branch}@{upstream}`,
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
if (upstreamCode === 0 && upstream.trim()) {
|
|
316
|
+
const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", upstream.trim()]);
|
|
317
|
+
if (code === 0 && mergeBase.trim()) return mergeBase.trim();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", branch]);
|
|
321
|
+
if (code === 0 && mergeBase.trim()) return mergeBase.trim();
|
|
322
|
+
return null;
|
|
323
|
+
} catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function getLocalBranches(pi: ExtensionAPI): Promise<string[]> {
|
|
329
|
+
const { stdout, code } = await pi.exec("git", ["branch", "--format=%(refname:short)"]);
|
|
330
|
+
if (code !== 0) return [];
|
|
331
|
+
return stdout
|
|
332
|
+
.trim()
|
|
333
|
+
.split("\n")
|
|
334
|
+
.map((b) => b.trim())
|
|
335
|
+
.filter(Boolean);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function getRecentCommits(pi: ExtensionAPI, limit = 20): Promise<Array<{ sha: string; title: string }>> {
|
|
339
|
+
const { stdout, code } = await pi.exec("git", ["log", "--oneline", "-n", `${limit}`]);
|
|
340
|
+
if (code !== 0) return [];
|
|
341
|
+
|
|
342
|
+
return stdout
|
|
343
|
+
.trim()
|
|
344
|
+
.split("\n")
|
|
345
|
+
.map((line) => {
|
|
346
|
+
const spaceIdx = line.indexOf(" ");
|
|
347
|
+
if (spaceIdx === -1) return { sha: line, title: "" };
|
|
348
|
+
return {
|
|
349
|
+
sha: line.slice(0, spaceIdx),
|
|
350
|
+
title: line.slice(spaceIdx + 1).trim(),
|
|
49
351
|
};
|
|
352
|
+
})
|
|
353
|
+
.filter((c) => c.sha.length > 0);
|
|
354
|
+
}
|
|
50
355
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
pi.sendMessage({
|
|
57
|
-
customType: "pi-review",
|
|
58
|
-
content: parsed.input ? `/review ${parsed.input}` : "/review",
|
|
59
|
-
display: true,
|
|
60
|
-
});
|
|
356
|
+
async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
|
|
357
|
+
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
|
358
|
+
return code === 0 && stdout.trim().length > 0;
|
|
359
|
+
}
|
|
61
360
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
361
|
+
async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> {
|
|
362
|
+
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
|
363
|
+
if (code !== 0) return false;
|
|
364
|
+
const lines = stdout.trim().split("\n").filter((line) => line.trim());
|
|
365
|
+
const trackedChanges = lines.filter((line) => !line.startsWith("??"));
|
|
366
|
+
return trackedChanges.length > 0;
|
|
367
|
+
}
|
|
67
368
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
perf: parsed.perf,
|
|
73
|
-
full: parsed.full,
|
|
74
|
-
gateModel: parsed.gateModel,
|
|
75
|
-
});
|
|
76
|
-
if (!prepared) {
|
|
77
|
-
notify("没有检测到需要审查的内容 (未找到修改、PR 或非 Git 仓库)。", "info");
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
369
|
+
function parsePrReference(ref: string): number | null {
|
|
370
|
+
const trimmed = ref.trim();
|
|
371
|
+
const num = Number.parseInt(trimmed, 10);
|
|
372
|
+
if (!Number.isNaN(num) && num > 0) return num;
|
|
80
373
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
374
|
+
const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
|
|
375
|
+
if (urlMatch) return Number.parseInt(urlMatch[1], 10);
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function getPrInfo(
|
|
380
|
+
pi: ExtensionAPI,
|
|
381
|
+
prNumber: number,
|
|
382
|
+
): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
|
|
383
|
+
const { stdout, code } = await pi.exec("gh", [
|
|
384
|
+
"pr",
|
|
385
|
+
"view",
|
|
386
|
+
String(prNumber),
|
|
387
|
+
"--json",
|
|
388
|
+
"baseRefName,title,headRefName",
|
|
389
|
+
]);
|
|
390
|
+
if (code !== 0) return null;
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const data = JSON.parse(stdout);
|
|
394
|
+
return {
|
|
395
|
+
baseBranch: data.baseRefName,
|
|
396
|
+
title: data.title,
|
|
397
|
+
headBranch: data.headRefName,
|
|
398
|
+
};
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function checkoutPr(pi: ExtensionAPI, prNumber: number): Promise<{ success: boolean; error?: string }> {
|
|
405
|
+
const { stdout, stderr, code } = await pi.exec("gh", ["pr", "checkout", String(prNumber)]);
|
|
406
|
+
if (code !== 0) {
|
|
407
|
+
return { success: false, error: stderr || stdout || "检出 PR 分支失败" };
|
|
408
|
+
}
|
|
409
|
+
return { success: true };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> {
|
|
413
|
+
const { stdout, code } = await pi.exec("git", ["branch", "--show-current"]);
|
|
414
|
+
if (code === 0 && stdout.trim()) return stdout.trim();
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
|
|
419
|
+
const { stdout, code } = await pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
|
|
420
|
+
if (code === 0 && stdout.trim()) {
|
|
421
|
+
return stdout.trim().replace("origin/", "");
|
|
422
|
+
}
|
|
423
|
+
const branches = await getLocalBranches(pi);
|
|
424
|
+
if (branches.includes("main")) return "main";
|
|
425
|
+
if (branches.includes("master")) return "master";
|
|
426
|
+
return "main";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function buildReviewPrompt(pi: ExtensionAPI, target: ReviewTarget): Promise<string> {
|
|
430
|
+
switch (target.type) {
|
|
431
|
+
case "uncommitted":
|
|
432
|
+
return UNCOMMITTED_PROMPT;
|
|
433
|
+
|
|
434
|
+
case "baseBranch": {
|
|
435
|
+
const mergeBase = await getMergeBase(pi, target.branch);
|
|
436
|
+
if (mergeBase) {
|
|
437
|
+
return BASE_BRANCH_PROMPT_WITH_MERGE_BASE.replace(/{baseBranch}/g, target.branch).replace(
|
|
438
|
+
/{mergeBaseSha}/g,
|
|
439
|
+
mergeBase,
|
|
88
440
|
);
|
|
89
|
-
} catch (err) {
|
|
90
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
91
|
-
notify(`代码审查启动失败: ${message}`, "error");
|
|
92
441
|
}
|
|
93
|
-
|
|
94
|
-
|
|
442
|
+
return BASE_BRANCH_PROMPT_FALLBACK.replace(/{branch}/g, target.branch);
|
|
443
|
+
}
|
|
95
444
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
445
|
+
case "commit":
|
|
446
|
+
if (target.title) {
|
|
447
|
+
return COMMIT_PROMPT_WITH_TITLE.replace("{sha}", target.sha).replace("{title}", target.title);
|
|
448
|
+
}
|
|
449
|
+
return COMMIT_PROMPT.replace("{sha}", target.sha);
|
|
450
|
+
|
|
451
|
+
case "custom":
|
|
452
|
+
return `审查代码并重点满足以下定制要求:${target.instructions}。所有输出必须使用纯正中文。`;
|
|
453
|
+
|
|
454
|
+
case "pullRequest": {
|
|
455
|
+
const mergeBase = await getMergeBase(pi, target.baseBranch);
|
|
456
|
+
if (mergeBase) {
|
|
457
|
+
return PULL_REQUEST_PROMPT.replace(/{prNumber}/g, String(target.prNumber))
|
|
458
|
+
.replace(/{title}/g, target.title)
|
|
459
|
+
.replace(/{baseBranch}/g, target.baseBranch)
|
|
460
|
+
.replace(/{mergeBaseSha}/g, mergeBase);
|
|
461
|
+
}
|
|
462
|
+
return PULL_REQUEST_PROMPT_FALLBACK.replace(/{prNumber}/g, String(target.prNumber))
|
|
463
|
+
.replace(/{title}/g, target.title)
|
|
464
|
+
.replace(/{baseBranch}/g, target.baseBranch);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case "folder":
|
|
468
|
+
return FOLDER_REVIEW_PROMPT.replace("{paths}", target.paths.join(", "));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function getUserFacingHint(target: ReviewTarget): string {
|
|
473
|
+
switch (target.type) {
|
|
474
|
+
case "uncommitted":
|
|
475
|
+
return "当前未提交改动 (工作区 + 暂存区)";
|
|
476
|
+
case "baseBranch":
|
|
477
|
+
return `与分支 '${target.branch}' 的改动对比`;
|
|
478
|
+
case "commit": {
|
|
479
|
+
const shortSha = target.sha.slice(0, 7);
|
|
480
|
+
return target.title ? `提交 ${shortSha}: ${target.title}` : `提交 ${shortSha}`;
|
|
481
|
+
}
|
|
482
|
+
case "custom":
|
|
483
|
+
return target.instructions.length > 40 ? `${target.instructions.slice(0, 37)}...` : target.instructions;
|
|
484
|
+
case "pullRequest": {
|
|
485
|
+
const shortTitle = target.title.length > 30 ? `${target.title.slice(0, 27)}...` : target.title;
|
|
486
|
+
return `PR #${target.prNumber}: ${shortTitle}`;
|
|
487
|
+
}
|
|
488
|
+
case "folder": {
|
|
489
|
+
const joined = target.paths.join(", ");
|
|
490
|
+
return joined.length > 40 ? `目录: ${joined.slice(0, 37)}...` : `目录: ${joined}`;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// 审查预设模式选项清单
|
|
496
|
+
const REVIEW_PRESETS = [
|
|
497
|
+
{ value: "uncommitted", label: "审查未提交改动", description: "(当前工作区已暂存 + 未暂存代码)" },
|
|
498
|
+
{ value: "baseBranch", label: "与基准分支对比审查", description: "(如与 main / master 分支对比)" },
|
|
499
|
+
{ value: "commit", label: "审查指定提交", description: "(从最近提交记录中挑选)" },
|
|
500
|
+
{ value: "pullRequest", label: "审查 Pull Request", description: "(输入 PR 编号或 GitHub URL 本地检出)" },
|
|
501
|
+
{ value: "folder", label: "审查指定目录/文件", description: "(静态快照审查,非 diff)" },
|
|
502
|
+
{ value: "custom", label: "自定义审查要求", description: "(输入针对性的安全/性能侧重点)" },
|
|
503
|
+
{ value: "config", label: "⚙️ 审查配置中心", description: "(切换单模型 / 多Subagent并发 2~6个)" },
|
|
504
|
+
] as const;
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 弹出交互式审查配置界面
|
|
508
|
+
*/
|
|
509
|
+
async function showConfigDialog(ctx: ExtensionContext): Promise<void> {
|
|
510
|
+
const settings = await loadSettings();
|
|
511
|
+
|
|
512
|
+
while (true) {
|
|
513
|
+
const modeDesc = settings.mode === "single" ? "单模型直接审查 (极速 100% 稳定)" : `多 Subagent 并发 (${settings.concurrency} 个专家)`;
|
|
514
|
+
const gateDesc = settings.gateEnabled ? "开启" : "关闭";
|
|
515
|
+
|
|
516
|
+
const menuItems: SelectItem[] = [
|
|
517
|
+
{
|
|
518
|
+
value: "toggle-mode",
|
|
519
|
+
label: `1. 审查引擎: [${settings.mode === "single" ? "单模型直接审查" : "多Subagent并发"}]`,
|
|
520
|
+
description: modeDesc,
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
value: "concurrency",
|
|
524
|
+
label: `2. 并发子代理数: [${settings.concurrency} 个专家]`,
|
|
525
|
+
description: "可自由设置并发 2、3、4、5、6 个专家子代理",
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
value: "gate",
|
|
529
|
+
label: `3. 门禁裁判长去重: [${gateDesc}]`,
|
|
530
|
+
description: "在多专家返回后由主审裁判长汇总去重并定级",
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
value: "save",
|
|
534
|
+
label: "✅ 保存配置并退出",
|
|
535
|
+
description: "将当前设置保存到 ~/.pi/agent/pi-review.json",
|
|
536
|
+
},
|
|
537
|
+
];
|
|
538
|
+
|
|
539
|
+
const choice = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
540
|
+
const container = new Container();
|
|
541
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
542
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("⚙️ 代码审查配置中心"))));
|
|
543
|
+
|
|
544
|
+
const selectList = new SelectList(menuItems, menuItems.length, {
|
|
545
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
546
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
547
|
+
description: (text) => theme.fg("muted", text),
|
|
548
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
549
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
selectList.onSelect = (item) => done(item.value);
|
|
553
|
+
selectList.onCancel = () => done(null);
|
|
554
|
+
|
|
555
|
+
container.addChild(selectList);
|
|
556
|
+
container.addChild(new Text(theme.fg("dim", "方向键选择 • 回车修改/确认 • ESC 取消")));
|
|
557
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
render(w) { return container.render(w); },
|
|
561
|
+
invalidate() { container.invalidate(); },
|
|
562
|
+
handleInput(d) { selectList.handleInput(d); tui.requestRender(); },
|
|
102
563
|
};
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
if (!choice || choice === "save") {
|
|
567
|
+
await saveSettings(settings);
|
|
568
|
+
ctx.ui.notify(`已保存审查设置:${settings.mode === "single" ? "单模型直接审查" : `多Subagent模式 (${settings.concurrency} 并发)`}`, "info");
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (choice === "toggle-mode") {
|
|
573
|
+
settings.mode = settings.mode === "single" ? "subagents" : "single";
|
|
574
|
+
} else if (choice === "concurrency") {
|
|
575
|
+
const concurrencyChoice = await ctx.ui.select(
|
|
576
|
+
"请选择并发 Subagent 专家数量 (2 ~ 6 个):",
|
|
577
|
+
[
|
|
578
|
+
"2 个专家 (Bug猎手 + 安全专家 · 轻量低延迟)",
|
|
579
|
+
"3 个专家 (Bug猎手 + 安全专家 + 性能探针 · 均衡推荐)",
|
|
580
|
+
"4 个专家 (+ 契约规范合规)",
|
|
581
|
+
"5 个专家 (+ 注释与代码可读性)",
|
|
582
|
+
"6 个专家 (全量 6 大专家深度会诊)",
|
|
583
|
+
]
|
|
584
|
+
);
|
|
585
|
+
if (concurrencyChoice !== undefined) {
|
|
586
|
+
const num = Number.parseInt(concurrencyChoice.slice(0, 1), 10);
|
|
587
|
+
if (num >= 2 && num <= 6) {
|
|
588
|
+
settings.concurrency = num;
|
|
111
589
|
}
|
|
112
|
-
pi.sendMessage({ customType: "pi-review-directive", content: prepared.directiveText, display: false }, { triggerTurn: true });
|
|
113
|
-
} catch (err) {
|
|
114
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
115
|
-
notify(`极速代码审查启动失败: ${message}`, "error");
|
|
116
590
|
}
|
|
117
|
-
}
|
|
591
|
+
} else if (choice === "gate") {
|
|
592
|
+
settings.gateEnabled = !settings.gateEnabled;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export default function reviewExtension(pi: ExtensionAPI) {
|
|
598
|
+
pi.on("session_start", (_event, ctx) => {
|
|
599
|
+
applyReviewState(ctx);
|
|
118
600
|
});
|
|
119
601
|
|
|
120
|
-
pi.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
602
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
603
|
+
applyReviewState(ctx);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
async function getSmartDefault(): Promise<"uncommitted" | "baseBranch" | "commit"> {
|
|
607
|
+
if (await hasUncommittedChanges(pi)) {
|
|
608
|
+
return "uncommitted";
|
|
609
|
+
}
|
|
610
|
+
const currentBranch = await getCurrentBranch(pi);
|
|
611
|
+
const defaultBranch = await getDefaultBranch(pi);
|
|
612
|
+
if (currentBranch && currentBranch !== defaultBranch) {
|
|
613
|
+
return "baseBranch";
|
|
614
|
+
}
|
|
615
|
+
return "commit";
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function showReviewSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
619
|
+
const smartDefault = await getSmartDefault();
|
|
620
|
+
const currentSettings = await loadSettings();
|
|
621
|
+
|
|
622
|
+
while (true) {
|
|
623
|
+
const modeTag = currentSettings.mode === "single" ? "单模型模式" : `${currentSettings.concurrency} 专家并发`;
|
|
624
|
+
const items: SelectItem[] = REVIEW_PRESETS.slice()
|
|
625
|
+
.sort((a, b) => {
|
|
626
|
+
if (a.value === "config") return 1;
|
|
627
|
+
if (b.value === "config") return -1;
|
|
628
|
+
if (a.value === smartDefault) return -1;
|
|
629
|
+
if (b.value === smartDefault) return 1;
|
|
630
|
+
return 0;
|
|
631
|
+
})
|
|
632
|
+
.map((preset) => ({
|
|
633
|
+
value: preset.value,
|
|
634
|
+
label: preset.label,
|
|
635
|
+
description: preset.value === "config" ? `[当前: ${modeTag}]` : preset.description,
|
|
636
|
+
}));
|
|
637
|
+
|
|
638
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
639
|
+
const container = new Container();
|
|
640
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
641
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(`请选择代码审查模式 [${modeTag}]`))));
|
|
642
|
+
|
|
643
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
644
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
645
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
646
|
+
description: (text) => theme.fg("muted", text),
|
|
647
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
648
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
selectList.onSelect = (item) => done(item.value);
|
|
652
|
+
selectList.onCancel = () => done(null);
|
|
653
|
+
|
|
654
|
+
container.addChild(selectList);
|
|
655
|
+
container.addChild(new Text(theme.fg("dim", "回车确认 • ESC 取消")));
|
|
656
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
render(width: number) {
|
|
660
|
+
return container.render(width);
|
|
661
|
+
},
|
|
662
|
+
invalidate() {
|
|
663
|
+
container.invalidate();
|
|
664
|
+
},
|
|
665
|
+
handleInput(data: string) {
|
|
666
|
+
selectList.handleInput(data);
|
|
667
|
+
tui.requestRender();
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
if (!result) return null;
|
|
673
|
+
|
|
674
|
+
if (result === "config") {
|
|
675
|
+
await showConfigDialog(ctx);
|
|
676
|
+
// 重新加载配置并循环展示主菜单
|
|
677
|
+
const updated = await loadSettings();
|
|
678
|
+
currentSettings.mode = updated.mode;
|
|
679
|
+
currentSettings.concurrency = updated.concurrency;
|
|
680
|
+
currentSettings.gateEnabled = updated.gateEnabled;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
switch (result) {
|
|
685
|
+
case "uncommitted":
|
|
686
|
+
return { type: "uncommitted" };
|
|
687
|
+
|
|
688
|
+
case "baseBranch": {
|
|
689
|
+
const target = await showBranchSelector(ctx);
|
|
690
|
+
if (target) return target;
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
case "commit": {
|
|
695
|
+
const target = await showCommitSelector(ctx);
|
|
696
|
+
if (target) return target;
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
case "custom": {
|
|
701
|
+
const target = await showCustomInput(ctx);
|
|
702
|
+
if (target) return target;
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
case "folder": {
|
|
707
|
+
const target = await showFolderInput(ctx);
|
|
708
|
+
if (target) return target;
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
case "pullRequest": {
|
|
713
|
+
const target = await showPrInput(ctx);
|
|
714
|
+
if (target) return target;
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
default:
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function showBranchSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
725
|
+
const branches = await getLocalBranches(pi);
|
|
726
|
+
const defaultBranch = await getDefaultBranch(pi);
|
|
727
|
+
|
|
728
|
+
if (branches.length === 0) {
|
|
729
|
+
ctx.ui.notify("未找到任何本地分支", "error");
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const sortedBranches = branches.sort((a, b) => {
|
|
734
|
+
if (a === defaultBranch) return -1;
|
|
735
|
+
if (b === defaultBranch) return 1;
|
|
736
|
+
return a.localeCompare(b);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const items: SelectItem[] = sortedBranches.map((branch) => ({
|
|
740
|
+
value: branch,
|
|
741
|
+
label: branch,
|
|
742
|
+
description: branch === defaultBranch ? "(默认主分支)" : "",
|
|
743
|
+
}));
|
|
744
|
+
|
|
745
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
746
|
+
const container = new Container();
|
|
747
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
748
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("请选择要对比的基准分支"))));
|
|
749
|
+
|
|
750
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
751
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
752
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
753
|
+
description: (text) => theme.fg("muted", text),
|
|
754
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
755
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
selectList.searchable = true;
|
|
759
|
+
selectList.onSelect = (item) => done(item.value);
|
|
760
|
+
selectList.onCancel = () => done(null);
|
|
761
|
+
|
|
762
|
+
container.addChild(selectList);
|
|
763
|
+
container.addChild(new Text(theme.fg("dim", "输入关键词可快速搜索 • 回车选择 • ESC 取消")));
|
|
764
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
render(width: number) {
|
|
768
|
+
return container.render(width);
|
|
769
|
+
},
|
|
770
|
+
invalidate() {
|
|
771
|
+
container.invalidate();
|
|
772
|
+
},
|
|
773
|
+
handleInput(data: string) {
|
|
774
|
+
selectList.handleInput(data);
|
|
775
|
+
tui.requestRender();
|
|
776
|
+
},
|
|
777
|
+
};
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
if (!result) return null;
|
|
781
|
+
return { type: "baseBranch", branch: result };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function showCommitSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
785
|
+
const commits = await getRecentCommits(pi, 20);
|
|
786
|
+
|
|
787
|
+
if (commits.length === 0) {
|
|
788
|
+
ctx.ui.notify("未找到最近的提交记录", "error");
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const items: SelectItem[] = commits.map((commit) => ({
|
|
793
|
+
value: commit.sha,
|
|
794
|
+
label: `${commit.sha.slice(0, 7)} ${commit.title}`,
|
|
795
|
+
description: "",
|
|
796
|
+
}));
|
|
797
|
+
|
|
798
|
+
const result = await ctx.ui.custom<{ sha: string; title: string } | null>((tui, theme, _kb, done) => {
|
|
799
|
+
const container = new Container();
|
|
800
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
801
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("请选择要审查的 Commit"))));
|
|
802
|
+
|
|
803
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
804
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
805
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
806
|
+
description: (text) => theme.fg("muted", text),
|
|
807
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
808
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
selectList.searchable = true;
|
|
812
|
+
selectList.onSelect = (item) => {
|
|
813
|
+
const commit = commits.find((c) => c.sha === item.value);
|
|
814
|
+
done(commit || null);
|
|
126
815
|
};
|
|
816
|
+
selectList.onCancel = () => done(null);
|
|
817
|
+
|
|
818
|
+
container.addChild(selectList);
|
|
819
|
+
container.addChild(new Text(theme.fg("dim", "输入可搜索 • 回车选择 • ESC 取消")));
|
|
820
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
render(width: number) {
|
|
824
|
+
return container.render(width);
|
|
825
|
+
},
|
|
826
|
+
invalidate() {
|
|
827
|
+
container.invalidate();
|
|
828
|
+
},
|
|
829
|
+
handleInput(data: string) {
|
|
830
|
+
selectList.handleInput(data);
|
|
831
|
+
tui.requestRender();
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
if (!result) return null;
|
|
837
|
+
return { type: "commit", sha: result.sha, title: result.title };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function showCustomInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
841
|
+
const result = await ctx.ui.editor(
|
|
842
|
+
"请输入本次审查的具体要求与侧重点 (如并发安全、GC开销、特定接口逻辑):",
|
|
843
|
+
"重点排查潜在的并发死锁、内存泄露以及外部输入合法性校验...",
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
if (!result?.trim()) return null;
|
|
847
|
+
return { type: "custom", instructions: result.trim() };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function parseReviewPaths(value: string): string[] {
|
|
851
|
+
return value
|
|
852
|
+
.split(/\s+/)
|
|
853
|
+
.map((item) => item.trim())
|
|
854
|
+
.filter((item) => item.length > 0);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function showFolderInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
858
|
+
const result = await ctx.ui.editor(
|
|
859
|
+
"请输入要审查的文件或目录路径 (多个路径使用空格或换行分隔):",
|
|
860
|
+
".",
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
if (!result?.trim()) return null;
|
|
864
|
+
const paths = parseReviewPaths(result);
|
|
865
|
+
if (paths.length === 0) return null;
|
|
866
|
+
|
|
867
|
+
return { type: "folder", paths };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function showPrInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
871
|
+
if (await hasPendingChanges(pi)) {
|
|
872
|
+
ctx.ui.notify("无法检出 PR:工作区存在未提交改动,请先提交或暂存 (git stash)。", "error");
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const prRef = await ctx.ui.editor(
|
|
877
|
+
"请输入 PR 编号或 GitHub URL (如 123 或 https://github.com/owner/repo/pull/123):",
|
|
878
|
+
"",
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
if (!prRef?.trim()) return null;
|
|
882
|
+
|
|
883
|
+
const prNumber = parsePrReference(prRef);
|
|
884
|
+
if (!prNumber) {
|
|
885
|
+
ctx.ui.notify("无效的 PR 格式,请输入纯数字编号或 GitHub PR 网页链接。", "error");
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
ctx.ui.notify(`正在获取 PR #${prNumber} 元数据...`, "info");
|
|
890
|
+
const prInfo = await getPrInfo(pi, prNumber);
|
|
891
|
+
|
|
892
|
+
if (!prInfo) {
|
|
893
|
+
ctx.ui.notify(`未找到 PR #${prNumber}。请确认已登录 gh (GitHub CLI) 且 PR 存在。`, "error");
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
ctx.ui.notify(`正在本地检出 PR #${prNumber} 分支...`, "info");
|
|
898
|
+
const checkoutResult = await checkoutPr(pi, prNumber);
|
|
899
|
+
|
|
900
|
+
if (!checkoutResult.success) {
|
|
901
|
+
ctx.ui.notify(`检出 PR 失败: ${checkoutResult.error}`, "error");
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
ctx.ui.notify(`已成功检出 PR #${prNumber} (${prInfo.headBranch})`, "info");
|
|
906
|
+
|
|
907
|
+
return {
|
|
908
|
+
type: "pullRequest",
|
|
909
|
+
prNumber,
|
|
910
|
+
baseBranch: prInfo.baseBranch,
|
|
911
|
+
title: prInfo.title,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function executeReview(
|
|
916
|
+
ctx: ExtensionCommandContext,
|
|
917
|
+
target: ReviewTarget,
|
|
918
|
+
useFreshSession: boolean,
|
|
919
|
+
runtimeSettings?: Partial<ReviewSettings>,
|
|
920
|
+
): Promise<void> {
|
|
921
|
+
if (reviewOriginId) {
|
|
922
|
+
ctx.ui.notify("当前已有正在进行的审查会话。请先使用 /end-review 结束。", "warning");
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const baseSettings = await loadSettings();
|
|
927
|
+
const settings: ReviewSettings = {
|
|
928
|
+
...baseSettings,
|
|
929
|
+
...runtimeSettings,
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
if (useFreshSession) {
|
|
933
|
+
const originId = ctx.sessionManager.getLeafId() ?? undefined;
|
|
934
|
+
if (!originId) {
|
|
935
|
+
ctx.ui.notify("无法获取当前会话位置,请在有消息的会话中重试。", "error");
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
reviewOriginId = originId;
|
|
939
|
+
const lockedOriginId = originId;
|
|
940
|
+
|
|
941
|
+
const entries = ctx.sessionManager.getEntries();
|
|
942
|
+
const firstUserMessage = entries.find((e) => e.type === "message" && e.message.role === "user");
|
|
943
|
+
|
|
944
|
+
if (!firstUserMessage) {
|
|
945
|
+
ctx.ui.notify("当前会话中未找到任何用户消息", "error");
|
|
946
|
+
reviewOriginId = undefined;
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
|
|
127
950
|
try {
|
|
128
|
-
const {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const prepared = await prepareRun({ cwd: ctx.cwd, input: args, perf: true });
|
|
132
|
-
if (!prepared) {
|
|
133
|
-
notify("没有检测到需要审查的内容 (未找到修改、PR 或非 Git 仓库)。", "info");
|
|
951
|
+
const result = await ctx.navigateTree(firstUserMessage.id, { summarize: false, label: "代码审查" });
|
|
952
|
+
if (result.cancelled) {
|
|
953
|
+
reviewOriginId = undefined;
|
|
134
954
|
return;
|
|
135
955
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
956
|
+
} catch (error) {
|
|
957
|
+
reviewOriginId = undefined;
|
|
958
|
+
ctx.ui.notify(`启动审查分支失败: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
959
|
+
return;
|
|
140
960
|
}
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
961
|
|
|
144
|
-
|
|
145
|
-
|
|
962
|
+
reviewOriginId = lockedOriginId;
|
|
963
|
+
ctx.ui.setEditorText("");
|
|
964
|
+
setReviewWidget(ctx, true);
|
|
965
|
+
pi.appendEntry(REVIEW_STATE_TYPE, { active: true, originId: lockedOriginId });
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const prompt = await buildReviewPrompt(pi, target);
|
|
969
|
+
const hint = getUserFacingHint(target);
|
|
970
|
+
const projectGuidelines = await loadProjectReviewGuidelines(ctx.cwd);
|
|
971
|
+
|
|
972
|
+
let fullPrompt = REVIEW_RUBRIC;
|
|
973
|
+
|
|
974
|
+
if (settings.mode === "subagents") {
|
|
975
|
+
fullPrompt += `\n\n---\n\n${buildSubagentOrchestrationPrompt(settings.concurrency, settings.gateEnabled)}`;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
fullPrompt += `\n\n---\n\n## 本次审查目标与任务指示\n\n${prompt}`;
|
|
979
|
+
|
|
980
|
+
if (projectGuidelines) {
|
|
981
|
+
fullPrompt += `\n\n## 本项目附加规范指南\n\n${projectGuidelines}`;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const modeLabel = settings.mode === "single" ? "单模型模式" : `多Subagent并发(${settings.concurrency}专家)`;
|
|
985
|
+
const modeHint = useFreshSession ? " (独立审查分支)" : "";
|
|
986
|
+
ctx.ui.notify(`正在启动代码审查: ${hint}${modeHint} [${modeLabel}]`, "info");
|
|
987
|
+
|
|
988
|
+
pi.sendUserMessage(fullPrompt);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function parseArgs(args: string | undefined): {
|
|
992
|
+
target: ReviewTarget | { type: "pr"; ref: string } | null;
|
|
993
|
+
settingsOverride: Partial<ReviewSettings>;
|
|
994
|
+
} {
|
|
995
|
+
const settingsOverride: Partial<ReviewSettings> = {};
|
|
996
|
+
if (!args?.trim()) return { target: null, settingsOverride };
|
|
997
|
+
|
|
998
|
+
const rawParts = args.trim().split(/\s+/);
|
|
999
|
+
const parts: string[] = [];
|
|
1000
|
+
|
|
1001
|
+
for (let i = 0; i < rawParts.length; i++) {
|
|
1002
|
+
const p = rawParts[i];
|
|
1003
|
+
if (p === "--subagents" || p === "-s") {
|
|
1004
|
+
settingsOverride.mode = "subagents";
|
|
1005
|
+
} else if (p === "--single") {
|
|
1006
|
+
settingsOverride.mode = "single";
|
|
1007
|
+
} else if (p === "--concurrency" || p === "-c") {
|
|
1008
|
+
const next = rawParts[i + 1];
|
|
1009
|
+
if (next) {
|
|
1010
|
+
const num = Number.parseInt(next, 10);
|
|
1011
|
+
if (num >= 2 && num <= 6) {
|
|
1012
|
+
settingsOverride.concurrency = num;
|
|
1013
|
+
settingsOverride.mode = "subagents";
|
|
1014
|
+
i++;
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
} else {
|
|
1019
|
+
parts.push(p);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (parts.length === 0) {
|
|
1024
|
+
return { target: null, settingsOverride };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const subcommand = parts[0]?.toLowerCase();
|
|
1028
|
+
|
|
1029
|
+
switch (subcommand) {
|
|
1030
|
+
case "uncommitted":
|
|
1031
|
+
case "--uncommitted":
|
|
1032
|
+
case "diff":
|
|
1033
|
+
return { target: { type: "uncommitted" }, settingsOverride };
|
|
1034
|
+
|
|
1035
|
+
case "branch":
|
|
1036
|
+
case "--branch": {
|
|
1037
|
+
const branch = parts[1];
|
|
1038
|
+
if (!branch) return { target: null, settingsOverride };
|
|
1039
|
+
return { target: { type: "baseBranch", branch }, settingsOverride };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
case "commit":
|
|
1043
|
+
case "--commit": {
|
|
1044
|
+
const sha = parts[1];
|
|
1045
|
+
if (!sha) return { target: null, settingsOverride };
|
|
1046
|
+
const title = parts.slice(2).join(" ") || undefined;
|
|
1047
|
+
return { target: { type: "commit", sha, title }, settingsOverride };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
case "custom":
|
|
1051
|
+
case "--custom": {
|
|
1052
|
+
const instructions = parts.slice(1).join(" ");
|
|
1053
|
+
if (!instructions) return { target: null, settingsOverride };
|
|
1054
|
+
return { target: { type: "custom", instructions }, settingsOverride };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
case "folder":
|
|
1058
|
+
case "--folder": {
|
|
1059
|
+
const paths = parseReviewPaths(parts.slice(1).join(" "));
|
|
1060
|
+
if (paths.length === 0) return { target: null, settingsOverride };
|
|
1061
|
+
return { target: { type: "folder", paths }, settingsOverride };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
case "pr":
|
|
1065
|
+
case "--pr": {
|
|
1066
|
+
const ref = parts[1];
|
|
1067
|
+
if (!ref) return { target: null, settingsOverride };
|
|
1068
|
+
return { target: { type: "pr", ref }, settingsOverride };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
default:
|
|
1072
|
+
return { target: { type: "custom", instructions: parts.join(" ") }, settingsOverride };
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// 注册 /review 主命令
|
|
1077
|
+
pi.registerCommand("review", {
|
|
1078
|
+
description: "启动 AI 代码审查 (交互式选择:未提交改动/分支对比/指定Commit/PR/目录/自定义/配置)",
|
|
146
1079
|
handler: async (args, ctx) => {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
1080
|
+
if (!ctx.hasUI) {
|
|
1081
|
+
ctx.ui.notify("代码审查需要交互式终端环境", "error");
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (reviewOriginId) {
|
|
1086
|
+
ctx.ui.notify("当前已有正在进行的审查。请先输入 /end-review 完成审查并返回。", "warning");
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
|
|
1091
|
+
if (code !== 0) {
|
|
1092
|
+
ctx.ui.notify("当前目录不是有效的 Git 仓库", "error");
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
let target: ReviewTarget | null = null;
|
|
1097
|
+
let fromSelector = false;
|
|
1098
|
+
const { target: parsedTarget, settingsOverride } = parseArgs(args);
|
|
1099
|
+
|
|
1100
|
+
if (parsedTarget) {
|
|
1101
|
+
if (parsedTarget.type === "pr") {
|
|
1102
|
+
target = await handlePrCheckout(ctx, parsedTarget.ref);
|
|
1103
|
+
if (!target) {
|
|
1104
|
+
ctx.ui.notify("PR 检出失败,返回主菜单。", "warning");
|
|
1105
|
+
}
|
|
1106
|
+
} else {
|
|
1107
|
+
target = parsedTarget;
|
|
159
1108
|
}
|
|
160
|
-
pi.sendMessage({ customType: "pi-review-directive", content: prepared.directiveText, display: false }, { triggerTurn: true });
|
|
161
|
-
} catch (err) {
|
|
162
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
163
|
-
notify(`全量代码审查启动失败: ${message}`, "error");
|
|
164
1109
|
}
|
|
165
|
-
},
|
|
166
|
-
});
|
|
167
1110
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
handler: async (_args, ctx) => {
|
|
171
|
-
const path = configPath();
|
|
172
|
-
if (!existsSync(path)) {
|
|
173
|
-
writeConfig(DEFAULT_CONFIG);
|
|
1111
|
+
if (!target) {
|
|
1112
|
+
fromSelector = true;
|
|
174
1113
|
}
|
|
175
1114
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const edited = await ctx.ui.editor("编辑 pi-review 配置文件 (JSON)", current);
|
|
180
|
-
if (edited === undefined) {
|
|
181
|
-
ctx.ui.notify("已取消编辑配置。", "info");
|
|
182
|
-
return;
|
|
1115
|
+
while (true) {
|
|
1116
|
+
if (!target && fromSelector) {
|
|
1117
|
+
target = await showReviewSelector(ctx);
|
|
183
1118
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (!editor) {
|
|
188
|
-
ctx.ui.notify(`请设置 $EDITOR 或在 TUI 模式下使用。配置文件路径: ${path}`, "warning");
|
|
1119
|
+
|
|
1120
|
+
if (!target) {
|
|
1121
|
+
ctx.ui.notify("已取消代码审查", "info");
|
|
189
1122
|
return;
|
|
190
1123
|
}
|
|
191
|
-
await pi.exec(editor, [path], { cwd: ctx.cwd });
|
|
192
|
-
raw = readFileSync(path, "utf-8");
|
|
193
|
-
}
|
|
194
1124
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
1125
|
+
const entries = ctx.sessionManager.getEntries();
|
|
1126
|
+
const messageCount = entries.filter((e) => e.type === "message").length;
|
|
1127
|
+
|
|
1128
|
+
let useFreshSession = false;
|
|
1129
|
+
|
|
1130
|
+
if (messageCount > 0) {
|
|
1131
|
+
const choice = await ctx.ui.select("选择审查执行环境:", [
|
|
1132
|
+
"独立分支审查 (推荐,主会话保持干净)",
|
|
1133
|
+
"当前会话直接审查",
|
|
1134
|
+
]);
|
|
1135
|
+
|
|
1136
|
+
if (choice === undefined) {
|
|
1137
|
+
if (fromSelector) {
|
|
1138
|
+
target = null;
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
ctx.ui.notify("已取消代码审查", "info");
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
useFreshSession = choice.startsWith("独立分支审查");
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
await executeReview(ctx, target, useFreshSession, settingsOverride);
|
|
200
1149
|
return;
|
|
201
1150
|
}
|
|
1151
|
+
},
|
|
1152
|
+
});
|
|
202
1153
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
1154
|
+
// 注册 /review-config 配置命令
|
|
1155
|
+
pi.registerCommand("review-config", {
|
|
1156
|
+
description: "配置代码审查模式 (切换单模型直接审查 / 多 Subagent 并发 2~6 个专家)",
|
|
1157
|
+
handler: async (_args, ctx) => {
|
|
1158
|
+
if (!ctx.hasUI) {
|
|
1159
|
+
ctx.ui.notify("review-config 需要交互式终端环境", "error");
|
|
207
1160
|
return;
|
|
208
1161
|
}
|
|
209
|
-
|
|
210
|
-
ctx.ui.notify("pi-review 配置文件已保存。", "info");
|
|
1162
|
+
await showConfigDialog(ctx);
|
|
211
1163
|
},
|
|
212
1164
|
});
|
|
213
1165
|
|
|
214
|
-
|
|
215
|
-
|
|
1166
|
+
// 极速单兵别名命令:/review-lite
|
|
1167
|
+
pi.registerCommand("review-lite", {
|
|
1168
|
+
description: "极速代码审查 (直接对当前工作区未提交改动做审查,无需弹窗选择)",
|
|
216
1169
|
handler: async (_args, ctx) => {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
for (const r of Object.values(config.reviewers)) {
|
|
221
|
-
const model = resolveModel(r.model, parent);
|
|
222
|
-
const status = r.enabled ? "已启用 (enabled)" : "已禁用 (disabled)";
|
|
223
|
-
lines.push(`- **${r.id}** (${r.label}) — ${status}`);
|
|
224
|
-
lines.push(` - 模型: ${model}`);
|
|
225
|
-
lines.push(` - 思考深度: ${r.thinking ?? "跟随主会话"}`);
|
|
1170
|
+
if (!ctx.hasUI) {
|
|
1171
|
+
ctx.ui.notify("代码审查需要交互式终端环境", "error");
|
|
1172
|
+
return;
|
|
226
1173
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
1174
|
+
|
|
1175
|
+
if (reviewOriginId) {
|
|
1176
|
+
ctx.ui.notify("当前已有正在进行的审查。请先输入 /end-review 完成审查并返回。", "warning");
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
|
|
1181
|
+
if (code !== 0) {
|
|
1182
|
+
ctx.ui.notify("当前目录不是有效的 Git 仓库", "error");
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
await executeReview(ctx, { type: "uncommitted" }, false);
|
|
232
1187
|
},
|
|
233
1188
|
});
|
|
234
1189
|
|
|
235
|
-
|
|
236
|
-
|
|
1190
|
+
// 审查总结专用提示词
|
|
1191
|
+
const REVIEW_SUMMARY_PROMPT = `我们即将结束代码审查并切回开发主对话。
|
|
1192
|
+
请将本次代码审查分支中发现的所有核心问题、缺陷与建议生成一份结构清晰的中文整改总结。
|
|
1193
|
+
|
|
1194
|
+
必须严格按以下格式输出总结(确保原样保留文件路径、行号与缺陷等级):
|
|
1195
|
+
|
|
1196
|
+
## 待办修复清单 (Next Steps)
|
|
1197
|
+
1. [需优先解决的 P0/P1 问题]
|
|
1198
|
+
|
|
1199
|
+
## 代码审查发现归档 (Code Review Findings)
|
|
1200
|
+
|
|
1201
|
+
### [P0|P1|P2|P3] 问题标题
|
|
1202
|
+
- 位置:path/to/file.ext:行号
|
|
1203
|
+
- 说明:缺陷描述与引发场景
|
|
1204
|
+
- 修复:最小修复建议
|
|
1205
|
+
`;
|
|
1206
|
+
|
|
1207
|
+
// 注册 /end-review 结束审查并返回命令
|
|
1208
|
+
pi.registerCommand("end-review", {
|
|
1209
|
+
description: "完成代码审查并一键返回主会话位置 (自动汇总待办并回填修复指令)",
|
|
237
1210
|
handler: async (_args, ctx) => {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
)
|
|
245
|
-
|
|
1211
|
+
if (!ctx.hasUI) {
|
|
1212
|
+
ctx.ui.notify("end-review 需要交互式终端环境", "error");
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (!reviewOriginId) {
|
|
1217
|
+
const state = getReviewState(ctx);
|
|
1218
|
+
if (state?.active && state.originId) {
|
|
1219
|
+
reviewOriginId = state.originId;
|
|
1220
|
+
} else if (state?.active) {
|
|
1221
|
+
setReviewWidget(ctx, false);
|
|
1222
|
+
pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
|
|
1223
|
+
ctx.ui.notify("未检测到分支关联信息,已重置审查状态。", "warning");
|
|
1224
|
+
return;
|
|
1225
|
+
} else {
|
|
1226
|
+
ctx.ui.notify("当前不在独立审查分支中 (当前审查是在主会话模式下进行的,无需返回)。", "info");
|
|
1227
|
+
return;
|
|
246
1228
|
}
|
|
247
1229
|
}
|
|
248
|
-
|
|
249
|
-
|
|
1230
|
+
|
|
1231
|
+
const summaryChoice = await ctx.ui.select("是否将审查结果汇总后返回?", [
|
|
1232
|
+
"汇总审查结果并返回主分支 (生成修复待办)",
|
|
1233
|
+
"直接返回 (不生成总结)",
|
|
1234
|
+
]);
|
|
1235
|
+
|
|
1236
|
+
if (summaryChoice === undefined) {
|
|
1237
|
+
ctx.ui.notify("已取消。输入 /end-review 可再次返回。", "info");
|
|
250
1238
|
return;
|
|
251
1239
|
}
|
|
252
|
-
pi.sendMessage({ customType: "pi-review", content: last.markdown, display: true });
|
|
253
|
-
},
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
1240
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
1241
|
+
const wantsSummary = summaryChoice.startsWith("汇总审查结果");
|
|
1242
|
+
const originId = reviewOriginId;
|
|
1243
|
+
|
|
1244
|
+
if (wantsSummary) {
|
|
1245
|
+
const result = await ctx.ui.custom<{ cancelled: boolean; error?: string } | null>((tui, theme, _kb, done) => {
|
|
1246
|
+
const loader = new BorderedLoader(tui, theme, "正在汇总代码审查报告并返回主分支...");
|
|
1247
|
+
loader.onAbort = () => done(null);
|
|
1248
|
+
|
|
1249
|
+
ctx.navigateTree(originId!, {
|
|
1250
|
+
summarize: true,
|
|
1251
|
+
customInstructions: REVIEW_SUMMARY_PROMPT,
|
|
1252
|
+
replaceInstructions: true,
|
|
1253
|
+
})
|
|
1254
|
+
.then(done)
|
|
1255
|
+
.catch((err) => done({ cancelled: false, error: err instanceof Error ? err.message : String(err) }));
|
|
1256
|
+
|
|
1257
|
+
return loader;
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
if (result === null) {
|
|
1261
|
+
ctx.ui.notify("已取消返回操作。", "info");
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (result.error) {
|
|
1266
|
+
ctx.ui.notify(`返回主分支失败: ${result.error}`, "error");
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
setReviewWidget(ctx, false);
|
|
1271
|
+
reviewOriginId = undefined;
|
|
1272
|
+
pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
|
|
1273
|
+
|
|
1274
|
+
if (result.cancelled) {
|
|
1275
|
+
ctx.ui.notify("导航已取消", "info");
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (!ctx.ui.getEditorText().trim()) {
|
|
1280
|
+
ctx.ui.setEditorText("根据上述代码审查发现进行修改修复");
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
ctx.ui.notify("代码审查已完成!已顺利返回主会话开发分支。", "info");
|
|
1284
|
+
} else {
|
|
1285
|
+
try {
|
|
1286
|
+
const result = await ctx.navigateTree(originId!, { summarize: false });
|
|
1287
|
+
|
|
1288
|
+
if (result.cancelled) {
|
|
1289
|
+
ctx.ui.notify("导航已取消", "info");
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
setReviewWidget(ctx, false);
|
|
1294
|
+
reviewOriginId = undefined;
|
|
1295
|
+
pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
|
|
1296
|
+
ctx.ui.notify("代码审查已结束,已直接返回主会话。", "info");
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
ctx.ui.notify(`返回失败: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
},
|
|
269
1302
|
});
|
|
270
|
-
if (!prepared) return "pi-review 试运行: 没有找到需要审查的代码。";
|
|
271
|
-
const m = prepared.manifest;
|
|
272
|
-
const gate = config.gate;
|
|
273
|
-
const lines = [
|
|
274
|
-
"pi-review 试运行摘要",
|
|
275
|
-
`审查目标: ${m.targetLabel}`,
|
|
276
|
-
`模式: ${parsed.lite ? "极速单专家 (无门禁)" : `多专家并发 (${m.targetKind})`}`,
|
|
277
|
-
`运行 ID: ${m.runId}`,
|
|
278
|
-
`工作区目录: ${m.workspacePath}`,
|
|
279
|
-
`Diff 哈希: ${m.diffSha256.slice(0, 16)}…`,
|
|
280
|
-
`变更文件数: ${m.changedFiles.length}`,
|
|
281
|
-
`纯文档变更: ${m.docsOnly ? "是" : "否"}`,
|
|
282
|
-
`包含 Git 历史: ${m.historyAvailable ? "是" : "否"}`,
|
|
283
|
-
`置信度阈值: ${gate.threshold}`,
|
|
284
|
-
`门禁裁判: ${gate.enabled ? `已启用 (${resolveModel(gate.model, undefined)})` : "未启用"}`,
|
|
285
|
-
];
|
|
286
|
-
if (m.rulePaths.length > 0) lines.push(`规范文件: ${m.rulePaths.join(", ")}`);
|
|
287
|
-
return lines.join("\n");
|
|
288
1303
|
}
|