@docyrus/docyrus 0.0.20 → 0.0.21
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/agent-loader.js +32 -1
- package/agent-loader.js.map +2 -2
- package/main.js +321 -70
- package/main.js.map +4 -4
- package/package.json +12 -2
- package/resources/chrome-tools/browser-content.js +103 -0
- package/resources/chrome-tools/browser-cookies.js +35 -0
- package/resources/chrome-tools/browser-eval.js +53 -0
- package/resources/chrome-tools/browser-hn-scraper.js +108 -0
- package/resources/chrome-tools/browser-nav.js +44 -0
- package/resources/chrome-tools/browser-pick.js +162 -0
- package/resources/chrome-tools/browser-screenshot.js +34 -0
- package/resources/chrome-tools/browser-start.js +86 -0
- package/resources/pi-agent/extensions/answer.ts +532 -0
- package/resources/pi-agent/extensions/context.ts +578 -0
- package/resources/pi-agent/extensions/control.ts +1779 -0
- package/resources/pi-agent/extensions/diff.ts +218 -0
- package/resources/pi-agent/extensions/files.ts +199 -0
- package/resources/pi-agent/extensions/loop.ts +446 -0
- package/resources/pi-agent/extensions/multi-edit.ts +835 -0
- package/resources/pi-agent/extensions/notify.ts +88 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/CHANGELOG.md +192 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/LICENSE +21 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/README.md +296 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/app-bridge.bundle.js +67 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/cli.js +108 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/commands.ts +211 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/config.ts +227 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/consent-manager.ts +64 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/direct-tools.ts +301 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/errors.ts +219 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/glimpse-ui.ts +80 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/host-html-template.ts +427 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/index.ts +232 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/init.ts +319 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/lifecycle.ts +93 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/logger.ts +169 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/mcp-panel.ts +713 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/metadata-cache.ts +191 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/npx-resolver.ts +419 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/oauth-handler.ts +56 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/package.json +85 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/paths.ts +29 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/proxy-modes.ts +635 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/resource-tools.ts +17 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/server-manager.ts +330 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/state.ts +41 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-metadata.ts +144 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-registrar.ts +46 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/types.ts +367 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-resource-handler.ts +145 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-server.ts +623 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-session.ts +384 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-stream-types.ts +89 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/utils.ts +75 -0
- package/resources/pi-agent/extensions/prompt-editor.ts +1315 -0
- package/resources/pi-agent/extensions/prompt-url-widget.ts +158 -0
- package/resources/pi-agent/extensions/redraws.ts +24 -0
- package/resources/pi-agent/extensions/review.ts +2160 -0
- package/resources/pi-agent/extensions/todos.ts +2076 -0
- package/resources/pi-agent/extensions/tps.ts +47 -0
- package/resources/pi-agent/extensions/whimsical.ts +474 -0
- package/resources/pi-agent/skills/changelog-generator/SKILL.md +425 -0
- package/resources/pi-agent/skills/docyrus-chrome-devtools-cli/SKILL.md +80 -0
- package/resources/pi-agent/skills/docyrus-platform/references/docyrus-cli-usage.md +51 -0
|
@@ -0,0 +1,2160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Review Extension (inspired by Codex's review feature)
|
|
3
|
+
*
|
|
4
|
+
* Provides a `/review` command that prompts the agent to review code changes.
|
|
5
|
+
* Supports multiple review modes:
|
|
6
|
+
* - Review a GitHub pull request (checks out the PR locally)
|
|
7
|
+
* - Review against a base branch (PR style)
|
|
8
|
+
* - Review uncommitted changes
|
|
9
|
+
* - Review a specific commit
|
|
10
|
+
* - Shared custom review instructions (applied to all review modes when configured)
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* - `/review` - show interactive selector
|
|
14
|
+
* - `/review pr 123` - review PR #123 (checks out locally)
|
|
15
|
+
* - `/review pr https://github.com/owner/repo/pull/123` - review PR from URL
|
|
16
|
+
* - `/review uncommitted` - review uncommitted changes directly
|
|
17
|
+
* - `/review branch main` - review against main branch
|
|
18
|
+
* - `/review commit abc123` - review specific commit
|
|
19
|
+
* - `/review folder src docs` - review specific folders/files (snapshot, not diff)
|
|
20
|
+
* - `/review` selector includes Add/Remove custom review instructions (applies to all modes)
|
|
21
|
+
* - `/review --extra "focus on performance regressions"` - add extra review instruction (works with any mode)
|
|
22
|
+
*
|
|
23
|
+
* Project-specific review guidelines:
|
|
24
|
+
* - If a REVIEW_GUIDELINES.md file exists in the project root, its contents
|
|
25
|
+
* are appended to the review prompt. Project roots are detected via `.docyrus`
|
|
26
|
+
* or `.pi` markers, with a nearest-ancestor fallback when no marker exists.
|
|
27
|
+
*
|
|
28
|
+
* Note: PR review requires a clean working tree (no uncommitted changes to tracked files).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
32
|
+
import { DynamicBorder, BorderedLoader } from "@mariozechner/pi-coding-agent";
|
|
33
|
+
import {
|
|
34
|
+
Container,
|
|
35
|
+
fuzzyFilter,
|
|
36
|
+
getEditorKeybindings,
|
|
37
|
+
Input,
|
|
38
|
+
type SelectItem,
|
|
39
|
+
SelectList,
|
|
40
|
+
Spacer,
|
|
41
|
+
Text,
|
|
42
|
+
} from "@mariozechner/pi-tui";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import { promises as fs } from "node:fs";
|
|
45
|
+
|
|
46
|
+
// State to track fresh session review (where we branched from).
|
|
47
|
+
// Module-level state means only one review can be active at a time.
|
|
48
|
+
// This is intentional - the UI and /end-review command assume a single active review.
|
|
49
|
+
let reviewOriginId: string | undefined = undefined;
|
|
50
|
+
let endReviewInProgress = false;
|
|
51
|
+
let reviewLoopFixingEnabled = false;
|
|
52
|
+
let reviewCustomInstructions: string | undefined = undefined;
|
|
53
|
+
let reviewLoopInProgress = false;
|
|
54
|
+
|
|
55
|
+
const REVIEW_STATE_TYPE = "review-session";
|
|
56
|
+
const REVIEW_ANCHOR_TYPE = "review-anchor";
|
|
57
|
+
const REVIEW_SETTINGS_TYPE = "review-settings";
|
|
58
|
+
const REVIEW_LOOP_MAX_ITERATIONS = 10;
|
|
59
|
+
const REVIEW_LOOP_START_TIMEOUT_MS = 15000;
|
|
60
|
+
const REVIEW_LOOP_START_POLL_MS = 50;
|
|
61
|
+
|
|
62
|
+
type ReviewSessionState = {
|
|
63
|
+
active: boolean;
|
|
64
|
+
originId?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type ReviewSettingsState = {
|
|
68
|
+
loopFixingEnabled?: boolean;
|
|
69
|
+
customInstructions?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function setReviewWidget(ctx: ExtensionContext, active: boolean) {
|
|
73
|
+
if (!ctx.hasUI) return;
|
|
74
|
+
if (!active) {
|
|
75
|
+
ctx.ui.setWidget("review", undefined);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
ctx.ui.setWidget("review", (_tui, theme) => {
|
|
80
|
+
const message = reviewLoopInProgress
|
|
81
|
+
? "Review session active (loop fixing running)"
|
|
82
|
+
: reviewLoopFixingEnabled
|
|
83
|
+
? "Review session active (loop fixing enabled), return with /end-review"
|
|
84
|
+
: "Review session active, return with /end-review";
|
|
85
|
+
const text = new Text(theme.fg("warning", message), 0, 0);
|
|
86
|
+
return {
|
|
87
|
+
render(width: number) {
|
|
88
|
+
return text.render(width);
|
|
89
|
+
},
|
|
90
|
+
invalidate() {
|
|
91
|
+
text.invalidate();
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getReviewState(ctx: ExtensionContext): ReviewSessionState | undefined {
|
|
98
|
+
let state: ReviewSessionState | undefined;
|
|
99
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
100
|
+
if (entry.type === "custom" && entry.customType === REVIEW_STATE_TYPE) {
|
|
101
|
+
state = entry.data as ReviewSessionState | undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return state;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applyReviewState(ctx: ExtensionContext) {
|
|
109
|
+
const state = getReviewState(ctx);
|
|
110
|
+
|
|
111
|
+
if (state?.active && state.originId) {
|
|
112
|
+
reviewOriginId = state.originId;
|
|
113
|
+
setReviewWidget(ctx, true);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
reviewOriginId = undefined;
|
|
118
|
+
setReviewWidget(ctx, false);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getReviewSettings(ctx: ExtensionContext): ReviewSettingsState {
|
|
122
|
+
let state: ReviewSettingsState | undefined;
|
|
123
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
124
|
+
if (entry.type === "custom" && entry.customType === REVIEW_SETTINGS_TYPE) {
|
|
125
|
+
state = entry.data as ReviewSettingsState | undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
loopFixingEnabled: state?.loopFixingEnabled === true,
|
|
131
|
+
customInstructions: state?.customInstructions?.trim() || undefined,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function applyReviewSettings(ctx: ExtensionContext) {
|
|
136
|
+
const state = getReviewSettings(ctx);
|
|
137
|
+
reviewLoopFixingEnabled = state.loopFixingEnabled === true;
|
|
138
|
+
reviewCustomInstructions = state.customInstructions?.trim() || undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseMarkdownHeading(line: string): { level: number; title: string } | null {
|
|
142
|
+
const headingMatch = line.match(/^\s*(#{1,6})\s+(.+?)\s*$/);
|
|
143
|
+
if (!headingMatch) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const rawTitle = headingMatch[2].replace(/\s+#+\s*$/, "").trim();
|
|
148
|
+
return {
|
|
149
|
+
level: headingMatch[1].length,
|
|
150
|
+
title: rawTitle,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getFindingsSectionBounds(lines: string[]): { start: number; end: number } | null {
|
|
155
|
+
let start = -1;
|
|
156
|
+
let findingsHeadingLevel: number | null = null;
|
|
157
|
+
|
|
158
|
+
for (let i = 0; i < lines.length; i++) {
|
|
159
|
+
const line = lines[i];
|
|
160
|
+
const heading = parseMarkdownHeading(line);
|
|
161
|
+
if (heading && /^findings\b/i.test(heading.title)) {
|
|
162
|
+
start = i + 1;
|
|
163
|
+
findingsHeadingLevel = heading.level;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
if (/^\s*findings\s*:?\s*$/i.test(line)) {
|
|
167
|
+
start = i + 1;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (start < 0) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let end = lines.length;
|
|
177
|
+
for (let i = start; i < lines.length; i++) {
|
|
178
|
+
const line = lines[i];
|
|
179
|
+
const heading = parseMarkdownHeading(line);
|
|
180
|
+
if (heading) {
|
|
181
|
+
const normalizedTitle = heading.title.replace(/[*_`]/g, "").trim();
|
|
182
|
+
if (/^(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(normalizedTitle)) {
|
|
183
|
+
end = i;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (/\[P[0-3]\]/i.test(heading.title)) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (findingsHeadingLevel !== null && heading.level <= findingsHeadingLevel) {
|
|
192
|
+
end = i;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (/^\s*(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(line)) {
|
|
198
|
+
end = i;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { start, end };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isLikelyFindingLine(line: string): boolean {
|
|
207
|
+
if (!/\[P[0-3]\]/i.test(line)) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (/^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+priority\s+tag\b/i.test(line)) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (/^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+\[P[0-3]\]\s*-\s*(?:drop everything|urgent|normal|low|nice to have)\b/i.test(line)) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const allPriorityTags = line.match(/\[P[0-3]\]/gi) ?? [];
|
|
220
|
+
if (allPriorityTags.length > 1) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (/^\s*(?:[-*+]|(?:\d+)[.)])\s+/.test(line)) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (/^\s*#{1,6}\s+/.test(line)) {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (/^\s*(?:\*\*|__)?\[P[0-3]\](?:\*\*|__)?(?=\s|:|-)/i.test(line)) {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeVerdictValue(value: string): string {
|
|
240
|
+
return value
|
|
241
|
+
.trim()
|
|
242
|
+
.replace(/^[-*+]\s*/, "")
|
|
243
|
+
.replace(/^['"`]+|['"`]+$/g, "")
|
|
244
|
+
.toLowerCase();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isNeedsAttentionVerdictValue(value: string): boolean {
|
|
248
|
+
const normalized = normalizeVerdictValue(value);
|
|
249
|
+
if (!normalized.includes("needs attention")) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (/\bnot\s+needs\s+attention\b/.test(normalized)) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Reject rubric/choice phrasing like "correct or needs attention", but
|
|
258
|
+
// keep legitimate verdict text that may contain unrelated "or".
|
|
259
|
+
if (/\bcorrect\b/.test(normalized) && /\bor\b/.test(normalized)) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function hasNeedsAttentionVerdict(messageText: string): boolean {
|
|
267
|
+
const lines = messageText.split(/\r?\n/);
|
|
268
|
+
|
|
269
|
+
for (const line of lines) {
|
|
270
|
+
const inlineMatch = line.match(/^\s*(?:[*-+]\s*)?(?:overall\s+)?verdict\s*:\s*(.+)$/i);
|
|
271
|
+
if (inlineMatch && isNeedsAttentionVerdictValue(inlineMatch[1])) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (let i = 0; i < lines.length; i++) {
|
|
277
|
+
const line = lines[i];
|
|
278
|
+
const heading = parseMarkdownHeading(line);
|
|
279
|
+
|
|
280
|
+
let verdictLevel: number | null = null;
|
|
281
|
+
if (heading) {
|
|
282
|
+
const normalizedHeading = heading.title.replace(/[*_`]/g, "").trim();
|
|
283
|
+
if (!/^(?:overall\s+)?verdict\b/i.test(normalizedHeading)) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
verdictLevel = heading.level;
|
|
287
|
+
} else if (!/^\s*(?:overall\s+)?verdict\s*:?\s*$/i.test(line)) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
292
|
+
const verdictLine = lines[j];
|
|
293
|
+
const nextHeading = parseMarkdownHeading(verdictLine);
|
|
294
|
+
if (nextHeading) {
|
|
295
|
+
const normalizedNextHeading = nextHeading.title.replace(/[*_`]/g, "").trim();
|
|
296
|
+
if (verdictLevel === null || nextHeading.level <= verdictLevel) {
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
if (/^(review scope|findings|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(normalizedNextHeading)) {
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const trimmed = verdictLine.trim();
|
|
305
|
+
if (!trimmed) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (isNeedsAttentionVerdictValue(trimmed)) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (/\bcorrect\b/i.test(normalizeVerdictValue(trimmed))) {
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function hasBlockingReviewFindings(messageText: string): boolean {
|
|
323
|
+
const lines = messageText.split(/\r?\n/);
|
|
324
|
+
const bounds = getFindingsSectionBounds(lines);
|
|
325
|
+
const candidateLines = bounds ? lines.slice(bounds.start, bounds.end) : lines;
|
|
326
|
+
|
|
327
|
+
let inCodeFence = false;
|
|
328
|
+
let foundTaggedFinding = false;
|
|
329
|
+
for (const line of candidateLines) {
|
|
330
|
+
if (/^\s*```/.test(line)) {
|
|
331
|
+
inCodeFence = !inCodeFence;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (inCodeFence) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!isLikelyFindingLine(line)) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
foundTaggedFinding = true;
|
|
343
|
+
if (/\[(P0|P1|P2)\]/i.test(line)) {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (foundTaggedFinding) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return hasNeedsAttentionVerdict(messageText);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Review target types (matching Codex's approach)
|
|
356
|
+
type ReviewTarget =
|
|
357
|
+
| { type: "uncommitted" }
|
|
358
|
+
| { type: "baseBranch"; branch: string }
|
|
359
|
+
| { type: "commit"; sha: string; title?: string }
|
|
360
|
+
| { type: "pullRequest"; prNumber: number; baseBranch: string; title: string }
|
|
361
|
+
| { type: "folder"; paths: string[] };
|
|
362
|
+
|
|
363
|
+
// Prompts (adapted from Codex)
|
|
364
|
+
const UNCOMMITTED_PROMPT =
|
|
365
|
+
"Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
|
|
366
|
+
|
|
367
|
+
const LOCAL_CHANGES_REVIEW_INSTRUCTIONS =
|
|
368
|
+
"Also include local working-tree changes (staged, unstaged, and untracked files) from this branch. Use `git status --porcelain`, `git diff`, `git diff --staged`, and `git ls-files --others --exclude-standard` so local fixes are part of this review cycle.";
|
|
369
|
+
|
|
370
|
+
const BASE_BRANCH_PROMPT_WITH_MERGE_BASE =
|
|
371
|
+
"Review the code changes against the base branch '{baseBranch}'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes relative to {baseBranch}. Provide prioritized, actionable findings.";
|
|
372
|
+
|
|
373
|
+
const BASE_BRANCH_PROMPT_FALLBACK =
|
|
374
|
+
"Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{upstream}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings.";
|
|
375
|
+
|
|
376
|
+
const COMMIT_PROMPT_WITH_TITLE =
|
|
377
|
+
'Review the code changes introduced by commit {sha} ("{title}"). Provide prioritized, actionable findings.';
|
|
378
|
+
|
|
379
|
+
const COMMIT_PROMPT = "Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
|
|
380
|
+
|
|
381
|
+
const PULL_REQUEST_PROMPT =
|
|
382
|
+
'Review pull request #{prNumber} ("{title}") against the base branch \'{baseBranch}\'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes that would be merged. Provide prioritized, actionable findings.';
|
|
383
|
+
|
|
384
|
+
const PULL_REQUEST_PROMPT_FALLBACK =
|
|
385
|
+
'Review pull request #{prNumber} ("{title}") against the base branch \'{baseBranch}\'. Start by finding the merge base between the current branch and {baseBranch} (e.g., `git merge-base HEAD {baseBranch}`), then run `git diff` against that SHA to see the changes that would be merged. Provide prioritized, actionable findings.';
|
|
386
|
+
|
|
387
|
+
const FOLDER_REVIEW_PROMPT =
|
|
388
|
+
"Review the code in the following paths: {paths}. This is a snapshot review (not a diff). Read the files directly in these paths and provide prioritized, actionable findings.";
|
|
389
|
+
|
|
390
|
+
// The detailed review rubric (adapted from Codex's review_prompt.md)
|
|
391
|
+
const REVIEW_RUBRIC = `# Review Guidelines
|
|
392
|
+
|
|
393
|
+
You are acting as a code reviewer for a proposed code change made by another engineer.
|
|
394
|
+
|
|
395
|
+
Below are default guidelines for determining what to flag. These are not the final word — if you encounter more specific guidelines elsewhere (in a developer message, user message, file, or project review guidelines appended below), those override these general instructions.
|
|
396
|
+
|
|
397
|
+
## Determining what to flag
|
|
398
|
+
|
|
399
|
+
Flag issues that:
|
|
400
|
+
1. Meaningfully impact the accuracy, performance, security, or maintainability of the code.
|
|
401
|
+
2. Are discrete and actionable (not general issues or multiple combined issues).
|
|
402
|
+
3. Don't demand rigor inconsistent with the rest of the codebase.
|
|
403
|
+
4. Were introduced in the changes being reviewed (not pre-existing bugs).
|
|
404
|
+
5. The author would likely fix if aware of them.
|
|
405
|
+
6. Don't rely on unstated assumptions about the codebase or author's intent.
|
|
406
|
+
7. Have provable impact on other parts of the code — it is not enough to speculate that a change may disrupt another part, you must identify the parts that are provably affected.
|
|
407
|
+
8. Are clearly not intentional changes by the author.
|
|
408
|
+
9. Be particularly careful with untrusted user input and follow the specific guidelines to review.
|
|
409
|
+
10. Treat silent local error recovery (especially parsing/IO/network fallbacks) as high-signal review candidates unless there is explicit boundary-level justification.
|
|
410
|
+
|
|
411
|
+
## Untrusted User Input
|
|
412
|
+
|
|
413
|
+
1. Be careful with open redirects, they must always be checked to only go to trusted domains (?next_page=...)
|
|
414
|
+
2. Always flag SQL that is not parametrized
|
|
415
|
+
3. In systems with user supplied URL input, http fetches always need to be protected against access to local resources (intercept DNS resolver!)
|
|
416
|
+
4. Escape, don't sanitize if you have the option (eg: HTML escaping)
|
|
417
|
+
|
|
418
|
+
## Comment guidelines
|
|
419
|
+
|
|
420
|
+
1. Be clear about why the issue is a problem.
|
|
421
|
+
2. Communicate severity appropriately - don't exaggerate.
|
|
422
|
+
3. Be brief - at most 1 paragraph.
|
|
423
|
+
4. Keep code snippets under 3 lines, wrapped in inline code or code blocks.
|
|
424
|
+
5. Use \`\`\`suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block). Preserve the exact leading whitespace of the replaced lines.
|
|
425
|
+
6. Explicitly state scenarios/environments where the issue arises.
|
|
426
|
+
7. Use a matter-of-fact tone - helpful AI assistant, not accusatory.
|
|
427
|
+
8. Write for quick comprehension without close reading.
|
|
428
|
+
9. Avoid excessive flattery or unhelpful phrases like "Great job...".
|
|
429
|
+
|
|
430
|
+
## Review priorities
|
|
431
|
+
|
|
432
|
+
1. Surface critical non-blocking human callouts (migrations, dependency churn, auth/permissions, compatibility, destructive operations) at the end.
|
|
433
|
+
2. Prefer simple, direct solutions over wrappers or abstractions without clear value.
|
|
434
|
+
3. Treat back pressure handling as critical to system stability.
|
|
435
|
+
4. Apply system-level thinking; flag changes that increase operational risk or on-call wakeups.
|
|
436
|
+
5. Ensure that errors are always checked against codes or stable identifiers, never error messages.
|
|
437
|
+
|
|
438
|
+
## Fail-fast error handling (strict)
|
|
439
|
+
|
|
440
|
+
When reviewing added or modified error handling, default to fail-fast behavior.
|
|
441
|
+
|
|
442
|
+
1. Evaluate every new or changed \`try/catch\`: identify what can fail and why local handling is correct at that exact layer.
|
|
443
|
+
2. Prefer propagation over local recovery. If the current scope cannot fully recover while preserving correctness, rethrow (optionally with context) instead of returning fallbacks.
|
|
444
|
+
3. Flag catch blocks that hide failure signals (e.g. returning \`null\`/\`[]\`/\`false\`, swallowing JSON parse failures, logging-and-continue, or “best effort” silent recovery).
|
|
445
|
+
4. JSON parsing/decoding should fail loudly by default. Quiet fallback parsing is only acceptable with an explicit compatibility requirement and clear tested behavior.
|
|
446
|
+
5. Boundary handlers (HTTP routes, CLI entrypoints, supervisors) may translate errors, but must not pretend success or silently degrade.
|
|
447
|
+
6. If a catch exists only to satisfy lint/style without real handling, treat it as a bug.
|
|
448
|
+
7. When uncertain, prefer crashing fast over silent degradation.
|
|
449
|
+
|
|
450
|
+
## Required human callouts (non-blocking, at the very end)
|
|
451
|
+
|
|
452
|
+
After findings/verdict, you MUST append this final section:
|
|
453
|
+
|
|
454
|
+
## Human Reviewer Callouts (Non-Blocking)
|
|
455
|
+
|
|
456
|
+
Include only applicable callouts (no yes/no lines):
|
|
457
|
+
|
|
458
|
+
- **This change adds a database migration:** <files/details>
|
|
459
|
+
- **This change introduces a new dependency:** <package(s)/details>
|
|
460
|
+
- **This change changes a dependency (or the lockfile):** <files/package(s)/details>
|
|
461
|
+
- **This change modifies auth/permission behavior:** <what changed and where>
|
|
462
|
+
- **This change introduces backwards-incompatible public schema/API/contract changes:** <what changed and where>
|
|
463
|
+
- **This change includes irreversible or destructive operations:** <operation and scope>
|
|
464
|
+
|
|
465
|
+
Rules for this section:
|
|
466
|
+
1. These are informational callouts for the human reviewer, not fix items.
|
|
467
|
+
2. Do not include them in Findings unless there is an independent defect.
|
|
468
|
+
3. These callouts alone must not change the verdict.
|
|
469
|
+
4. Only include callouts that apply to the reviewed change.
|
|
470
|
+
5. Keep each emitted callout bold exactly as written.
|
|
471
|
+
6. If none apply, write "- (none)".
|
|
472
|
+
|
|
473
|
+
## Priority levels
|
|
474
|
+
|
|
475
|
+
Tag each finding with a priority level in the title:
|
|
476
|
+
- [P0] - Drop everything to fix. Blocking release/operations. Only for universal issues that do not depend on assumptions about inputs.
|
|
477
|
+
- [P1] - Urgent. Should be addressed in the next cycle.
|
|
478
|
+
- [P2] - Normal. To be fixed eventually.
|
|
479
|
+
- [P3] - Low. Nice to have.
|
|
480
|
+
|
|
481
|
+
## Output format
|
|
482
|
+
|
|
483
|
+
Provide your findings in a clear, structured format:
|
|
484
|
+
1. List each finding with its priority tag, file location, and explanation.
|
|
485
|
+
2. Findings must reference locations that overlap with the actual diff — don't flag pre-existing code.
|
|
486
|
+
3. Keep line references as short as possible (avoid ranges over 5-10 lines; pick the most suitable subrange).
|
|
487
|
+
4. Provide an overall verdict: "correct" (no blocking issues) or "needs attention" (has blocking issues).
|
|
488
|
+
5. Ignore trivial style issues unless they obscure meaning or violate documented standards.
|
|
489
|
+
6. Do not generate a full PR fix — only flag issues and optionally provide short suggestion blocks.
|
|
490
|
+
7. End with the required "Human Reviewer Callouts (Non-Blocking)" section and all applicable bold callouts (no yes/no).
|
|
491
|
+
|
|
492
|
+
Output all findings the author would fix if they knew about them. If there are no qualifying findings, explicitly state the code looks good. Don't stop at the first finding - list every qualifying issue. Then append the required non-blocking callouts section.`;
|
|
493
|
+
|
|
494
|
+
async function hasProjectMarker(dirPath: string): Promise<boolean> {
|
|
495
|
+
for (const marker of [".docyrus", ".pi"]) {
|
|
496
|
+
const markerStats = await fs.stat(path.join(dirPath, marker)).catch(() => null);
|
|
497
|
+
if (markerStats?.isDirectory()) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function readReviewGuidelinesFile(dirPath: string): Promise<string | null> {
|
|
506
|
+
const guidelinesPath = path.join(dirPath, "REVIEW_GUIDELINES.md");
|
|
507
|
+
const guidelineStats = await fs.stat(guidelinesPath).catch(() => null);
|
|
508
|
+
if (!guidelineStats?.isFile()) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const content = await fs.readFile(guidelinesPath, "utf8");
|
|
514
|
+
const trimmed = content.trim();
|
|
515
|
+
return trimmed ? trimmed : null;
|
|
516
|
+
} catch {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function getScopedProjectRootFromAgentDir(cwd: string): string | null {
|
|
522
|
+
const rawAgentDir = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
523
|
+
if (!rawAgentDir) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const resolvedAgentDir = path.resolve(rawAgentDir);
|
|
528
|
+
const parsed = path.parse(resolvedAgentDir);
|
|
529
|
+
const relativeSegments = resolvedAgentDir
|
|
530
|
+
.slice(parsed.root.length)
|
|
531
|
+
.split(path.sep)
|
|
532
|
+
.filter(Boolean);
|
|
533
|
+
|
|
534
|
+
if (relativeSegments.length < 3) {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const trailingSegments = relativeSegments.slice(-3);
|
|
539
|
+
if (trailingSegments[0] !== ".docyrus" || trailingSegments[1] !== "pi" || trailingSegments[2] !== "agent") {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const projectRoot = path.resolve(resolvedAgentDir, "..", "..", "..");
|
|
544
|
+
const resolvedCwd = path.resolve(cwd);
|
|
545
|
+
|
|
546
|
+
if (resolvedCwd === projectRoot || resolvedCwd.startsWith(`${projectRoot}${path.sep}`)) {
|
|
547
|
+
return projectRoot;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export async function loadProjectReviewGuidelines(cwd: string): Promise<string | null> {
|
|
554
|
+
const ancestorDirs: string[] = [];
|
|
555
|
+
let currentDir = path.resolve(cwd);
|
|
556
|
+
|
|
557
|
+
while (true) {
|
|
558
|
+
ancestorDirs.push(currentDir);
|
|
559
|
+
|
|
560
|
+
if (await hasProjectMarker(currentDir)) {
|
|
561
|
+
return await readReviewGuidelinesFile(currentDir);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const parentDir = path.dirname(currentDir);
|
|
565
|
+
if (parentDir === currentDir) {
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
currentDir = parentDir;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const scopedProjectRoot = getScopedProjectRootFromAgentDir(cwd);
|
|
572
|
+
if (scopedProjectRoot && !ancestorDirs.includes(scopedProjectRoot)) {
|
|
573
|
+
const scopedContent = await readReviewGuidelinesFile(scopedProjectRoot);
|
|
574
|
+
if (scopedContent) {
|
|
575
|
+
return scopedContent;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
for (const dirPath of ancestorDirs) {
|
|
580
|
+
const content = await readReviewGuidelinesFile(dirPath);
|
|
581
|
+
if (content) {
|
|
582
|
+
return content;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Get the merge base between HEAD and a branch
|
|
591
|
+
*/
|
|
592
|
+
async function getMergeBase(
|
|
593
|
+
pi: ExtensionAPI,
|
|
594
|
+
branch: string,
|
|
595
|
+
): Promise<string | null> {
|
|
596
|
+
try {
|
|
597
|
+
// First try to get the upstream tracking branch
|
|
598
|
+
const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
|
|
599
|
+
"rev-parse",
|
|
600
|
+
"--abbrev-ref",
|
|
601
|
+
`${branch}@{upstream}`,
|
|
602
|
+
]);
|
|
603
|
+
|
|
604
|
+
if (upstreamCode === 0 && upstream.trim()) {
|
|
605
|
+
const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", upstream.trim()]);
|
|
606
|
+
if (code === 0 && mergeBase.trim()) {
|
|
607
|
+
return mergeBase.trim();
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Fall back to using the branch directly
|
|
612
|
+
const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", branch]);
|
|
613
|
+
if (code === 0 && mergeBase.trim()) {
|
|
614
|
+
return mergeBase.trim();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return null;
|
|
618
|
+
} catch {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Get list of local branches
|
|
625
|
+
*/
|
|
626
|
+
async function getLocalBranches(pi: ExtensionAPI): Promise<string[]> {
|
|
627
|
+
const { stdout, code } = await pi.exec("git", ["branch", "--format=%(refname:short)"]);
|
|
628
|
+
if (code !== 0) return [];
|
|
629
|
+
return stdout
|
|
630
|
+
.trim()
|
|
631
|
+
.split("\n")
|
|
632
|
+
.filter((b) => b.trim());
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Get list of recent commits
|
|
637
|
+
*/
|
|
638
|
+
async function getRecentCommits(pi: ExtensionAPI, limit: number = 10): Promise<Array<{ sha: string; title: string }>> {
|
|
639
|
+
const { stdout, code } = await pi.exec("git", ["log", `--oneline`, `-n`, `${limit}`]);
|
|
640
|
+
if (code !== 0) return [];
|
|
641
|
+
|
|
642
|
+
return stdout
|
|
643
|
+
.trim()
|
|
644
|
+
.split("\n")
|
|
645
|
+
.filter((line) => line.trim())
|
|
646
|
+
.map((line) => {
|
|
647
|
+
const [sha, ...rest] = line.trim().split(" ");
|
|
648
|
+
return { sha, title: rest.join(" ") };
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Check if there are uncommitted changes (staged, unstaged, or untracked)
|
|
654
|
+
*/
|
|
655
|
+
async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
|
|
656
|
+
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
|
657
|
+
return code === 0 && stdout.trim().length > 0;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Check if there are changes that would prevent switching branches
|
|
662
|
+
* (staged or unstaged changes to tracked files - untracked files are fine)
|
|
663
|
+
*/
|
|
664
|
+
async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> {
|
|
665
|
+
// Check for staged or unstaged changes to tracked files
|
|
666
|
+
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
|
667
|
+
if (code !== 0) return false;
|
|
668
|
+
|
|
669
|
+
// Filter out untracked files (lines starting with ??)
|
|
670
|
+
const lines = stdout.trim().split("\n").filter((line) => line.trim());
|
|
671
|
+
const trackedChanges = lines.filter((line) => !line.startsWith("??"));
|
|
672
|
+
return trackedChanges.length > 0;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Parse a PR reference (URL or number) and return the PR number
|
|
677
|
+
*/
|
|
678
|
+
function parsePrReference(ref: string): number | null {
|
|
679
|
+
const trimmed = ref.trim();
|
|
680
|
+
|
|
681
|
+
// Try as a number first
|
|
682
|
+
const num = parseInt(trimmed, 10);
|
|
683
|
+
if (!isNaN(num) && num > 0) {
|
|
684
|
+
return num;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Try to extract from GitHub URL
|
|
688
|
+
// Formats: https://github.com/owner/repo/pull/123
|
|
689
|
+
// github.com/owner/repo/pull/123
|
|
690
|
+
const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
|
|
691
|
+
if (urlMatch) {
|
|
692
|
+
return parseInt(urlMatch[1], 10);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Get PR information from GitHub CLI
|
|
700
|
+
*/
|
|
701
|
+
async function getPrInfo(pi: ExtensionAPI, prNumber: number): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
|
|
702
|
+
const { stdout, code } = await pi.exec("gh", [
|
|
703
|
+
"pr", "view", String(prNumber),
|
|
704
|
+
"--json", "baseRefName,title,headRefName",
|
|
705
|
+
]);
|
|
706
|
+
|
|
707
|
+
if (code !== 0) return null;
|
|
708
|
+
|
|
709
|
+
try {
|
|
710
|
+
const data = JSON.parse(stdout);
|
|
711
|
+
return {
|
|
712
|
+
baseBranch: data.baseRefName,
|
|
713
|
+
title: data.title,
|
|
714
|
+
headBranch: data.headRefName,
|
|
715
|
+
};
|
|
716
|
+
} catch {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Checkout a PR using GitHub CLI
|
|
723
|
+
*/
|
|
724
|
+
async function checkoutPr(pi: ExtensionAPI, prNumber: number): Promise<{ success: boolean; error?: string }> {
|
|
725
|
+
const { stdout, stderr, code } = await pi.exec("gh", ["pr", "checkout", String(prNumber)]);
|
|
726
|
+
|
|
727
|
+
if (code !== 0) {
|
|
728
|
+
return { success: false, error: stderr || stdout || "Failed to checkout PR" };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return { success: true };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Get the current branch name
|
|
736
|
+
*/
|
|
737
|
+
async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> {
|
|
738
|
+
const { stdout, code } = await pi.exec("git", ["branch", "--show-current"]);
|
|
739
|
+
if (code === 0 && stdout.trim()) {
|
|
740
|
+
return stdout.trim();
|
|
741
|
+
}
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Get the default branch (main or master)
|
|
747
|
+
*/
|
|
748
|
+
async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
|
|
749
|
+
// Try to get from remote HEAD
|
|
750
|
+
const { stdout, code } = await pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
|
|
751
|
+
if (code === 0 && stdout.trim()) {
|
|
752
|
+
return stdout.trim().replace("origin/", "");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Fall back to checking if main or master exists
|
|
756
|
+
const branches = await getLocalBranches(pi);
|
|
757
|
+
if (branches.includes("main")) return "main";
|
|
758
|
+
if (branches.includes("master")) return "master";
|
|
759
|
+
|
|
760
|
+
return "main"; // Default fallback
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Build the review prompt based on target
|
|
765
|
+
*/
|
|
766
|
+
async function buildReviewPrompt(
|
|
767
|
+
pi: ExtensionAPI,
|
|
768
|
+
target: ReviewTarget,
|
|
769
|
+
options?: { includeLocalChanges?: boolean },
|
|
770
|
+
): Promise<string> {
|
|
771
|
+
const includeLocalChanges = options?.includeLocalChanges === true;
|
|
772
|
+
|
|
773
|
+
switch (target.type) {
|
|
774
|
+
case "uncommitted":
|
|
775
|
+
return UNCOMMITTED_PROMPT;
|
|
776
|
+
|
|
777
|
+
case "baseBranch": {
|
|
778
|
+
const mergeBase = await getMergeBase(pi, target.branch);
|
|
779
|
+
const basePrompt = mergeBase
|
|
780
|
+
? BASE_BRANCH_PROMPT_WITH_MERGE_BASE.replace(/{baseBranch}/g, target.branch).replace(/{mergeBaseSha}/g, mergeBase)
|
|
781
|
+
: BASE_BRANCH_PROMPT_FALLBACK.replace(/{branch}/g, target.branch);
|
|
782
|
+
return includeLocalChanges ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}` : basePrompt;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
case "commit":
|
|
786
|
+
if (target.title) {
|
|
787
|
+
return COMMIT_PROMPT_WITH_TITLE.replace("{sha}", target.sha).replace("{title}", target.title);
|
|
788
|
+
}
|
|
789
|
+
return COMMIT_PROMPT.replace("{sha}", target.sha);
|
|
790
|
+
|
|
791
|
+
case "pullRequest": {
|
|
792
|
+
const mergeBase = await getMergeBase(pi, target.baseBranch);
|
|
793
|
+
const basePrompt = mergeBase
|
|
794
|
+
? PULL_REQUEST_PROMPT
|
|
795
|
+
.replace(/{prNumber}/g, String(target.prNumber))
|
|
796
|
+
.replace(/{title}/g, target.title)
|
|
797
|
+
.replace(/{baseBranch}/g, target.baseBranch)
|
|
798
|
+
.replace(/{mergeBaseSha}/g, mergeBase)
|
|
799
|
+
: PULL_REQUEST_PROMPT_FALLBACK
|
|
800
|
+
.replace(/{prNumber}/g, String(target.prNumber))
|
|
801
|
+
.replace(/{title}/g, target.title)
|
|
802
|
+
.replace(/{baseBranch}/g, target.baseBranch);
|
|
803
|
+
return includeLocalChanges ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}` : basePrompt;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
case "folder":
|
|
807
|
+
return FOLDER_REVIEW_PROMPT.replace("{paths}", target.paths.join(", "));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Get user-facing hint for the review target
|
|
813
|
+
*/
|
|
814
|
+
function getUserFacingHint(target: ReviewTarget): string {
|
|
815
|
+
switch (target.type) {
|
|
816
|
+
case "uncommitted":
|
|
817
|
+
return "current changes";
|
|
818
|
+
case "baseBranch":
|
|
819
|
+
return `changes against '${target.branch}'`;
|
|
820
|
+
case "commit": {
|
|
821
|
+
const shortSha = target.sha.slice(0, 7);
|
|
822
|
+
return target.title ? `commit ${shortSha}: ${target.title}` : `commit ${shortSha}`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
case "pullRequest": {
|
|
826
|
+
const shortTitle = target.title.length > 30 ? target.title.slice(0, 27) + "..." : target.title;
|
|
827
|
+
return `PR #${target.prNumber}: ${shortTitle}`;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
case "folder": {
|
|
831
|
+
const joined = target.paths.join(", ");
|
|
832
|
+
return joined.length > 40 ? `folders: ${joined.slice(0, 37)}...` : `folders: ${joined}`;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
type AssistantSnapshot = {
|
|
838
|
+
id: string;
|
|
839
|
+
text: string;
|
|
840
|
+
stopReason?: string;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
function extractAssistantTextContent(content: unknown): string {
|
|
844
|
+
if (typeof content === "string") {
|
|
845
|
+
return content.trim();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (!Array.isArray(content)) {
|
|
849
|
+
return "";
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const textParts = content
|
|
853
|
+
.filter(
|
|
854
|
+
(part): part is { type: "text"; text: string } =>
|
|
855
|
+
Boolean(part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part),
|
|
856
|
+
)
|
|
857
|
+
.map((part) => part.text);
|
|
858
|
+
return textParts.join("\n").trim();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function getLastAssistantSnapshot(ctx: ExtensionContext): AssistantSnapshot | null {
|
|
862
|
+
const entries = ctx.sessionManager.getBranch();
|
|
863
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
864
|
+
const entry = entries[i];
|
|
865
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") {
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const assistantMessage = entry.message as { content?: unknown; stopReason?: string };
|
|
870
|
+
return {
|
|
871
|
+
id: entry.id,
|
|
872
|
+
text: extractAssistantTextContent(assistantMessage.content),
|
|
873
|
+
stopReason: assistantMessage.stopReason,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function sleep(ms: number): Promise<void> {
|
|
881
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function waitForLoopTurnToStart(ctx: ExtensionContext, previousAssistantId?: string): Promise<boolean> {
|
|
885
|
+
const deadline = Date.now() + REVIEW_LOOP_START_TIMEOUT_MS;
|
|
886
|
+
|
|
887
|
+
while (Date.now() < deadline) {
|
|
888
|
+
const lastAssistantId = getLastAssistantSnapshot(ctx)?.id;
|
|
889
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages() || (lastAssistantId && lastAssistantId !== previousAssistantId)) {
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
await sleep(REVIEW_LOOP_START_POLL_MS);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Review preset options for the selector (keep this order stable)
|
|
899
|
+
const REVIEW_PRESETS = [
|
|
900
|
+
{ value: "uncommitted", label: "Review uncommitted changes", description: "" },
|
|
901
|
+
{ value: "baseBranch", label: "Review against a base branch", description: "(local)" },
|
|
902
|
+
{ value: "commit", label: "Review a commit", description: "" },
|
|
903
|
+
{ value: "pullRequest", label: "Review a pull request", description: "(GitHub PR)" },
|
|
904
|
+
{ value: "folder", label: "Review a folder (or more)", description: "(snapshot, not diff)" },
|
|
905
|
+
] as const;
|
|
906
|
+
|
|
907
|
+
const TOGGLE_LOOP_FIXING_VALUE = "toggleLoopFixing" as const;
|
|
908
|
+
const TOGGLE_CUSTOM_INSTRUCTIONS_VALUE = "toggleCustomInstructions" as const;
|
|
909
|
+
type ReviewPresetValue =
|
|
910
|
+
| (typeof REVIEW_PRESETS)[number]["value"]
|
|
911
|
+
| typeof TOGGLE_LOOP_FIXING_VALUE
|
|
912
|
+
| typeof TOGGLE_CUSTOM_INSTRUCTIONS_VALUE;
|
|
913
|
+
|
|
914
|
+
export default function reviewExtension(pi: ExtensionAPI) {
|
|
915
|
+
function persistReviewSettings() {
|
|
916
|
+
pi.appendEntry(REVIEW_SETTINGS_TYPE, {
|
|
917
|
+
loopFixingEnabled: reviewLoopFixingEnabled,
|
|
918
|
+
customInstructions: reviewCustomInstructions,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function setReviewLoopFixingEnabled(enabled: boolean) {
|
|
923
|
+
reviewLoopFixingEnabled = enabled;
|
|
924
|
+
persistReviewSettings();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function setReviewCustomInstructions(instructions: string | undefined) {
|
|
928
|
+
reviewCustomInstructions = instructions?.trim() || undefined;
|
|
929
|
+
persistReviewSettings();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function applyAllReviewState(ctx: ExtensionContext) {
|
|
933
|
+
applyReviewSettings(ctx);
|
|
934
|
+
applyReviewState(ctx);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
pi.on("session_start", (_event, ctx) => {
|
|
938
|
+
applyAllReviewState(ctx);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
pi.on("session_switch", (_event, ctx) => {
|
|
942
|
+
applyAllReviewState(ctx);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
946
|
+
applyAllReviewState(ctx);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Determine the smart default review type based on git state
|
|
951
|
+
*/
|
|
952
|
+
async function getSmartDefault(): Promise<"uncommitted" | "baseBranch" | "commit"> {
|
|
953
|
+
// Priority 1: If there are uncommitted changes, default to reviewing them
|
|
954
|
+
if (await hasUncommittedChanges(pi)) {
|
|
955
|
+
return "uncommitted";
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Priority 2: If on a feature branch (not the default branch), default to PR-style review
|
|
959
|
+
const currentBranch = await getCurrentBranch(pi);
|
|
960
|
+
const defaultBranch = await getDefaultBranch(pi);
|
|
961
|
+
if (currentBranch && currentBranch !== defaultBranch) {
|
|
962
|
+
return "baseBranch";
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Priority 3: Default to reviewing a specific commit
|
|
966
|
+
return "commit";
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Show the review preset selector
|
|
971
|
+
*/
|
|
972
|
+
async function showReviewSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
973
|
+
// Determine smart default (but keep the list order stable)
|
|
974
|
+
const smartDefault = await getSmartDefault();
|
|
975
|
+
const presetItems: SelectItem[] = REVIEW_PRESETS.map((preset) => ({
|
|
976
|
+
value: preset.value,
|
|
977
|
+
label: preset.label,
|
|
978
|
+
description: preset.description,
|
|
979
|
+
}));
|
|
980
|
+
const smartDefaultIndex = presetItems.findIndex((item) => item.value === smartDefault);
|
|
981
|
+
|
|
982
|
+
while (true) {
|
|
983
|
+
const customInstructionsLabel = reviewCustomInstructions
|
|
984
|
+
? "Remove custom review instructions"
|
|
985
|
+
: "Add custom review instructions";
|
|
986
|
+
const customInstructionsDescription = reviewCustomInstructions
|
|
987
|
+
? "(currently set)"
|
|
988
|
+
: "(applies to all review modes)";
|
|
989
|
+
const loopToggleLabel = reviewLoopFixingEnabled ? "Disable Loop Fixing" : "Enable Loop Fixing";
|
|
990
|
+
const loopToggleDescription = reviewLoopFixingEnabled ? "(currently on)" : "(currently off)";
|
|
991
|
+
const items: SelectItem[] = [
|
|
992
|
+
...presetItems,
|
|
993
|
+
{
|
|
994
|
+
value: TOGGLE_CUSTOM_INSTRUCTIONS_VALUE,
|
|
995
|
+
label: customInstructionsLabel,
|
|
996
|
+
description: customInstructionsDescription,
|
|
997
|
+
},
|
|
998
|
+
{ value: TOGGLE_LOOP_FIXING_VALUE, label: loopToggleLabel, description: loopToggleDescription },
|
|
999
|
+
];
|
|
1000
|
+
|
|
1001
|
+
const result = await ctx.ui.custom<ReviewPresetValue | null>((tui, theme, _kb, done) => {
|
|
1002
|
+
const container = new Container();
|
|
1003
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
1004
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Select a review preset"))));
|
|
1005
|
+
|
|
1006
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
1007
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
1008
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
1009
|
+
description: (text) => theme.fg("muted", text),
|
|
1010
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
1011
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
// Preselect the smart default without reordering the list
|
|
1015
|
+
if (smartDefaultIndex >= 0) {
|
|
1016
|
+
selectList.setSelectedIndex(smartDefaultIndex);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
selectList.onSelect = (item) => done(item.value as ReviewPresetValue);
|
|
1020
|
+
selectList.onCancel = () => done(null);
|
|
1021
|
+
|
|
1022
|
+
container.addChild(selectList);
|
|
1023
|
+
container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back")));
|
|
1024
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
1025
|
+
|
|
1026
|
+
return {
|
|
1027
|
+
render(width: number) {
|
|
1028
|
+
return container.render(width);
|
|
1029
|
+
},
|
|
1030
|
+
invalidate() {
|
|
1031
|
+
container.invalidate();
|
|
1032
|
+
},
|
|
1033
|
+
handleInput(data: string) {
|
|
1034
|
+
selectList.handleInput(data);
|
|
1035
|
+
tui.requestRender();
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
if (!result) return null;
|
|
1041
|
+
|
|
1042
|
+
if (result === TOGGLE_LOOP_FIXING_VALUE) {
|
|
1043
|
+
const nextEnabled = !reviewLoopFixingEnabled;
|
|
1044
|
+
setReviewLoopFixingEnabled(nextEnabled);
|
|
1045
|
+
ctx.ui.notify(nextEnabled ? "Loop fixing enabled" : "Loop fixing disabled", "info");
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (result === TOGGLE_CUSTOM_INSTRUCTIONS_VALUE) {
|
|
1050
|
+
if (reviewCustomInstructions) {
|
|
1051
|
+
setReviewCustomInstructions(undefined);
|
|
1052
|
+
ctx.ui.notify("Custom review instructions removed", "info");
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const customInstructions = await ctx.ui.editor(
|
|
1057
|
+
"Enter custom review instructions (applies to all review modes):",
|
|
1058
|
+
"",
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
if (!customInstructions?.trim()) {
|
|
1062
|
+
ctx.ui.notify("Custom review instructions not changed", "info");
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
setReviewCustomInstructions(customInstructions);
|
|
1067
|
+
ctx.ui.notify("Custom review instructions saved", "info");
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Handle each preset type
|
|
1072
|
+
switch (result) {
|
|
1073
|
+
case "uncommitted":
|
|
1074
|
+
return { type: "uncommitted" };
|
|
1075
|
+
|
|
1076
|
+
case "baseBranch": {
|
|
1077
|
+
const target = await showBranchSelector(ctx);
|
|
1078
|
+
if (target) return target;
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
case "commit": {
|
|
1083
|
+
if (reviewLoopFixingEnabled) {
|
|
1084
|
+
ctx.ui.notify("Loop mode does not work with commit review.", "error");
|
|
1085
|
+
break;
|
|
1086
|
+
}
|
|
1087
|
+
const target = await showCommitSelector(ctx);
|
|
1088
|
+
if (target) return target;
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
case "folder": {
|
|
1093
|
+
const target = await showFolderInput(ctx);
|
|
1094
|
+
if (target) return target;
|
|
1095
|
+
break;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
case "pullRequest": {
|
|
1099
|
+
const target = await showPrInput(ctx);
|
|
1100
|
+
if (target) return target;
|
|
1101
|
+
break;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
default:
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Show branch selector for base branch review
|
|
1112
|
+
*/
|
|
1113
|
+
async function showBranchSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
1114
|
+
const branches = await getLocalBranches(pi);
|
|
1115
|
+
const currentBranch = await getCurrentBranch(pi);
|
|
1116
|
+
const defaultBranch = await getDefaultBranch(pi);
|
|
1117
|
+
|
|
1118
|
+
// Never offer the current branch as a base branch (reviewing against itself is meaningless).
|
|
1119
|
+
const candidateBranches = currentBranch ? branches.filter((b) => b !== currentBranch) : branches;
|
|
1120
|
+
|
|
1121
|
+
if (candidateBranches.length === 0) {
|
|
1122
|
+
ctx.ui.notify(
|
|
1123
|
+
currentBranch ? `No other branches found (current branch: ${currentBranch})` : "No branches found",
|
|
1124
|
+
"error",
|
|
1125
|
+
);
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Sort branches with default branch first
|
|
1130
|
+
const sortedBranches = candidateBranches.sort((a, b) => {
|
|
1131
|
+
if (a === defaultBranch) return -1;
|
|
1132
|
+
if (b === defaultBranch) return 1;
|
|
1133
|
+
return a.localeCompare(b);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
const items: SelectItem[] = sortedBranches.map((branch) => ({
|
|
1137
|
+
value: branch,
|
|
1138
|
+
label: branch,
|
|
1139
|
+
description: branch === defaultBranch ? "(default)" : "",
|
|
1140
|
+
}));
|
|
1141
|
+
|
|
1142
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
1143
|
+
const container = new Container();
|
|
1144
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
1145
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Select base branch"))));
|
|
1146
|
+
|
|
1147
|
+
const searchInput = new Input();
|
|
1148
|
+
container.addChild(searchInput);
|
|
1149
|
+
container.addChild(new Spacer(1));
|
|
1150
|
+
|
|
1151
|
+
const listContainer = new Container();
|
|
1152
|
+
container.addChild(listContainer);
|
|
1153
|
+
container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
|
|
1154
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
1155
|
+
|
|
1156
|
+
let filteredItems = items;
|
|
1157
|
+
let selectList: SelectList | null = null;
|
|
1158
|
+
|
|
1159
|
+
const updateList = () => {
|
|
1160
|
+
listContainer.clear();
|
|
1161
|
+
if (filteredItems.length === 0) {
|
|
1162
|
+
listContainer.addChild(new Text(theme.fg("warning", " No matching branches")));
|
|
1163
|
+
selectList = null;
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 10), {
|
|
1168
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
1169
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
1170
|
+
description: (text) => theme.fg("muted", text),
|
|
1171
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
1172
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
selectList.onSelect = (item) => done(item.value);
|
|
1176
|
+
selectList.onCancel = () => done(null);
|
|
1177
|
+
listContainer.addChild(selectList);
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
const applyFilter = () => {
|
|
1181
|
+
const query = searchInput.getValue();
|
|
1182
|
+
filteredItems = query
|
|
1183
|
+
? fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`)
|
|
1184
|
+
: items;
|
|
1185
|
+
updateList();
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
applyFilter();
|
|
1189
|
+
|
|
1190
|
+
return {
|
|
1191
|
+
render(width: number) {
|
|
1192
|
+
return container.render(width);
|
|
1193
|
+
},
|
|
1194
|
+
invalidate() {
|
|
1195
|
+
container.invalidate();
|
|
1196
|
+
},
|
|
1197
|
+
handleInput(data: string) {
|
|
1198
|
+
const kb = getEditorKeybindings();
|
|
1199
|
+
if (
|
|
1200
|
+
kb.matches(data, "selectUp") ||
|
|
1201
|
+
kb.matches(data, "selectDown") ||
|
|
1202
|
+
kb.matches(data, "selectConfirm") ||
|
|
1203
|
+
kb.matches(data, "selectCancel")
|
|
1204
|
+
) {
|
|
1205
|
+
if (selectList) {
|
|
1206
|
+
selectList.handleInput(data);
|
|
1207
|
+
} else if (kb.matches(data, "selectCancel")) {
|
|
1208
|
+
done(null);
|
|
1209
|
+
}
|
|
1210
|
+
tui.requestRender();
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
searchInput.handleInput(data);
|
|
1215
|
+
applyFilter();
|
|
1216
|
+
tui.requestRender();
|
|
1217
|
+
},
|
|
1218
|
+
};
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
if (!result) return null;
|
|
1222
|
+
return { type: "baseBranch", branch: result };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Show commit selector
|
|
1227
|
+
*/
|
|
1228
|
+
async function showCommitSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
1229
|
+
const commits = await getRecentCommits(pi, 20);
|
|
1230
|
+
|
|
1231
|
+
if (commits.length === 0) {
|
|
1232
|
+
ctx.ui.notify("No commits found", "error");
|
|
1233
|
+
return null;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const items: SelectItem[] = commits.map((commit) => ({
|
|
1237
|
+
value: commit.sha,
|
|
1238
|
+
label: `${commit.sha.slice(0, 7)} ${commit.title}`,
|
|
1239
|
+
description: "",
|
|
1240
|
+
}));
|
|
1241
|
+
|
|
1242
|
+
const result = await ctx.ui.custom<{ sha: string; title: string } | null>((tui, theme, _kb, done) => {
|
|
1243
|
+
const container = new Container();
|
|
1244
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
1245
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Select commit to review"))));
|
|
1246
|
+
|
|
1247
|
+
const searchInput = new Input();
|
|
1248
|
+
container.addChild(searchInput);
|
|
1249
|
+
container.addChild(new Spacer(1));
|
|
1250
|
+
|
|
1251
|
+
const listContainer = new Container();
|
|
1252
|
+
container.addChild(listContainer);
|
|
1253
|
+
container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
|
|
1254
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
1255
|
+
|
|
1256
|
+
let filteredItems = items;
|
|
1257
|
+
let selectList: SelectList | null = null;
|
|
1258
|
+
|
|
1259
|
+
const updateList = () => {
|
|
1260
|
+
listContainer.clear();
|
|
1261
|
+
if (filteredItems.length === 0) {
|
|
1262
|
+
listContainer.addChild(new Text(theme.fg("warning", " No matching commits")));
|
|
1263
|
+
selectList = null;
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 10), {
|
|
1268
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
1269
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
1270
|
+
description: (text) => theme.fg("muted", text),
|
|
1271
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
1272
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
selectList.onSelect = (item) => {
|
|
1276
|
+
const commit = commits.find((c) => c.sha === item.value);
|
|
1277
|
+
if (commit) {
|
|
1278
|
+
done(commit);
|
|
1279
|
+
} else {
|
|
1280
|
+
done(null);
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1283
|
+
selectList.onCancel = () => done(null);
|
|
1284
|
+
listContainer.addChild(selectList);
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
const applyFilter = () => {
|
|
1288
|
+
const query = searchInput.getValue();
|
|
1289
|
+
filteredItems = query
|
|
1290
|
+
? fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`)
|
|
1291
|
+
: items;
|
|
1292
|
+
updateList();
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
applyFilter();
|
|
1296
|
+
|
|
1297
|
+
return {
|
|
1298
|
+
render(width: number) {
|
|
1299
|
+
return container.render(width);
|
|
1300
|
+
},
|
|
1301
|
+
invalidate() {
|
|
1302
|
+
container.invalidate();
|
|
1303
|
+
},
|
|
1304
|
+
handleInput(data: string) {
|
|
1305
|
+
const kb = getEditorKeybindings();
|
|
1306
|
+
if (
|
|
1307
|
+
kb.matches(data, "selectUp") ||
|
|
1308
|
+
kb.matches(data, "selectDown") ||
|
|
1309
|
+
kb.matches(data, "selectConfirm") ||
|
|
1310
|
+
kb.matches(data, "selectCancel")
|
|
1311
|
+
) {
|
|
1312
|
+
if (selectList) {
|
|
1313
|
+
selectList.handleInput(data);
|
|
1314
|
+
} else if (kb.matches(data, "selectCancel")) {
|
|
1315
|
+
done(null);
|
|
1316
|
+
}
|
|
1317
|
+
tui.requestRender();
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
searchInput.handleInput(data);
|
|
1322
|
+
applyFilter();
|
|
1323
|
+
tui.requestRender();
|
|
1324
|
+
},
|
|
1325
|
+
};
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
if (!result) return null;
|
|
1329
|
+
return { type: "commit", sha: result.sha, title: result.title };
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
function parseReviewPaths(value: string): string[] {
|
|
1334
|
+
return value
|
|
1335
|
+
.split(/\s+/)
|
|
1336
|
+
.map((item) => item.trim())
|
|
1337
|
+
.filter((item) => item.length > 0);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Show folder input
|
|
1342
|
+
*/
|
|
1343
|
+
async function showFolderInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
1344
|
+
const result = await ctx.ui.editor(
|
|
1345
|
+
"Enter folders/files to review (space-separated or one per line):",
|
|
1346
|
+
".",
|
|
1347
|
+
);
|
|
1348
|
+
|
|
1349
|
+
if (!result?.trim()) return null;
|
|
1350
|
+
const paths = parseReviewPaths(result);
|
|
1351
|
+
if (paths.length === 0) return null;
|
|
1352
|
+
|
|
1353
|
+
return { type: "folder", paths };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Show PR input and handle checkout
|
|
1358
|
+
*/
|
|
1359
|
+
async function showPrInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
|
|
1360
|
+
// First check for pending changes that would prevent branch switching
|
|
1361
|
+
if (await hasPendingChanges(pi)) {
|
|
1362
|
+
ctx.ui.notify("Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", "error");
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Get PR reference from user
|
|
1367
|
+
const prRef = await ctx.ui.editor(
|
|
1368
|
+
"Enter PR number or URL (e.g. 123 or https://github.com/owner/repo/pull/123):",
|
|
1369
|
+
"",
|
|
1370
|
+
);
|
|
1371
|
+
|
|
1372
|
+
if (!prRef?.trim()) return null;
|
|
1373
|
+
|
|
1374
|
+
const prNumber = parsePrReference(prRef);
|
|
1375
|
+
if (!prNumber) {
|
|
1376
|
+
ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Get PR info from GitHub
|
|
1381
|
+
ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info");
|
|
1382
|
+
const prInfo = await getPrInfo(pi, prNumber);
|
|
1383
|
+
|
|
1384
|
+
if (!prInfo) {
|
|
1385
|
+
ctx.ui.notify(`Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, "error");
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Check again for pending changes (in case something changed)
|
|
1390
|
+
if (await hasPendingChanges(pi)) {
|
|
1391
|
+
ctx.ui.notify("Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", "error");
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Checkout the PR
|
|
1396
|
+
ctx.ui.notify(`Checking out PR #${prNumber}...`, "info");
|
|
1397
|
+
const checkoutResult = await checkoutPr(pi, prNumber);
|
|
1398
|
+
|
|
1399
|
+
if (!checkoutResult.success) {
|
|
1400
|
+
ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error");
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
ctx.ui.notify(`Checked out PR #${prNumber} (${prInfo.headBranch})`, "info");
|
|
1405
|
+
|
|
1406
|
+
return {
|
|
1407
|
+
type: "pullRequest",
|
|
1408
|
+
prNumber,
|
|
1409
|
+
baseBranch: prInfo.baseBranch,
|
|
1410
|
+
title: prInfo.title,
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Execute the review
|
|
1416
|
+
*/
|
|
1417
|
+
async function executeReview(
|
|
1418
|
+
ctx: ExtensionCommandContext,
|
|
1419
|
+
target: ReviewTarget,
|
|
1420
|
+
useFreshSession: boolean,
|
|
1421
|
+
options?: { includeLocalChanges?: boolean; extraInstruction?: string },
|
|
1422
|
+
): Promise<boolean> {
|
|
1423
|
+
// Check if we're already in a review
|
|
1424
|
+
if (reviewOriginId) {
|
|
1425
|
+
ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
|
|
1426
|
+
return false;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// Handle fresh session mode
|
|
1430
|
+
if (useFreshSession) {
|
|
1431
|
+
// Store current position (where we'll return to).
|
|
1432
|
+
// In an empty session there is no leaf yet, so create a lightweight anchor first.
|
|
1433
|
+
let originId = ctx.sessionManager.getLeafId() ?? undefined;
|
|
1434
|
+
if (!originId) {
|
|
1435
|
+
pi.appendEntry(REVIEW_ANCHOR_TYPE, { createdAt: new Date().toISOString() });
|
|
1436
|
+
originId = ctx.sessionManager.getLeafId() ?? undefined;
|
|
1437
|
+
}
|
|
1438
|
+
if (!originId) {
|
|
1439
|
+
ctx.ui.notify("Failed to determine review origin.", "error");
|
|
1440
|
+
return false;
|
|
1441
|
+
}
|
|
1442
|
+
reviewOriginId = originId;
|
|
1443
|
+
|
|
1444
|
+
// Keep a local copy so session_tree events during navigation don't wipe it
|
|
1445
|
+
const lockedOriginId = originId;
|
|
1446
|
+
|
|
1447
|
+
// Find the first user message in the session.
|
|
1448
|
+
// If none exists (e.g. brand-new session), we'll stay on the current leaf.
|
|
1449
|
+
const entries = ctx.sessionManager.getEntries();
|
|
1450
|
+
const firstUserMessage = entries.find(
|
|
1451
|
+
(e) => e.type === "message" && e.message.role === "user",
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1454
|
+
if (firstUserMessage) {
|
|
1455
|
+
// Navigate to first user message to create a new branch from that point
|
|
1456
|
+
// Label it as "code-review" so it's visible in the tree
|
|
1457
|
+
try {
|
|
1458
|
+
const result = await ctx.navigateTree(firstUserMessage.id, { summarize: false, label: "code-review" });
|
|
1459
|
+
if (result.cancelled) {
|
|
1460
|
+
reviewOriginId = undefined;
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
// Clean up state if navigation fails
|
|
1465
|
+
reviewOriginId = undefined;
|
|
1466
|
+
ctx.ui.notify(`Failed to start review: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1467
|
+
return false;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Clear the editor (navigating to user message fills it with the message text)
|
|
1471
|
+
ctx.ui.setEditorText("");
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// Restore origin after navigation events (session_tree can reset it)
|
|
1475
|
+
reviewOriginId = lockedOriginId;
|
|
1476
|
+
|
|
1477
|
+
// Show widget indicating review is active
|
|
1478
|
+
setReviewWidget(ctx, true);
|
|
1479
|
+
|
|
1480
|
+
// Persist review state so tree navigation can restore/reset it
|
|
1481
|
+
pi.appendEntry(REVIEW_STATE_TYPE, { active: true, originId: lockedOriginId });
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const prompt = await buildReviewPrompt(pi, target, {
|
|
1485
|
+
includeLocalChanges: options?.includeLocalChanges === true,
|
|
1486
|
+
});
|
|
1487
|
+
const hint = getUserFacingHint(target);
|
|
1488
|
+
const projectGuidelines = await loadProjectReviewGuidelines(ctx.cwd);
|
|
1489
|
+
|
|
1490
|
+
// Combine the review rubric with the specific prompt
|
|
1491
|
+
let fullPrompt = `${REVIEW_RUBRIC}\n\n---\n\nPlease perform a code review with the following focus:\n\n${prompt}`;
|
|
1492
|
+
|
|
1493
|
+
if (reviewCustomInstructions) {
|
|
1494
|
+
fullPrompt += `\n\nShared custom review instructions (applies to all reviews):\n\n${reviewCustomInstructions}`;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
if (options?.extraInstruction?.trim()) {
|
|
1498
|
+
fullPrompt += `\n\nAdditional user-provided review instruction:\n\n${options.extraInstruction.trim()}`;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
if (projectGuidelines) {
|
|
1502
|
+
fullPrompt += `\n\nThis project has additional instructions for code reviews:\n\n${projectGuidelines}`;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
const modeHint = useFreshSession ? " (fresh session)" : "";
|
|
1506
|
+
ctx.ui.notify(`Starting review: ${hint}${modeHint}`, "info");
|
|
1507
|
+
|
|
1508
|
+
// Send as a user message that triggers a turn
|
|
1509
|
+
pi.sendUserMessage(fullPrompt);
|
|
1510
|
+
return true;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Parse command arguments for direct invocation
|
|
1515
|
+
* Returns the target or a special marker for PR that needs async handling
|
|
1516
|
+
*/
|
|
1517
|
+
type ParsedReviewArgs = {
|
|
1518
|
+
target: ReviewTarget | { type: "pr"; ref: string } | null;
|
|
1519
|
+
extraInstruction?: string;
|
|
1520
|
+
error?: string;
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
function tokenizeArgs(value: string): string[] {
|
|
1524
|
+
const tokens: string[] = [];
|
|
1525
|
+
let current = "";
|
|
1526
|
+
let quote: '"' | "'" | null = null;
|
|
1527
|
+
|
|
1528
|
+
for (let i = 0; i < value.length; i++) {
|
|
1529
|
+
const char = value[i];
|
|
1530
|
+
|
|
1531
|
+
if (quote) {
|
|
1532
|
+
if (char === "\\" && i + 1 < value.length) {
|
|
1533
|
+
current += value[i + 1];
|
|
1534
|
+
i += 1;
|
|
1535
|
+
continue;
|
|
1536
|
+
}
|
|
1537
|
+
if (char === quote) {
|
|
1538
|
+
quote = null;
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
current += char;
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
if (char === '"' || char === "'") {
|
|
1546
|
+
quote = char;
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
if (/\s/.test(char)) {
|
|
1551
|
+
if (current.length > 0) {
|
|
1552
|
+
tokens.push(current);
|
|
1553
|
+
current = "";
|
|
1554
|
+
}
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
current += char;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (current.length > 0) {
|
|
1562
|
+
tokens.push(current);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
return tokens;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function parseArgs(args: string | undefined): ParsedReviewArgs {
|
|
1569
|
+
if (!args?.trim()) return { target: null };
|
|
1570
|
+
|
|
1571
|
+
const rawParts = tokenizeArgs(args.trim());
|
|
1572
|
+
const parts: string[] = [];
|
|
1573
|
+
let extraInstruction: string | undefined;
|
|
1574
|
+
|
|
1575
|
+
for (let i = 0; i < rawParts.length; i++) {
|
|
1576
|
+
const part = rawParts[i];
|
|
1577
|
+
if (part === "--extra") {
|
|
1578
|
+
const next = rawParts[i + 1];
|
|
1579
|
+
if (!next) {
|
|
1580
|
+
return { target: null, error: "Missing value for --extra" };
|
|
1581
|
+
}
|
|
1582
|
+
extraInstruction = next;
|
|
1583
|
+
i += 1;
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
if (part.startsWith("--extra=")) {
|
|
1588
|
+
extraInstruction = part.slice("--extra=".length);
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
parts.push(part);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (parts.length === 0) {
|
|
1596
|
+
return { target: null, extraInstruction };
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const subcommand = parts[0]?.toLowerCase();
|
|
1600
|
+
|
|
1601
|
+
switch (subcommand) {
|
|
1602
|
+
case "uncommitted":
|
|
1603
|
+
return { target: { type: "uncommitted" }, extraInstruction };
|
|
1604
|
+
|
|
1605
|
+
case "branch": {
|
|
1606
|
+
const branch = parts[1];
|
|
1607
|
+
if (!branch) return { target: null, extraInstruction };
|
|
1608
|
+
return { target: { type: "baseBranch", branch }, extraInstruction };
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
case "commit": {
|
|
1612
|
+
const sha = parts[1];
|
|
1613
|
+
if (!sha) return { target: null, extraInstruction };
|
|
1614
|
+
const title = parts.slice(2).join(" ") || undefined;
|
|
1615
|
+
return { target: { type: "commit", sha, title }, extraInstruction };
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
case "folder": {
|
|
1620
|
+
const paths = parseReviewPaths(parts.slice(1).join(" "));
|
|
1621
|
+
if (paths.length === 0) return { target: null, extraInstruction };
|
|
1622
|
+
return { target: { type: "folder", paths }, extraInstruction };
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
case "pr": {
|
|
1626
|
+
const ref = parts[1];
|
|
1627
|
+
if (!ref) return { target: null, extraInstruction };
|
|
1628
|
+
return { target: { type: "pr", ref }, extraInstruction };
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
default:
|
|
1632
|
+
return { target: null, extraInstruction };
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* Handle PR checkout and return a ReviewTarget (or null on failure)
|
|
1638
|
+
*/
|
|
1639
|
+
async function handlePrCheckout(ctx: ExtensionContext, ref: string): Promise<ReviewTarget | null> {
|
|
1640
|
+
// First check for pending changes
|
|
1641
|
+
if (await hasPendingChanges(pi)) {
|
|
1642
|
+
ctx.ui.notify("Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", "error");
|
|
1643
|
+
return null;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
const prNumber = parsePrReference(ref);
|
|
1647
|
+
if (!prNumber) {
|
|
1648
|
+
ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
|
|
1649
|
+
return null;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Get PR info
|
|
1653
|
+
ctx.ui.notify(`Fetching PR #${prNumber} info...`, "info");
|
|
1654
|
+
const prInfo = await getPrInfo(pi, prNumber);
|
|
1655
|
+
|
|
1656
|
+
if (!prInfo) {
|
|
1657
|
+
ctx.ui.notify(`Could not find PR #${prNumber}. Make sure gh is authenticated and the PR exists.`, "error");
|
|
1658
|
+
return null;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Checkout the PR
|
|
1662
|
+
ctx.ui.notify(`Checking out PR #${prNumber}...`, "info");
|
|
1663
|
+
const checkoutResult = await checkoutPr(pi, prNumber);
|
|
1664
|
+
|
|
1665
|
+
if (!checkoutResult.success) {
|
|
1666
|
+
ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error");
|
|
1667
|
+
return null;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
ctx.ui.notify(`Checked out PR #${prNumber} (${prInfo.headBranch})`, "info");
|
|
1671
|
+
|
|
1672
|
+
return {
|
|
1673
|
+
type: "pullRequest",
|
|
1674
|
+
prNumber,
|
|
1675
|
+
baseBranch: prInfo.baseBranch,
|
|
1676
|
+
title: prInfo.title,
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function isLoopCompatibleTarget(target: ReviewTarget): boolean {
|
|
1681
|
+
if (target.type !== "commit") {
|
|
1682
|
+
return true;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
return false;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
async function runLoopFixingReview(
|
|
1689
|
+
ctx: ExtensionCommandContext,
|
|
1690
|
+
target: ReviewTarget,
|
|
1691
|
+
extraInstruction?: string,
|
|
1692
|
+
): Promise<void> {
|
|
1693
|
+
if (reviewLoopInProgress) {
|
|
1694
|
+
ctx.ui.notify("Loop fixing review is already running.", "warning");
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
reviewLoopInProgress = true;
|
|
1699
|
+
setReviewWidget(ctx, Boolean(reviewOriginId));
|
|
1700
|
+
try {
|
|
1701
|
+
ctx.ui.notify(
|
|
1702
|
+
"Loop fixing enabled: using Empty branch mode and cycling until no blocking findings remain.",
|
|
1703
|
+
"info",
|
|
1704
|
+
);
|
|
1705
|
+
|
|
1706
|
+
for (let pass = 1; pass <= REVIEW_LOOP_MAX_ITERATIONS; pass++) {
|
|
1707
|
+
const reviewBaselineAssistantId = getLastAssistantSnapshot(ctx)?.id;
|
|
1708
|
+
const started = await executeReview(ctx, target, true, {
|
|
1709
|
+
includeLocalChanges: true,
|
|
1710
|
+
extraInstruction,
|
|
1711
|
+
});
|
|
1712
|
+
if (!started) {
|
|
1713
|
+
ctx.ui.notify("Loop fixing stopped before starting the review pass.", "warning");
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const reviewTurnStarted = await waitForLoopTurnToStart(ctx, reviewBaselineAssistantId);
|
|
1718
|
+
if (!reviewTurnStarted) {
|
|
1719
|
+
ctx.ui.notify("Loop fixing stopped: review pass did not start in time.", "error");
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
await ctx.waitForIdle();
|
|
1724
|
+
|
|
1725
|
+
const reviewSnapshot = getLastAssistantSnapshot(ctx);
|
|
1726
|
+
if (!reviewSnapshot || reviewSnapshot.id === reviewBaselineAssistantId) {
|
|
1727
|
+
ctx.ui.notify("Loop fixing stopped: could not read the review result.", "warning");
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (reviewSnapshot.stopReason === "aborted") {
|
|
1732
|
+
ctx.ui.notify("Loop fixing stopped: review was aborted.", "warning");
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
if (reviewSnapshot.stopReason === "error") {
|
|
1737
|
+
ctx.ui.notify("Loop fixing stopped: review failed with an error.", "error");
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
if (reviewSnapshot.stopReason === "length") {
|
|
1742
|
+
ctx.ui.notify("Loop fixing stopped: review output was truncated (stopReason=length).", "warning");
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
if (!hasBlockingReviewFindings(reviewSnapshot.text)) {
|
|
1747
|
+
const finalized = await executeEndReviewAction(ctx, "returnAndSummarize", {
|
|
1748
|
+
showSummaryLoader: true,
|
|
1749
|
+
notifySuccess: false,
|
|
1750
|
+
});
|
|
1751
|
+
if (finalized !== "ok") {
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
ctx.ui.notify("Loop fixing complete: no blocking findings remain.", "info");
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
ctx.ui.notify(`Loop fixing pass ${pass}: found blocking findings, returning to fix them...`, "info");
|
|
1760
|
+
|
|
1761
|
+
const fixBaselineAssistantId = getLastAssistantSnapshot(ctx)?.id;
|
|
1762
|
+
const sentFixPrompt = await executeEndReviewAction(ctx, "returnAndFix", {
|
|
1763
|
+
showSummaryLoader: true,
|
|
1764
|
+
notifySuccess: false,
|
|
1765
|
+
});
|
|
1766
|
+
if (sentFixPrompt !== "ok") {
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
const fixTurnStarted = await waitForLoopTurnToStart(ctx, fixBaselineAssistantId);
|
|
1771
|
+
if (!fixTurnStarted) {
|
|
1772
|
+
ctx.ui.notify("Loop fixing stopped: fix pass did not start in time.", "error");
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
await ctx.waitForIdle();
|
|
1777
|
+
|
|
1778
|
+
const fixSnapshot = getLastAssistantSnapshot(ctx);
|
|
1779
|
+
if (!fixSnapshot || fixSnapshot.id === fixBaselineAssistantId) {
|
|
1780
|
+
ctx.ui.notify("Loop fixing stopped: could not read the fix pass result.", "warning");
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
if (fixSnapshot.stopReason === "aborted") {
|
|
1784
|
+
ctx.ui.notify("Loop fixing stopped: fix pass was aborted.", "warning");
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
if (fixSnapshot.stopReason === "error") {
|
|
1788
|
+
ctx.ui.notify("Loop fixing stopped: fix pass failed with an error.", "error");
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
if (fixSnapshot.stopReason === "length") {
|
|
1792
|
+
ctx.ui.notify("Loop fixing stopped: fix pass output was truncated (stopReason=length).", "warning");
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
ctx.ui.notify(
|
|
1798
|
+
`Loop fixing stopped after ${REVIEW_LOOP_MAX_ITERATIONS} passes (safety limit reached).`,
|
|
1799
|
+
"warning",
|
|
1800
|
+
);
|
|
1801
|
+
} finally {
|
|
1802
|
+
reviewLoopInProgress = false;
|
|
1803
|
+
setReviewWidget(ctx, Boolean(reviewOriginId));
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// Register the /review command
|
|
1808
|
+
pi.registerCommand("review", {
|
|
1809
|
+
description: "Review code changes (PR, uncommitted, branch, commit, or folder)",
|
|
1810
|
+
handler: async (args, ctx) => {
|
|
1811
|
+
if (!ctx.hasUI) {
|
|
1812
|
+
ctx.ui.notify("Review requires interactive mode", "error");
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
if (reviewLoopInProgress) {
|
|
1817
|
+
ctx.ui.notify("Loop fixing review is already running.", "warning");
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// Check if we're already in a review
|
|
1822
|
+
if (reviewOriginId) {
|
|
1823
|
+
ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// Check if we're in a git repository
|
|
1828
|
+
const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
|
|
1829
|
+
if (code !== 0) {
|
|
1830
|
+
ctx.ui.notify("Not a git repository", "error");
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// Try to parse direct arguments
|
|
1835
|
+
let target: ReviewTarget | null = null;
|
|
1836
|
+
let fromSelector = false;
|
|
1837
|
+
let extraInstruction: string | undefined;
|
|
1838
|
+
const parsed = parseArgs(args);
|
|
1839
|
+
if (parsed.error) {
|
|
1840
|
+
ctx.ui.notify(parsed.error, "error");
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
extraInstruction = parsed.extraInstruction?.trim() || undefined;
|
|
1844
|
+
|
|
1845
|
+
if (parsed.target) {
|
|
1846
|
+
if (parsed.target.type === "pr") {
|
|
1847
|
+
// Handle PR checkout (async operation)
|
|
1848
|
+
target = await handlePrCheckout(ctx, parsed.target.ref);
|
|
1849
|
+
if (!target) {
|
|
1850
|
+
ctx.ui.notify("PR review failed. Returning to review menu.", "warning");
|
|
1851
|
+
}
|
|
1852
|
+
} else {
|
|
1853
|
+
target = parsed.target;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// If no args or invalid args, show selector
|
|
1858
|
+
if (!target) {
|
|
1859
|
+
fromSelector = true;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
while (true) {
|
|
1863
|
+
if (!target && fromSelector) {
|
|
1864
|
+
target = await showReviewSelector(ctx);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
if (!target) {
|
|
1868
|
+
ctx.ui.notify("Review cancelled", "info");
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
if (reviewLoopFixingEnabled && !isLoopCompatibleTarget(target)) {
|
|
1873
|
+
ctx.ui.notify("Loop mode does not work with commit review.", "error");
|
|
1874
|
+
if (fromSelector) {
|
|
1875
|
+
target = null;
|
|
1876
|
+
continue;
|
|
1877
|
+
}
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
if (reviewLoopFixingEnabled) {
|
|
1882
|
+
await runLoopFixingReview(ctx, target, extraInstruction);
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// Determine if we should use fresh session mode
|
|
1887
|
+
// Check if this is a new session (no messages yet)
|
|
1888
|
+
const entries = ctx.sessionManager.getEntries();
|
|
1889
|
+
const messageCount = entries.filter((e) => e.type === "message").length;
|
|
1890
|
+
|
|
1891
|
+
// In an empty session, default to fresh review mode so /end-review works consistently.
|
|
1892
|
+
let useFreshSession = messageCount === 0;
|
|
1893
|
+
|
|
1894
|
+
if (messageCount > 0) {
|
|
1895
|
+
// Existing session - ask user which mode they want
|
|
1896
|
+
const choice = await ctx.ui.select("Start review in:", ["Empty branch", "Current session"]);
|
|
1897
|
+
|
|
1898
|
+
if (choice === undefined) {
|
|
1899
|
+
if (fromSelector) {
|
|
1900
|
+
target = null;
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
ctx.ui.notify("Review cancelled", "info");
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
useFreshSession = choice === "Empty branch";
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
await executeReview(ctx, target, useFreshSession, { extraInstruction });
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
},
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
// Custom prompt for review summaries - focuses on preserving actionable findings
|
|
1917
|
+
const REVIEW_SUMMARY_PROMPT = `We are leaving a code-review branch and returning to the main coding branch.
|
|
1918
|
+
Create a structured handoff that can be used immediately to implement fixes.
|
|
1919
|
+
|
|
1920
|
+
You MUST summarize the review that happened in this branch so findings can be acted on.
|
|
1921
|
+
Do not omit findings: include every actionable issue that was identified.
|
|
1922
|
+
|
|
1923
|
+
Required sections (in order):
|
|
1924
|
+
|
|
1925
|
+
## Review Scope
|
|
1926
|
+
- What was reviewed (files/paths, changes, and scope)
|
|
1927
|
+
|
|
1928
|
+
## Verdict
|
|
1929
|
+
- "correct" or "needs attention"
|
|
1930
|
+
|
|
1931
|
+
## Findings
|
|
1932
|
+
For EACH finding, include:
|
|
1933
|
+
- Priority tag ([P0]..[P3]) and short title
|
|
1934
|
+
- File location (\`path/to/file.ext:line\`)
|
|
1935
|
+
- Why it matters (brief)
|
|
1936
|
+
- What should change (brief, actionable)
|
|
1937
|
+
|
|
1938
|
+
## Fix Queue
|
|
1939
|
+
1. Ordered implementation checklist (highest priority first)
|
|
1940
|
+
|
|
1941
|
+
## Constraints & Preferences
|
|
1942
|
+
- Any constraints or preferences mentioned during review
|
|
1943
|
+
- Or "(none)"
|
|
1944
|
+
|
|
1945
|
+
## Human Reviewer Callouts (Non-Blocking)
|
|
1946
|
+
Include only applicable callouts (no yes/no lines):
|
|
1947
|
+
- **This change adds a database migration:** <files/details>
|
|
1948
|
+
- **This change introduces a new dependency:** <package(s)/details>
|
|
1949
|
+
- **This change changes a dependency (or the lockfile):** <files/package(s)/details>
|
|
1950
|
+
- **This change modifies auth/permission behavior:** <what changed and where>
|
|
1951
|
+
- **This change introduces backwards-incompatible public schema/API/contract changes:** <what changed and where>
|
|
1952
|
+
- **This change includes irreversible or destructive operations:** <operation and scope>
|
|
1953
|
+
|
|
1954
|
+
If none apply, write "- (none)".
|
|
1955
|
+
|
|
1956
|
+
These are informational callouts for humans and are not fix items by themselves.
|
|
1957
|
+
|
|
1958
|
+
Preserve exact file paths, function names, and error messages where available.`;
|
|
1959
|
+
|
|
1960
|
+
const REVIEW_FIX_FINDINGS_PROMPT = `Use the latest review summary in this session and implement the review findings now.
|
|
1961
|
+
|
|
1962
|
+
Instructions:
|
|
1963
|
+
1. Treat the summary's Findings/Fix Queue as a checklist.
|
|
1964
|
+
2. Fix in priority order: P0, P1, then P2 (include P3 if quick and safe).
|
|
1965
|
+
3. If a finding is invalid/already fixed/not possible right now, briefly explain why and continue.
|
|
1966
|
+
4. Treat "Human Reviewer Callouts (Non-Blocking)" as informational only; do not convert them into fix tasks unless there is a separate explicit finding.
|
|
1967
|
+
5. Follow fail-fast error handling: do not add local catch/fallback recovery unless this scope is an explicit boundary that can safely translate the failure.
|
|
1968
|
+
6. If you add or keep a \`try/catch\`, explain the expected failure mode and either rethrow with context or return a boundary-safe error response.
|
|
1969
|
+
7. JSON parsing/decoding should fail loudly by default; avoid silent fallback parsing.
|
|
1970
|
+
8. Run relevant tests/checks for touched code where practical.
|
|
1971
|
+
9. End with: fixed items, deferred/skipped items (with reasons), and verification results.`;
|
|
1972
|
+
|
|
1973
|
+
type EndReviewAction = "returnOnly" | "returnAndFix" | "returnAndSummarize";
|
|
1974
|
+
type EndReviewActionResult = "ok" | "cancelled" | "error";
|
|
1975
|
+
type EndReviewActionOptions = {
|
|
1976
|
+
showSummaryLoader?: boolean;
|
|
1977
|
+
notifySuccess?: boolean;
|
|
1978
|
+
};
|
|
1979
|
+
|
|
1980
|
+
function getActiveReviewOrigin(ctx: ExtensionContext): string | undefined {
|
|
1981
|
+
if (reviewOriginId) {
|
|
1982
|
+
return reviewOriginId;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
const state = getReviewState(ctx);
|
|
1986
|
+
if (state?.active && state.originId) {
|
|
1987
|
+
reviewOriginId = state.originId;
|
|
1988
|
+
return reviewOriginId;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
if (state?.active) {
|
|
1992
|
+
setReviewWidget(ctx, false);
|
|
1993
|
+
pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
|
|
1994
|
+
ctx.ui.notify("Review state was missing origin info; cleared review status.", "warning");
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
return undefined;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function clearReviewState(ctx: ExtensionContext) {
|
|
2001
|
+
setReviewWidget(ctx, false);
|
|
2002
|
+
reviewOriginId = undefined;
|
|
2003
|
+
pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
async function navigateWithSummary(
|
|
2007
|
+
ctx: ExtensionCommandContext,
|
|
2008
|
+
originId: string,
|
|
2009
|
+
showLoader: boolean,
|
|
2010
|
+
): Promise<{ cancelled: boolean; error?: string } | null> {
|
|
2011
|
+
if (showLoader && ctx.hasUI) {
|
|
2012
|
+
return ctx.ui.custom<{ cancelled: boolean; error?: string } | null>((tui, theme, _kb, done) => {
|
|
2013
|
+
const loader = new BorderedLoader(tui, theme, "Returning and summarizing review branch...");
|
|
2014
|
+
loader.onAbort = () => done(null);
|
|
2015
|
+
|
|
2016
|
+
ctx.navigateTree(originId, {
|
|
2017
|
+
summarize: true,
|
|
2018
|
+
customInstructions: REVIEW_SUMMARY_PROMPT,
|
|
2019
|
+
replaceInstructions: true,
|
|
2020
|
+
})
|
|
2021
|
+
.then(done)
|
|
2022
|
+
.catch((err) => done({ cancelled: false, error: err instanceof Error ? err.message : String(err) }));
|
|
2023
|
+
|
|
2024
|
+
return loader;
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
try {
|
|
2029
|
+
return await ctx.navigateTree(originId, {
|
|
2030
|
+
summarize: true,
|
|
2031
|
+
customInstructions: REVIEW_SUMMARY_PROMPT,
|
|
2032
|
+
replaceInstructions: true,
|
|
2033
|
+
});
|
|
2034
|
+
} catch (error) {
|
|
2035
|
+
return { cancelled: false, error: error instanceof Error ? error.message : String(error) };
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
async function executeEndReviewAction(
|
|
2040
|
+
ctx: ExtensionCommandContext,
|
|
2041
|
+
action: EndReviewAction,
|
|
2042
|
+
options: EndReviewActionOptions = {},
|
|
2043
|
+
): Promise<EndReviewActionResult> {
|
|
2044
|
+
const originId = getActiveReviewOrigin(ctx);
|
|
2045
|
+
if (!originId) {
|
|
2046
|
+
if (!getReviewState(ctx)?.active) {
|
|
2047
|
+
ctx.ui.notify("Not in a review branch (use /review first, or review was started in current session mode)", "info");
|
|
2048
|
+
}
|
|
2049
|
+
return "error";
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
const notifySuccess = options.notifySuccess ?? true;
|
|
2053
|
+
|
|
2054
|
+
if (action === "returnOnly") {
|
|
2055
|
+
try {
|
|
2056
|
+
const result = await ctx.navigateTree(originId, { summarize: false });
|
|
2057
|
+
if (result.cancelled) {
|
|
2058
|
+
ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info");
|
|
2059
|
+
return "cancelled";
|
|
2060
|
+
}
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
ctx.ui.notify(`Failed to return: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2063
|
+
return "error";
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
clearReviewState(ctx);
|
|
2067
|
+
if (notifySuccess) {
|
|
2068
|
+
ctx.ui.notify("Review complete! Returned to original position.", "info");
|
|
2069
|
+
}
|
|
2070
|
+
return "ok";
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
const summaryResult = await navigateWithSummary(ctx, originId, options.showSummaryLoader ?? false);
|
|
2074
|
+
if (summaryResult === null) {
|
|
2075
|
+
ctx.ui.notify("Summarization cancelled. Use /end-review to try again.", "info");
|
|
2076
|
+
return "cancelled";
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
if (summaryResult.error) {
|
|
2080
|
+
ctx.ui.notify(`Summarization failed: ${summaryResult.error}`, "error");
|
|
2081
|
+
return "error";
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
if (summaryResult.cancelled) {
|
|
2085
|
+
ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info");
|
|
2086
|
+
return "cancelled";
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
clearReviewState(ctx);
|
|
2090
|
+
|
|
2091
|
+
if (action === "returnAndSummarize") {
|
|
2092
|
+
if (!ctx.ui.getEditorText().trim()) {
|
|
2093
|
+
ctx.ui.setEditorText("Act on the review findings");
|
|
2094
|
+
}
|
|
2095
|
+
if (notifySuccess) {
|
|
2096
|
+
ctx.ui.notify("Review complete! Returned and summarized.", "info");
|
|
2097
|
+
}
|
|
2098
|
+
return "ok";
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
pi.sendUserMessage(REVIEW_FIX_FINDINGS_PROMPT, { deliverAs: "followUp" });
|
|
2102
|
+
if (notifySuccess) {
|
|
2103
|
+
ctx.ui.notify("Review complete! Returned and queued a follow-up to fix findings.", "info");
|
|
2104
|
+
}
|
|
2105
|
+
return "ok";
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
async function runEndReview(ctx: ExtensionCommandContext): Promise<void> {
|
|
2109
|
+
if (!ctx.hasUI) {
|
|
2110
|
+
ctx.ui.notify("End-review requires interactive mode", "error");
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
if (reviewLoopInProgress) {
|
|
2115
|
+
ctx.ui.notify("Loop fixing review is running. Wait for it to finish.", "info");
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
if (endReviewInProgress) {
|
|
2120
|
+
ctx.ui.notify("/end-review is already running", "info");
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
endReviewInProgress = true;
|
|
2125
|
+
try {
|
|
2126
|
+
const choice = await ctx.ui.select("Finish review:", [
|
|
2127
|
+
"Return only",
|
|
2128
|
+
"Return and fix findings",
|
|
2129
|
+
"Return and summarize",
|
|
2130
|
+
]);
|
|
2131
|
+
|
|
2132
|
+
if (choice === undefined) {
|
|
2133
|
+
ctx.ui.notify("Cancelled. Use /end-review to try again.", "info");
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
const action: EndReviewAction =
|
|
2138
|
+
choice === "Return and fix findings"
|
|
2139
|
+
? "returnAndFix"
|
|
2140
|
+
: choice === "Return and summarize"
|
|
2141
|
+
? "returnAndSummarize"
|
|
2142
|
+
: "returnOnly";
|
|
2143
|
+
|
|
2144
|
+
await executeEndReviewAction(ctx, action, {
|
|
2145
|
+
showSummaryLoader: true,
|
|
2146
|
+
notifySuccess: true,
|
|
2147
|
+
});
|
|
2148
|
+
} finally {
|
|
2149
|
+
endReviewInProgress = false;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// Register the /end-review command
|
|
2154
|
+
pi.registerCommand("end-review", {
|
|
2155
|
+
description: "Complete review and return to original position",
|
|
2156
|
+
handler: async (_args, ctx) => {
|
|
2157
|
+
await runEndReview(ctx);
|
|
2158
|
+
},
|
|
2159
|
+
});
|
|
2160
|
+
}
|