tau-all-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/extensions/answer.ts +601 -0
- package/extensions/branch-term.ts +405 -0
- package/extensions/btw.ts +444 -0
- package/extensions/ghostty.ts +301 -0
- package/extensions/git-diff-stats.ts +277 -0
- package/extensions/git-pr-status.ts +286 -0
- package/extensions/insights.ts +2367 -0
- package/extensions/interlude.ts +144 -0
- package/extensions/loop.ts +529 -0
- package/extensions/memory.ts +1889 -0
- package/extensions/notify.ts +161 -0
- package/extensions/openai-fast.ts +227 -0
- package/extensions/openai-verbosity.ts +223 -0
- package/extensions/review.ts +4347 -0
- package/extensions/sandbox/index.ts +2578 -0
- package/extensions/telegram/README.md +81 -0
- package/extensions/telegram/daemon.mjs +1942 -0
- package/extensions/telegram/index.ts +1073 -0
- package/extensions/telegram/message-text.d.mts +1 -0
- package/extensions/telegram/message-text.mjs +16 -0
- package/extensions/usage/anthropic.ts +198 -0
- package/extensions/usage/github-copilot.ts +204 -0
- package/extensions/usage/google-gemini-cli.ts +232 -0
- package/extensions/usage/index.ts +2310 -0
- package/extensions/usage/minimax.ts +208 -0
- package/extensions/usage/openai-codex.ts +180 -0
- package/extensions/usage/openrouter.ts +168 -0
- package/extensions/usage/providers.ts +26 -0
- package/extensions/usage/shared.ts +156 -0
- package/extensions/usage/types.ts +53 -0
- package/extensions/usage/zai.ts +186 -0
- package/extensions/websearch/README.md +68 -0
- package/extensions/websearch/browser/chromium.ts +330 -0
- package/extensions/websearch/browser/discovery.ts +75 -0
- package/extensions/websearch/browser/firefox.ts +150 -0
- package/extensions/websearch/browser/sqlite.ts +38 -0
- package/extensions/websearch/config.ts +76 -0
- package/extensions/websearch/index.ts +312 -0
- package/extensions/websearch/normalize.ts +155 -0
- package/extensions/websearch/providers/anthropic.pi.ts +139 -0
- package/extensions/websearch/providers/gemini.browser.ts +185 -0
- package/extensions/websearch/providers/gemini.pi.ts +108 -0
- package/extensions/websearch/providers/openai-codex.browser.ts +77 -0
- package/extensions/websearch/providers/openai-codex.pi.ts +49 -0
- package/extensions/websearch/providers/openai-codex.shared.ts +123 -0
- package/extensions/websearch/providers/pi-model.shared.ts +86 -0
- package/extensions/websearch/providers/search-prompt.shared.ts +17 -0
- package/extensions/websearch/providers/shared.ts +76 -0
- package/extensions/websearch/types.ts +52 -0
- package/extensions/worktree.ts +2223 -0
- package/package.json +76 -0
- package/skills/browser-tools/SKILL.md +253 -0
- package/skills/browser-tools/scripts/browser-content.js +100 -0
- package/skills/browser-tools/scripts/browser-cookies.js +33 -0
- package/skills/browser-tools/scripts/browser-dismiss-cookies.js +455 -0
- package/skills/browser-tools/scripts/browser-eval.js +75 -0
- package/skills/browser-tools/scripts/browser-logs-tail.js +89 -0
- package/skills/browser-tools/scripts/browser-nav.js +28 -0
- package/skills/browser-tools/scripts/browser-net-summary.js +115 -0
- package/skills/browser-tools/scripts/browser-pick.js +133 -0
- package/skills/browser-tools/scripts/browser-screenshot.js +18 -0
- package/skills/browser-tools/scripts/browser-start.js +281 -0
- package/skills/browser-tools/scripts/browser-watch.js +321 -0
- package/skills/browser-tools/scripts/utils.js +60 -0
- package/skills/git-clean-history/SKILL.md +45 -0
- package/skills/git-commit/SKILL.md +55 -0
- package/skills/homeassistant-ops/SKILL.md +184 -0
- package/skills/homeassistant-ops/references/api.md +64 -0
- package/skills/homeassistant-ops/references/id_conventions.md +71 -0
- package/skills/homeassistant-ops/references/playbook.md +150 -0
- package/skills/homeassistant-ops/scripts/ha_ops.js +2942 -0
- package/skills/openscad/SKILL.md +149 -0
- package/skills/openscad/examples/parametric_box.scad +92 -0
- package/skills/openscad/examples/phone_stand.scad +95 -0
- package/skills/openscad/scripts/common.sh +49 -0
- package/skills/openscad/scripts/export-stl.sh +56 -0
- package/skills/openscad/scripts/extract-params.sh +147 -0
- package/skills/openscad/scripts/multi-preview.sh +68 -0
- package/skills/openscad/scripts/preview.sh +74 -0
- package/skills/openscad/scripts/render-with-params.sh +91 -0
- package/skills/openscad/scripts/validate.sh +46 -0
- package/skills/oracle/SKILL.md +64 -0
- package/skills/oracle/scripts/oracle +249 -0
- package/skills/oracle/scripts/oracle-bundle +227 -0
- package/skills/sentry/SKILL.md +198 -0
- package/skills/sentry/lib/auth.js +99 -0
- package/skills/sentry/scripts/fetch-event.js +325 -0
- package/skills/sentry/scripts/fetch-issue.js +368 -0
- package/skills/sentry/scripts/list-issues.js +245 -0
- package/skills/sentry/scripts/search-events.js +291 -0
- package/skills/sentry/scripts/search-logs.js +234 -0
- package/skills/update-changelog/SKILL.md +135 -0
- package/skills/web-design/SKILL.md +117 -0
|
@@ -0,0 +1,4347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified /review + /triage + /fix extension.
|
|
3
|
+
*
|
|
4
|
+
* What this extension does:
|
|
5
|
+
* - /review runs multiple parallel focuses (general, reuse, quality, efficiency),
|
|
6
|
+
* then emits a single findings report.
|
|
7
|
+
* - /triage fetches PR feedback from GitHub, inspects the checked-out PR diff,
|
|
8
|
+
* and classifies each item as address, push_back, research, or ignore.
|
|
9
|
+
* - /fix applies findings from the review report. It reuses the last message only
|
|
10
|
+
* when that message is a matching review payload, even if stale. Otherwise it
|
|
11
|
+
* runs a fresh review first. If that fresh review becomes stale while running,
|
|
12
|
+
* /fix shows the findings and waits for an explicit rerun before applying fixes.
|
|
13
|
+
*
|
|
14
|
+
* Key behavior:
|
|
15
|
+
* - /review is findings-only (no direct edits).
|
|
16
|
+
* - /triage is feedback triage only (no direct edits).
|
|
17
|
+
* - Focus outputs are strict JSON; final review output is human-readable markdown plus
|
|
18
|
+
* a typed custom message payload (customType: "review").
|
|
19
|
+
* - /fix staleness checks use a repo fingerprint: HEAD SHA + branch + tracked diff hash + untracked content hash.
|
|
20
|
+
*
|
|
21
|
+
* Commands:
|
|
22
|
+
* - /review [mode] [models=...] [context=...]
|
|
23
|
+
* - /triage <number|url>
|
|
24
|
+
* - /fix [mode] [models=...] [context=...]
|
|
25
|
+
*
|
|
26
|
+
* Modes supported by both commands:
|
|
27
|
+
* - auto (default)
|
|
28
|
+
* - uncommitted
|
|
29
|
+
* - branch <name>
|
|
30
|
+
* - commit <sha>
|
|
31
|
+
* - pr <number|url>
|
|
32
|
+
* - folder <paths...>
|
|
33
|
+
* - custom "<instructions>"
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type {
|
|
37
|
+
ExtensionAPI,
|
|
38
|
+
ExtensionCommandContext,
|
|
39
|
+
ExtensionContext,
|
|
40
|
+
} from "@mariozechner/pi-coding-agent";
|
|
41
|
+
import { supportsXhigh, type Api, type Model } from "@mariozechner/pi-ai";
|
|
42
|
+
import { matchesKey } from "@mariozechner/pi-tui";
|
|
43
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
44
|
+
import { createHash } from "node:crypto";
|
|
45
|
+
import path from "node:path";
|
|
46
|
+
import { promises as fs } from "node:fs";
|
|
47
|
+
|
|
48
|
+
// --- Constants ---
|
|
49
|
+
|
|
50
|
+
const REVIEW_FOCUS_TOOLS = "read,bash,grep,find,ls";
|
|
51
|
+
const REVIEW_TASK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
52
|
+
const REVIEW_STARTUP_RETRY_DELAYS_MS = [500, 1000, 2000, 4000, 8000] as const;
|
|
53
|
+
const REVIEW_STARTUP_RETRY_JITTER_RATIO = 0.2;
|
|
54
|
+
const REVIEW_UNTRACKED_HASH_DISABLED = "__disabled__";
|
|
55
|
+
const REVIEW_CANCELLED_ERROR = "Review aborted";
|
|
56
|
+
const REVIEW_STALE_SECTION_TITLE = "Repository changed";
|
|
57
|
+
const REVIEW_STALE_REVIEW_WARNING = "Repository changed while this review was running.";
|
|
58
|
+
const REVIEW_STALE_REVIEW_NEXT_STEP =
|
|
59
|
+
"Results are shown anyway. Run /review again to refresh them.";
|
|
60
|
+
const REVIEW_STALE_FIX_WARNING = "Repository changed while /fix was gathering findings.";
|
|
61
|
+
const REVIEW_STALE_FIX_NEXT_STEP =
|
|
62
|
+
"No fixes were applied. Run /fix again to continue, or /review first to refresh.";
|
|
63
|
+
const REVIEW_STALE_PAYLOAD_WARNING = "Repository changed since this review was generated.";
|
|
64
|
+
const REVIEW_STALE_PAYLOAD_NEXT_STEP = "Run /review first to refresh it.";
|
|
65
|
+
const REVIEW_STALE_REUSE_WARNING =
|
|
66
|
+
"Last review is stale. Continuing with /fix. Run /review first to refresh it.";
|
|
67
|
+
const REVIEW_EVENT_START = "review:start";
|
|
68
|
+
const REVIEW_EVENT_END = "review:end";
|
|
69
|
+
const REVIEW_MODE_HINTS = [
|
|
70
|
+
"help",
|
|
71
|
+
"auto",
|
|
72
|
+
"uncommitted",
|
|
73
|
+
"branch",
|
|
74
|
+
"commit",
|
|
75
|
+
"pr",
|
|
76
|
+
"folder",
|
|
77
|
+
"custom",
|
|
78
|
+
] as const;
|
|
79
|
+
|
|
80
|
+
const STATUS_KEY = "0-review";
|
|
81
|
+
const STATUS_SPINNER_INTERVAL_MS = 80;
|
|
82
|
+
const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
83
|
+
|
|
84
|
+
const OPENAI_FAST_MODEL_ID = "gpt-5.3-codex-spark";
|
|
85
|
+
const ANTHROPIC_FAST_MODEL_ID = "claude-haiku-4-5";
|
|
86
|
+
|
|
87
|
+
const REVIEW_RUBRIC_PROMPT = `# Review Guidelines
|
|
88
|
+
|
|
89
|
+
You are acting as a code reviewer for a proposed code change made by another engineer.
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
## Determining what to flag
|
|
94
|
+
|
|
95
|
+
Flag issues that:
|
|
96
|
+
1. Meaningfully impact the accuracy, performance, security, or maintainability of the code.
|
|
97
|
+
2. Are discrete and actionable (not general issues or multiple combined issues).
|
|
98
|
+
3. Don't demand rigor inconsistent with the rest of the codebase.
|
|
99
|
+
4. Were introduced in the changes being reviewed (not pre-existing bugs).
|
|
100
|
+
5. The author would likely fix if aware of them.
|
|
101
|
+
6. Don't rely on unstated assumptions about the codebase or author's intent.
|
|
102
|
+
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.
|
|
103
|
+
8. Are clearly not intentional changes by the author.
|
|
104
|
+
9. Be particularly careful with untrusted user input and follow the specific guidelines to review.
|
|
105
|
+
10. Call out newly added dependencies explicitly and explain why they're needed.
|
|
106
|
+
11. Apply system-level thinking; flag changes that increase operational risk or on-call burden.
|
|
107
|
+
|
|
108
|
+
## Untrusted User Input
|
|
109
|
+
|
|
110
|
+
1. Be careful with open redirects, they must always be checked to only go to trusted domains (?next_page=...)
|
|
111
|
+
2. Always flag SQL that is not parametrized
|
|
112
|
+
3. In systems with user supplied URL input, http fetches always need to be protected against access to local resources (intercept DNS resolver!)
|
|
113
|
+
4. Escape, don't sanitize if you have the option (eg: HTML escaping)
|
|
114
|
+
|
|
115
|
+
## Comment guidelines
|
|
116
|
+
|
|
117
|
+
1. Be clear about why the issue is a problem.
|
|
118
|
+
2. Communicate severity appropriately - don't exaggerate.
|
|
119
|
+
3. Be brief - at most 1 paragraph.
|
|
120
|
+
4. Keep code snippets under 3 lines, wrapped in inline code or code blocks.
|
|
121
|
+
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.
|
|
122
|
+
6. Explicitly state scenarios/environments where the issue arises.
|
|
123
|
+
7. Use a matter-of-fact tone - helpful AI assistant, not accusatory.
|
|
124
|
+
8. Write for quick comprehension without close reading.
|
|
125
|
+
9. Avoid excessive flattery or unhelpful phrases like "Great job...".
|
|
126
|
+
|
|
127
|
+
## Priority levels
|
|
128
|
+
|
|
129
|
+
Tag each finding with a priority level in the title:
|
|
130
|
+
- [P0] - Drop everything to fix. Blocking release/operations. Only for universal issues that do not depend on assumptions about inputs.
|
|
131
|
+
- [P1] - Urgent. Should be addressed in the next cycle.
|
|
132
|
+
- [P2] - Normal. To be fixed eventually.
|
|
133
|
+
- [P3] - Low. Nice to have.`;
|
|
134
|
+
|
|
135
|
+
const REVIEW_FOCUSES: Record<FocusName, FocusDefinition> = {
|
|
136
|
+
general: {
|
|
137
|
+
suffix: "",
|
|
138
|
+
qualifier: "",
|
|
139
|
+
context: REVIEW_RUBRIC_PROMPT,
|
|
140
|
+
},
|
|
141
|
+
reuse: {
|
|
142
|
+
suffix: " specializing in reuse analysis",
|
|
143
|
+
qualifier: " reuse",
|
|
144
|
+
context: `For each change:
|
|
145
|
+
1. Search for existing utilities and helpers that could replace newly written code. Start with ripgrep-style searches (use the grep tool first), then inspect utility directories, shared modules, and adjacent files.
|
|
146
|
+
2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.
|
|
147
|
+
3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.`,
|
|
148
|
+
},
|
|
149
|
+
quality: {
|
|
150
|
+
suffix: " specializing in quality analysis",
|
|
151
|
+
qualifier: " quality",
|
|
152
|
+
context: `Review the changes for:
|
|
153
|
+
1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls.
|
|
154
|
+
2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones.
|
|
155
|
+
3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction.
|
|
156
|
+
4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries.
|
|
157
|
+
5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase.
|
|
158
|
+
6. Simplicity: prefer simple, direct solutions over wrappers or abstractions without clear reuse value.
|
|
159
|
+
7. Fail-fast: favor explicit failures over logging-and-continue patterns that hide errors. Prefer predictable failure modes over silent degradation.
|
|
160
|
+
8. Error classification: ensure errors are checked against codes or stable identifiers, never error message strings.`,
|
|
161
|
+
},
|
|
162
|
+
efficiency: {
|
|
163
|
+
suffix: " specializing in efficiency analysis",
|
|
164
|
+
qualifier: " efficiency",
|
|
165
|
+
context: `Review the changes for:
|
|
166
|
+
1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns.
|
|
167
|
+
2. Missed concurrency: independent operations run sequentially when they could run in parallel.
|
|
168
|
+
3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths.
|
|
169
|
+
4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error.
|
|
170
|
+
5. Memory: unbounded data structures, missing cleanup, event listener leaks.
|
|
171
|
+
6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one.
|
|
172
|
+
7. Backpressure: treat backpressure handling as critical to system stability; flag unbounded queues, missing flow control, or producer-consumer imbalances.`,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const REVIEW_ADDITIONAL_CONTEXT_SECTION_PROMPT = `Additional context from user:
|
|
177
|
+
{ADDITIONAL_CONTEXT}
|
|
178
|
+
`;
|
|
179
|
+
|
|
180
|
+
const REVIEW_PROJECT_GUIDELINES_SECTION_PROMPT = `Project-specific review guidelines:
|
|
181
|
+
{PROJECT_GUIDELINES}
|
|
182
|
+
`;
|
|
183
|
+
|
|
184
|
+
const REVIEW_JSON_OUTPUT_CONTRACT_PROMPT = `Output requirements:
|
|
185
|
+
- Return valid JSON only (no markdown, no prose outside JSON).
|
|
186
|
+
- Do not wrap output in code fences.
|
|
187
|
+
- Use this exact shape:
|
|
188
|
+
{
|
|
189
|
+
"findings": [
|
|
190
|
+
{
|
|
191
|
+
"priority": "P0|P1|P2|P3",
|
|
192
|
+
"location": "path/to/file:line or path/to/file",
|
|
193
|
+
"finding": "what is wrong and why it matters",
|
|
194
|
+
"suggestion": "actionable suggestion"
|
|
195
|
+
}
|
|
196
|
+
],
|
|
197
|
+
"note": "optional"
|
|
198
|
+
}
|
|
199
|
+
- If no issues are found, return findings: [].
|
|
200
|
+
- If uncertain, return findings: [] with a note instead of prose.
|
|
201
|
+
- Before sending, self-check that JSON.parse(output) would succeed.`;
|
|
202
|
+
|
|
203
|
+
const REVIEW_FOCUS_PROMPT = `You are an expert code reviewer{FOCUS_SUFFIX}.
|
|
204
|
+
|
|
205
|
+
Objective:
|
|
206
|
+
- Find concrete, high-confidence{FOCUS_QUALIFIER} issues introduced by the scoped changes.
|
|
207
|
+
- Output every finding the author would fix if they were made aware of it. Do not stop at the first qualifying finding — continue until you have listed every qualifying finding.
|
|
208
|
+
- Do not flag issues the author would not fix. If there is no finding that a person would definitely want to see and fix, prefer outputting no findings.
|
|
209
|
+
|
|
210
|
+
{SCOPE_INSTRUCTIONS}
|
|
211
|
+
|
|
212
|
+
{FOCUS_CONTEXT}
|
|
213
|
+
|
|
214
|
+
Important:
|
|
215
|
+
- Focus only on issues introduced in the reviewed scope.
|
|
216
|
+
- Keep each finding independent, discrete, and actionable.
|
|
217
|
+
- Assign each finding a priority P0..P3.
|
|
218
|
+
- This is a read-only review focus. Do not modify files or repository state; do not run mutating commands.
|
|
219
|
+
|
|
220
|
+
{ADDITIONAL_CONTEXT_SECTION}{PROJECT_GUIDELINES_SECTION}
|
|
221
|
+
{OUTPUT_CONTRACT}`;
|
|
222
|
+
|
|
223
|
+
const FIX_PROMPT = `You are an expert software engineer applying fixes and improvements from a completed code review.
|
|
224
|
+
|
|
225
|
+
Use ONLY the findings in the review payload below as your worklist.
|
|
226
|
+
|
|
227
|
+
You are the decision-maker: if a finding is invalid, duplicate, too risky, or clearly not worth fixing, skip it with a brief reason and continue.
|
|
228
|
+
|
|
229
|
+
Process:
|
|
230
|
+
|
|
231
|
+
1) Work findings one by one in priority order: P0, P1, P2, P3.
|
|
232
|
+
2) For each finding:
|
|
233
|
+
- Validate against current code.
|
|
234
|
+
- If valid and worthwhile, implement the minimal correct fix.
|
|
235
|
+
- If not, skip with a short reason.
|
|
236
|
+
3) Run relevant verification for touched code (targeted tests/checks preferred; avoid unnecessary full-suite runs).
|
|
237
|
+
4) Keep changes focused; avoid unrelated refactors.
|
|
238
|
+
5) Do not stop at first fix; continue through the whole list.
|
|
239
|
+
|
|
240
|
+
Output formatting requirements:
|
|
241
|
+
|
|
242
|
+
- In Verification, prefer plain text. If you cite executed commands, append them after a semicolon and wrap only the command snippet in inline backticks.
|
|
243
|
+
- In Notes, use plain prose. Use inline backticks sparingly when they improve clarity, such as for exact identifiers, paths, or command snippets.
|
|
244
|
+
- Do not use code fences.
|
|
245
|
+
- Do not include the pipe character in any cell text (including inside backticks). Avoid regex alternation patterns like (a|b); rewrite checks without pipes and separate multiple checks with semicolons.
|
|
246
|
+
- Decision values must be exactly fixed or skipped.
|
|
247
|
+
|
|
248
|
+
Review findings:
|
|
249
|
+
|
|
250
|
+
{REVIEW_FINDINGS_JSON}
|
|
251
|
+
|
|
252
|
+
At the end, output only this table (no section headings, no summary):
|
|
253
|
+
|
|
254
|
+
| # | Location | Finding | Decision | Verification | Notes |
|
|
255
|
+
|---|---|---|---|---|---|`;
|
|
256
|
+
|
|
257
|
+
const TRIAGE_PROMPT = `You are an expert code reviewer triaging pull request feedback.
|
|
258
|
+
|
|
259
|
+
Your job is to classify each feedback item into exactly one of these decisions:
|
|
260
|
+
- address: the feedback is correct or worthwhile enough to handle in this PR.
|
|
261
|
+
- push_back: the feedback seems incorrect, inapplicable, or not worth changing.
|
|
262
|
+
- research: you cannot decide yet without external docs, repo conventions, or further verification.
|
|
263
|
+
- ignore: the item is non-actionable noise, already-resolved chatter, or has no remaining ask.
|
|
264
|
+
|
|
265
|
+
Process:
|
|
266
|
+
1) Review the scoped PR diff and relevant files before deciding.
|
|
267
|
+
2) Use the diff command in the scope instructions as mandatory context.
|
|
268
|
+
3) Triage every feedback item exactly once. Do not omit any id.
|
|
269
|
+
4) If a review thread contains back-and-forth, focus on the latest remaining ask.
|
|
270
|
+
5) Resolved or outdated threads often become ignore, but verify before deciding.
|
|
271
|
+
6) This is a read-only triage. Do not modify files or repository state; do not run mutating commands.
|
|
272
|
+
|
|
273
|
+
{SCOPE_INSTRUCTIONS}
|
|
274
|
+
|
|
275
|
+
{PROJECT_GUIDELINES_SECTION}
|
|
276
|
+
|
|
277
|
+
PR feedback payload (authoritative JSON):
|
|
278
|
+
{TRIAGE_INPUT_JSON}
|
|
279
|
+
|
|
280
|
+
Output JSON only, with this exact shape:
|
|
281
|
+
{
|
|
282
|
+
"items": [
|
|
283
|
+
{
|
|
284
|
+
"id": "feedback-1",
|
|
285
|
+
"decision": "address",
|
|
286
|
+
"summary": "brief description of the feedback",
|
|
287
|
+
"rationale": "why this decision is correct",
|
|
288
|
+
"action": "what to do next"
|
|
289
|
+
}
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
Requirements:
|
|
294
|
+
- Return exactly one item per input feedback id.
|
|
295
|
+
- Keep summary, rationale, and action concise and specific.
|
|
296
|
+
- Before sending, self-check that JSON.parse(output) would succeed.`;
|
|
297
|
+
|
|
298
|
+
const REVIEW_DEDUP_PROMPT = `You are identifying duplicate findings from multiple independent code review passes.
|
|
299
|
+
|
|
300
|
+
This is a pure deduplication step. Do not inspect the repository, do not use tools, and do not rewrite findings.
|
|
301
|
+
|
|
302
|
+
Input findings JSON (authoritative):
|
|
303
|
+
{REVIEW_FINDINGS_JSON}
|
|
304
|
+
|
|
305
|
+
Output JSON only, with this exact shape:
|
|
306
|
+
{
|
|
307
|
+
"groups": [
|
|
308
|
+
{
|
|
309
|
+
"ids": [1, 2],
|
|
310
|
+
"reason": "same underlying issue"
|
|
311
|
+
}
|
|
312
|
+
]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
Requirements:
|
|
316
|
+
- Only group findings that are truly duplicates: same underlying issue and materially the same fix.
|
|
317
|
+
- Treat wording differences, overlapping line ranges in the same file, and different reviewer terminology as duplicates when the root cause is the same.
|
|
318
|
+
- Do not group related but distinct issues with different root causes, impacts, or fixes.
|
|
319
|
+
- ids must reference input finding ids.
|
|
320
|
+
- Do not include singleton groups. Every group must contain at least two ids.
|
|
321
|
+
- Each finding id may appear in at most one group total.
|
|
322
|
+
- Input findings are already ordered by review priority. The host will keep the lowest id in each group.
|
|
323
|
+
- Keep reason very short.
|
|
324
|
+
- If there are no duplicates, return { "groups": [] }.
|
|
325
|
+
- Before sending, self-check that JSON.parse(output) would succeed.`;
|
|
326
|
+
|
|
327
|
+
const TRIAGE_METADATA_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
328
|
+
repository(owner: $owner, name: $name) {
|
|
329
|
+
pullRequest(number: $number) {
|
|
330
|
+
number
|
|
331
|
+
url
|
|
332
|
+
title
|
|
333
|
+
body
|
|
334
|
+
baseRefName
|
|
335
|
+
headRefName
|
|
336
|
+
author {
|
|
337
|
+
login
|
|
338
|
+
}
|
|
339
|
+
comments(first: 100) {
|
|
340
|
+
nodes {
|
|
341
|
+
id
|
|
342
|
+
body
|
|
343
|
+
url
|
|
344
|
+
createdAt
|
|
345
|
+
author {
|
|
346
|
+
login
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
reviews(first: 100) {
|
|
351
|
+
nodes {
|
|
352
|
+
id
|
|
353
|
+
body
|
|
354
|
+
state
|
|
355
|
+
url
|
|
356
|
+
submittedAt
|
|
357
|
+
author {
|
|
358
|
+
login
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}`;
|
|
365
|
+
|
|
366
|
+
const TRIAGE_THREADS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $endCursor: String) {
|
|
367
|
+
repository(owner: $owner, name: $name) {
|
|
368
|
+
pullRequest(number: $number) {
|
|
369
|
+
reviewThreads(first: 100, after: $endCursor) {
|
|
370
|
+
nodes {
|
|
371
|
+
id
|
|
372
|
+
isResolved
|
|
373
|
+
isOutdated
|
|
374
|
+
path
|
|
375
|
+
line
|
|
376
|
+
originalLine
|
|
377
|
+
startLine
|
|
378
|
+
originalStartLine
|
|
379
|
+
comments(first: 100) {
|
|
380
|
+
nodes {
|
|
381
|
+
id
|
|
382
|
+
body
|
|
383
|
+
url
|
|
384
|
+
createdAt
|
|
385
|
+
author {
|
|
386
|
+
login
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
pageInfo {
|
|
392
|
+
hasNextPage
|
|
393
|
+
endCursor
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}`;
|
|
399
|
+
|
|
400
|
+
const REVIEW_FOCUS_NAMES = Object.keys(REVIEW_FOCUSES) as FocusName[];
|
|
401
|
+
|
|
402
|
+
// --- Types ---
|
|
403
|
+
|
|
404
|
+
type Priority = "P0" | "P1" | "P2" | "P3";
|
|
405
|
+
type FocusName = "general" | "reuse" | "quality" | "efficiency";
|
|
406
|
+
type FocusDefinition = { suffix: string; qualifier: string; context: string };
|
|
407
|
+
type ReviewRunSource = "review" | "fix" | "triage";
|
|
408
|
+
type ReviewRunOutcome = "success" | "failed" | "cancelled";
|
|
409
|
+
type ModelFamily = "openai" | "anthropic";
|
|
410
|
+
|
|
411
|
+
type ReviewTarget =
|
|
412
|
+
| { type: "auto" }
|
|
413
|
+
| { type: "uncommitted" }
|
|
414
|
+
| { type: "branch"; branch: string }
|
|
415
|
+
| { type: "commit"; sha: string }
|
|
416
|
+
| { type: "pr"; ref: string }
|
|
417
|
+
| { type: "folder"; paths: string[] }
|
|
418
|
+
| { type: "custom"; instructions: string };
|
|
419
|
+
|
|
420
|
+
type ReviewRequestMode =
|
|
421
|
+
| "auto"
|
|
422
|
+
| "uncommitted"
|
|
423
|
+
| `branch:${string}`
|
|
424
|
+
| `commit:${string}`
|
|
425
|
+
| `pr:${string}`
|
|
426
|
+
| `folder:${string}`
|
|
427
|
+
| "custom";
|
|
428
|
+
|
|
429
|
+
type ParsedRequest = {
|
|
430
|
+
target: ReviewTarget;
|
|
431
|
+
mode: ReviewRequestMode;
|
|
432
|
+
models: string[];
|
|
433
|
+
rawArgs: string;
|
|
434
|
+
additionalContext?: string;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
type ReviewFingerprint = {
|
|
438
|
+
headSha: string;
|
|
439
|
+
branch: string;
|
|
440
|
+
trackedDiffHash: string;
|
|
441
|
+
untrackedHash: string;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
type FocusFinding = {
|
|
445
|
+
priority: Priority;
|
|
446
|
+
location: string;
|
|
447
|
+
finding: string;
|
|
448
|
+
suggestion: string;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
type ReviewThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
452
|
+
type ReviewThinkingSource = "explicit" | "inherited";
|
|
453
|
+
|
|
454
|
+
type FocusOutput = {
|
|
455
|
+
focus: FocusName;
|
|
456
|
+
model: string;
|
|
457
|
+
findings: FocusFinding[];
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
type ResolvedReviewProviderCandidate = {
|
|
461
|
+
modelArg?: string;
|
|
462
|
+
baseModelArg?: string;
|
|
463
|
+
supportsThinking?: boolean;
|
|
464
|
+
supportsXhigh?: boolean;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
type ResolvedReviewModel = {
|
|
468
|
+
modelPattern: string;
|
|
469
|
+
modelPatternBase: string;
|
|
470
|
+
thinkingSource: ReviewThinkingSource;
|
|
471
|
+
requestedThinkingLevel?: ReviewThinkingLevel;
|
|
472
|
+
providerCandidates: ResolvedReviewProviderCandidate[];
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
type FocusTask = {
|
|
476
|
+
model: ResolvedReviewModel;
|
|
477
|
+
focus: FocusName;
|
|
478
|
+
prompt: string;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
type FocusTaskAttempt = FocusTask & {
|
|
482
|
+
providerCandidate: ResolvedReviewProviderCandidate;
|
|
483
|
+
currentThinkingLevel?: ReviewThinkingLevel;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
type FocusTaskErrorKind =
|
|
487
|
+
| "lock_contention"
|
|
488
|
+
| "missing_api_key"
|
|
489
|
+
| "rate_limit"
|
|
490
|
+
| "unsupported_model"
|
|
491
|
+
| "unsupported_reasoning"
|
|
492
|
+
| "other";
|
|
493
|
+
|
|
494
|
+
type FocusTaskResult = {
|
|
495
|
+
focus: FocusName;
|
|
496
|
+
model: string;
|
|
497
|
+
ok: boolean;
|
|
498
|
+
output?: FocusOutput;
|
|
499
|
+
error?: string;
|
|
500
|
+
errorKind?: FocusTaskErrorKind;
|
|
501
|
+
missingApiProvider?: string;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
type ReviewReportFinding = {
|
|
505
|
+
priority: Priority;
|
|
506
|
+
location: string;
|
|
507
|
+
finding: string;
|
|
508
|
+
suggestion: string;
|
|
509
|
+
focus: string;
|
|
510
|
+
model: string;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
type ReviewDedupGroup = {
|
|
514
|
+
ids: number[];
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
type ReviewStaleness = {
|
|
518
|
+
status: "stale";
|
|
519
|
+
warning: string;
|
|
520
|
+
nextStep: string;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
type ReviewMessageKind = "report" | "failure" | "triage" | "help";
|
|
524
|
+
|
|
525
|
+
type ReviewMessageDetails = {
|
|
526
|
+
kind?: "report";
|
|
527
|
+
request: {
|
|
528
|
+
mode: ReviewRequestMode;
|
|
529
|
+
signature: string;
|
|
530
|
+
};
|
|
531
|
+
scope: {
|
|
532
|
+
mode: ResolvedScope["kind"];
|
|
533
|
+
description: string;
|
|
534
|
+
};
|
|
535
|
+
fingerprint: ReviewFingerprint;
|
|
536
|
+
staleness?: ReviewStaleness;
|
|
537
|
+
focusStatus: Array<{
|
|
538
|
+
focus: FocusName;
|
|
539
|
+
model: string;
|
|
540
|
+
ok: boolean;
|
|
541
|
+
error?: string;
|
|
542
|
+
}>;
|
|
543
|
+
findings: ReviewReportFinding[];
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
type ReviewRunResult = { ok: false; error: string } | { ok: true; details: ReviewMessageDetails };
|
|
547
|
+
|
|
548
|
+
type ResolvedScope =
|
|
549
|
+
| {
|
|
550
|
+
kind: "working-tree";
|
|
551
|
+
trackedFiles: string[];
|
|
552
|
+
untrackedFiles: string[];
|
|
553
|
+
hasHead: boolean;
|
|
554
|
+
description: string;
|
|
555
|
+
}
|
|
556
|
+
| {
|
|
557
|
+
kind: "branch-diff";
|
|
558
|
+
baseBranch: string;
|
|
559
|
+
mergeBase: string;
|
|
560
|
+
diffFiles: string[];
|
|
561
|
+
description: string;
|
|
562
|
+
}
|
|
563
|
+
| {
|
|
564
|
+
kind: "commit";
|
|
565
|
+
sha: string;
|
|
566
|
+
description: string;
|
|
567
|
+
}
|
|
568
|
+
| {
|
|
569
|
+
kind: "folder";
|
|
570
|
+
paths: string[];
|
|
571
|
+
description: string;
|
|
572
|
+
}
|
|
573
|
+
| {
|
|
574
|
+
kind: "custom";
|
|
575
|
+
instructions: string;
|
|
576
|
+
description: string;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
type ReviewExecutionControl = {
|
|
580
|
+
isCancelled: () => boolean;
|
|
581
|
+
registerProcess: (proc: ChildProcess) => () => void;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
type ManagedReviewRun = {
|
|
585
|
+
control: ReviewExecutionControl;
|
|
586
|
+
markSuccessful: () => void;
|
|
587
|
+
markCancelled: () => void;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
type PiJsonTaskStatus =
|
|
591
|
+
| "ok"
|
|
592
|
+
| "cancelled"
|
|
593
|
+
| "timeout"
|
|
594
|
+
| "spawn_error"
|
|
595
|
+
| "non_zero_exit"
|
|
596
|
+
| "assistant_error";
|
|
597
|
+
|
|
598
|
+
type PiJsonTaskResult = {
|
|
599
|
+
status: PiJsonTaskStatus;
|
|
600
|
+
assistantOutput: string;
|
|
601
|
+
stderr: string;
|
|
602
|
+
exitCode?: number;
|
|
603
|
+
error?: string;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
type PiJsonTaskOptions = {
|
|
607
|
+
args: string[];
|
|
608
|
+
prompt: string;
|
|
609
|
+
cwd: string;
|
|
610
|
+
timeoutMs: number;
|
|
611
|
+
control?: ReviewExecutionControl;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
type PreparedReviewRun = {
|
|
615
|
+
scope: ResolvedScope;
|
|
616
|
+
includeUntracked: boolean;
|
|
617
|
+
baselineFingerprint: ReviewFingerprint;
|
|
618
|
+
models: ResolvedReviewModel[];
|
|
619
|
+
tasks: FocusTask[];
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
type TriageFeedbackKind = "review-thread" | "review-summary" | "pr-comment";
|
|
623
|
+
type TriageDecision = "address" | "push_back" | "research" | "ignore";
|
|
624
|
+
|
|
625
|
+
type TriageFeedbackComment = {
|
|
626
|
+
author: string;
|
|
627
|
+
body: string;
|
|
628
|
+
url?: string;
|
|
629
|
+
createdAt?: string;
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
type TriageFeedbackItem = {
|
|
633
|
+
id: string;
|
|
634
|
+
kind: TriageFeedbackKind;
|
|
635
|
+
author: string;
|
|
636
|
+
location: string;
|
|
637
|
+
url?: string;
|
|
638
|
+
body?: string;
|
|
639
|
+
state?: string;
|
|
640
|
+
isResolved?: boolean;
|
|
641
|
+
isOutdated?: boolean;
|
|
642
|
+
comments?: TriageFeedbackComment[];
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
type TriageItem = {
|
|
646
|
+
id: string;
|
|
647
|
+
decision: TriageDecision;
|
|
648
|
+
summary: string;
|
|
649
|
+
rationale: string;
|
|
650
|
+
action: string;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
type TriageMessageItem = TriageItem & {
|
|
654
|
+
feedbackKind: TriageFeedbackKind;
|
|
655
|
+
location: string;
|
|
656
|
+
author: string;
|
|
657
|
+
url?: string;
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
type TriageMessageDetails = {
|
|
661
|
+
kind: "triage";
|
|
662
|
+
generatedAt: string;
|
|
663
|
+
pr: {
|
|
664
|
+
number: number;
|
|
665
|
+
url: string;
|
|
666
|
+
title: string;
|
|
667
|
+
baseBranch: string;
|
|
668
|
+
headBranch: string;
|
|
669
|
+
ref: string;
|
|
670
|
+
};
|
|
671
|
+
scope: {
|
|
672
|
+
mode: ResolvedScope["kind"];
|
|
673
|
+
description: string;
|
|
674
|
+
};
|
|
675
|
+
feedbackCount: number;
|
|
676
|
+
items: TriageMessageItem[];
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
type TriageRunResult = { ok: false; error: string } | { ok: true; details: TriageMessageDetails };
|
|
680
|
+
|
|
681
|
+
type TriagePrContext = {
|
|
682
|
+
prNumber: number;
|
|
683
|
+
prRef: string;
|
|
684
|
+
prUrl: string;
|
|
685
|
+
title: string;
|
|
686
|
+
body: string;
|
|
687
|
+
baseBranch: string;
|
|
688
|
+
headBranch: string;
|
|
689
|
+
author: string;
|
|
690
|
+
scope: Extract<ResolvedScope, { kind: "branch-diff" }>;
|
|
691
|
+
baselineFingerprint: ReviewFingerprint;
|
|
692
|
+
feedbackItems: TriageFeedbackItem[];
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
// --- Helpers ---
|
|
696
|
+
|
|
697
|
+
const runtimeState = {
|
|
698
|
+
activeReviewRuns: new Set<string>(),
|
|
699
|
+
activeReviewCancels: new Map<string, () => void>(),
|
|
700
|
+
activePromptCount: 0,
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const providerCandidateAvailabilityCache = new WeakMap<
|
|
704
|
+
ResolvedReviewModel,
|
|
705
|
+
Map<string, "supported" | "unsupported">
|
|
706
|
+
>();
|
|
707
|
+
const providerCandidateProbeCache = new WeakMap<
|
|
708
|
+
ResolvedReviewModel,
|
|
709
|
+
Map<string, Promise<"supported" | "unsupported">>
|
|
710
|
+
>();
|
|
711
|
+
|
|
712
|
+
async function withManagedReviewRun<T>(
|
|
713
|
+
pi: ExtensionAPI,
|
|
714
|
+
ctx: ExtensionCommandContext,
|
|
715
|
+
source: ReviewRunSource,
|
|
716
|
+
run: (managed: ManagedReviewRun) => Promise<T>,
|
|
717
|
+
): Promise<T> {
|
|
718
|
+
const sessionKey = getReviewSessionKey(ctx);
|
|
719
|
+
const activeProcesses = new Set<ChildProcess>();
|
|
720
|
+
let cancelRequested = false;
|
|
721
|
+
let outcome: ReviewRunOutcome = "failed";
|
|
722
|
+
|
|
723
|
+
const cancelActiveProcesses = () => {
|
|
724
|
+
for (const proc of activeProcesses) {
|
|
725
|
+
try {
|
|
726
|
+
proc.kill("SIGKILL");
|
|
727
|
+
} catch {
|
|
728
|
+
// Best effort.
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
activeProcesses.clear();
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
const requestCancellation = () => {
|
|
735
|
+
if (cancelRequested) return;
|
|
736
|
+
cancelRequested = true;
|
|
737
|
+
cancelActiveProcesses();
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const control: ReviewExecutionControl = {
|
|
741
|
+
isCancelled: () => cancelRequested,
|
|
742
|
+
registerProcess: (proc) => {
|
|
743
|
+
activeProcesses.add(proc);
|
|
744
|
+
return () => {
|
|
745
|
+
activeProcesses.delete(proc);
|
|
746
|
+
};
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
pi.events.emit(REVIEW_EVENT_START, { sessionKey, source });
|
|
751
|
+
runtimeState.activeReviewCancels.set(sessionKey, requestCancellation);
|
|
752
|
+
const unsubscribeInterrupt = ctx.hasUI
|
|
753
|
+
? ctx.ui.onTerminalInput((data) => {
|
|
754
|
+
if (!matchesKey(data, "escape")) return undefined;
|
|
755
|
+
if (runtimeState.activePromptCount > 0) return undefined;
|
|
756
|
+
requestCancellation();
|
|
757
|
+
return { consume: true };
|
|
758
|
+
})
|
|
759
|
+
: undefined;
|
|
760
|
+
|
|
761
|
+
try {
|
|
762
|
+
return await run({
|
|
763
|
+
control,
|
|
764
|
+
markSuccessful: () => {
|
|
765
|
+
outcome = "success";
|
|
766
|
+
},
|
|
767
|
+
markCancelled: () => {
|
|
768
|
+
outcome = "cancelled";
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
} finally {
|
|
772
|
+
unsubscribeInterrupt?.();
|
|
773
|
+
cancelActiveProcesses();
|
|
774
|
+
if (runtimeState.activeReviewCancels.get(sessionKey) === requestCancellation) {
|
|
775
|
+
runtimeState.activeReviewCancels.delete(sessionKey);
|
|
776
|
+
}
|
|
777
|
+
pi.events.emit(REVIEW_EVENT_END, { sessionKey, source, outcome });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function notify(
|
|
782
|
+
ctx: ExtensionContext,
|
|
783
|
+
message: string,
|
|
784
|
+
type: "info" | "warning" | "error" = "info",
|
|
785
|
+
) {
|
|
786
|
+
if (!ctx.hasUI) return;
|
|
787
|
+
ctx.ui.notify(message, type);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function getReviewSessionKey(ctx: ExtensionContext): string {
|
|
791
|
+
return ctx.sessionManager.getSessionFile() ?? `session:${ctx.sessionManager.getSessionId()}`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async function withSpinner<T>(
|
|
795
|
+
ctx: ExtensionContext,
|
|
796
|
+
buildStatusText: () => string,
|
|
797
|
+
run: () => Promise<T>,
|
|
798
|
+
): Promise<T> {
|
|
799
|
+
if (!ctx.hasUI) return run();
|
|
800
|
+
|
|
801
|
+
let frame = 0;
|
|
802
|
+
const render = () => {
|
|
803
|
+
const spinner = STATUS_SPINNER_FRAMES[frame % STATUS_SPINNER_FRAMES.length];
|
|
804
|
+
ctx.ui.setStatus(STATUS_KEY, `${spinner} ${buildStatusText()}`);
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
render();
|
|
808
|
+
const timer = setInterval(() => {
|
|
809
|
+
frame = (frame + 1) % STATUS_SPINNER_FRAMES.length;
|
|
810
|
+
render();
|
|
811
|
+
}, STATUS_SPINNER_INTERVAL_MS);
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
return await run();
|
|
815
|
+
} finally {
|
|
816
|
+
clearInterval(timer);
|
|
817
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function priorityRank(priority: Priority): number {
|
|
822
|
+
return priority.charCodeAt(1) - 48; // '0' = 48
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function hashString(value: string): string {
|
|
826
|
+
return createHash("sha256").update(value).digest("hex");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function withJitter(baseMs: number): number {
|
|
830
|
+
const range = Math.max(0, Math.floor(baseMs * REVIEW_STARTUP_RETRY_JITTER_RATIO));
|
|
831
|
+
if (range === 0) return baseMs;
|
|
832
|
+
const offset = Math.floor(Math.random() * (range * 2 + 1) - range);
|
|
833
|
+
return Math.max(0, baseMs + offset);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// --- Command parsing ---
|
|
837
|
+
|
|
838
|
+
function isHelpRequest(args: string | undefined): boolean {
|
|
839
|
+
const tokens = tokenizeArgs(args?.trim() ?? "");
|
|
840
|
+
if (tokens.length === 0) return false;
|
|
841
|
+
const first = unquoteToken(tokens[0]).toLowerCase();
|
|
842
|
+
return first === "help";
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function getReviewArgumentCompletions(
|
|
846
|
+
prefix: string,
|
|
847
|
+
): Array<{ value: string; label: string }> | null {
|
|
848
|
+
const trimmed = prefix.trim().toLowerCase();
|
|
849
|
+
if (trimmed.includes(" ")) return null;
|
|
850
|
+
|
|
851
|
+
const matches = REVIEW_MODE_HINTS.filter((value) => value.startsWith(trimmed));
|
|
852
|
+
if (matches.length === 0) return null;
|
|
853
|
+
return matches.map((value) => ({ value, label: value }));
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function showReviewHelp(pi: ExtensionAPI) {
|
|
857
|
+
pi.sendMessage({
|
|
858
|
+
customType: "review",
|
|
859
|
+
display: true,
|
|
860
|
+
details: { kind: "help" satisfies ReviewMessageKind },
|
|
861
|
+
content: `## /review help
|
|
862
|
+
|
|
863
|
+
Run findings-only code review in 4 parallel focuses (general, reuse, quality, efficiency).
|
|
864
|
+
|
|
865
|
+
### Syntax
|
|
866
|
+
- \`/review [mode] [models=<a,b>] [context=<text>]\`
|
|
867
|
+
- \`/fix [mode] [models=<a,b>] [context=<text>]\`
|
|
868
|
+
- \`/triage <number|url>\`
|
|
869
|
+
|
|
870
|
+
### Modes
|
|
871
|
+
- \`auto\` (default): working tree first, then branch diff vs base branch.
|
|
872
|
+
- \`uncommitted\`: review tracked + untracked local changes.
|
|
873
|
+
- \`branch <name>\`: review diff from merge-base(name)..HEAD.
|
|
874
|
+
- \`commit <sha>\`: review one commit.
|
|
875
|
+
- \`pr <number|url>\`: checkout PR branch and review against PR base.
|
|
876
|
+
- \`folder <paths...>\`: snapshot review of files/folders (no git diff).
|
|
877
|
+
- \`custom "<instructions>"\`: custom scoped review instructions.
|
|
878
|
+
|
|
879
|
+
### Options
|
|
880
|
+
- \`models=<a,b>\`: run all review focuses for each listed model.
|
|
881
|
+
- \`context=<text>\`: add extra guidance to every review focus.
|
|
882
|
+
- For spaces, quote the value, e.g. \`context="security and backpressure"\`.
|
|
883
|
+
|
|
884
|
+
### Examples
|
|
885
|
+
- \`/review\`
|
|
886
|
+
- \`/review help\`
|
|
887
|
+
- \`/review branch main\`
|
|
888
|
+
- \`/review pr 123 models=sonnet,gpt-5\`
|
|
889
|
+
- \`/review uncommitted context="security and error handling"\`
|
|
890
|
+
- \`/triage 123\`
|
|
891
|
+
- \`/triage https://github.com/owner/repo/pull/123\`
|
|
892
|
+
- \`/fix\`
|
|
893
|
+
- \`/fix help\`
|
|
894
|
+
- \`/fix branch main models=sonnet\`
|
|
895
|
+
|
|
896
|
+
### /fix behavior
|
|
897
|
+
- Uses the last message only when it is a matching \`customType: "review"\` report payload.
|
|
898
|
+
- Otherwise, runs a fresh \`/review\` first.
|
|
899
|
+
- If the last review is stale, \`/fix\` warns and continues.
|
|
900
|
+
- If a review started by \`/fix\` goes stale mid-run, it shows the findings and applies no fixes.
|
|
901
|
+
- Skips execution when there are zero findings.
|
|
902
|
+
|
|
903
|
+
### /triage behavior
|
|
904
|
+
- Fetches PR feedback from GitHub for the given PR number or URL.
|
|
905
|
+
- Checks out the PR branch locally, then inspects the diff and repository state.
|
|
906
|
+
- Produces one triage row per feedback item with decision \`address\`, \`push_back\`, \`research\`, or \`ignore\`.`,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function tokenizeArgs(input: string): string[] {
|
|
911
|
+
return input.match(/[^\s"'=]+=(?:"[^"]*"|'[^']*')|"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function unquoteToken(token: string): string {
|
|
915
|
+
const quoted = token.match(/^(['"])([\s\S]*)\1$/);
|
|
916
|
+
if (!quoted) return token;
|
|
917
|
+
return quoted[2] ?? "";
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function parseKeyValueOption(
|
|
921
|
+
token: string,
|
|
922
|
+
key: "models" | "model" | "context",
|
|
923
|
+
): string | undefined {
|
|
924
|
+
const pattern = new RegExp(`^${key}=(?:"([\\s\\S]*)"|'([\\s\\S]*)'|(\\S+))$`);
|
|
925
|
+
const match = token.match(pattern);
|
|
926
|
+
if (!match) return undefined;
|
|
927
|
+
const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
|
928
|
+
return value;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function parseRequestArgs(args: string | undefined): ParsedRequest {
|
|
932
|
+
const raw = args?.trim() ?? "";
|
|
933
|
+
if (!raw) {
|
|
934
|
+
return { target: { type: "auto" }, mode: "auto", models: [], rawArgs: raw };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const tokens = tokenizeArgs(raw);
|
|
938
|
+
const rawModels: string[] = [];
|
|
939
|
+
const rawContext: string[] = [];
|
|
940
|
+
const modeTokens: string[] = [];
|
|
941
|
+
|
|
942
|
+
for (const token of tokens) {
|
|
943
|
+
const modelsValue = parseKeyValueOption(token, "models") ?? parseKeyValueOption(token, "model");
|
|
944
|
+
if (modelsValue !== undefined) {
|
|
945
|
+
if (!modelsValue) continue;
|
|
946
|
+
for (const model of modelsValue.split(",")) {
|
|
947
|
+
const trimmed = model.trim();
|
|
948
|
+
if (trimmed) rawModels.push(trimmed);
|
|
949
|
+
}
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const contextValue = parseKeyValueOption(token, "context");
|
|
954
|
+
if (contextValue !== undefined) {
|
|
955
|
+
if (contextValue) rawContext.push(contextValue);
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
modeTokens.push(unquoteToken(token));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const models = Array.from(new Set(rawModels));
|
|
963
|
+
const additionalContextJoined = rawContext
|
|
964
|
+
.map((c) => c.trim())
|
|
965
|
+
.filter(Boolean)
|
|
966
|
+
.join("\n\n");
|
|
967
|
+
const additionalContext =
|
|
968
|
+
additionalContextJoined.length > 0 ? additionalContextJoined : undefined;
|
|
969
|
+
const toMode = (target: ReviewTarget): ReviewRequestMode => {
|
|
970
|
+
switch (target.type) {
|
|
971
|
+
case "auto":
|
|
972
|
+
return "auto";
|
|
973
|
+
case "uncommitted":
|
|
974
|
+
return "uncommitted";
|
|
975
|
+
case "branch":
|
|
976
|
+
return `branch:${target.branch}`;
|
|
977
|
+
case "commit":
|
|
978
|
+
return `commit:${target.sha}`;
|
|
979
|
+
case "pr":
|
|
980
|
+
return `pr:${target.ref}`;
|
|
981
|
+
case "folder":
|
|
982
|
+
return `folder:${target.paths.join(",")}`;
|
|
983
|
+
case "custom":
|
|
984
|
+
return "custom";
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
const withMeta = (target: ReviewTarget): ParsedRequest => ({
|
|
988
|
+
target,
|
|
989
|
+
mode: toMode(target),
|
|
990
|
+
models,
|
|
991
|
+
rawArgs: raw,
|
|
992
|
+
additionalContext,
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
if (modeTokens.length === 0) {
|
|
996
|
+
return withMeta({ type: "auto" });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const mode = modeTokens[0].toLowerCase();
|
|
1000
|
+
const rest = modeTokens.slice(1);
|
|
1001
|
+
|
|
1002
|
+
if (mode === "auto" || mode === "uncommitted") {
|
|
1003
|
+
if (rest.length > 0)
|
|
1004
|
+
throw new Error(
|
|
1005
|
+
`${mode} mode does not accept positional args. Use models=... and/or context=...`,
|
|
1006
|
+
);
|
|
1007
|
+
return withMeta({ type: mode });
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (mode === "branch" || mode === "commit" || mode === "pr") {
|
|
1011
|
+
if (!rest[0]) {
|
|
1012
|
+
if (mode === "branch")
|
|
1013
|
+
throw new Error("branch mode requires a branch name (e.g. /review branch main)");
|
|
1014
|
+
if (mode === "commit")
|
|
1015
|
+
throw new Error("commit mode requires a commit SHA (e.g. /review commit abc1234)");
|
|
1016
|
+
throw new Error("pr mode requires a PR number or URL (e.g. /review pr 123)");
|
|
1017
|
+
}
|
|
1018
|
+
if (rest.length > 1) {
|
|
1019
|
+
const valueLabel =
|
|
1020
|
+
mode === "branch" ? "branch name" : mode === "commit" ? "SHA" : "reference";
|
|
1021
|
+
throw new Error(
|
|
1022
|
+
`${mode} mode accepts one ${valueLabel}. Use models=... and/or context=... for options.`,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (mode === "branch") return withMeta({ type: "branch", branch: rest[0] });
|
|
1027
|
+
if (mode === "commit") return withMeta({ type: "commit", sha: rest[0] });
|
|
1028
|
+
return withMeta({ type: "pr", ref: rest[0] });
|
|
1029
|
+
}
|
|
1030
|
+
if (mode === "folder") {
|
|
1031
|
+
if (rest.length === 0)
|
|
1032
|
+
throw new Error("folder mode requires at least one path (e.g. /review folder src docs)");
|
|
1033
|
+
return withMeta({ type: "folder", paths: rest.map((p) => p.trim()).filter(Boolean) });
|
|
1034
|
+
}
|
|
1035
|
+
if (mode === "custom") {
|
|
1036
|
+
const instructions = rest.join(" ").trim();
|
|
1037
|
+
if (!instructions)
|
|
1038
|
+
throw new Error('custom mode requires instructions (e.g. /review custom "focus on auth")');
|
|
1039
|
+
return withMeta({ type: "custom", instructions });
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
throw new Error(
|
|
1043
|
+
`Unknown mode "${mode}". Supported modes: auto, uncommitted, branch, commit, pr, folder, custom.`,
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function buildRequestSignature(request: ParsedRequest): string {
|
|
1048
|
+
const additionalContext = request.additionalContext?.trim();
|
|
1049
|
+
const target =
|
|
1050
|
+
request.target.type === "folder"
|
|
1051
|
+
? {
|
|
1052
|
+
type: "folder",
|
|
1053
|
+
paths: Array.from(new Set(request.target.paths)).sort((a, b) => a.localeCompare(b)),
|
|
1054
|
+
}
|
|
1055
|
+
: request.target.type === "custom"
|
|
1056
|
+
? { type: "custom", instructions: request.target.instructions.trim() }
|
|
1057
|
+
: request.target.type === "pr"
|
|
1058
|
+
? { type: "pr", ref: parsePrReference(request.target.ref) ?? request.target.ref.trim() }
|
|
1059
|
+
: request.target.type === "branch"
|
|
1060
|
+
? { type: "branch", branch: request.target.branch }
|
|
1061
|
+
: request.target.type === "commit"
|
|
1062
|
+
? { type: "commit", sha: request.target.sha }
|
|
1063
|
+
: { type: request.target.type };
|
|
1064
|
+
|
|
1065
|
+
return JSON.stringify({
|
|
1066
|
+
target,
|
|
1067
|
+
models: Array.from(new Set(request.models)).sort((a, b) => a.localeCompare(b)),
|
|
1068
|
+
additionalContext: additionalContext && additionalContext.length > 0 ? additionalContext : null,
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function reviewMatchesRequest(details: ReviewMessageDetails, request: ParsedRequest): boolean {
|
|
1073
|
+
return details.request.signature === buildRequestSignature(request);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// --- Git & fingerprinting ---
|
|
1077
|
+
|
|
1078
|
+
async function runGit(
|
|
1079
|
+
pi: ExtensionAPI,
|
|
1080
|
+
args: string[],
|
|
1081
|
+
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
1082
|
+
const { stdout, stderr, code } = await pi.exec("git", args);
|
|
1083
|
+
return { stdout, stderr, code };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function isGitRepo(pi: ExtensionAPI): Promise<boolean> {
|
|
1087
|
+
const result = await runGit(pi, ["rev-parse", "--git-dir"]);
|
|
1088
|
+
return result.code === 0;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> {
|
|
1092
|
+
const { stdout, code } = await runGit(pi, ["branch", "--show-current"]);
|
|
1093
|
+
if (code !== 0) return null;
|
|
1094
|
+
const branch = stdout.trim();
|
|
1095
|
+
return branch.length > 0 ? branch : null;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async function resolveHeadSha(pi: ExtensionAPI): Promise<string | null> {
|
|
1099
|
+
const { code, stdout } = await runGit(pi, ["rev-parse", "--verify", "HEAD"]);
|
|
1100
|
+
if (code !== 0) return null;
|
|
1101
|
+
const sha = stdout.trim();
|
|
1102
|
+
return sha.length > 0 ? sha : null;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
async function hasHeadCommit(pi: ExtensionAPI): Promise<boolean> {
|
|
1106
|
+
return (await resolveHeadSha(pi)) !== null;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
|
|
1110
|
+
const remoteHead = await runGit(pi, ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
|
|
1111
|
+
if (remoteHead.code === 0 && remoteHead.stdout.trim()) {
|
|
1112
|
+
return remoteHead.stdout.trim().replace(/^origin\//, "");
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const branches = await runGit(pi, ["branch", "--format=%(refname:short)"]);
|
|
1116
|
+
if (branches.code === 0) {
|
|
1117
|
+
const names = parseGitFileList(branches.stdout);
|
|
1118
|
+
if (names.includes("main")) return "main";
|
|
1119
|
+
if (names.includes("master")) return "master";
|
|
1120
|
+
if (names.length > 0) return names[0];
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return "main";
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async function getMergeBase(pi: ExtensionAPI, branch: string): Promise<string | null> {
|
|
1127
|
+
const { stdout, code } = await runGit(pi, ["merge-base", "HEAD", branch]);
|
|
1128
|
+
if (code !== 0) return null;
|
|
1129
|
+
const sha = stdout.trim();
|
|
1130
|
+
return sha.length > 0 ? sha : null;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function parseGitFileList(stdout: string): string[] {
|
|
1134
|
+
return stdout
|
|
1135
|
+
.split("\n")
|
|
1136
|
+
.map((line) => line.trim())
|
|
1137
|
+
.filter(Boolean);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async function getTrackedChangedFiles(pi: ExtensionAPI, hasHead?: boolean): Promise<string[]> {
|
|
1141
|
+
const headAvailable = hasHead ?? (await hasHeadCommit(pi));
|
|
1142
|
+
if (!headAvailable) {
|
|
1143
|
+
const [staged, unstaged] = await Promise.all([
|
|
1144
|
+
runGit(pi, ["diff", "--cached", "--name-only"]),
|
|
1145
|
+
runGit(pi, ["diff", "--name-only"]),
|
|
1146
|
+
]);
|
|
1147
|
+
|
|
1148
|
+
const files = new Set<string>([
|
|
1149
|
+
...(staged.code === 0 ? parseGitFileList(staged.stdout) : []),
|
|
1150
|
+
...(unstaged.code === 0 ? parseGitFileList(unstaged.stdout) : []),
|
|
1151
|
+
]);
|
|
1152
|
+
return Array.from(files).sort((a, b) => a.localeCompare(b));
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const { stdout, code } = await runGit(pi, ["diff", "HEAD", "--name-only"]);
|
|
1156
|
+
if (code !== 0) return [];
|
|
1157
|
+
return parseGitFileList(stdout);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async function getUntrackedFiles(pi: ExtensionAPI): Promise<string[]> {
|
|
1161
|
+
const { stdout, code } = await runGit(pi, ["ls-files", "--others", "--exclude-standard"]);
|
|
1162
|
+
if (code !== 0) return [];
|
|
1163
|
+
return parseGitFileList(stdout);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
async function getDiffFilesInRange(pi: ExtensionAPI, range: string): Promise<string[]> {
|
|
1167
|
+
const { stdout, code } = await runGit(pi, ["diff", "--name-only", range]);
|
|
1168
|
+
if (code !== 0) return [];
|
|
1169
|
+
return parseGitFileList(stdout);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
async function hasPendingTrackedChanges(pi: ExtensionAPI): Promise<boolean> {
|
|
1173
|
+
const { stdout, code } = await runGit(pi, ["status", "--porcelain"]);
|
|
1174
|
+
if (code !== 0) return false;
|
|
1175
|
+
const lines = stdout.split("\n").filter((line) => line.length > 0);
|
|
1176
|
+
return lines.some((line) => !line.startsWith("??"));
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function parsePrReference(ref: string): number | null {
|
|
1180
|
+
const trimmed = ref.trim();
|
|
1181
|
+
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
|
|
1182
|
+
|
|
1183
|
+
const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/i);
|
|
1184
|
+
if (!urlMatch?.[1]) return null;
|
|
1185
|
+
return Number.parseInt(urlMatch[1], 10);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function getPrInfo(
|
|
1189
|
+
pi: ExtensionAPI,
|
|
1190
|
+
prNumber: number,
|
|
1191
|
+
): Promise<{ baseBranch: string; headBranch: string } | null> {
|
|
1192
|
+
const { stdout, code } = await pi.exec("gh", [
|
|
1193
|
+
"pr",
|
|
1194
|
+
"view",
|
|
1195
|
+
String(prNumber),
|
|
1196
|
+
"--json",
|
|
1197
|
+
"baseRefName,headRefName",
|
|
1198
|
+
]);
|
|
1199
|
+
if (code !== 0) return null;
|
|
1200
|
+
try {
|
|
1201
|
+
const data = JSON.parse(stdout);
|
|
1202
|
+
if (typeof data?.baseRefName === "string" && typeof data?.headRefName === "string") {
|
|
1203
|
+
return {
|
|
1204
|
+
baseBranch: data.baseRefName,
|
|
1205
|
+
headBranch: data.headRefName,
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
return null;
|
|
1209
|
+
} catch {
|
|
1210
|
+
return null;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
async function checkoutPr(
|
|
1215
|
+
pi: ExtensionAPI,
|
|
1216
|
+
prNumber: number,
|
|
1217
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
1218
|
+
const fetch = await runGit(pi, ["fetch", "origin", `refs/pull/${prNumber}/head`]);
|
|
1219
|
+
if (fetch.code !== 0) {
|
|
1220
|
+
const error = (fetch.stderr || fetch.stdout || "Failed to fetch PR").trim();
|
|
1221
|
+
return { ok: false, error };
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const checkout = await runGit(pi, ["switch", "--detach", "FETCH_HEAD"]);
|
|
1225
|
+
if (checkout.code !== 0) {
|
|
1226
|
+
const error = (checkout.stderr || checkout.stdout || "Failed to checkout PR").trim();
|
|
1227
|
+
return { ok: false, error };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
return { ok: true };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
async function getPrCheckoutBlockedError(pi: ExtensionAPI): Promise<string | null> {
|
|
1234
|
+
return (await hasPendingTrackedChanges(pi))
|
|
1235
|
+
? "Cannot checkout PR with pending tracked changes. Commit or stash first."
|
|
1236
|
+
: null;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function preparePrCheckoutScope(
|
|
1240
|
+
pi: ExtensionAPI,
|
|
1241
|
+
ctx: ExtensionContext,
|
|
1242
|
+
details: { prNumber: number; baseBranch: string; headBranch: string },
|
|
1243
|
+
): Promise<
|
|
1244
|
+
| { ok: false; error: string }
|
|
1245
|
+
| { ok: true; scope: Extract<ResolvedScope, { kind: "branch-diff" }> }
|
|
1246
|
+
> {
|
|
1247
|
+
const blockedError = await getPrCheckoutBlockedError(pi);
|
|
1248
|
+
if (blockedError) {
|
|
1249
|
+
return { ok: false, error: blockedError };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
notify(ctx, `Checking out PR #${details.prNumber}...`, "info");
|
|
1253
|
+
const checkout = await checkoutPr(pi, details.prNumber);
|
|
1254
|
+
if (!checkout.ok) {
|
|
1255
|
+
return {
|
|
1256
|
+
ok: false,
|
|
1257
|
+
error: `Failed to checkout PR #${details.prNumber}: ${checkout.error ?? "unknown error"}`,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
notify(ctx, `Checked out PR #${details.prNumber} (${details.headBranch}).`, "info");
|
|
1261
|
+
|
|
1262
|
+
const resolvedScope = await resolveBranchDiffScope(pi, {
|
|
1263
|
+
baseBranch: details.baseBranch,
|
|
1264
|
+
description: (diffFileCount) =>
|
|
1265
|
+
`PR #${details.prNumber} diff vs ${details.baseBranch} (${diffFileCount} files)`,
|
|
1266
|
+
mergeBaseError: `Could not determine merge-base against PR base branch ${details.baseBranch}.`,
|
|
1267
|
+
emptyDiffError: `No differences found for PR #${details.prNumber} against ${details.baseBranch}.`,
|
|
1268
|
+
});
|
|
1269
|
+
if (!resolvedScope.scope || resolvedScope.scope.kind !== "branch-diff") {
|
|
1270
|
+
return {
|
|
1271
|
+
ok: false,
|
|
1272
|
+
error: resolvedScope.error ?? `Failed to resolve PR #${details.prNumber} scope.`,
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
return { ok: true, scope: resolvedScope.scope };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
1280
|
+
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function getConnectionNodes(value: unknown): unknown[] {
|
|
1284
|
+
const record = asRecord(value);
|
|
1285
|
+
return record && Array.isArray(record.nodes) ? record.nodes : [];
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function getNestedRecord(value: unknown, ...keys: string[]): Record<string, unknown> | null {
|
|
1289
|
+
let current: unknown = value;
|
|
1290
|
+
for (const key of keys) {
|
|
1291
|
+
const record = asRecord(current);
|
|
1292
|
+
if (!record) return null;
|
|
1293
|
+
current = record[key];
|
|
1294
|
+
}
|
|
1295
|
+
return asRecord(current);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function getOptionalString(value: unknown): string | undefined {
|
|
1299
|
+
return typeof value === "string" ? value : undefined;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function getRequiredString(value: unknown): string | null {
|
|
1303
|
+
return typeof value === "string" ? value : null;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function getOptionalNumber(value: unknown): number | undefined {
|
|
1307
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function getOptionalBoolean(value: unknown): boolean | undefined {
|
|
1311
|
+
return typeof value === "boolean" ? value : undefined;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function getAuthorLogin(value: unknown): string {
|
|
1315
|
+
const author = asRecord(value);
|
|
1316
|
+
return typeof author?.login === "string" ? author.login : "unknown";
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
async function fetchPrTriageMetadata(
|
|
1320
|
+
pi: ExtensionAPI,
|
|
1321
|
+
prNumber: number,
|
|
1322
|
+
): Promise<{
|
|
1323
|
+
prNumber: number;
|
|
1324
|
+
prUrl: string;
|
|
1325
|
+
title: string;
|
|
1326
|
+
body: string;
|
|
1327
|
+
baseBranch: string;
|
|
1328
|
+
headBranch: string;
|
|
1329
|
+
author: string;
|
|
1330
|
+
comments: TriageFeedbackItem[];
|
|
1331
|
+
reviews: TriageFeedbackItem[];
|
|
1332
|
+
} | null> {
|
|
1333
|
+
const { stdout, code } = await pi.exec("gh", [
|
|
1334
|
+
"api",
|
|
1335
|
+
"graphql",
|
|
1336
|
+
"-F",
|
|
1337
|
+
"owner={owner}",
|
|
1338
|
+
"-F",
|
|
1339
|
+
"name={repo}",
|
|
1340
|
+
"-F",
|
|
1341
|
+
`number=${prNumber}`,
|
|
1342
|
+
"-f",
|
|
1343
|
+
`query=${TRIAGE_METADATA_QUERY}`,
|
|
1344
|
+
]);
|
|
1345
|
+
if (code !== 0) return null;
|
|
1346
|
+
|
|
1347
|
+
try {
|
|
1348
|
+
const root = asRecord(JSON.parse(stdout));
|
|
1349
|
+
const pr = getNestedRecord(root, "data", "repository", "pullRequest");
|
|
1350
|
+
if (!pr) return null;
|
|
1351
|
+
|
|
1352
|
+
const number = getOptionalNumber(pr.number);
|
|
1353
|
+
const prUrl = getRequiredString(pr.url);
|
|
1354
|
+
const title = getRequiredString(pr.title);
|
|
1355
|
+
const body = getRequiredString(pr.body) ?? "";
|
|
1356
|
+
const baseBranch = getRequiredString(pr.baseRefName);
|
|
1357
|
+
const headBranch = getRequiredString(pr.headRefName);
|
|
1358
|
+
if (!number || !prUrl || !title || !baseBranch || !headBranch) return null;
|
|
1359
|
+
|
|
1360
|
+
const prAuthor = getAuthorLogin(pr.author);
|
|
1361
|
+
const comments = getConnectionNodes(pr.comments)
|
|
1362
|
+
.map((node, index): TriageFeedbackItem | null => {
|
|
1363
|
+
const record = asRecord(node);
|
|
1364
|
+
if (!record) return null;
|
|
1365
|
+
const bodyText = (getRequiredString(record.body) ?? "").trim();
|
|
1366
|
+
const author = getAuthorLogin(record.author);
|
|
1367
|
+
if (!bodyText || author === prAuthor) return null;
|
|
1368
|
+
return {
|
|
1369
|
+
id: `comment-${index + 1}`,
|
|
1370
|
+
kind: "pr-comment",
|
|
1371
|
+
author,
|
|
1372
|
+
location: "PR conversation",
|
|
1373
|
+
url: getOptionalString(record.url),
|
|
1374
|
+
body: bodyText,
|
|
1375
|
+
};
|
|
1376
|
+
})
|
|
1377
|
+
.filter((item): item is TriageFeedbackItem => item !== null);
|
|
1378
|
+
|
|
1379
|
+
const reviews = getConnectionNodes(pr.reviews)
|
|
1380
|
+
.map((node, index): TriageFeedbackItem | null => {
|
|
1381
|
+
const record = asRecord(node);
|
|
1382
|
+
if (!record) return null;
|
|
1383
|
+
const bodyText = (getRequiredString(record.body) ?? "").trim();
|
|
1384
|
+
if (!bodyText) return null;
|
|
1385
|
+
return {
|
|
1386
|
+
id: `review-${index + 1}`,
|
|
1387
|
+
kind: "review-summary",
|
|
1388
|
+
author: getAuthorLogin(record.author),
|
|
1389
|
+
location: "Review summary",
|
|
1390
|
+
url: getOptionalString(record.url),
|
|
1391
|
+
body: bodyText,
|
|
1392
|
+
state: getOptionalString(record.state),
|
|
1393
|
+
};
|
|
1394
|
+
})
|
|
1395
|
+
.filter((item): item is TriageFeedbackItem => item !== null);
|
|
1396
|
+
|
|
1397
|
+
return {
|
|
1398
|
+
prNumber: number,
|
|
1399
|
+
prUrl,
|
|
1400
|
+
title,
|
|
1401
|
+
body,
|
|
1402
|
+
baseBranch,
|
|
1403
|
+
headBranch,
|
|
1404
|
+
author: prAuthor,
|
|
1405
|
+
comments,
|
|
1406
|
+
reviews,
|
|
1407
|
+
};
|
|
1408
|
+
} catch {
|
|
1409
|
+
return null;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function formatThreadLocation(options: {
|
|
1414
|
+
path?: string;
|
|
1415
|
+
line?: number;
|
|
1416
|
+
originalLine?: number;
|
|
1417
|
+
startLine?: number;
|
|
1418
|
+
originalStartLine?: number;
|
|
1419
|
+
}): string {
|
|
1420
|
+
const path = options.path?.trim();
|
|
1421
|
+
const endLine = options.line ?? options.originalLine;
|
|
1422
|
+
const startLine = options.startLine ?? options.originalStartLine;
|
|
1423
|
+
if (!path) return endLine ? `review thread:${endLine}` : "review thread";
|
|
1424
|
+
if (!endLine) return path;
|
|
1425
|
+
if (startLine && startLine !== endLine) return `${path}:${startLine}-${endLine}`;
|
|
1426
|
+
return `${path}:${endLine}`;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function pickThreadAuthor(comments: TriageFeedbackComment[], prAuthor: string): string {
|
|
1430
|
+
const externalComment = comments.find(
|
|
1431
|
+
(comment) => comment.author !== prAuthor && comment.author !== "unknown",
|
|
1432
|
+
);
|
|
1433
|
+
if (externalComment) return externalComment.author;
|
|
1434
|
+
return comments[0]?.author ?? "unknown";
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
async function fetchPrReviewThreads(
|
|
1438
|
+
pi: ExtensionAPI,
|
|
1439
|
+
prNumber: number,
|
|
1440
|
+
prAuthor: string,
|
|
1441
|
+
): Promise<TriageFeedbackItem[] | null> {
|
|
1442
|
+
const { stdout, code } = await pi.exec("gh", [
|
|
1443
|
+
"api",
|
|
1444
|
+
"graphql",
|
|
1445
|
+
"--paginate",
|
|
1446
|
+
"--slurp",
|
|
1447
|
+
"-F",
|
|
1448
|
+
"owner={owner}",
|
|
1449
|
+
"-F",
|
|
1450
|
+
"name={repo}",
|
|
1451
|
+
"-F",
|
|
1452
|
+
`number=${prNumber}`,
|
|
1453
|
+
"-f",
|
|
1454
|
+
`query=${TRIAGE_THREADS_QUERY}`,
|
|
1455
|
+
]);
|
|
1456
|
+
if (code !== 0) return null;
|
|
1457
|
+
|
|
1458
|
+
try {
|
|
1459
|
+
const pages = JSON.parse(stdout);
|
|
1460
|
+
if (!Array.isArray(pages)) return null;
|
|
1461
|
+
|
|
1462
|
+
const items: TriageFeedbackItem[] = [];
|
|
1463
|
+
for (const page of pages) {
|
|
1464
|
+
const threads = getConnectionNodes(
|
|
1465
|
+
getNestedRecord(page, "data", "repository", "pullRequest", "reviewThreads"),
|
|
1466
|
+
);
|
|
1467
|
+
for (const thread of threads) {
|
|
1468
|
+
const record = asRecord(thread);
|
|
1469
|
+
if (!record) continue;
|
|
1470
|
+
const comments = getConnectionNodes(record.comments)
|
|
1471
|
+
.map((node): TriageFeedbackComment | null => {
|
|
1472
|
+
const comment = asRecord(node);
|
|
1473
|
+
if (!comment) return null;
|
|
1474
|
+
const body = (getRequiredString(comment.body) ?? "").trim();
|
|
1475
|
+
if (!body) return null;
|
|
1476
|
+
return {
|
|
1477
|
+
author: getAuthorLogin(comment.author),
|
|
1478
|
+
body,
|
|
1479
|
+
url: getOptionalString(comment.url),
|
|
1480
|
+
createdAt: getOptionalString(comment.createdAt),
|
|
1481
|
+
};
|
|
1482
|
+
})
|
|
1483
|
+
.filter((comment): comment is TriageFeedbackComment => comment !== null);
|
|
1484
|
+
if (comments.length === 0) continue;
|
|
1485
|
+
|
|
1486
|
+
items.push({
|
|
1487
|
+
id: `thread-${items.length + 1}`,
|
|
1488
|
+
kind: "review-thread",
|
|
1489
|
+
author: pickThreadAuthor(comments, prAuthor),
|
|
1490
|
+
location: formatThreadLocation({
|
|
1491
|
+
path: getOptionalString(record.path),
|
|
1492
|
+
line: getOptionalNumber(record.line),
|
|
1493
|
+
originalLine: getOptionalNumber(record.originalLine),
|
|
1494
|
+
startLine: getOptionalNumber(record.startLine),
|
|
1495
|
+
originalStartLine: getOptionalNumber(record.originalStartLine),
|
|
1496
|
+
}),
|
|
1497
|
+
url: comments[comments.length - 1]?.url,
|
|
1498
|
+
isResolved: getOptionalBoolean(record.isResolved),
|
|
1499
|
+
isOutdated: getOptionalBoolean(record.isOutdated),
|
|
1500
|
+
comments,
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
return items;
|
|
1506
|
+
} catch {
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function buildTriageFeedbackItems(metadata: {
|
|
1512
|
+
comments: TriageFeedbackItem[];
|
|
1513
|
+
reviews: TriageFeedbackItem[];
|
|
1514
|
+
threads: TriageFeedbackItem[];
|
|
1515
|
+
}): TriageFeedbackItem[] {
|
|
1516
|
+
return [...metadata.threads, ...metadata.reviews, ...metadata.comments];
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
async function loadProjectReviewGuidelines(cwd: string): Promise<string | null> {
|
|
1520
|
+
let currentDir = path.resolve(cwd);
|
|
1521
|
+
|
|
1522
|
+
while (true) {
|
|
1523
|
+
const piDir = path.join(currentDir, ".pi");
|
|
1524
|
+
const guidelinesPath = path.join(currentDir, "REVIEW_GUIDELINES.md");
|
|
1525
|
+
|
|
1526
|
+
const piStats = await fs.stat(piDir).catch(() => null);
|
|
1527
|
+
if (piStats?.isDirectory()) {
|
|
1528
|
+
try {
|
|
1529
|
+
const content = await fs.readFile(guidelinesPath, "utf8");
|
|
1530
|
+
const trimmed = content.trim();
|
|
1531
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1532
|
+
} catch {
|
|
1533
|
+
return null;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const parent = path.dirname(currentDir);
|
|
1538
|
+
if (parent === currentDir) return null;
|
|
1539
|
+
currentDir = parent;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
async function hashGitDiff(pi: ExtensionAPI): Promise<string> {
|
|
1544
|
+
const head = await runGit(pi, ["diff", "--no-ext-diff", "HEAD"]);
|
|
1545
|
+
if (head.code === 0) return hashString(head.stdout);
|
|
1546
|
+
|
|
1547
|
+
const [staged, unstaged] = await Promise.all([
|
|
1548
|
+
runGit(pi, ["diff", "--no-ext-diff", "--cached"]),
|
|
1549
|
+
runGit(pi, ["diff", "--no-ext-diff"]),
|
|
1550
|
+
]);
|
|
1551
|
+
const stagedHash = staged.code === 0 ? hashString(staged.stdout) : null;
|
|
1552
|
+
const unstagedHash = unstaged.code === 0 ? hashString(unstaged.stdout) : null;
|
|
1553
|
+
if (!stagedHash && !unstagedHash) return hashString("");
|
|
1554
|
+
return hashString(`${stagedHash ?? ""}\n${unstagedHash ?? ""}`);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async function computeUntrackedContentHash(
|
|
1558
|
+
pi: ExtensionAPI,
|
|
1559
|
+
cwd: string,
|
|
1560
|
+
precomputedUntrackedFiles?: string[],
|
|
1561
|
+
): Promise<string> {
|
|
1562
|
+
const untrackedFiles = [...(precomputedUntrackedFiles ?? (await getUntrackedFiles(pi)))].sort(
|
|
1563
|
+
(a, b) => a.localeCompare(b),
|
|
1564
|
+
);
|
|
1565
|
+
if (untrackedFiles.length === 0) return hashString("");
|
|
1566
|
+
|
|
1567
|
+
const hashes = await hashObjectBatch(cwd, untrackedFiles);
|
|
1568
|
+
const entries = untrackedFiles.map((file, i) => `${file}\0${hashes[i] ?? "missing"}`);
|
|
1569
|
+
return hashString(entries.join("\n"));
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function hashObjectBatch(cwd: string, files: string[]): Promise<string[]> {
|
|
1573
|
+
return new Promise((resolve) => {
|
|
1574
|
+
const proc = spawn("git", ["hash-object", "--stdin-paths"], {
|
|
1575
|
+
cwd,
|
|
1576
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
1577
|
+
});
|
|
1578
|
+
let stdout = "";
|
|
1579
|
+
proc.stdout.on("data", (chunk: Buffer) => {
|
|
1580
|
+
stdout += chunk.toString();
|
|
1581
|
+
});
|
|
1582
|
+
proc.on("error", () => resolve([]));
|
|
1583
|
+
proc.on("close", (code) => {
|
|
1584
|
+
resolve(code === 0 ? stdout.trim().split("\n") : []);
|
|
1585
|
+
});
|
|
1586
|
+
proc.stdin.on("error", () => {
|
|
1587
|
+
/* ignore broken pipe */
|
|
1588
|
+
});
|
|
1589
|
+
proc.stdin.end(files.join("\n"));
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
async function computeCurrentFingerprint(
|
|
1594
|
+
pi: ExtensionAPI,
|
|
1595
|
+
cwd: string,
|
|
1596
|
+
includeUntracked: boolean,
|
|
1597
|
+
precomputedUntrackedFiles?: string[],
|
|
1598
|
+
): Promise<ReviewFingerprint> {
|
|
1599
|
+
const [headSha, branch, trackedDiffHash, untrackedHash] = await Promise.all([
|
|
1600
|
+
resolveHeadSha(pi).then((sha) => sha ?? ""),
|
|
1601
|
+
getCurrentBranch(pi).then((branch) => branch ?? ""),
|
|
1602
|
+
hashGitDiff(pi),
|
|
1603
|
+
includeUntracked
|
|
1604
|
+
? computeUntrackedContentHash(pi, cwd, precomputedUntrackedFiles)
|
|
1605
|
+
: Promise.resolve(REVIEW_UNTRACKED_HASH_DISABLED),
|
|
1606
|
+
]);
|
|
1607
|
+
|
|
1608
|
+
return { headSha, branch, trackedDiffHash, untrackedHash };
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function fingerprintsEqual(a: ReviewFingerprint, b: ReviewFingerprint): boolean {
|
|
1612
|
+
return (
|
|
1613
|
+
a.headSha === b.headSha &&
|
|
1614
|
+
a.branch === b.branch &&
|
|
1615
|
+
a.trackedDiffHash === b.trackedDiffHash &&
|
|
1616
|
+
a.untrackedHash === b.untrackedHash
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// --- Scope resolution ---
|
|
1621
|
+
|
|
1622
|
+
type BranchDiffScopeOptions = {
|
|
1623
|
+
baseBranch: string;
|
|
1624
|
+
description: (diffFileCount: number) => string;
|
|
1625
|
+
mergeBaseError: string;
|
|
1626
|
+
emptyDiffError: string;
|
|
1627
|
+
};
|
|
1628
|
+
|
|
1629
|
+
async function resolveBranchDiffScope(
|
|
1630
|
+
pi: ExtensionAPI,
|
|
1631
|
+
options: BranchDiffScopeOptions,
|
|
1632
|
+
): Promise<{ scope?: ResolvedScope; error?: string }> {
|
|
1633
|
+
const mergeBase = await getMergeBase(pi, options.baseBranch);
|
|
1634
|
+
if (!mergeBase) {
|
|
1635
|
+
return { error: options.mergeBaseError };
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
const range = `${mergeBase}..HEAD`;
|
|
1639
|
+
const diffFiles = await getDiffFilesInRange(pi, range);
|
|
1640
|
+
if (diffFiles.length === 0) {
|
|
1641
|
+
return { error: options.emptyDiffError };
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
return {
|
|
1645
|
+
scope: {
|
|
1646
|
+
kind: "branch-diff",
|
|
1647
|
+
baseBranch: options.baseBranch,
|
|
1648
|
+
mergeBase,
|
|
1649
|
+
diffFiles,
|
|
1650
|
+
description: options.description(diffFiles.length),
|
|
1651
|
+
},
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
async function resolveScope(
|
|
1656
|
+
pi: ExtensionAPI,
|
|
1657
|
+
ctx: ExtensionContext,
|
|
1658
|
+
target: ReviewTarget,
|
|
1659
|
+
): Promise<{ scope?: ResolvedScope; error?: string }> {
|
|
1660
|
+
switch (target.type) {
|
|
1661
|
+
case "auto":
|
|
1662
|
+
case "uncommitted": {
|
|
1663
|
+
const untrackedPromise = getUntrackedFiles(pi);
|
|
1664
|
+
const headSha = await resolveHeadSha(pi);
|
|
1665
|
+
const hasHead = headSha !== null;
|
|
1666
|
+
const [trackedFiles, untrackedFiles] = await Promise.all([
|
|
1667
|
+
getTrackedChangedFiles(pi, hasHead),
|
|
1668
|
+
untrackedPromise,
|
|
1669
|
+
]);
|
|
1670
|
+
if (trackedFiles.length > 0 || untrackedFiles.length > 0) {
|
|
1671
|
+
return {
|
|
1672
|
+
scope: {
|
|
1673
|
+
kind: "working-tree",
|
|
1674
|
+
trackedFiles,
|
|
1675
|
+
untrackedFiles,
|
|
1676
|
+
hasHead,
|
|
1677
|
+
description: `working tree (tracked: ${trackedFiles.length}, untracked: ${untrackedFiles.length})`,
|
|
1678
|
+
},
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
if (target.type === "uncommitted") {
|
|
1683
|
+
return { error: "No uncommitted changes to review." };
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
const baseBranch = await getDefaultBranch(pi);
|
|
1687
|
+
return resolveBranchDiffScope(pi, {
|
|
1688
|
+
baseBranch,
|
|
1689
|
+
description: (diffFileCount) => `branch diff vs ${baseBranch} (${diffFileCount} files)`,
|
|
1690
|
+
mergeBaseError: `Could not determine merge-base against ${baseBranch}`,
|
|
1691
|
+
emptyDiffError: `No reviewable changes found (clean working tree and no branch diff vs ${baseBranch}). Use an explicit mode (branch/commit/pr/folder/custom).`,
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
case "branch": {
|
|
1695
|
+
return resolveBranchDiffScope(pi, {
|
|
1696
|
+
baseBranch: target.branch,
|
|
1697
|
+
description: (diffFileCount) => `branch diff vs ${target.branch} (${diffFileCount} files)`,
|
|
1698
|
+
mergeBaseError: `Could not determine merge-base against ${target.branch}`,
|
|
1699
|
+
emptyDiffError: `No differences found against branch ${target.branch}.`,
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
case "commit": {
|
|
1703
|
+
return {
|
|
1704
|
+
scope: {
|
|
1705
|
+
kind: "commit",
|
|
1706
|
+
sha: target.sha,
|
|
1707
|
+
description: `commit ${target.sha}`,
|
|
1708
|
+
},
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
case "pr": {
|
|
1712
|
+
const blockedError = await getPrCheckoutBlockedError(pi);
|
|
1713
|
+
if (blockedError) {
|
|
1714
|
+
return { error: blockedError };
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const prNumber = parsePrReference(target.ref);
|
|
1718
|
+
if (!prNumber) {
|
|
1719
|
+
return { error: `Invalid PR reference: ${target.ref}` };
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
notify(ctx, `Fetching PR #${prNumber} information...`, "info");
|
|
1723
|
+
const prInfo = await getPrInfo(pi, prNumber);
|
|
1724
|
+
if (!prInfo) {
|
|
1725
|
+
return {
|
|
1726
|
+
error: `Could not load PR #${prNumber}. Ensure gh is authenticated and PR exists.`,
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
const preparedPrScope = await preparePrCheckoutScope(pi, ctx, {
|
|
1731
|
+
prNumber,
|
|
1732
|
+
baseBranch: prInfo.baseBranch,
|
|
1733
|
+
headBranch: prInfo.headBranch,
|
|
1734
|
+
});
|
|
1735
|
+
if (!preparedPrScope.ok) {
|
|
1736
|
+
return { error: preparedPrScope.error };
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
return { scope: preparedPrScope.scope };
|
|
1740
|
+
}
|
|
1741
|
+
case "folder": {
|
|
1742
|
+
if (target.paths.length === 0) return { error: "No folder/file paths provided." };
|
|
1743
|
+
return {
|
|
1744
|
+
scope: {
|
|
1745
|
+
kind: "folder",
|
|
1746
|
+
paths: target.paths,
|
|
1747
|
+
description: `snapshot review for ${target.paths.join(", ")}`,
|
|
1748
|
+
},
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
case "custom": {
|
|
1752
|
+
if (!target.instructions.trim()) return { error: "Custom instructions are empty." };
|
|
1753
|
+
return {
|
|
1754
|
+
scope: {
|
|
1755
|
+
kind: "custom",
|
|
1756
|
+
instructions: target.instructions.trim(),
|
|
1757
|
+
description: "custom review instructions",
|
|
1758
|
+
},
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function buildScopeInstructions(scope: ResolvedScope): string {
|
|
1765
|
+
switch (scope.kind) {
|
|
1766
|
+
case "working-tree": {
|
|
1767
|
+
const trackedCommand = scope.hasHead
|
|
1768
|
+
? "`git diff HEAD`"
|
|
1769
|
+
: "`git diff --cached` and `git diff`";
|
|
1770
|
+
const tracked =
|
|
1771
|
+
scope.trackedFiles.length > 0
|
|
1772
|
+
? `- First capture the full tracked diff with: ${trackedCommand} (treat this diff as mandatory review context).\n- Tracked files (${scope.trackedFiles.length}):\n${scope.trackedFiles.map((f) => ` - ${f}`).join("\n")}`
|
|
1773
|
+
: "- There are no tracked-file diffs.";
|
|
1774
|
+
const untracked =
|
|
1775
|
+
scope.untrackedFiles.length > 0
|
|
1776
|
+
? `- Also review untracked files as snapshots by reading them directly.\n- Untracked files (${scope.untrackedFiles.length}):\n${scope.untrackedFiles.map((f) => ` - ${f}`).join("\n")}`
|
|
1777
|
+
: "- There are no untracked files.";
|
|
1778
|
+
return `Scope: working tree review.\n${tracked}\n${untracked}`;
|
|
1779
|
+
}
|
|
1780
|
+
case "branch-diff": {
|
|
1781
|
+
return `Scope: branch diff review against base branch ${scope.baseBranch}.\n- Merge base: ${scope.mergeBase}\n- First capture the full diff with: \`git diff ${scope.mergeBase}..HEAD\` (treat this diff as mandatory review context).\n- Files in diff (${scope.diffFiles.length}):\n${scope.diffFiles.map((f) => ` - ${f}`).join("\n")}`;
|
|
1782
|
+
}
|
|
1783
|
+
case "commit": {
|
|
1784
|
+
return `Scope: commit review for ${scope.sha}.\n- First capture the full commit patch with: \`git show --stat --patch ${scope.sha}\` (treat this patch as mandatory review context).\n- Focus only on changes introduced by this commit.`;
|
|
1785
|
+
}
|
|
1786
|
+
case "folder": {
|
|
1787
|
+
return `Scope: snapshot review of selected paths (not a diff).\n- Paths:\n${scope.paths
|
|
1788
|
+
.map((p) => ` - ${p}`)
|
|
1789
|
+
.join("\n")}\n- Read files directly from these paths and review what exists currently.`;
|
|
1790
|
+
}
|
|
1791
|
+
case "custom": {
|
|
1792
|
+
return `Scope: custom review instructions.\n- Additional user instruction: ${scope.instructions}`;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function buildFocusPrompt(
|
|
1798
|
+
focus: FocusName,
|
|
1799
|
+
scopeInstructions: string,
|
|
1800
|
+
projectGuidelines: string | null,
|
|
1801
|
+
additionalContext: string | undefined,
|
|
1802
|
+
): string {
|
|
1803
|
+
const additionalContextSection = additionalContext?.trim()
|
|
1804
|
+
? REVIEW_ADDITIONAL_CONTEXT_SECTION_PROMPT.replace("{ADDITIONAL_CONTEXT}", () =>
|
|
1805
|
+
additionalContext.trim(),
|
|
1806
|
+
)
|
|
1807
|
+
: "";
|
|
1808
|
+
const projectGuidelinesSection = projectGuidelines
|
|
1809
|
+
? REVIEW_PROJECT_GUIDELINES_SECTION_PROMPT.replace(
|
|
1810
|
+
"{PROJECT_GUIDELINES}",
|
|
1811
|
+
() => projectGuidelines,
|
|
1812
|
+
)
|
|
1813
|
+
: "";
|
|
1814
|
+
|
|
1815
|
+
const def = REVIEW_FOCUSES[focus];
|
|
1816
|
+
|
|
1817
|
+
return REVIEW_FOCUS_PROMPT.replace("{FOCUS_SUFFIX}", () => def.suffix)
|
|
1818
|
+
.replace("{FOCUS_QUALIFIER}", () => def.qualifier)
|
|
1819
|
+
.replace("{SCOPE_INSTRUCTIONS}", () => scopeInstructions)
|
|
1820
|
+
.replace("{FOCUS_CONTEXT}", () => def.context)
|
|
1821
|
+
.replace("{ADDITIONAL_CONTEXT_SECTION}", () => additionalContextSection)
|
|
1822
|
+
.replace("{PROJECT_GUIDELINES_SECTION}", () => projectGuidelinesSection)
|
|
1823
|
+
.replace("{OUTPUT_CONTRACT}", () => REVIEW_JSON_OUTPUT_CONTRACT_PROMPT);
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function buildTriagePrompt(context: TriagePrContext, projectGuidelines: string | null): string {
|
|
1827
|
+
const projectGuidelinesSection = projectGuidelines
|
|
1828
|
+
? REVIEW_PROJECT_GUIDELINES_SECTION_PROMPT.replace(
|
|
1829
|
+
"{PROJECT_GUIDELINES}",
|
|
1830
|
+
() => projectGuidelines,
|
|
1831
|
+
)
|
|
1832
|
+
: "";
|
|
1833
|
+
const triageInput = JSON.stringify(
|
|
1834
|
+
{
|
|
1835
|
+
pr: {
|
|
1836
|
+
number: context.prNumber,
|
|
1837
|
+
url: context.prUrl,
|
|
1838
|
+
title: context.title,
|
|
1839
|
+
body: context.body,
|
|
1840
|
+
base_branch: context.baseBranch,
|
|
1841
|
+
head_branch: context.headBranch,
|
|
1842
|
+
author: context.author,
|
|
1843
|
+
},
|
|
1844
|
+
feedback: context.feedbackItems,
|
|
1845
|
+
},
|
|
1846
|
+
null,
|
|
1847
|
+
2,
|
|
1848
|
+
);
|
|
1849
|
+
|
|
1850
|
+
return TRIAGE_PROMPT.replace("{SCOPE_INSTRUCTIONS}", () => buildScopeInstructions(context.scope))
|
|
1851
|
+
.replace("{PROJECT_GUIDELINES_SECTION}", () => projectGuidelinesSection)
|
|
1852
|
+
.replace("{TRIAGE_INPUT_JSON}", () => triageInput);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function buildReviewDedupPrompt(findings: ReviewReportFinding[]): string {
|
|
1856
|
+
const findingsWithIds = findings.map((finding, index) => ({ id: index + 1, ...finding }));
|
|
1857
|
+
return REVIEW_DEDUP_PROMPT.replace("{REVIEW_FINDINGS_JSON}", () =>
|
|
1858
|
+
JSON.stringify({ findings: findingsWithIds }, null, 2),
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// --- Focus task execution ---
|
|
1863
|
+
|
|
1864
|
+
function extractTextContent(content: unknown): string {
|
|
1865
|
+
if (typeof content === "string") return content;
|
|
1866
|
+
if (!Array.isArray(content)) return "";
|
|
1867
|
+
return content
|
|
1868
|
+
.map((item) => {
|
|
1869
|
+
if (typeof item !== "object" || item === null) return "";
|
|
1870
|
+
if (!("type" in item) || item.type !== "text") return "";
|
|
1871
|
+
if (!("text" in item) || typeof item.text !== "string") return "";
|
|
1872
|
+
return item.text;
|
|
1873
|
+
})
|
|
1874
|
+
.filter(Boolean)
|
|
1875
|
+
.join("\n");
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function extractAssistantMessageFromEvent(event: unknown): Record<string, unknown> | null {
|
|
1879
|
+
const record = asRecord(event);
|
|
1880
|
+
if (!record || typeof record.type !== "string") return null;
|
|
1881
|
+
|
|
1882
|
+
if (record.type === "message_end" || record.type === "turn_end") {
|
|
1883
|
+
const message = asRecord(record.message);
|
|
1884
|
+
return message?.role === "assistant" ? message : null;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
if (record.type !== "agent_end" || !Array.isArray(record.messages)) return null;
|
|
1888
|
+
for (let i = record.messages.length - 1; i >= 0; i -= 1) {
|
|
1889
|
+
const message = asRecord(record.messages[i]);
|
|
1890
|
+
if (message?.role === "assistant") {
|
|
1891
|
+
return message;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
return null;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function parsePossiblyWrappedJson(raw: string): unknown {
|
|
1898
|
+
const trimmed = raw.trim();
|
|
1899
|
+
if (!trimmed) throw new Error("Empty output");
|
|
1900
|
+
|
|
1901
|
+
const codeFenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
1902
|
+
const candidate = codeFenceMatch?.[1]?.trim() || trimmed;
|
|
1903
|
+
|
|
1904
|
+
try {
|
|
1905
|
+
return JSON.parse(candidate);
|
|
1906
|
+
} catch {
|
|
1907
|
+
const firstBrace = candidate.indexOf("{");
|
|
1908
|
+
const lastBrace = candidate.lastIndexOf("}");
|
|
1909
|
+
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
1910
|
+
const sliced = candidate.slice(firstBrace, lastBrace + 1);
|
|
1911
|
+
return JSON.parse(sliced);
|
|
1912
|
+
}
|
|
1913
|
+
throw new Error("Output is not valid JSON");
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function validateFocusOutput(parsed: unknown): FocusFinding[] {
|
|
1918
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1919
|
+
throw new Error("Focus output must be a JSON object.");
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
if (!("findings" in parsed) || !Array.isArray(parsed.findings)) {
|
|
1923
|
+
throw new Error('Focus output is missing required "findings" array.');
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
const findings: FocusFinding[] = [];
|
|
1927
|
+
for (const finding of parsed.findings) {
|
|
1928
|
+
if (typeof finding !== "object" || finding === null) continue;
|
|
1929
|
+
const rec = finding as Record<string, unknown>;
|
|
1930
|
+
const priority =
|
|
1931
|
+
String(rec.priority ?? "")
|
|
1932
|
+
.toUpperCase()
|
|
1933
|
+
.match(/^P[0-3]$/)?.[0] ?? "";
|
|
1934
|
+
const location = String(rec.location ?? "").trim();
|
|
1935
|
+
const findingText = String(rec.finding ?? "").trim();
|
|
1936
|
+
const suggestion = String(rec.suggestion ?? "").trim();
|
|
1937
|
+
if (!priority || !location || !findingText || !suggestion) continue;
|
|
1938
|
+
findings.push({ priority: priority as Priority, location, finding: findingText, suggestion });
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
if (findings.length === 0 && parsed.findings.length > 0) {
|
|
1942
|
+
throw new Error("All findings in focus output are malformed.");
|
|
1943
|
+
}
|
|
1944
|
+
return findings;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function parseReviewDedupOutput(parsed: unknown, totalFindings: number): ReviewDedupGroup[] | null {
|
|
1948
|
+
if (
|
|
1949
|
+
!parsed ||
|
|
1950
|
+
typeof parsed !== "object" ||
|
|
1951
|
+
!("groups" in parsed) ||
|
|
1952
|
+
!Array.isArray(parsed.groups)
|
|
1953
|
+
) {
|
|
1954
|
+
return null;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const groups: ReviewDedupGroup[] = [];
|
|
1958
|
+
const seenIds = new Set<number>();
|
|
1959
|
+
|
|
1960
|
+
for (const group of parsed.groups) {
|
|
1961
|
+
if (
|
|
1962
|
+
typeof group !== "object" ||
|
|
1963
|
+
group === null ||
|
|
1964
|
+
!("ids" in group) ||
|
|
1965
|
+
!Array.isArray(group.ids)
|
|
1966
|
+
) {
|
|
1967
|
+
return null;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
const normalizedIds = group.ids
|
|
1971
|
+
.map((value: unknown) => Number(value))
|
|
1972
|
+
.filter((id: number): id is number => Number.isInteger(id) && id >= 1 && id <= totalFindings);
|
|
1973
|
+
const ids = [...new Set<number>(normalizedIds)].sort((a, b) => a - b);
|
|
1974
|
+
if (ids.length < 2) {
|
|
1975
|
+
return null;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
for (const id of ids) {
|
|
1979
|
+
if (seenIds.has(id)) {
|
|
1980
|
+
return null;
|
|
1981
|
+
}
|
|
1982
|
+
seenIds.add(id);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
groups.push({ ids });
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
return groups;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function validateTriageOutput(parsed: unknown, feedbackItems: TriageFeedbackItem[]): TriageItem[] {
|
|
1992
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1993
|
+
throw new Error("Triage output must be a JSON object.");
|
|
1994
|
+
}
|
|
1995
|
+
if (!("items" in parsed) || !Array.isArray(parsed.items)) {
|
|
1996
|
+
throw new Error('Triage output is missing required "items" array.');
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
const knownIds = new Set(feedbackItems.map((item) => item.id));
|
|
2000
|
+
const triageById = new Map<string, TriageItem>();
|
|
2001
|
+
for (const item of parsed.items) {
|
|
2002
|
+
if (typeof item !== "object" || item === null) continue;
|
|
2003
|
+
const record = item as Record<string, unknown>;
|
|
2004
|
+
const id = String(record.id ?? "").trim();
|
|
2005
|
+
const decision = String(record.decision ?? "").trim();
|
|
2006
|
+
const summary = String(record.summary ?? "").trim();
|
|
2007
|
+
const rationale = String(record.rationale ?? "").trim();
|
|
2008
|
+
const action = String(record.action ?? "").trim();
|
|
2009
|
+
if (!id || !knownIds.has(id)) continue;
|
|
2010
|
+
if (
|
|
2011
|
+
decision !== "address" &&
|
|
2012
|
+
decision !== "push_back" &&
|
|
2013
|
+
decision !== "research" &&
|
|
2014
|
+
decision !== "ignore"
|
|
2015
|
+
) {
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
if (!summary || !rationale || !action) continue;
|
|
2019
|
+
if (triageById.has(id)) {
|
|
2020
|
+
throw new Error(`Triage output contains duplicate id ${id}.`);
|
|
2021
|
+
}
|
|
2022
|
+
triageById.set(id, {
|
|
2023
|
+
id,
|
|
2024
|
+
decision: decision as TriageDecision,
|
|
2025
|
+
summary,
|
|
2026
|
+
rationale,
|
|
2027
|
+
action,
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
if (triageById.size === 0 && parsed.items.length > 0) {
|
|
2032
|
+
throw new Error("All triage items are malformed.");
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
const missingIds = feedbackItems
|
|
2036
|
+
.filter((item) => !triageById.has(item.id))
|
|
2037
|
+
.map((item) => item.id);
|
|
2038
|
+
if (missingIds.length > 0) {
|
|
2039
|
+
const preview = missingIds.slice(0, 5).join(", ");
|
|
2040
|
+
const suffix = missingIds.length > 5 ? ", ..." : "";
|
|
2041
|
+
throw new Error(
|
|
2042
|
+
`Triage output is missing ${missingIds.length} feedback item(s): ${preview}${suffix}`,
|
|
2043
|
+
);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
return feedbackItems.map((item) => triageById.get(item.id) as TriageItem);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function extractStructuredErrorRecord(value: unknown, depth = 0): Record<string, unknown> | null {
|
|
2050
|
+
if (depth > 4) return null;
|
|
2051
|
+
|
|
2052
|
+
const record = asRecord(value);
|
|
2053
|
+
if (!record) return null;
|
|
2054
|
+
|
|
2055
|
+
for (const key of ["error", "details", "response", "body"]) {
|
|
2056
|
+
const nested = extractStructuredErrorRecord(record[key], depth + 1);
|
|
2057
|
+
if (nested) return nested;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
return record;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function extractStructuredErrorMessage(value: unknown, depth = 0): string | undefined {
|
|
2064
|
+
if (depth > 4) return undefined;
|
|
2065
|
+
if (typeof value === "string") {
|
|
2066
|
+
const trimmed = value.trim();
|
|
2067
|
+
return trimmed || undefined;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
if (Array.isArray(value)) {
|
|
2071
|
+
for (const item of value) {
|
|
2072
|
+
const message = extractStructuredErrorMessage(item, depth + 1);
|
|
2073
|
+
if (message) return message;
|
|
2074
|
+
}
|
|
2075
|
+
return undefined;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
const record = asRecord(value);
|
|
2079
|
+
if (!record) return undefined;
|
|
2080
|
+
|
|
2081
|
+
for (const key of ["message", "detail", "error_description", "title", "reason"]) {
|
|
2082
|
+
const candidate = record[key];
|
|
2083
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
2084
|
+
return candidate.trim();
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
for (const key of ["error", "details", "response", "body"]) {
|
|
2089
|
+
const message = extractStructuredErrorMessage(record[key], depth + 1);
|
|
2090
|
+
if (message) return message;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
return undefined;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function parseStructuredErrorPayload(detail: string | undefined): Record<string, unknown> | null {
|
|
2097
|
+
const trimmed = detail?.trim();
|
|
2098
|
+
if (!trimmed) return null;
|
|
2099
|
+
|
|
2100
|
+
const candidates = [trimmed];
|
|
2101
|
+
const jsonStart = trimmed.indexOf("{");
|
|
2102
|
+
if (jsonStart > 0) {
|
|
2103
|
+
candidates.push(trimmed.slice(jsonStart));
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
for (const candidate of candidates) {
|
|
2107
|
+
try {
|
|
2108
|
+
const parsed = parsePossiblyWrappedJson(candidate);
|
|
2109
|
+
const record = extractStructuredErrorRecord(parsed);
|
|
2110
|
+
if (record) return record;
|
|
2111
|
+
} catch {
|
|
2112
|
+
// Ignore parse failures.
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
return null;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function getStructuredErrorString(
|
|
2120
|
+
value: Record<string, unknown> | null,
|
|
2121
|
+
key: string,
|
|
2122
|
+
): string | undefined {
|
|
2123
|
+
const candidate = value?.[key];
|
|
2124
|
+
return typeof candidate === "string" && candidate.trim() ? candidate.trim() : undefined;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function summarizeErrorDetail(detail: string | undefined): string | undefined {
|
|
2128
|
+
const trimmed = detail?.trim();
|
|
2129
|
+
if (!trimmed) return undefined;
|
|
2130
|
+
|
|
2131
|
+
const payload = parseStructuredErrorPayload(trimmed);
|
|
2132
|
+
if (payload) {
|
|
2133
|
+
return extractStructuredErrorMessage(payload) ?? trimmed;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
try {
|
|
2137
|
+
const parsed = parsePossiblyWrappedJson(trimmed);
|
|
2138
|
+
return extractStructuredErrorMessage(parsed) ?? trimmed;
|
|
2139
|
+
} catch {
|
|
2140
|
+
return trimmed;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
function appendErrorDetails(message: string, detail: string | undefined): string {
|
|
2145
|
+
const summarized = summarizeErrorDetail(detail);
|
|
2146
|
+
if (!summarized || summarized === message) return message;
|
|
2147
|
+
return `${message} Details: ${summarized}`;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function buildProviderErrorMessage(message: string, detail: string | undefined): string {
|
|
2151
|
+
const summarized = summarizeErrorDetail(detail);
|
|
2152
|
+
if (!summarized || summarized === message) return message;
|
|
2153
|
+
if (summarized !== detail?.trim()) return summarized;
|
|
2154
|
+
return `${message} Details: ${summarized}`;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function classifyFocusError(errorText: string): {
|
|
2158
|
+
errorKind: FocusTaskErrorKind;
|
|
2159
|
+
missingApiProvider?: string;
|
|
2160
|
+
} {
|
|
2161
|
+
if (/Lock file is already being held/i.test(errorText)) {
|
|
2162
|
+
return { errorKind: "lock_contention" };
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
const apiKeyMatch = errorText.match(/No API key found for\s+([\w.-]+)/i);
|
|
2166
|
+
if (apiKeyMatch?.[1]) {
|
|
2167
|
+
return { errorKind: "missing_api_key", missingApiProvider: apiKeyMatch[1] };
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const authFailedMatch = errorText.match(/Authentication failed for\s+"([\w.-]+)"/i);
|
|
2171
|
+
if (authFailedMatch?.[1]) {
|
|
2172
|
+
return { errorKind: "missing_api_key", missingApiProvider: authFailedMatch[1] };
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
const payload = parseStructuredErrorPayload(errorText);
|
|
2176
|
+
const structuredMessage = getStructuredErrorString(payload, "message")?.toLowerCase();
|
|
2177
|
+
const structuredCode = getStructuredErrorString(payload, "code")?.toLowerCase();
|
|
2178
|
+
const structuredParam = getStructuredErrorString(payload, "param")?.toLowerCase();
|
|
2179
|
+
|
|
2180
|
+
if (structuredCode === "model_not_supported" || structuredParam === "model") {
|
|
2181
|
+
return { errorKind: "unsupported_model" };
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
if (
|
|
2185
|
+
structuredParam?.includes("reasoning") ||
|
|
2186
|
+
structuredParam?.includes("thinking") ||
|
|
2187
|
+
structuredCode?.includes("reasoning") ||
|
|
2188
|
+
structuredCode?.includes("thinking") ||
|
|
2189
|
+
structuredMessage?.includes("reasoning") ||
|
|
2190
|
+
structuredMessage?.includes("thinking")
|
|
2191
|
+
) {
|
|
2192
|
+
return { errorKind: "unsupported_reasoning" };
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
if (/rate.?limit|too many requests|\b429\b/i.test(errorText)) {
|
|
2196
|
+
return { errorKind: "rate_limit" };
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
const summarized = summarizeErrorDetail(errorText)?.toLowerCase();
|
|
2200
|
+
if (summarized?.includes("service_tier") && summarized.includes("not supported")) {
|
|
2201
|
+
return { errorKind: "unsupported_model" };
|
|
2202
|
+
}
|
|
2203
|
+
if (summarized?.includes("requested model is not supported")) {
|
|
2204
|
+
return { errorKind: "unsupported_model" };
|
|
2205
|
+
}
|
|
2206
|
+
if (
|
|
2207
|
+
summarized &&
|
|
2208
|
+
(summarized.includes("reasoning") || summarized.includes("thinking")) &&
|
|
2209
|
+
summarized.includes("not supported")
|
|
2210
|
+
) {
|
|
2211
|
+
return { errorKind: "unsupported_reasoning" };
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
return { errorKind: "other" };
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
function formatThinkingFallbackError(
|
|
2218
|
+
result: FocusTaskResult,
|
|
2219
|
+
triedLevels: ReviewThinkingLevel[],
|
|
2220
|
+
): FocusTaskResult {
|
|
2221
|
+
if (triedLevels.length <= 1 || !result.error) return result;
|
|
2222
|
+
return {
|
|
2223
|
+
...result,
|
|
2224
|
+
error: `Automatic reasoning fallback failed after trying ${triedLevels.join(" -> ")}. Last error: ${result.error}`,
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function getExplicitThinkingSupportError(attempt: FocusTaskAttempt): string | undefined {
|
|
2229
|
+
if (attempt.model.thinkingSource !== "explicit" || !attempt.currentThinkingLevel)
|
|
2230
|
+
return undefined;
|
|
2231
|
+
if (
|
|
2232
|
+
attempt.currentThinkingLevel !== "off" &&
|
|
2233
|
+
attempt.providerCandidate.supportsThinking === false
|
|
2234
|
+
) {
|
|
2235
|
+
return `Reasoning level '${attempt.currentThinkingLevel}' is not supported by this model.`;
|
|
2236
|
+
}
|
|
2237
|
+
if (
|
|
2238
|
+
attempt.currentThinkingLevel === "xhigh" &&
|
|
2239
|
+
attempt.providerCandidate.supportsXhigh === false
|
|
2240
|
+
) {
|
|
2241
|
+
return "Reasoning level 'xhigh' is not supported by this model.";
|
|
2242
|
+
}
|
|
2243
|
+
return undefined;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
function createUnsupportedReasoningFocusAttemptResult(
|
|
2247
|
+
attempt: FocusTaskAttempt,
|
|
2248
|
+
error: string,
|
|
2249
|
+
): FocusTaskResult {
|
|
2250
|
+
return {
|
|
2251
|
+
focus: attempt.focus,
|
|
2252
|
+
model: buildFocusTaskAttemptModelLabel(attempt),
|
|
2253
|
+
ok: false,
|
|
2254
|
+
error,
|
|
2255
|
+
errorKind: "unsupported_reasoning",
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function createCancelledFocusAttemptResult(attempt: FocusTaskAttempt): FocusTaskResult {
|
|
2260
|
+
return {
|
|
2261
|
+
focus: attempt.focus,
|
|
2262
|
+
model: buildFocusTaskAttemptModelLabel(attempt),
|
|
2263
|
+
ok: false,
|
|
2264
|
+
error: REVIEW_CANCELLED_ERROR,
|
|
2265
|
+
errorKind: "other",
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
function createCancelledFocusResult(task: FocusTask): FocusTaskResult {
|
|
2270
|
+
return createCancelledFocusAttemptResult(
|
|
2271
|
+
createFocusTaskAttempt(task, getResolvedReviewPrimaryProviderCandidate(task.model)),
|
|
2272
|
+
);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
async function runPiJsonTask({
|
|
2276
|
+
args,
|
|
2277
|
+
prompt,
|
|
2278
|
+
cwd,
|
|
2279
|
+
timeoutMs,
|
|
2280
|
+
control,
|
|
2281
|
+
}: PiJsonTaskOptions): Promise<PiJsonTaskResult> {
|
|
2282
|
+
if (control?.isCancelled()) {
|
|
2283
|
+
return {
|
|
2284
|
+
status: "cancelled",
|
|
2285
|
+
assistantOutput: "",
|
|
2286
|
+
stderr: "",
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
return new Promise<PiJsonTaskResult>((resolve) => {
|
|
2291
|
+
const proc = spawn("pi", [...args, prompt], {
|
|
2292
|
+
cwd,
|
|
2293
|
+
shell: false,
|
|
2294
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2295
|
+
env: process.env,
|
|
2296
|
+
});
|
|
2297
|
+
const unregisterProcess = control?.registerProcess(proc);
|
|
2298
|
+
|
|
2299
|
+
let stdoutBuffer = "";
|
|
2300
|
+
let latestAssistantOutput = "";
|
|
2301
|
+
let latestAssistantError = "";
|
|
2302
|
+
let stderr = "";
|
|
2303
|
+
let settled = false;
|
|
2304
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
2305
|
+
|
|
2306
|
+
const finish = (result: PiJsonTaskResult) => {
|
|
2307
|
+
if (settled) return;
|
|
2308
|
+
settled = true;
|
|
2309
|
+
if (timeoutId) {
|
|
2310
|
+
clearTimeout(timeoutId);
|
|
2311
|
+
timeoutId = undefined;
|
|
2312
|
+
}
|
|
2313
|
+
unregisterProcess?.();
|
|
2314
|
+
resolve(result);
|
|
2315
|
+
};
|
|
2316
|
+
|
|
2317
|
+
const processLine = (line: string) => {
|
|
2318
|
+
const trimmed = line.trim();
|
|
2319
|
+
if (!trimmed) return;
|
|
2320
|
+
try {
|
|
2321
|
+
const event = JSON.parse(trimmed);
|
|
2322
|
+
const message = extractAssistantMessageFromEvent(event);
|
|
2323
|
+
if (message) {
|
|
2324
|
+
const text = extractTextContent(message.content);
|
|
2325
|
+
if (text) latestAssistantOutput = text;
|
|
2326
|
+
latestAssistantError =
|
|
2327
|
+
message.stopReason === "error" && typeof message.errorMessage === "string"
|
|
2328
|
+
? message.errorMessage
|
|
2329
|
+
: "";
|
|
2330
|
+
}
|
|
2331
|
+
} catch {
|
|
2332
|
+
// Ignore non-JSON lines.
|
|
2333
|
+
}
|
|
2334
|
+
};
|
|
2335
|
+
|
|
2336
|
+
if (control?.isCancelled()) {
|
|
2337
|
+
try {
|
|
2338
|
+
proc.kill("SIGKILL");
|
|
2339
|
+
} catch {
|
|
2340
|
+
// Best effort.
|
|
2341
|
+
}
|
|
2342
|
+
finish({
|
|
2343
|
+
status: "cancelled",
|
|
2344
|
+
assistantOutput: latestAssistantOutput,
|
|
2345
|
+
stderr,
|
|
2346
|
+
});
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
timeoutId = setTimeout(() => {
|
|
2351
|
+
try {
|
|
2352
|
+
proc.kill("SIGKILL");
|
|
2353
|
+
} catch {
|
|
2354
|
+
// Best effort.
|
|
2355
|
+
}
|
|
2356
|
+
finish({
|
|
2357
|
+
status: "timeout",
|
|
2358
|
+
assistantOutput: latestAssistantOutput,
|
|
2359
|
+
stderr,
|
|
2360
|
+
});
|
|
2361
|
+
}, timeoutMs);
|
|
2362
|
+
|
|
2363
|
+
proc.stdout.on("data", (chunk) => {
|
|
2364
|
+
stdoutBuffer += chunk.toString();
|
|
2365
|
+
const lines = stdoutBuffer.split("\n");
|
|
2366
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
2367
|
+
for (const line of lines) {
|
|
2368
|
+
processLine(line);
|
|
2369
|
+
}
|
|
2370
|
+
});
|
|
2371
|
+
|
|
2372
|
+
proc.stderr.on("data", (chunk) => {
|
|
2373
|
+
stderr += chunk.toString();
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
proc.on("error", (error) => {
|
|
2377
|
+
finish({
|
|
2378
|
+
status: "spawn_error",
|
|
2379
|
+
assistantOutput: latestAssistantOutput,
|
|
2380
|
+
stderr,
|
|
2381
|
+
error: error.message,
|
|
2382
|
+
});
|
|
2383
|
+
});
|
|
2384
|
+
|
|
2385
|
+
proc.on("close", (code) => {
|
|
2386
|
+
if (stdoutBuffer.trim()) {
|
|
2387
|
+
processLine(stdoutBuffer);
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
if ((code ?? 1) !== 0) {
|
|
2391
|
+
finish({
|
|
2392
|
+
status: "non_zero_exit",
|
|
2393
|
+
assistantOutput: latestAssistantOutput,
|
|
2394
|
+
stderr,
|
|
2395
|
+
exitCode: code ?? 1,
|
|
2396
|
+
});
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
if (latestAssistantError) {
|
|
2401
|
+
finish({
|
|
2402
|
+
status: "assistant_error",
|
|
2403
|
+
assistantOutput: latestAssistantOutput,
|
|
2404
|
+
stderr,
|
|
2405
|
+
error: latestAssistantError,
|
|
2406
|
+
exitCode: 0,
|
|
2407
|
+
});
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
finish({
|
|
2412
|
+
status: "ok",
|
|
2413
|
+
assistantOutput: latestAssistantOutput,
|
|
2414
|
+
stderr,
|
|
2415
|
+
exitCode: 0,
|
|
2416
|
+
});
|
|
2417
|
+
});
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
async function runFocusTaskOnce(
|
|
2422
|
+
task: FocusTaskAttempt,
|
|
2423
|
+
cwd: string,
|
|
2424
|
+
control?: ReviewExecutionControl,
|
|
2425
|
+
): Promise<FocusTaskResult> {
|
|
2426
|
+
const modelLabel = buildFocusTaskAttemptModelLabel(task);
|
|
2427
|
+
const modelArg = getFocusTaskAttemptModelArg(task);
|
|
2428
|
+
const args = [
|
|
2429
|
+
"--mode",
|
|
2430
|
+
"json",
|
|
2431
|
+
"-p",
|
|
2432
|
+
"--no-session",
|
|
2433
|
+
"--tools",
|
|
2434
|
+
REVIEW_FOCUS_TOOLS,
|
|
2435
|
+
"--no-extensions",
|
|
2436
|
+
"--no-skills",
|
|
2437
|
+
"--no-prompt-templates",
|
|
2438
|
+
"--no-themes",
|
|
2439
|
+
];
|
|
2440
|
+
if (modelArg) {
|
|
2441
|
+
args.push("--model", modelArg);
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
const taskResult = await runPiJsonTask({
|
|
2445
|
+
args,
|
|
2446
|
+
prompt: task.prompt,
|
|
2447
|
+
cwd,
|
|
2448
|
+
timeoutMs: REVIEW_TASK_TIMEOUT_MS,
|
|
2449
|
+
control,
|
|
2450
|
+
});
|
|
2451
|
+
|
|
2452
|
+
if (taskResult.status === "cancelled") {
|
|
2453
|
+
return createCancelledFocusAttemptResult(task);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
if (taskResult.status === "timeout") {
|
|
2457
|
+
return {
|
|
2458
|
+
focus: task.focus,
|
|
2459
|
+
model: modelLabel,
|
|
2460
|
+
ok: false,
|
|
2461
|
+
error: "Review timed out after 30 minutes.",
|
|
2462
|
+
errorKind: "other",
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
if (taskResult.status === "spawn_error") {
|
|
2467
|
+
const error = `Failed to start focus process: ${taskResult.error ?? "unknown error"}`;
|
|
2468
|
+
return {
|
|
2469
|
+
focus: task.focus,
|
|
2470
|
+
model: modelLabel,
|
|
2471
|
+
ok: false,
|
|
2472
|
+
error,
|
|
2473
|
+
...classifyFocusError(error),
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
if (taskResult.status === "non_zero_exit") {
|
|
2478
|
+
const stderr = taskResult.stderr.trim();
|
|
2479
|
+
const error = `Focus exited with code ${taskResult.exitCode ?? 1}${stderr ? `: ${stderr}` : ""}`;
|
|
2480
|
+
return {
|
|
2481
|
+
focus: task.focus,
|
|
2482
|
+
model: modelLabel,
|
|
2483
|
+
ok: false,
|
|
2484
|
+
error,
|
|
2485
|
+
...classifyFocusError(`${taskResult.stderr}\n${error}`),
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
if (taskResult.status === "assistant_error") {
|
|
2490
|
+
const classification = classifyFocusError(taskResult.error ?? "");
|
|
2491
|
+
const error =
|
|
2492
|
+
classification.errorKind === "missing_api_key"
|
|
2493
|
+
? `Missing API key for provider '${classification.missingApiProvider ?? "unknown"}'. Use /login or configure credentials for that provider.`
|
|
2494
|
+
: classification.errorKind === "rate_limit"
|
|
2495
|
+
? appendErrorDetails(
|
|
2496
|
+
"Focus failed due to rate limiting. Try again later or switch models.",
|
|
2497
|
+
taskResult.error,
|
|
2498
|
+
)
|
|
2499
|
+
: buildProviderErrorMessage("Focus failed due to a provider error.", taskResult.error);
|
|
2500
|
+
return {
|
|
2501
|
+
focus: task.focus,
|
|
2502
|
+
model: modelLabel,
|
|
2503
|
+
ok: false,
|
|
2504
|
+
error,
|
|
2505
|
+
...classification,
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
const assistantOutput = taskResult.assistantOutput;
|
|
2510
|
+
if (!assistantOutput.trim()) {
|
|
2511
|
+
return {
|
|
2512
|
+
focus: task.focus,
|
|
2513
|
+
model: modelLabel,
|
|
2514
|
+
ok: false,
|
|
2515
|
+
error: "Focus returned no assistant output.",
|
|
2516
|
+
errorKind: "other",
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
try {
|
|
2521
|
+
const parsed = parsePossiblyWrappedJson(assistantOutput);
|
|
2522
|
+
const findings = validateFocusOutput(parsed);
|
|
2523
|
+
return {
|
|
2524
|
+
focus: task.focus,
|
|
2525
|
+
model: modelLabel,
|
|
2526
|
+
ok: true,
|
|
2527
|
+
output: { focus: task.focus, model: modelLabel, findings },
|
|
2528
|
+
};
|
|
2529
|
+
} catch (error) {
|
|
2530
|
+
return {
|
|
2531
|
+
focus: task.focus,
|
|
2532
|
+
model: modelLabel,
|
|
2533
|
+
ok: false,
|
|
2534
|
+
error: `Focus output is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
2535
|
+
errorKind: "other",
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
async function runFocusTaskWithRetry(
|
|
2541
|
+
task: FocusTaskAttempt,
|
|
2542
|
+
cwd: string,
|
|
2543
|
+
control?: ReviewExecutionControl,
|
|
2544
|
+
): Promise<FocusTaskResult> {
|
|
2545
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
2546
|
+
const result = await runFocusTaskOnce(task, cwd, control);
|
|
2547
|
+
if (result.ok || attempt >= REVIEW_STARTUP_RETRY_DELAYS_MS.length) return result;
|
|
2548
|
+
|
|
2549
|
+
const retryable =
|
|
2550
|
+
result.errorKind === "lock_contention" || result.errorKind === "missing_api_key";
|
|
2551
|
+
if (!retryable) return result;
|
|
2552
|
+
if (control?.isCancelled()) {
|
|
2553
|
+
return createCancelledFocusAttemptResult(task);
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
const baseDelayMs =
|
|
2557
|
+
REVIEW_STARTUP_RETRY_DELAYS_MS[attempt] ??
|
|
2558
|
+
REVIEW_STARTUP_RETRY_DELAYS_MS[REVIEW_STARTUP_RETRY_DELAYS_MS.length - 1];
|
|
2559
|
+
await new Promise((resolve) => setTimeout(resolve, withJitter(baseDelayMs)));
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
function getFocusTaskThinkingLevels(
|
|
2564
|
+
task: FocusTask,
|
|
2565
|
+
providerCandidate: ResolvedReviewProviderCandidate,
|
|
2566
|
+
): Array<ReviewThinkingLevel | undefined> {
|
|
2567
|
+
const currentThinkingLevel = getResolvedReviewCurrentThinkingLevel(task.model, providerCandidate);
|
|
2568
|
+
if (task.model.thinkingSource !== "inherited" || !currentThinkingLevel) {
|
|
2569
|
+
return [currentThinkingLevel];
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
return getFallbackThinkingLevels(currentThinkingLevel);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
function getProviderCandidateCacheKey(candidate: ResolvedReviewProviderCandidate): string {
|
|
2576
|
+
return candidate.modelArg ?? candidate.baseModelArg ?? "__default__";
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
function getProviderCandidateAvailabilityMap(
|
|
2580
|
+
model: ResolvedReviewModel,
|
|
2581
|
+
): Map<string, "supported" | "unsupported"> {
|
|
2582
|
+
let cache = providerCandidateAvailabilityCache.get(model);
|
|
2583
|
+
if (!cache) {
|
|
2584
|
+
cache = new Map();
|
|
2585
|
+
providerCandidateAvailabilityCache.set(model, cache);
|
|
2586
|
+
}
|
|
2587
|
+
return cache;
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
function getProviderCandidateProbeMap(
|
|
2591
|
+
model: ResolvedReviewModel,
|
|
2592
|
+
): Map<string, Promise<"supported" | "unsupported">> {
|
|
2593
|
+
let cache = providerCandidateProbeCache.get(model);
|
|
2594
|
+
if (!cache) {
|
|
2595
|
+
cache = new Map();
|
|
2596
|
+
providerCandidateProbeCache.set(model, cache);
|
|
2597
|
+
}
|
|
2598
|
+
return cache;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
function getProviderCandidateAvailability(
|
|
2602
|
+
model: ResolvedReviewModel,
|
|
2603
|
+
candidate: ResolvedReviewProviderCandidate,
|
|
2604
|
+
): "supported" | "unsupported" | undefined {
|
|
2605
|
+
return getProviderCandidateAvailabilityMap(model).get(getProviderCandidateCacheKey(candidate));
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
function setProviderCandidateAvailability(
|
|
2609
|
+
model: ResolvedReviewModel,
|
|
2610
|
+
candidate: ResolvedReviewProviderCandidate,
|
|
2611
|
+
availability: "supported" | "unsupported",
|
|
2612
|
+
): void {
|
|
2613
|
+
getProviderCandidateAvailabilityMap(model).set(
|
|
2614
|
+
getProviderCandidateCacheKey(candidate),
|
|
2615
|
+
availability,
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
function getProviderCandidateProbe(
|
|
2620
|
+
model: ResolvedReviewModel,
|
|
2621
|
+
candidate: ResolvedReviewProviderCandidate,
|
|
2622
|
+
): Promise<"supported" | "unsupported"> | undefined {
|
|
2623
|
+
return getProviderCandidateProbeMap(model).get(getProviderCandidateCacheKey(candidate));
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
function setProviderCandidateProbe(
|
|
2627
|
+
model: ResolvedReviewModel,
|
|
2628
|
+
candidate: ResolvedReviewProviderCandidate,
|
|
2629
|
+
probe: Promise<"supported" | "unsupported">,
|
|
2630
|
+
): void {
|
|
2631
|
+
getProviderCandidateProbeMap(model).set(getProviderCandidateCacheKey(candidate), probe);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
function clearProviderCandidateProbe(
|
|
2635
|
+
model: ResolvedReviewModel,
|
|
2636
|
+
candidate: ResolvedReviewProviderCandidate,
|
|
2637
|
+
): void {
|
|
2638
|
+
getProviderCandidateProbeMap(model).delete(getProviderCandidateCacheKey(candidate));
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
function getProviderCandidateAvailabilityFromResult(
|
|
2642
|
+
result: FocusTaskResult,
|
|
2643
|
+
): "supported" | "unsupported" {
|
|
2644
|
+
return result.errorKind === "unsupported_model" || result.errorKind === "unsupported_reasoning"
|
|
2645
|
+
? "unsupported"
|
|
2646
|
+
: "supported";
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
async function runFocusTaskForProviderCandidate(
|
|
2650
|
+
task: FocusTask,
|
|
2651
|
+
providerCandidate: ResolvedReviewProviderCandidate,
|
|
2652
|
+
cwd: string,
|
|
2653
|
+
control?: ReviewExecutionControl,
|
|
2654
|
+
): Promise<FocusTaskResult> {
|
|
2655
|
+
const thinkingLevels = getFocusTaskThinkingLevels(task, providerCandidate);
|
|
2656
|
+
const triedLevels: ReviewThinkingLevel[] = [];
|
|
2657
|
+
let lastResult: FocusTaskResult | undefined;
|
|
2658
|
+
|
|
2659
|
+
for (let index = 0; index < thinkingLevels.length; index += 1) {
|
|
2660
|
+
const currentThinkingLevel = thinkingLevels[index];
|
|
2661
|
+
const attempt = createFocusTaskAttempt(task, providerCandidate, currentThinkingLevel);
|
|
2662
|
+
const explicitThinkingSupportError = getExplicitThinkingSupportError(attempt);
|
|
2663
|
+
if (explicitThinkingSupportError) {
|
|
2664
|
+
return createUnsupportedReasoningFocusAttemptResult(attempt, explicitThinkingSupportError);
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
const result = await runFocusTaskWithRetry(attempt, cwd, control);
|
|
2668
|
+
if (result.ok) return result;
|
|
2669
|
+
|
|
2670
|
+
lastResult = result;
|
|
2671
|
+
if (currentThinkingLevel !== undefined) {
|
|
2672
|
+
triedLevels.push(currentThinkingLevel);
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
if (result.errorKind === "unsupported_model") {
|
|
2676
|
+
return result;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
const hasLowerThinkingLevel = index < thinkingLevels.length - 1;
|
|
2680
|
+
const shouldFallback = hasLowerThinkingLevel && result.errorKind === "unsupported_reasoning";
|
|
2681
|
+
if (!shouldFallback) {
|
|
2682
|
+
return formatThinkingFallbackError(result, triedLevels);
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
return lastResult ?? createCancelledFocusResult(task);
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
async function runFocusTask(
|
|
2690
|
+
task: FocusTask,
|
|
2691
|
+
cwd: string,
|
|
2692
|
+
control?: ReviewExecutionControl,
|
|
2693
|
+
): Promise<FocusTaskResult> {
|
|
2694
|
+
if (control?.isCancelled()) {
|
|
2695
|
+
return createCancelledFocusResult(task);
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
let lastResult: FocusTaskResult | undefined;
|
|
2699
|
+
|
|
2700
|
+
for (const providerCandidate of task.model.providerCandidates) {
|
|
2701
|
+
const availability = getProviderCandidateAvailability(task.model, providerCandidate);
|
|
2702
|
+
if (availability === "unsupported") {
|
|
2703
|
+
continue;
|
|
2704
|
+
}
|
|
2705
|
+
if (availability === "supported") {
|
|
2706
|
+
const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, control);
|
|
2707
|
+
if (result.ok) return result;
|
|
2708
|
+
lastResult = result;
|
|
2709
|
+
if (
|
|
2710
|
+
result.errorKind === "unsupported_model" ||
|
|
2711
|
+
result.errorKind === "unsupported_reasoning"
|
|
2712
|
+
) {
|
|
2713
|
+
setProviderCandidateAvailability(task.model, providerCandidate, "unsupported");
|
|
2714
|
+
continue;
|
|
2715
|
+
}
|
|
2716
|
+
return result;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
const existingProbe = getProviderCandidateProbe(task.model, providerCandidate);
|
|
2720
|
+
if (existingProbe) {
|
|
2721
|
+
const probedAvailability = await existingProbe;
|
|
2722
|
+
if (probedAvailability === "unsupported") {
|
|
2723
|
+
continue;
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, control);
|
|
2727
|
+
if (result.ok) return result;
|
|
2728
|
+
lastResult = result;
|
|
2729
|
+
if (
|
|
2730
|
+
result.errorKind === "unsupported_model" ||
|
|
2731
|
+
result.errorKind === "unsupported_reasoning"
|
|
2732
|
+
) {
|
|
2733
|
+
setProviderCandidateAvailability(task.model, providerCandidate, "unsupported");
|
|
2734
|
+
continue;
|
|
2735
|
+
}
|
|
2736
|
+
return result;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
let resolveProbe: ((value: "supported" | "unsupported") => void) | undefined;
|
|
2740
|
+
let rejectProbe: ((reason?: unknown) => void) | undefined;
|
|
2741
|
+
const probe = new Promise<"supported" | "unsupported">((resolve, reject) => {
|
|
2742
|
+
resolveProbe = resolve;
|
|
2743
|
+
rejectProbe = reject;
|
|
2744
|
+
});
|
|
2745
|
+
setProviderCandidateProbe(task.model, providerCandidate, probe);
|
|
2746
|
+
|
|
2747
|
+
try {
|
|
2748
|
+
const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, control);
|
|
2749
|
+
lastResult = result;
|
|
2750
|
+
const probedAvailability = getProviderCandidateAvailabilityFromResult(result);
|
|
2751
|
+
setProviderCandidateAvailability(task.model, providerCandidate, probedAvailability);
|
|
2752
|
+
resolveProbe?.(probedAvailability);
|
|
2753
|
+
if (result.ok) return result;
|
|
2754
|
+
if (probedAvailability === "unsupported") {
|
|
2755
|
+
continue;
|
|
2756
|
+
}
|
|
2757
|
+
return result;
|
|
2758
|
+
} catch (error) {
|
|
2759
|
+
rejectProbe?.(error);
|
|
2760
|
+
throw error;
|
|
2761
|
+
} finally {
|
|
2762
|
+
clearProviderCandidateProbe(task.model, providerCandidate);
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
return lastResult ?? createCancelledFocusResult(task);
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
async function runReviewDedupTask(options: {
|
|
2770
|
+
ctx: ExtensionCommandContext;
|
|
2771
|
+
cwd: string;
|
|
2772
|
+
findings: ReviewReportFinding[];
|
|
2773
|
+
control?: ReviewExecutionControl;
|
|
2774
|
+
}): Promise<ReviewDedupGroup[] | null> {
|
|
2775
|
+
const { ctx, cwd, findings, control } = options;
|
|
2776
|
+
if (findings.length <= 1) return [];
|
|
2777
|
+
if (control?.isCancelled()) return null;
|
|
2778
|
+
|
|
2779
|
+
const model = selectReviewDedupModel(ctx);
|
|
2780
|
+
if (!model) return null;
|
|
2781
|
+
|
|
2782
|
+
const taskResult = await withSpinner(
|
|
2783
|
+
ctx,
|
|
2784
|
+
() => `deduplicating ${findings.length} review findings`,
|
|
2785
|
+
() =>
|
|
2786
|
+
runPiJsonTask({
|
|
2787
|
+
args: [
|
|
2788
|
+
"--mode",
|
|
2789
|
+
"json",
|
|
2790
|
+
"-p",
|
|
2791
|
+
"--no-session",
|
|
2792
|
+
"--no-tools",
|
|
2793
|
+
"--no-extensions",
|
|
2794
|
+
"--no-skills",
|
|
2795
|
+
"--no-prompt-templates",
|
|
2796
|
+
"--no-themes",
|
|
2797
|
+
"--model",
|
|
2798
|
+
model.modelArg,
|
|
2799
|
+
],
|
|
2800
|
+
prompt: buildReviewDedupPrompt(findings),
|
|
2801
|
+
cwd,
|
|
2802
|
+
timeoutMs: REVIEW_TASK_TIMEOUT_MS,
|
|
2803
|
+
control,
|
|
2804
|
+
}),
|
|
2805
|
+
);
|
|
2806
|
+
|
|
2807
|
+
if (taskResult.status !== "ok") {
|
|
2808
|
+
return null;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
try {
|
|
2812
|
+
return parseReviewDedupOutput(
|
|
2813
|
+
parsePossiblyWrappedJson(taskResult.assistantOutput),
|
|
2814
|
+
findings.length,
|
|
2815
|
+
);
|
|
2816
|
+
} catch {
|
|
2817
|
+
return null;
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
async function runTriageTask(options: {
|
|
2822
|
+
ctx: ExtensionCommandContext;
|
|
2823
|
+
cwd: string;
|
|
2824
|
+
prompt: string;
|
|
2825
|
+
model: ResolvedReviewModel;
|
|
2826
|
+
feedbackItems: TriageFeedbackItem[];
|
|
2827
|
+
control?: ReviewExecutionControl;
|
|
2828
|
+
}): Promise<{ ok: true; items: TriageItem[] } | { ok: false; error: string }> {
|
|
2829
|
+
const { ctx, cwd, prompt, model, feedbackItems, control } = options;
|
|
2830
|
+
const args = [
|
|
2831
|
+
"--mode",
|
|
2832
|
+
"json",
|
|
2833
|
+
"-p",
|
|
2834
|
+
"--no-session",
|
|
2835
|
+
"--tools",
|
|
2836
|
+
REVIEW_FOCUS_TOOLS,
|
|
2837
|
+
"--no-extensions",
|
|
2838
|
+
"--no-skills",
|
|
2839
|
+
"--no-prompt-templates",
|
|
2840
|
+
"--no-themes",
|
|
2841
|
+
];
|
|
2842
|
+
const modelArg = getResolvedReviewStatusModelArg(model);
|
|
2843
|
+
if (modelArg) {
|
|
2844
|
+
args.push("--model", modelArg);
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
2848
|
+
if (control?.isCancelled()) {
|
|
2849
|
+
return { ok: false, error: REVIEW_CANCELLED_ERROR };
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
const taskResult = await withSpinner(
|
|
2853
|
+
ctx,
|
|
2854
|
+
() => `triaging PR feedback (${feedbackItems.length} items)`,
|
|
2855
|
+
() =>
|
|
2856
|
+
runPiJsonTask({
|
|
2857
|
+
args,
|
|
2858
|
+
prompt,
|
|
2859
|
+
cwd,
|
|
2860
|
+
timeoutMs: REVIEW_TASK_TIMEOUT_MS,
|
|
2861
|
+
control,
|
|
2862
|
+
}),
|
|
2863
|
+
);
|
|
2864
|
+
|
|
2865
|
+
if (taskResult.status === "cancelled") {
|
|
2866
|
+
return { ok: false, error: REVIEW_CANCELLED_ERROR };
|
|
2867
|
+
}
|
|
2868
|
+
if (taskResult.status === "timeout") {
|
|
2869
|
+
return { ok: false, error: "PR triage timed out after 30 minutes." };
|
|
2870
|
+
}
|
|
2871
|
+
if (taskResult.status === "spawn_error") {
|
|
2872
|
+
return {
|
|
2873
|
+
ok: false,
|
|
2874
|
+
error: `Failed to start triage process: ${taskResult.error ?? "unknown error"}`,
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
if (taskResult.status === "non_zero_exit") {
|
|
2878
|
+
const stderr = taskResult.stderr.trim();
|
|
2879
|
+
const error = `Triage exited with code ${taskResult.exitCode ?? 1}${stderr ? `: ${stderr}` : ""}`;
|
|
2880
|
+
const classification = classifyFocusError(`${taskResult.stderr}\n${error}`);
|
|
2881
|
+
if (classification.errorKind === "missing_api_key") {
|
|
2882
|
+
return {
|
|
2883
|
+
ok: false,
|
|
2884
|
+
error: `Missing API key for provider '${classification.missingApiProvider ?? "unknown"}'. Use /login or configure credentials for that provider.`,
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
if (
|
|
2888
|
+
classification.errorKind === "lock_contention" &&
|
|
2889
|
+
attempt < REVIEW_STARTUP_RETRY_DELAYS_MS.length
|
|
2890
|
+
) {
|
|
2891
|
+
const baseDelayMs =
|
|
2892
|
+
REVIEW_STARTUP_RETRY_DELAYS_MS[attempt] ??
|
|
2893
|
+
REVIEW_STARTUP_RETRY_DELAYS_MS[REVIEW_STARTUP_RETRY_DELAYS_MS.length - 1];
|
|
2894
|
+
await new Promise((resolve) => setTimeout(resolve, withJitter(baseDelayMs)));
|
|
2895
|
+
continue;
|
|
2896
|
+
}
|
|
2897
|
+
return { ok: false, error };
|
|
2898
|
+
}
|
|
2899
|
+
if (taskResult.status === "assistant_error") {
|
|
2900
|
+
const classification = classifyFocusError(taskResult.error ?? "");
|
|
2901
|
+
if (classification.errorKind === "missing_api_key") {
|
|
2902
|
+
return {
|
|
2903
|
+
ok: false,
|
|
2904
|
+
error: `Missing API key for provider '${classification.missingApiProvider ?? "unknown"}'. Use /login or configure credentials for that provider.`,
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
if (classification.errorKind === "rate_limit") {
|
|
2908
|
+
return {
|
|
2909
|
+
ok: false,
|
|
2910
|
+
error: appendErrorDetails(
|
|
2911
|
+
"Triage failed due to rate limiting. Try again later or switch models.",
|
|
2912
|
+
taskResult.error,
|
|
2913
|
+
),
|
|
2914
|
+
};
|
|
2915
|
+
}
|
|
2916
|
+
return {
|
|
2917
|
+
ok: false,
|
|
2918
|
+
error: buildProviderErrorMessage(
|
|
2919
|
+
"Triage failed due to a provider error.",
|
|
2920
|
+
taskResult.error,
|
|
2921
|
+
),
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
const assistantOutput = taskResult.assistantOutput;
|
|
2926
|
+
if (!assistantOutput.trim()) {
|
|
2927
|
+
return { ok: false, error: "Triage returned no assistant output." };
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
try {
|
|
2931
|
+
const parsed = parsePossiblyWrappedJson(assistantOutput);
|
|
2932
|
+
return { ok: true, items: validateTriageOutput(parsed, feedbackItems) };
|
|
2933
|
+
} catch (error) {
|
|
2934
|
+
return {
|
|
2935
|
+
ok: false,
|
|
2936
|
+
error: `Triage output is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
// --- Model resolution & output ---
|
|
2943
|
+
|
|
2944
|
+
const REVIEW_THINKING_LEVEL_ORDER: ReviewThinkingLevel[] = [
|
|
2945
|
+
"off",
|
|
2946
|
+
"minimal",
|
|
2947
|
+
"low",
|
|
2948
|
+
"medium",
|
|
2949
|
+
"high",
|
|
2950
|
+
"xhigh",
|
|
2951
|
+
];
|
|
2952
|
+
const REVIEW_THINKING_LEVELS = new Set<ReviewThinkingLevel>(REVIEW_THINKING_LEVEL_ORDER);
|
|
2953
|
+
|
|
2954
|
+
function detectModelFamily(provider: string): ModelFamily | null {
|
|
2955
|
+
const normalizedProvider = provider.toLowerCase();
|
|
2956
|
+
if (normalizedProvider.includes("openai")) return "openai";
|
|
2957
|
+
if (normalizedProvider.includes("anthropic")) return "anthropic";
|
|
2958
|
+
return null;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
function isReviewThinkingLevel(value: string): value is ReviewThinkingLevel {
|
|
2962
|
+
return REVIEW_THINKING_LEVELS.has(value as ReviewThinkingLevel);
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
function splitModelPatternThinkingSuffix(modelPattern: string): {
|
|
2966
|
+
basePattern: string;
|
|
2967
|
+
thinkingSuffix?: ReviewThinkingLevel;
|
|
2968
|
+
} {
|
|
2969
|
+
const lastColon = modelPattern.lastIndexOf(":");
|
|
2970
|
+
if (lastColon <= 0) return { basePattern: modelPattern };
|
|
2971
|
+
|
|
2972
|
+
const thinkingSuffix = modelPattern
|
|
2973
|
+
.slice(lastColon + 1)
|
|
2974
|
+
.trim()
|
|
2975
|
+
.toLowerCase();
|
|
2976
|
+
if (!isReviewThinkingLevel(thinkingSuffix)) {
|
|
2977
|
+
return { basePattern: modelPattern };
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
const basePattern = modelPattern.slice(0, lastColon).trim();
|
|
2981
|
+
return basePattern ? { basePattern, thinkingSuffix } : { basePattern: modelPattern };
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function clampInheritedThinkingLevel(
|
|
2985
|
+
thinkingLevel: ReviewThinkingLevel,
|
|
2986
|
+
supportsThinking: boolean | undefined,
|
|
2987
|
+
supportsXhighThinking: boolean | undefined,
|
|
2988
|
+
): ReviewThinkingLevel {
|
|
2989
|
+
if (supportsThinking === false) return "off";
|
|
2990
|
+
if (thinkingLevel === "xhigh" && supportsXhighThinking === false) return "high";
|
|
2991
|
+
return thinkingLevel;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
function getFallbackThinkingLevels(thinkingLevel: ReviewThinkingLevel): ReviewThinkingLevel[] {
|
|
2995
|
+
const requestedIndex = REVIEW_THINKING_LEVEL_ORDER.indexOf(thinkingLevel);
|
|
2996
|
+
if (requestedIndex === -1) return [];
|
|
2997
|
+
return REVIEW_THINKING_LEVEL_ORDER.slice(0, requestedIndex + 1).reverse();
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
function buildGlobRegExp(pattern: string): RegExp {
|
|
3001
|
+
let regex = "^";
|
|
3002
|
+
|
|
3003
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
3004
|
+
const char = pattern[index];
|
|
3005
|
+
if (char === "*") {
|
|
3006
|
+
regex += ".*";
|
|
3007
|
+
continue;
|
|
3008
|
+
}
|
|
3009
|
+
if (char === "?") {
|
|
3010
|
+
regex += ".";
|
|
3011
|
+
continue;
|
|
3012
|
+
}
|
|
3013
|
+
if (char === "[") {
|
|
3014
|
+
const closingIndex = pattern.indexOf("]", index + 1);
|
|
3015
|
+
if (closingIndex > index + 1) {
|
|
3016
|
+
regex += pattern.slice(index, closingIndex + 1);
|
|
3017
|
+
index = closingIndex;
|
|
3018
|
+
continue;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
regex += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
return new RegExp(`${regex}$`, "i");
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
function appendModelThinkingSuffix(
|
|
3028
|
+
modelArg: string,
|
|
3029
|
+
thinkingSuffix: ReviewThinkingLevel | undefined,
|
|
3030
|
+
): string {
|
|
3031
|
+
return thinkingSuffix ? `${modelArg}:${thinkingSuffix}` : modelArg;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
function createResolvedReviewProviderCandidate(options: {
|
|
3035
|
+
modelArg?: string;
|
|
3036
|
+
baseModelArg?: string;
|
|
3037
|
+
supportsThinking?: boolean;
|
|
3038
|
+
supportsXhigh?: boolean;
|
|
3039
|
+
}): ResolvedReviewProviderCandidate {
|
|
3040
|
+
return {
|
|
3041
|
+
modelArg: options.modelArg,
|
|
3042
|
+
baseModelArg: options.baseModelArg,
|
|
3043
|
+
supportsThinking: options.supportsThinking,
|
|
3044
|
+
supportsXhigh: options.supportsXhigh,
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
function getResolvedReviewPrimaryProviderCandidate(
|
|
3049
|
+
model: ResolvedReviewModel,
|
|
3050
|
+
): ResolvedReviewProviderCandidate {
|
|
3051
|
+
return model.providerCandidates[0] ?? createResolvedReviewProviderCandidate({});
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
function getResolvedReviewCurrentThinkingLevel(
|
|
3055
|
+
model: ResolvedReviewModel,
|
|
3056
|
+
providerCandidate: ResolvedReviewProviderCandidate,
|
|
3057
|
+
): ReviewThinkingLevel | undefined {
|
|
3058
|
+
if (model.requestedThinkingLevel === undefined) return undefined;
|
|
3059
|
+
return model.thinkingSource === "inherited"
|
|
3060
|
+
? clampInheritedThinkingLevel(
|
|
3061
|
+
model.requestedThinkingLevel,
|
|
3062
|
+
providerCandidate.supportsThinking,
|
|
3063
|
+
providerCandidate.supportsXhigh,
|
|
3064
|
+
)
|
|
3065
|
+
: model.requestedThinkingLevel;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
function shouldShowResolvedReviewThinkingSuffix(
|
|
3069
|
+
thinkingSource: ReviewThinkingSource,
|
|
3070
|
+
requestedThinkingLevel: ReviewThinkingLevel | undefined,
|
|
3071
|
+
currentThinkingLevel: ReviewThinkingLevel | undefined,
|
|
3072
|
+
): boolean {
|
|
3073
|
+
if (!currentThinkingLevel) return false;
|
|
3074
|
+
if (thinkingSource === "explicit") return true;
|
|
3075
|
+
return requestedThinkingLevel !== undefined && requestedThinkingLevel !== currentThinkingLevel;
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
function buildResolvedReviewModelLabel(
|
|
3079
|
+
model: ResolvedReviewModel,
|
|
3080
|
+
currentThinkingLevel: ReviewThinkingLevel | undefined,
|
|
3081
|
+
): string {
|
|
3082
|
+
if (model.thinkingSource === "explicit") return model.modelPattern;
|
|
3083
|
+
return shouldShowResolvedReviewThinkingSuffix(
|
|
3084
|
+
model.thinkingSource,
|
|
3085
|
+
model.requestedThinkingLevel,
|
|
3086
|
+
currentThinkingLevel,
|
|
3087
|
+
)
|
|
3088
|
+
? appendModelThinkingSuffix(model.modelPatternBase, currentThinkingLevel)
|
|
3089
|
+
: model.modelPatternBase;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
function getResolvedReviewStatusModelArg(model: ResolvedReviewModel): string | undefined {
|
|
3093
|
+
const providerCandidate = getResolvedReviewPrimaryProviderCandidate(model);
|
|
3094
|
+
if (providerCandidate.modelArg) return providerCandidate.modelArg;
|
|
3095
|
+
if (!providerCandidate.baseModelArg) return undefined;
|
|
3096
|
+
return appendModelThinkingSuffix(
|
|
3097
|
+
providerCandidate.baseModelArg,
|
|
3098
|
+
getResolvedReviewCurrentThinkingLevel(model, providerCandidate),
|
|
3099
|
+
);
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
function buildResolvedReviewStatusModelLabel(model: ResolvedReviewModel): string {
|
|
3103
|
+
const providerCandidate = getResolvedReviewPrimaryProviderCandidate(model);
|
|
3104
|
+
const currentThinkingLevel = getResolvedReviewCurrentThinkingLevel(model, providerCandidate);
|
|
3105
|
+
if (!providerCandidate.baseModelArg) {
|
|
3106
|
+
return buildResolvedReviewModelLabel(model, currentThinkingLevel);
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
return shouldShowResolvedReviewThinkingSuffix(
|
|
3110
|
+
model.thinkingSource,
|
|
3111
|
+
model.requestedThinkingLevel,
|
|
3112
|
+
currentThinkingLevel,
|
|
3113
|
+
)
|
|
3114
|
+
? appendModelThinkingSuffix(providerCandidate.baseModelArg, currentThinkingLevel)
|
|
3115
|
+
: providerCandidate.baseModelArg;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
function buildFocusTaskAttemptModelLabel(attempt: FocusTaskAttempt): string {
|
|
3119
|
+
return buildResolvedReviewModelLabel(attempt.model, attempt.currentThinkingLevel);
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
function getFocusTaskAttemptModelArg(attempt: FocusTaskAttempt): string | undefined {
|
|
3123
|
+
if (attempt.providerCandidate.modelArg) return attempt.providerCandidate.modelArg;
|
|
3124
|
+
return attempt.providerCandidate.baseModelArg
|
|
3125
|
+
? appendModelThinkingSuffix(
|
|
3126
|
+
attempt.providerCandidate.baseModelArg,
|
|
3127
|
+
attempt.currentThinkingLevel,
|
|
3128
|
+
)
|
|
3129
|
+
: undefined;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
function createResolvedReviewModel(options: {
|
|
3133
|
+
modelPattern: string;
|
|
3134
|
+
modelPatternBase: string;
|
|
3135
|
+
thinkingSource: ReviewThinkingSource;
|
|
3136
|
+
requestedThinkingLevel?: ReviewThinkingLevel;
|
|
3137
|
+
providerCandidates: ResolvedReviewProviderCandidate[];
|
|
3138
|
+
}): ResolvedReviewModel {
|
|
3139
|
+
return {
|
|
3140
|
+
modelPattern: options.modelPattern,
|
|
3141
|
+
modelPatternBase: options.modelPatternBase,
|
|
3142
|
+
thinkingSource: options.thinkingSource,
|
|
3143
|
+
requestedThinkingLevel: options.requestedThinkingLevel,
|
|
3144
|
+
providerCandidates:
|
|
3145
|
+
options.providerCandidates.length > 0
|
|
3146
|
+
? options.providerCandidates
|
|
3147
|
+
: [createResolvedReviewProviderCandidate({})],
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
function createFocusTaskAttempt(
|
|
3152
|
+
task: FocusTask,
|
|
3153
|
+
providerCandidate: ResolvedReviewProviderCandidate,
|
|
3154
|
+
currentThinkingLevel = getResolvedReviewCurrentThinkingLevel(task.model, providerCandidate),
|
|
3155
|
+
): FocusTaskAttempt {
|
|
3156
|
+
return {
|
|
3157
|
+
...task,
|
|
3158
|
+
providerCandidate,
|
|
3159
|
+
currentThinkingLevel,
|
|
3160
|
+
};
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
function selectReviewDedupModel(ctx: ExtensionContext): { modelArg: string } | null {
|
|
3164
|
+
if (!ctx.model) return null;
|
|
3165
|
+
|
|
3166
|
+
const family = detectModelFamily(ctx.model.provider);
|
|
3167
|
+
if (family) {
|
|
3168
|
+
const modelId = family === "openai" ? OPENAI_FAST_MODEL_ID : ANTHROPIC_FAST_MODEL_ID;
|
|
3169
|
+
const providerCandidates =
|
|
3170
|
+
family === "openai"
|
|
3171
|
+
? [ctx.model.provider, "openai-codex", "openai"]
|
|
3172
|
+
: [ctx.model.provider, "anthropic"];
|
|
3173
|
+
const availableModels = ctx.modelRegistry.getAvailable();
|
|
3174
|
+
|
|
3175
|
+
for (const provider of new Set(providerCandidates)) {
|
|
3176
|
+
const candidate = availableModels.find(
|
|
3177
|
+
(model) => model.provider === provider && model.id === modelId,
|
|
3178
|
+
);
|
|
3179
|
+
if (!candidate) continue;
|
|
3180
|
+
|
|
3181
|
+
return {
|
|
3182
|
+
modelArg: `${candidate.provider}/${candidate.id}`,
|
|
3183
|
+
};
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
return {
|
|
3188
|
+
modelArg: `${ctx.model.provider}/${ctx.model.id}`,
|
|
3189
|
+
};
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
function rankModelCandidateGroup<T extends { id: string; provider: string }>(candidates: T[]): T[] {
|
|
3193
|
+
if (candidates.length === 0) return [];
|
|
3194
|
+
|
|
3195
|
+
const aliases = candidates.filter(
|
|
3196
|
+
(candidate) => candidate.id.endsWith("-latest") || !/-\d{8}$/.test(candidate.id),
|
|
3197
|
+
);
|
|
3198
|
+
const ranked = (aliases.length > 0 ? aliases : candidates).slice();
|
|
3199
|
+
ranked.sort((a, b) => b.id.localeCompare(a.id));
|
|
3200
|
+
return ranked;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
function rankPreferredModelCandidates<T extends { id: string; provider: string }>(
|
|
3204
|
+
candidates: T[],
|
|
3205
|
+
currentProvider: string | undefined,
|
|
3206
|
+
): T[] {
|
|
3207
|
+
if (!currentProvider) return rankModelCandidateGroup(candidates);
|
|
3208
|
+
|
|
3209
|
+
const preferredProviderCandidates = candidates.filter(
|
|
3210
|
+
(candidate) => candidate.provider.toLowerCase() === currentProvider.toLowerCase(),
|
|
3211
|
+
);
|
|
3212
|
+
const otherProviderCandidates = candidates.filter(
|
|
3213
|
+
(candidate) => candidate.provider.toLowerCase() !== currentProvider.toLowerCase(),
|
|
3214
|
+
);
|
|
3215
|
+
|
|
3216
|
+
return [
|
|
3217
|
+
...rankModelCandidateGroup(preferredProviderCandidates),
|
|
3218
|
+
...rankModelCandidateGroup(otherProviderCandidates),
|
|
3219
|
+
];
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
function createResolvedReviewProviderCandidateFromModel(
|
|
3223
|
+
model: Model<Api>,
|
|
3224
|
+
): ResolvedReviewProviderCandidate {
|
|
3225
|
+
return createResolvedReviewProviderCandidate({
|
|
3226
|
+
baseModelArg: `${model.provider}/${model.id}`,
|
|
3227
|
+
supportsThinking: model.reasoning,
|
|
3228
|
+
supportsXhigh: supportsXhigh(model),
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
function getProviderFallbackModelKey(model: { id: string }): string {
|
|
3233
|
+
const slash = model.id.lastIndexOf("/");
|
|
3234
|
+
return slash === -1 ? model.id : model.id.slice(slash + 1);
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
function resolveUnqualifiedModelPattern(
|
|
3238
|
+
modelPattern: string,
|
|
3239
|
+
availableModels: Array<Model<Api>>,
|
|
3240
|
+
currentProvider: string | undefined,
|
|
3241
|
+
inheritedThinkingLevel: ReviewThinkingLevel,
|
|
3242
|
+
): ResolvedReviewModel | undefined {
|
|
3243
|
+
const { basePattern, thinkingSuffix } = splitModelPatternThinkingSuffix(modelPattern);
|
|
3244
|
+
const thinkingSource: ReviewThinkingSource = thinkingSuffix ? "explicit" : "inherited";
|
|
3245
|
+
const requestedThinkingLevel = thinkingSuffix ?? inheritedThinkingLevel;
|
|
3246
|
+
const normalizedPattern = basePattern.toLowerCase();
|
|
3247
|
+
const hasWildcard =
|
|
3248
|
+
basePattern.includes("*") || basePattern.includes("?") || basePattern.includes("[");
|
|
3249
|
+
const globMatcher = hasWildcard ? buildGlobRegExp(basePattern) : undefined;
|
|
3250
|
+
const exactMatches = hasWildcard
|
|
3251
|
+
? []
|
|
3252
|
+
: availableModels.filter((model) => model.id.toLowerCase() === normalizedPattern);
|
|
3253
|
+
const candidates =
|
|
3254
|
+
exactMatches.length > 0
|
|
3255
|
+
? exactMatches
|
|
3256
|
+
: availableModels.filter((model) => {
|
|
3257
|
+
if (globMatcher) {
|
|
3258
|
+
const fullReference = `${model.provider}/${model.id}`;
|
|
3259
|
+
return (
|
|
3260
|
+
globMatcher.test(model.id) ||
|
|
3261
|
+
(model.name ? globMatcher.test(model.name) : false) ||
|
|
3262
|
+
globMatcher.test(fullReference)
|
|
3263
|
+
);
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
const byId = model.id.toLowerCase().includes(normalizedPattern);
|
|
3267
|
+
const byName = model.name?.toLowerCase().includes(normalizedPattern) ?? false;
|
|
3268
|
+
return byId || byName;
|
|
3269
|
+
});
|
|
3270
|
+
if (candidates.length === 0) return undefined;
|
|
3271
|
+
|
|
3272
|
+
const rankedCandidates = rankPreferredModelCandidates(candidates, currentProvider);
|
|
3273
|
+
const preferredCandidate = rankedCandidates[0];
|
|
3274
|
+
const providerFallbackCandidates = rankedCandidates.filter(
|
|
3275
|
+
(candidate) =>
|
|
3276
|
+
getProviderFallbackModelKey(candidate) === getProviderFallbackModelKey(preferredCandidate),
|
|
3277
|
+
);
|
|
3278
|
+
|
|
3279
|
+
return createResolvedReviewModel({
|
|
3280
|
+
modelPattern,
|
|
3281
|
+
modelPatternBase: basePattern,
|
|
3282
|
+
thinkingSource,
|
|
3283
|
+
requestedThinkingLevel,
|
|
3284
|
+
providerCandidates: providerFallbackCandidates.map(
|
|
3285
|
+
createResolvedReviewProviderCandidateFromModel,
|
|
3286
|
+
),
|
|
3287
|
+
});
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
async function resolveModels(
|
|
3291
|
+
ctx: ExtensionContext,
|
|
3292
|
+
requestedModels: string[],
|
|
3293
|
+
currentThinkingLevel: ReviewThinkingLevel,
|
|
3294
|
+
): Promise<ResolvedReviewModel[]> {
|
|
3295
|
+
const currentProvider = typeof ctx.model?.provider === "string" ? ctx.model.provider : undefined;
|
|
3296
|
+
const currentModelId = ctx.model?.id;
|
|
3297
|
+
const availableModels = ctx.modelRegistry.getAvailable();
|
|
3298
|
+
|
|
3299
|
+
const resolveRequestedModel = (modelPattern: string): ResolvedReviewModel => {
|
|
3300
|
+
const { basePattern, thinkingSuffix } = splitModelPatternThinkingSuffix(modelPattern);
|
|
3301
|
+
const thinkingSource: ReviewThinkingSource = thinkingSuffix ? "explicit" : "inherited";
|
|
3302
|
+
const requestedThinkingLevel = thinkingSuffix ?? currentThinkingLevel;
|
|
3303
|
+
const slash = basePattern.indexOf("/");
|
|
3304
|
+
const explicitProvider = slash > 0 ? basePattern.slice(0, slash).trim() : "";
|
|
3305
|
+
if (explicitProvider) {
|
|
3306
|
+
const exactCandidate = availableModels.find(
|
|
3307
|
+
(model) => `${model.provider}/${model.id}`.toLowerCase() === basePattern.toLowerCase(),
|
|
3308
|
+
);
|
|
3309
|
+
return createResolvedReviewModel({
|
|
3310
|
+
modelPattern,
|
|
3311
|
+
modelPatternBase: basePattern,
|
|
3312
|
+
thinkingSource,
|
|
3313
|
+
requestedThinkingLevel,
|
|
3314
|
+
providerCandidates: [
|
|
3315
|
+
exactCandidate
|
|
3316
|
+
? createResolvedReviewProviderCandidateFromModel(exactCandidate)
|
|
3317
|
+
: createResolvedReviewProviderCandidate({ modelArg: modelPattern }),
|
|
3318
|
+
],
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
const resolved = resolveUnqualifiedModelPattern(
|
|
3323
|
+
modelPattern,
|
|
3324
|
+
availableModels,
|
|
3325
|
+
currentProvider,
|
|
3326
|
+
currentThinkingLevel,
|
|
3327
|
+
);
|
|
3328
|
+
if (resolved) return resolved;
|
|
3329
|
+
|
|
3330
|
+
return createResolvedReviewModel({
|
|
3331
|
+
modelPattern,
|
|
3332
|
+
modelPatternBase: basePattern,
|
|
3333
|
+
thinkingSource,
|
|
3334
|
+
requestedThinkingLevel,
|
|
3335
|
+
providerCandidates: [createResolvedReviewProviderCandidate({ modelArg: modelPattern })],
|
|
3336
|
+
});
|
|
3337
|
+
};
|
|
3338
|
+
|
|
3339
|
+
if (requestedModels.length > 0) {
|
|
3340
|
+
return requestedModels.map(resolveRequestedModel);
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
const modelPatternBase = currentModelId ?? "default";
|
|
3344
|
+
const baseModelArg =
|
|
3345
|
+
currentModelId && currentProvider ? `${currentProvider}/${currentModelId}` : currentModelId;
|
|
3346
|
+
return [
|
|
3347
|
+
createResolvedReviewModel({
|
|
3348
|
+
modelPattern: modelPatternBase,
|
|
3349
|
+
modelPatternBase,
|
|
3350
|
+
thinkingSource: "inherited",
|
|
3351
|
+
requestedThinkingLevel: currentThinkingLevel,
|
|
3352
|
+
providerCandidates: [
|
|
3353
|
+
createResolvedReviewProviderCandidate({
|
|
3354
|
+
baseModelArg,
|
|
3355
|
+
supportsThinking: ctx.model?.reasoning,
|
|
3356
|
+
supportsXhigh: ctx.model ? supportsXhigh(ctx.model) : undefined,
|
|
3357
|
+
}),
|
|
3358
|
+
],
|
|
3359
|
+
}),
|
|
3360
|
+
];
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
function escapeCell(value: string): string {
|
|
3364
|
+
return value.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
function formatDuration(ms: number): string {
|
|
3368
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
3369
|
+
const seconds = totalSeconds % 60;
|
|
3370
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
3371
|
+
const minutes = totalMinutes % 60;
|
|
3372
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
3373
|
+
|
|
3374
|
+
if (hours > 0) return `${hours}h${minutes}m${seconds}s`;
|
|
3375
|
+
if (totalMinutes > 0) return `${totalMinutes}m${seconds}s`;
|
|
3376
|
+
return `${seconds}s`;
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
function buildReviewedScopeLine(scope: ResolvedScope, durationMs: number): string {
|
|
3380
|
+
const scopeText =
|
|
3381
|
+
scope.kind === "working-tree"
|
|
3382
|
+
? `working tree (${scope.trackedFiles.length} tracked, ${scope.untrackedFiles.length} untracked)`
|
|
3383
|
+
: scope.kind === "branch-diff"
|
|
3384
|
+
? `branch diff vs ${scope.baseBranch} (${scope.diffFiles.length} files)`
|
|
3385
|
+
: scope.kind === "commit"
|
|
3386
|
+
? `commit ${scope.sha}`
|
|
3387
|
+
: scope.kind === "folder"
|
|
3388
|
+
? `snapshot for ${scope.paths.join(", ")}`
|
|
3389
|
+
: "custom scope";
|
|
3390
|
+
return `Reviewed ${scopeText} in ${formatDuration(durationMs)}.`;
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
function buildTriagedPrLine(context: TriagePrContext, durationMs: number): string {
|
|
3394
|
+
return `Triaged PR #${context.prNumber} (${context.title}) in ${formatDuration(durationMs)}.`;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
function buildTriageMarkdown(
|
|
3398
|
+
context: TriagePrContext,
|
|
3399
|
+
items: TriageMessageItem[],
|
|
3400
|
+
durationMs: number,
|
|
3401
|
+
): string {
|
|
3402
|
+
const header = buildTriagedPrLine(context, durationMs);
|
|
3403
|
+
if (items.length === 0) {
|
|
3404
|
+
return `${header}\n\nNo PR feedback items found.`;
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
let table = "| # | Kind | Location | Author | Summary | Decision | Rationale | Action |\n";
|
|
3408
|
+
table += "|---|---|---|---|---|---|---|---|\n";
|
|
3409
|
+
items.forEach((item, index) => {
|
|
3410
|
+
table += `| ${index + 1} | ${escapeCell(item.feedbackKind)} | ${escapeCell(item.location)} | ${escapeCell(item.author)} | ${escapeCell(item.summary)} | ${escapeCell(item.decision)} | ${escapeCell(item.rationale)} | ${escapeCell(item.action)} |\n`;
|
|
3411
|
+
});
|
|
3412
|
+
|
|
3413
|
+
return `${header}\n\n${table}`;
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
function appendMarkdownListSection(markdown: string, title: string, items: string[]): string {
|
|
3417
|
+
if (items.length === 0) return markdown;
|
|
3418
|
+
return `${markdown.trimEnd()}\n\n${title}:\n${items.map((item) => `- ${item}`).join("\n")}\n`;
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
function buildReviewFindingsMarkdown(
|
|
3422
|
+
reviewedScopeLine: string,
|
|
3423
|
+
findings: ReviewReportFinding[],
|
|
3424
|
+
completedReviews: number,
|
|
3425
|
+
totalReviews: number,
|
|
3426
|
+
footerNotes: string[] = [],
|
|
3427
|
+
): string {
|
|
3428
|
+
const reviewWord = totalReviews === 1 ? "review" : "reviews";
|
|
3429
|
+
const completionLine =
|
|
3430
|
+
completedReviews === totalReviews
|
|
3431
|
+
? `All ${totalReviews} ${reviewWord} completed`
|
|
3432
|
+
: `${completedReviews} of ${totalReviews} ${reviewWord} completed`;
|
|
3433
|
+
|
|
3434
|
+
if (findings.length === 0) {
|
|
3435
|
+
return appendMarkdownListSection(
|
|
3436
|
+
`${reviewedScopeLine}\n\n${completionLine}.\n\nNo findings.\n`,
|
|
3437
|
+
REVIEW_STALE_SECTION_TITLE,
|
|
3438
|
+
footerNotes,
|
|
3439
|
+
);
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
let table = "| # | Focus | Model | Priority | Location | Finding | Suggestion |\n";
|
|
3443
|
+
table += "|---|---|---|---|---|---|---|\n";
|
|
3444
|
+
findings.forEach((finding, index) => {
|
|
3445
|
+
table += `| ${index + 1} | ${escapeCell(finding.focus)} | ${escapeCell(finding.model)} | ${escapeCell(
|
|
3446
|
+
finding.priority,
|
|
3447
|
+
)} | ${escapeCell(finding.location)} | ${escapeCell(finding.finding)} | ${escapeCell(finding.suggestion)} |\n`;
|
|
3448
|
+
});
|
|
3449
|
+
return appendMarkdownListSection(
|
|
3450
|
+
`${reviewedScopeLine}\n\n${completionLine}:\n\n${table}\n`,
|
|
3451
|
+
REVIEW_STALE_SECTION_TITLE,
|
|
3452
|
+
footerNotes,
|
|
3453
|
+
);
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
function buildReviewFailuresMarkdown(failedFocuses: FocusTaskResult[]): string {
|
|
3457
|
+
const reviewWord = failedFocuses.length === 1 ? "review" : "reviews";
|
|
3458
|
+
let table = "| Focus | Model | Error |\n";
|
|
3459
|
+
table += "|---|---|---|\n";
|
|
3460
|
+
for (const focus of failedFocuses) {
|
|
3461
|
+
table += `| ${escapeCell(focus.focus)} | ${escapeCell(focus.model)} | ${escapeCell(focus.error ?? "Unknown failure")} |\n`;
|
|
3462
|
+
}
|
|
3463
|
+
return `${failedFocuses.length} ${reviewWord} failed:\n\n${table}\n`;
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
const SCOPE_MODES = new Set(["working-tree", "branch-diff", "commit", "folder", "custom"]);
|
|
3467
|
+
|
|
3468
|
+
function isScopeMode(value: unknown): value is ResolvedScope["kind"] {
|
|
3469
|
+
return typeof value === "string" && SCOPE_MODES.has(value);
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
const REQUEST_MODE_PREFIXES = ["branch:", "commit:", "pr:", "folder:"];
|
|
3473
|
+
|
|
3474
|
+
function isReviewRequestMode(value: unknown): value is ReviewRequestMode {
|
|
3475
|
+
if (typeof value !== "string") return false;
|
|
3476
|
+
if (value === "auto" || value === "uncommitted" || value === "custom") return true;
|
|
3477
|
+
return REQUEST_MODE_PREFIXES.some(
|
|
3478
|
+
(prefix) => value.startsWith(prefix) && value.length > prefix.length,
|
|
3479
|
+
);
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
function parseReviewStaleness(value: unknown): ReviewStaleness | undefined | null {
|
|
3483
|
+
if (value === undefined) return undefined;
|
|
3484
|
+
const staleness = asRecord(value);
|
|
3485
|
+
if (!staleness) return null;
|
|
3486
|
+
if (staleness.status !== "stale") return null;
|
|
3487
|
+
if (typeof staleness.warning !== "string") return null;
|
|
3488
|
+
if (typeof staleness.nextStep !== "string") return null;
|
|
3489
|
+
return {
|
|
3490
|
+
status: "stale",
|
|
3491
|
+
warning: staleness.warning,
|
|
3492
|
+
nextStep: staleness.nextStep,
|
|
3493
|
+
};
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
function buildReviewStaleness(source: ReviewRunSource): ReviewStaleness {
|
|
3497
|
+
if (source === "fix") {
|
|
3498
|
+
return {
|
|
3499
|
+
status: "stale",
|
|
3500
|
+
warning: REVIEW_STALE_FIX_WARNING,
|
|
3501
|
+
nextStep: REVIEW_STALE_FIX_NEXT_STEP,
|
|
3502
|
+
};
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
return {
|
|
3506
|
+
status: "stale",
|
|
3507
|
+
warning: REVIEW_STALE_REVIEW_WARNING,
|
|
3508
|
+
nextStep: REVIEW_STALE_REVIEW_NEXT_STEP,
|
|
3509
|
+
};
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
function buildStalePayloadStaleness(): ReviewStaleness {
|
|
3513
|
+
return {
|
|
3514
|
+
status: "stale",
|
|
3515
|
+
warning: REVIEW_STALE_PAYLOAD_WARNING,
|
|
3516
|
+
nextStep: REVIEW_STALE_PAYLOAD_NEXT_STEP,
|
|
3517
|
+
};
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
function buildReviewFooterNotes(staleness: ReviewStaleness | undefined): string[] {
|
|
3521
|
+
if (!staleness) return [];
|
|
3522
|
+
return [staleness.warning, staleness.nextStep];
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
function parseReviewMessageDetails(value: unknown): ReviewMessageDetails | null {
|
|
3526
|
+
if (!value || typeof value !== "object") return null;
|
|
3527
|
+
const details = value as ReviewMessageDetails;
|
|
3528
|
+
if (details.kind !== undefined && details.kind !== "report") return null;
|
|
3529
|
+
if (!isReviewRequestMode(details.request?.mode)) return null;
|
|
3530
|
+
if (typeof details.request?.signature !== "string") return null;
|
|
3531
|
+
if (!isScopeMode(details.scope?.mode)) return null;
|
|
3532
|
+
if (
|
|
3533
|
+
!details.fingerprint ||
|
|
3534
|
+
typeof details.fingerprint !== "object" ||
|
|
3535
|
+
typeof details.fingerprint.headSha !== "string" ||
|
|
3536
|
+
typeof details.fingerprint.branch !== "string" ||
|
|
3537
|
+
typeof details.fingerprint.trackedDiffHash !== "string" ||
|
|
3538
|
+
typeof details.fingerprint.untrackedHash !== "string"
|
|
3539
|
+
) {
|
|
3540
|
+
return null;
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
const staleness = parseReviewStaleness(details.staleness);
|
|
3544
|
+
if (staleness === null) return null;
|
|
3545
|
+
|
|
3546
|
+
if (!Array.isArray(details.focusStatus) || !Array.isArray(details.findings)) return null;
|
|
3547
|
+
return {
|
|
3548
|
+
...details,
|
|
3549
|
+
staleness,
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
function getLastMessageReviewDetails(ctx: ExtensionContext): ReviewMessageDetails | null {
|
|
3554
|
+
const entry = ctx.sessionManager.getBranch().at(-1);
|
|
3555
|
+
if (!entry || entry.type !== "custom_message" || entry.customType !== "review") {
|
|
3556
|
+
return null;
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
return parseReviewMessageDetails(entry.details);
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
// --- Review pipeline ---
|
|
3563
|
+
|
|
3564
|
+
function buildReviewTasks(
|
|
3565
|
+
scope: ResolvedScope,
|
|
3566
|
+
guidelines: string | null,
|
|
3567
|
+
additionalContext: string | undefined,
|
|
3568
|
+
models: ResolvedReviewModel[],
|
|
3569
|
+
): FocusTask[] {
|
|
3570
|
+
const scopeInstructions = buildScopeInstructions(scope);
|
|
3571
|
+
const tasks: FocusTask[] = [];
|
|
3572
|
+
|
|
3573
|
+
for (const model of models) {
|
|
3574
|
+
for (const focus of REVIEW_FOCUS_NAMES) {
|
|
3575
|
+
tasks.push({
|
|
3576
|
+
model,
|
|
3577
|
+
focus,
|
|
3578
|
+
prompt: buildFocusPrompt(focus, scopeInstructions, guidelines, additionalContext),
|
|
3579
|
+
});
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
return tasks;
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
async function prepareReviewRun(
|
|
3587
|
+
pi: ExtensionAPI,
|
|
3588
|
+
ctx: ExtensionCommandContext,
|
|
3589
|
+
request: ParsedRequest,
|
|
3590
|
+
): Promise<{ ok: false; error: string } | { ok: true; data: PreparedReviewRun }> {
|
|
3591
|
+
if (!(await isGitRepo(pi))) {
|
|
3592
|
+
return { ok: false, error: "Not a git repository." };
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
const resolved = await resolveScope(pi, ctx, request.target);
|
|
3596
|
+
if (!resolved.scope) {
|
|
3597
|
+
return { ok: false, error: resolved.error ?? "Failed to resolve review scope." };
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
const scope = resolved.scope;
|
|
3601
|
+
const includeUntracked = scope.kind === "working-tree" || scope.kind === "folder";
|
|
3602
|
+
const scopeUntrackedFiles = scope.kind === "working-tree" ? scope.untrackedFiles : undefined;
|
|
3603
|
+
const [baselineFingerprint, guidelines, models] = await Promise.all([
|
|
3604
|
+
computeCurrentFingerprint(pi, ctx.cwd, includeUntracked, scopeUntrackedFiles),
|
|
3605
|
+
loadProjectReviewGuidelines(ctx.cwd),
|
|
3606
|
+
resolveModels(ctx, request.models, pi.getThinkingLevel()),
|
|
3607
|
+
]);
|
|
3608
|
+
|
|
3609
|
+
return {
|
|
3610
|
+
ok: true,
|
|
3611
|
+
data: {
|
|
3612
|
+
scope,
|
|
3613
|
+
includeUntracked,
|
|
3614
|
+
baselineFingerprint,
|
|
3615
|
+
models,
|
|
3616
|
+
tasks: buildReviewTasks(scope, guidelines, request.additionalContext, models),
|
|
3617
|
+
},
|
|
3618
|
+
};
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
async function prepareTriageContext(
|
|
3622
|
+
pi: ExtensionAPI,
|
|
3623
|
+
ctx: ExtensionCommandContext,
|
|
3624
|
+
prRef: string,
|
|
3625
|
+
): Promise<{ ok: false; error: string } | { ok: true; data: TriagePrContext }> {
|
|
3626
|
+
if (!(await isGitRepo(pi))) {
|
|
3627
|
+
return { ok: false, error: "Not a git repository." };
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
const blockedError = await getPrCheckoutBlockedError(pi);
|
|
3631
|
+
if (blockedError) {
|
|
3632
|
+
return { ok: false, error: blockedError };
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
const prNumber = parsePrReference(prRef);
|
|
3636
|
+
if (!prNumber) {
|
|
3637
|
+
return { ok: false, error: `Invalid PR reference: ${prRef}` };
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
notify(ctx, `Fetching PR #${prNumber} feedback...`, "info");
|
|
3641
|
+
const metadata = await fetchPrTriageMetadata(pi, prNumber);
|
|
3642
|
+
if (!metadata) {
|
|
3643
|
+
return {
|
|
3644
|
+
ok: false,
|
|
3645
|
+
error: `Could not load PR #${prNumber}. Ensure gh is authenticated and PR exists.`,
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
const preparedPrScope = await preparePrCheckoutScope(pi, ctx, {
|
|
3650
|
+
prNumber,
|
|
3651
|
+
baseBranch: metadata.baseBranch,
|
|
3652
|
+
headBranch: metadata.headBranch,
|
|
3653
|
+
});
|
|
3654
|
+
if (!preparedPrScope.ok) {
|
|
3655
|
+
return { ok: false, error: preparedPrScope.error };
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
const threads = await fetchPrReviewThreads(pi, prNumber, metadata.author);
|
|
3659
|
+
if (!threads) {
|
|
3660
|
+
return {
|
|
3661
|
+
ok: false,
|
|
3662
|
+
error: `Could not load review threads for PR #${prNumber}. Ensure gh is authenticated and GraphQL access is available.`,
|
|
3663
|
+
};
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
const feedbackItems = buildTriageFeedbackItems({
|
|
3667
|
+
comments: metadata.comments,
|
|
3668
|
+
reviews: metadata.reviews,
|
|
3669
|
+
threads,
|
|
3670
|
+
});
|
|
3671
|
+
const baselineFingerprint = await computeCurrentFingerprint(pi, ctx.cwd, false);
|
|
3672
|
+
|
|
3673
|
+
return {
|
|
3674
|
+
ok: true,
|
|
3675
|
+
data: {
|
|
3676
|
+
prNumber,
|
|
3677
|
+
prRef,
|
|
3678
|
+
prUrl: metadata.prUrl,
|
|
3679
|
+
title: metadata.title,
|
|
3680
|
+
body: metadata.body,
|
|
3681
|
+
baseBranch: metadata.baseBranch,
|
|
3682
|
+
headBranch: metadata.headBranch,
|
|
3683
|
+
author: metadata.author,
|
|
3684
|
+
scope: preparedPrScope.scope,
|
|
3685
|
+
baselineFingerprint,
|
|
3686
|
+
feedbackItems,
|
|
3687
|
+
},
|
|
3688
|
+
};
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
async function runFocusTasks(
|
|
3692
|
+
ctx: ExtensionCommandContext,
|
|
3693
|
+
cwd: string,
|
|
3694
|
+
tasks: FocusTask[],
|
|
3695
|
+
control: ReviewExecutionControl,
|
|
3696
|
+
): Promise<FocusTaskResult[]> {
|
|
3697
|
+
let completed = 0;
|
|
3698
|
+
return withSpinner(
|
|
3699
|
+
ctx,
|
|
3700
|
+
() => `reviewing (completed ${completed}/${tasks.length})`,
|
|
3701
|
+
() =>
|
|
3702
|
+
Promise.all(
|
|
3703
|
+
tasks.map(async (task) => {
|
|
3704
|
+
try {
|
|
3705
|
+
if (control.isCancelled()) return createCancelledFocusResult(task);
|
|
3706
|
+
return await runFocusTask(task, cwd, control);
|
|
3707
|
+
} finally {
|
|
3708
|
+
completed = Math.min(tasks.length, completed + 1);
|
|
3709
|
+
}
|
|
3710
|
+
}),
|
|
3711
|
+
),
|
|
3712
|
+
);
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
function sortReviewFindings(findings: ReviewReportFinding[]): void {
|
|
3716
|
+
findings.sort((a, b) => {
|
|
3717
|
+
const prio = priorityRank(a.priority) - priorityRank(b.priority);
|
|
3718
|
+
if (prio !== 0) return prio;
|
|
3719
|
+
|
|
3720
|
+
const locationCmp = a.location.localeCompare(b.location);
|
|
3721
|
+
if (locationCmp !== 0) return locationCmp;
|
|
3722
|
+
|
|
3723
|
+
if (a.focus !== b.focus) return a.focus.localeCompare(b.focus);
|
|
3724
|
+
return a.model.localeCompare(b.model);
|
|
3725
|
+
});
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
function applyReviewDedupGroups(
|
|
3729
|
+
findings: ReviewReportFinding[],
|
|
3730
|
+
groups: ReviewDedupGroup[],
|
|
3731
|
+
): ReviewReportFinding[] {
|
|
3732
|
+
if (groups.length === 0) return findings;
|
|
3733
|
+
|
|
3734
|
+
const next = findings.slice();
|
|
3735
|
+
const dropped = new Set<number>();
|
|
3736
|
+
|
|
3737
|
+
for (const group of groups) {
|
|
3738
|
+
// Dedup ids are 1-based positions in priority-sorted order, so the lowest id is the preferred survivor.
|
|
3739
|
+
const keepId = Math.min(...group.ids);
|
|
3740
|
+
const groupedFindings = group.ids.map((id) => findings[id - 1]);
|
|
3741
|
+
const survivor = findings[keepId - 1];
|
|
3742
|
+
const priorities = groupedFindings
|
|
3743
|
+
.map((finding) => finding.priority)
|
|
3744
|
+
.sort((a, b) => priorityRank(a) - priorityRank(b));
|
|
3745
|
+
const focuses = Array.from(new Set(groupedFindings.map((finding) => finding.focus))).join(", ");
|
|
3746
|
+
const models = Array.from(new Set(groupedFindings.map((finding) => finding.model))).join(", ");
|
|
3747
|
+
|
|
3748
|
+
next[keepId - 1] = {
|
|
3749
|
+
...survivor,
|
|
3750
|
+
priority: priorities[0],
|
|
3751
|
+
focus: focuses,
|
|
3752
|
+
model: models,
|
|
3753
|
+
};
|
|
3754
|
+
|
|
3755
|
+
for (const id of group.ids) {
|
|
3756
|
+
if (id !== keepId) {
|
|
3757
|
+
dropped.add(id);
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
return next.filter((_, index) => !dropped.has(index + 1));
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
async function buildReviewFindings(
|
|
3766
|
+
ctx: ExtensionCommandContext,
|
|
3767
|
+
cwd: string,
|
|
3768
|
+
successfulFocuses: Array<FocusTaskResult & { output: FocusOutput }>,
|
|
3769
|
+
control?: ReviewExecutionControl,
|
|
3770
|
+
): Promise<ReviewReportFinding[]> {
|
|
3771
|
+
const findings = successfulFocuses.flatMap((focus) =>
|
|
3772
|
+
focus.output.findings.map((finding) => ({
|
|
3773
|
+
...finding,
|
|
3774
|
+
focus: focus.focus,
|
|
3775
|
+
model: focus.model,
|
|
3776
|
+
})),
|
|
3777
|
+
);
|
|
3778
|
+
sortReviewFindings(findings);
|
|
3779
|
+
|
|
3780
|
+
if (findings.length <= 1) {
|
|
3781
|
+
return findings;
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
const dedupGroups = await runReviewDedupTask({
|
|
3785
|
+
ctx,
|
|
3786
|
+
cwd,
|
|
3787
|
+
findings,
|
|
3788
|
+
control,
|
|
3789
|
+
});
|
|
3790
|
+
return dedupGroups ? applyReviewDedupGroups(findings, dedupGroups) : findings;
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
async function runReviewPipeline(
|
|
3794
|
+
pi: ExtensionAPI,
|
|
3795
|
+
ctx: ExtensionCommandContext,
|
|
3796
|
+
request: ParsedRequest,
|
|
3797
|
+
source: ReviewRunSource,
|
|
3798
|
+
): Promise<ReviewRunResult> {
|
|
3799
|
+
const startedAtMs = Date.now();
|
|
3800
|
+
const prepared = await prepareReviewRun(pi, ctx, request);
|
|
3801
|
+
if (!prepared.ok) {
|
|
3802
|
+
return { ok: false, error: prepared.error };
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
return withManagedReviewRun(pi, ctx, source, async (managed) => {
|
|
3806
|
+
const { scope, includeUntracked, baselineFingerprint, models, tasks } = prepared.data;
|
|
3807
|
+
if (source === "review") {
|
|
3808
|
+
const modelsText = models
|
|
3809
|
+
.map((model) => buildResolvedReviewStatusModelLabel(model))
|
|
3810
|
+
.join(", ");
|
|
3811
|
+
notify(
|
|
3812
|
+
ctx,
|
|
3813
|
+
`Review focus: ${REVIEW_FOCUS_NAMES.join(", ")} · models: ${modelsText}.`,
|
|
3814
|
+
"info",
|
|
3815
|
+
);
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
const focusResults = await runFocusTasks(ctx, ctx.cwd, tasks, managed.control);
|
|
3819
|
+
if (managed.control.isCancelled()) {
|
|
3820
|
+
managed.markCancelled();
|
|
3821
|
+
return { ok: false, error: REVIEW_CANCELLED_ERROR };
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
const failedFocuses = focusResults.filter((focus) => !focus.ok);
|
|
3825
|
+
const failedCount = failedFocuses.length;
|
|
3826
|
+
const totalReviews = focusResults.length;
|
|
3827
|
+
const completedReviews = totalReviews - failedCount;
|
|
3828
|
+
const successfulFocuses = focusResults.filter(
|
|
3829
|
+
(result): result is FocusTaskResult & { output: FocusOutput } =>
|
|
3830
|
+
Boolean(result.ok && result.output),
|
|
3831
|
+
);
|
|
3832
|
+
if (successfulFocuses.length === 0) {
|
|
3833
|
+
if (failedCount > 0) {
|
|
3834
|
+
const reviewedScopeLine = buildReviewedScopeLine(scope, Date.now() - startedAtMs);
|
|
3835
|
+
const failureReport = `${reviewedScopeLine}\n\n${buildReviewFailuresMarkdown(failedFocuses)}`;
|
|
3836
|
+
pi.sendMessage(
|
|
3837
|
+
{
|
|
3838
|
+
customType: "review",
|
|
3839
|
+
content: failureReport,
|
|
3840
|
+
display: true,
|
|
3841
|
+
details: { kind: "failure" satisfies ReviewMessageKind },
|
|
3842
|
+
},
|
|
3843
|
+
{ deliverAs: "followUp" },
|
|
3844
|
+
);
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
const missingApiProvider = failedFocuses.find(
|
|
3848
|
+
(focus) => focus.errorKind === "missing_api_key" && Boolean(focus.missingApiProvider),
|
|
3849
|
+
)?.missingApiProvider;
|
|
3850
|
+
if (missingApiProvider) {
|
|
3851
|
+
return {
|
|
3852
|
+
ok: false,
|
|
3853
|
+
error: `All reviews failed. Missing API key for provider '${missingApiProvider}'. Use /login or configure credentials for that provider.`,
|
|
3854
|
+
};
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
const sampleError =
|
|
3858
|
+
focusResults.find((focus) => focus.error)?.error ?? "Unknown focus failure";
|
|
3859
|
+
return {
|
|
3860
|
+
ok: false,
|
|
3861
|
+
error: `All reviews failed. ${sampleError}`,
|
|
3862
|
+
};
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
const preDedupFingerprint = await computeCurrentFingerprint(pi, ctx.cwd, includeUntracked);
|
|
3866
|
+
let reviewStaleness: ReviewStaleness | undefined;
|
|
3867
|
+
if (!fingerprintsEqual(baselineFingerprint, preDedupFingerprint)) {
|
|
3868
|
+
reviewStaleness = buildReviewStaleness(source);
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3871
|
+
const findings = await buildReviewFindings(ctx, ctx.cwd, successfulFocuses, managed.control);
|
|
3872
|
+
|
|
3873
|
+
const endingFingerprint = await computeCurrentFingerprint(pi, ctx.cwd, includeUntracked);
|
|
3874
|
+
if (!reviewStaleness && !fingerprintsEqual(baselineFingerprint, endingFingerprint)) {
|
|
3875
|
+
reviewStaleness = buildReviewStaleness(source);
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
if (managed.control.isCancelled()) {
|
|
3879
|
+
managed.markCancelled();
|
|
3880
|
+
return { ok: false, error: REVIEW_CANCELLED_ERROR };
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
const reviewedScopeLine = buildReviewedScopeLine(scope, Date.now() - startedAtMs);
|
|
3884
|
+
let findingsMarkdown = buildReviewFindingsMarkdown(
|
|
3885
|
+
reviewedScopeLine,
|
|
3886
|
+
findings,
|
|
3887
|
+
completedReviews,
|
|
3888
|
+
totalReviews,
|
|
3889
|
+
buildReviewFooterNotes(reviewStaleness),
|
|
3890
|
+
);
|
|
3891
|
+
if (failedCount > 0) {
|
|
3892
|
+
findingsMarkdown = `${findingsMarkdown.trimEnd()}\n\n${buildReviewFailuresMarkdown(failedFocuses)}`;
|
|
3893
|
+
}
|
|
3894
|
+
const details: ReviewMessageDetails = {
|
|
3895
|
+
kind: "report",
|
|
3896
|
+
request: {
|
|
3897
|
+
mode: request.mode,
|
|
3898
|
+
signature: buildRequestSignature(request),
|
|
3899
|
+
},
|
|
3900
|
+
scope: {
|
|
3901
|
+
mode: scope.kind,
|
|
3902
|
+
description: scope.description,
|
|
3903
|
+
},
|
|
3904
|
+
fingerprint: reviewStaleness ? baselineFingerprint : endingFingerprint,
|
|
3905
|
+
staleness: reviewStaleness,
|
|
3906
|
+
focusStatus: focusResults.map((focus) => ({
|
|
3907
|
+
focus: focus.focus,
|
|
3908
|
+
model: focus.model,
|
|
3909
|
+
ok: focus.ok,
|
|
3910
|
+
error: focus.error,
|
|
3911
|
+
})),
|
|
3912
|
+
findings,
|
|
3913
|
+
};
|
|
3914
|
+
|
|
3915
|
+
pi.sendMessage(
|
|
3916
|
+
{
|
|
3917
|
+
customType: "review",
|
|
3918
|
+
content: findingsMarkdown,
|
|
3919
|
+
display: true,
|
|
3920
|
+
details,
|
|
3921
|
+
},
|
|
3922
|
+
{ deliverAs: "followUp" },
|
|
3923
|
+
);
|
|
3924
|
+
|
|
3925
|
+
if (reviewStaleness) {
|
|
3926
|
+
if (failedCount > 0) {
|
|
3927
|
+
const failedLabel = `review${failedCount === 1 ? "" : "s"}`;
|
|
3928
|
+
notify(
|
|
3929
|
+
ctx,
|
|
3930
|
+
`Review completed with stale partial results: ${failedCount} ${failedLabel} failed.`,
|
|
3931
|
+
"warning",
|
|
3932
|
+
);
|
|
3933
|
+
} else {
|
|
3934
|
+
notify(
|
|
3935
|
+
ctx,
|
|
3936
|
+
`Review completed with stale results: ${findings.length} finding(s).`,
|
|
3937
|
+
"warning",
|
|
3938
|
+
);
|
|
3939
|
+
}
|
|
3940
|
+
} else if (failedCount > 0) {
|
|
3941
|
+
const failedLabel = `review${failedCount === 1 ? "" : "s"}`;
|
|
3942
|
+
notify(
|
|
3943
|
+
ctx,
|
|
3944
|
+
`Review completed with partial results: ${failedCount} ${failedLabel} failed.`,
|
|
3945
|
+
"warning",
|
|
3946
|
+
);
|
|
3947
|
+
} else {
|
|
3948
|
+
notify(ctx, `Review completed: ${findings.length} finding(s).`, "info");
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
managed.markSuccessful();
|
|
3952
|
+
return { ok: true, details };
|
|
3953
|
+
});
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
async function runTriagePipeline(
|
|
3957
|
+
pi: ExtensionAPI,
|
|
3958
|
+
ctx: ExtensionCommandContext,
|
|
3959
|
+
prRef: string,
|
|
3960
|
+
): Promise<TriageRunResult> {
|
|
3961
|
+
const startedAtMs = Date.now();
|
|
3962
|
+
const prepared = await prepareTriageContext(pi, ctx, prRef);
|
|
3963
|
+
if (!prepared.ok) {
|
|
3964
|
+
return { ok: false, error: prepared.error };
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
return withManagedReviewRun(pi, ctx, "triage", async (managed) => {
|
|
3968
|
+
const context = prepared.data;
|
|
3969
|
+
if (context.feedbackItems.length === 0) {
|
|
3970
|
+
const details: TriageMessageDetails = {
|
|
3971
|
+
kind: "triage",
|
|
3972
|
+
generatedAt: new Date().toISOString(),
|
|
3973
|
+
pr: {
|
|
3974
|
+
number: context.prNumber,
|
|
3975
|
+
url: context.prUrl,
|
|
3976
|
+
title: context.title,
|
|
3977
|
+
baseBranch: context.baseBranch,
|
|
3978
|
+
headBranch: context.headBranch,
|
|
3979
|
+
ref: context.prRef,
|
|
3980
|
+
},
|
|
3981
|
+
scope: {
|
|
3982
|
+
mode: context.scope.kind,
|
|
3983
|
+
description: context.scope.description,
|
|
3984
|
+
},
|
|
3985
|
+
feedbackCount: 0,
|
|
3986
|
+
items: [],
|
|
3987
|
+
};
|
|
3988
|
+
pi.sendMessage(
|
|
3989
|
+
{
|
|
3990
|
+
customType: "review",
|
|
3991
|
+
content: buildTriageMarkdown(context, [], Date.now() - startedAtMs),
|
|
3992
|
+
display: true,
|
|
3993
|
+
details,
|
|
3994
|
+
},
|
|
3995
|
+
{ deliverAs: "followUp" },
|
|
3996
|
+
);
|
|
3997
|
+
notify(ctx, "No PR feedback items found for triage.", "info");
|
|
3998
|
+
managed.markSuccessful();
|
|
3999
|
+
return { ok: true, details };
|
|
4000
|
+
}
|
|
4001
|
+
|
|
4002
|
+
const [projectGuidelines, models] = await Promise.all([
|
|
4003
|
+
loadProjectReviewGuidelines(ctx.cwd),
|
|
4004
|
+
resolveModels(ctx, [], pi.getThinkingLevel()),
|
|
4005
|
+
]);
|
|
4006
|
+
const model = models[0];
|
|
4007
|
+
notify(
|
|
4008
|
+
ctx,
|
|
4009
|
+
`Triaging ${context.feedbackItems.length} feedback item(s) with ${buildResolvedReviewStatusModelLabel(model)}.`,
|
|
4010
|
+
"info",
|
|
4011
|
+
);
|
|
4012
|
+
|
|
4013
|
+
const triageResult = await runTriageTask({
|
|
4014
|
+
ctx,
|
|
4015
|
+
cwd: ctx.cwd,
|
|
4016
|
+
prompt: buildTriagePrompt(context, projectGuidelines),
|
|
4017
|
+
model,
|
|
4018
|
+
feedbackItems: context.feedbackItems,
|
|
4019
|
+
control: managed.control,
|
|
4020
|
+
});
|
|
4021
|
+
if (!triageResult.ok) {
|
|
4022
|
+
if (triageResult.error === REVIEW_CANCELLED_ERROR) {
|
|
4023
|
+
managed.markCancelled();
|
|
4024
|
+
}
|
|
4025
|
+
return triageResult;
|
|
4026
|
+
}
|
|
4027
|
+
if (managed.control.isCancelled()) {
|
|
4028
|
+
managed.markCancelled();
|
|
4029
|
+
return { ok: false, error: REVIEW_CANCELLED_ERROR };
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
const endingFingerprint = await computeCurrentFingerprint(pi, ctx.cwd, false);
|
|
4033
|
+
if (!fingerprintsEqual(context.baselineFingerprint, endingFingerprint)) {
|
|
4034
|
+
return {
|
|
4035
|
+
ok: false,
|
|
4036
|
+
error: "PR triage became stale while running (repository changed). Rerun /triage.",
|
|
4037
|
+
};
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
const items: TriageMessageItem[] = context.feedbackItems.map((feedback, index) => {
|
|
4041
|
+
const triageItem = triageResult.items[index];
|
|
4042
|
+
return {
|
|
4043
|
+
...triageItem,
|
|
4044
|
+
feedbackKind: feedback.kind,
|
|
4045
|
+
location: feedback.location,
|
|
4046
|
+
author: feedback.author,
|
|
4047
|
+
url: feedback.url,
|
|
4048
|
+
};
|
|
4049
|
+
});
|
|
4050
|
+
const details: TriageMessageDetails = {
|
|
4051
|
+
kind: "triage",
|
|
4052
|
+
generatedAt: new Date().toISOString(),
|
|
4053
|
+
pr: {
|
|
4054
|
+
number: context.prNumber,
|
|
4055
|
+
url: context.prUrl,
|
|
4056
|
+
title: context.title,
|
|
4057
|
+
baseBranch: context.baseBranch,
|
|
4058
|
+
headBranch: context.headBranch,
|
|
4059
|
+
ref: context.prRef,
|
|
4060
|
+
},
|
|
4061
|
+
scope: {
|
|
4062
|
+
mode: context.scope.kind,
|
|
4063
|
+
description: context.scope.description,
|
|
4064
|
+
},
|
|
4065
|
+
feedbackCount: context.feedbackItems.length,
|
|
4066
|
+
items,
|
|
4067
|
+
};
|
|
4068
|
+
|
|
4069
|
+
pi.sendMessage(
|
|
4070
|
+
{
|
|
4071
|
+
customType: "review",
|
|
4072
|
+
content: buildTriageMarkdown(context, items, Date.now() - startedAtMs),
|
|
4073
|
+
display: true,
|
|
4074
|
+
details,
|
|
4075
|
+
},
|
|
4076
|
+
{ deliverAs: "followUp" },
|
|
4077
|
+
);
|
|
4078
|
+
notify(ctx, `Triage completed: ${items.length} feedback item(s).`, "info");
|
|
4079
|
+
managed.markSuccessful();
|
|
4080
|
+
return { ok: true, details };
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
// --- Command handlers ---
|
|
4085
|
+
|
|
4086
|
+
function buildFixPrompt(reviewMessageDetails: ReviewMessageDetails): string {
|
|
4087
|
+
const worklistPayload = JSON.stringify(
|
|
4088
|
+
{
|
|
4089
|
+
scope: reviewMessageDetails.scope,
|
|
4090
|
+
...(reviewMessageDetails.staleness ? { staleness: reviewMessageDetails.staleness } : {}),
|
|
4091
|
+
findings: reviewMessageDetails.findings.map(
|
|
4092
|
+
({ priority, location, finding, suggestion, focus }) => ({
|
|
4093
|
+
priority,
|
|
4094
|
+
location,
|
|
4095
|
+
finding,
|
|
4096
|
+
suggestion,
|
|
4097
|
+
focus,
|
|
4098
|
+
}),
|
|
4099
|
+
),
|
|
4100
|
+
},
|
|
4101
|
+
null,
|
|
4102
|
+
2,
|
|
4103
|
+
);
|
|
4104
|
+
|
|
4105
|
+
return FIX_PROMPT.replace("{REVIEW_FINDINGS_JSON}", () => worklistPayload);
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
function parseTriagePrRef(
|
|
4109
|
+
pi: ExtensionAPI,
|
|
4110
|
+
args: string | undefined,
|
|
4111
|
+
ctx: ExtensionCommandContext,
|
|
4112
|
+
): string | null {
|
|
4113
|
+
const raw = args?.trim() ?? "";
|
|
4114
|
+
if (!raw) {
|
|
4115
|
+
notify(ctx, "Usage: /triage <pr-number|url>", "error");
|
|
4116
|
+
return null;
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
const tokens = tokenizeArgs(raw).map(unquoteToken);
|
|
4120
|
+
if (tokens.length === 1 && tokens[0]?.toLowerCase() === "help") {
|
|
4121
|
+
showReviewHelp(pi);
|
|
4122
|
+
return null;
|
|
4123
|
+
}
|
|
4124
|
+
if (tokens.length !== 1 || !tokens[0]) {
|
|
4125
|
+
notify(ctx, "Usage: /triage <pr-number|url>", "error");
|
|
4126
|
+
return null;
|
|
4127
|
+
}
|
|
4128
|
+
if (!parsePrReference(tokens[0])) {
|
|
4129
|
+
notify(ctx, `Invalid PR reference: ${tokens[0]}`, "error");
|
|
4130
|
+
return null;
|
|
4131
|
+
}
|
|
4132
|
+
return tokens[0];
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
function parseCommandRequest(
|
|
4136
|
+
pi: ExtensionAPI,
|
|
4137
|
+
args: string | undefined,
|
|
4138
|
+
ctx: ExtensionCommandContext,
|
|
4139
|
+
): ParsedRequest | null {
|
|
4140
|
+
if (isHelpRequest(args)) {
|
|
4141
|
+
showReviewHelp(pi);
|
|
4142
|
+
return null;
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
try {
|
|
4146
|
+
return parseRequestArgs(args);
|
|
4147
|
+
} catch (error) {
|
|
4148
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
4149
|
+
return null;
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
function acquireReviewRunLock(ctx: ExtensionContext, busyMessage: string): string | null {
|
|
4154
|
+
const sessionKey = getReviewSessionKey(ctx);
|
|
4155
|
+
if (runtimeState.activeReviewRuns.has(sessionKey)) {
|
|
4156
|
+
notify(ctx, busyMessage, "warning");
|
|
4157
|
+
return null;
|
|
4158
|
+
}
|
|
4159
|
+
|
|
4160
|
+
runtimeState.activeReviewRuns.add(sessionKey);
|
|
4161
|
+
return sessionKey;
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
function releaseReviewRunLock(sessionKey: string): void {
|
|
4165
|
+
runtimeState.activeReviewRuns.delete(sessionKey);
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4168
|
+
export default function reviewExtension(pi: ExtensionAPI) {
|
|
4169
|
+
pi.events.on("ui:prompt_start", () => {
|
|
4170
|
+
runtimeState.activePromptCount += 1;
|
|
4171
|
+
});
|
|
4172
|
+
|
|
4173
|
+
pi.events.on("ui:prompt_end", () => {
|
|
4174
|
+
runtimeState.activePromptCount = Math.max(0, runtimeState.activePromptCount - 1);
|
|
4175
|
+
});
|
|
4176
|
+
|
|
4177
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
4178
|
+
runtimeState.activePromptCount = 0;
|
|
4179
|
+
const sessionKey = getReviewSessionKey(ctx);
|
|
4180
|
+
for (const [key, cancel] of runtimeState.activeReviewCancels) {
|
|
4181
|
+
if (key !== sessionKey) cancel();
|
|
4182
|
+
}
|
|
4183
|
+
});
|
|
4184
|
+
|
|
4185
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
4186
|
+
const sessionKey = getReviewSessionKey(ctx);
|
|
4187
|
+
runtimeState.activeReviewCancels.get(sessionKey)?.();
|
|
4188
|
+
runtimeState.activeReviewCancels.delete(sessionKey);
|
|
4189
|
+
runtimeState.activeReviewRuns.delete(sessionKey);
|
|
4190
|
+
runtimeState.activePromptCount = 0;
|
|
4191
|
+
});
|
|
4192
|
+
|
|
4193
|
+
pi.registerCommand("review", {
|
|
4194
|
+
description:
|
|
4195
|
+
"Run findings-only review across 4 focuses (general/reuse/quality/efficiency). Use /review help for full usage.",
|
|
4196
|
+
getArgumentCompletions: getReviewArgumentCompletions,
|
|
4197
|
+
handler: async (args, ctx) => {
|
|
4198
|
+
const request = parseCommandRequest(pi, args, ctx);
|
|
4199
|
+
if (!request) return;
|
|
4200
|
+
|
|
4201
|
+
const sessionKey = acquireReviewRunLock(
|
|
4202
|
+
ctx,
|
|
4203
|
+
"A /review run is already active in this session.",
|
|
4204
|
+
);
|
|
4205
|
+
if (!sessionKey) return;
|
|
4206
|
+
|
|
4207
|
+
notify(ctx, "Starting review in background...", "info");
|
|
4208
|
+
void (async () => {
|
|
4209
|
+
try {
|
|
4210
|
+
const result = await runReviewPipeline(pi, ctx, request, "review");
|
|
4211
|
+
if (!result.ok) {
|
|
4212
|
+
notify(ctx, result.error, "error");
|
|
4213
|
+
}
|
|
4214
|
+
} catch (error) {
|
|
4215
|
+
notify(
|
|
4216
|
+
ctx,
|
|
4217
|
+
`Review run failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
4218
|
+
"error",
|
|
4219
|
+
);
|
|
4220
|
+
} finally {
|
|
4221
|
+
releaseReviewRunLock(sessionKey);
|
|
4222
|
+
}
|
|
4223
|
+
})();
|
|
4224
|
+
},
|
|
4225
|
+
});
|
|
4226
|
+
|
|
4227
|
+
pi.registerCommand("triage", {
|
|
4228
|
+
description:
|
|
4229
|
+
"Fetch PR feedback from GitHub, inspect the checked-out PR diff, and classify each item as address, push_back, research, or ignore.",
|
|
4230
|
+
handler: async (args, ctx) => {
|
|
4231
|
+
const prRef = parseTriagePrRef(pi, args, ctx);
|
|
4232
|
+
if (!prRef) return;
|
|
4233
|
+
|
|
4234
|
+
const sessionKey = acquireReviewRunLock(
|
|
4235
|
+
ctx,
|
|
4236
|
+
"A review-related run is already active in this session. Wait for it to finish before /triage.",
|
|
4237
|
+
);
|
|
4238
|
+
if (!sessionKey) return;
|
|
4239
|
+
|
|
4240
|
+
notify(ctx, "Starting PR triage in background...", "info");
|
|
4241
|
+
void (async () => {
|
|
4242
|
+
try {
|
|
4243
|
+
const result = await runTriagePipeline(pi, ctx, prRef);
|
|
4244
|
+
if (!result.ok) {
|
|
4245
|
+
notify(ctx, result.error, "error");
|
|
4246
|
+
}
|
|
4247
|
+
} catch (error) {
|
|
4248
|
+
notify(
|
|
4249
|
+
ctx,
|
|
4250
|
+
`PR triage failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
4251
|
+
"error",
|
|
4252
|
+
);
|
|
4253
|
+
} finally {
|
|
4254
|
+
releaseReviewRunLock(sessionKey);
|
|
4255
|
+
}
|
|
4256
|
+
})();
|
|
4257
|
+
},
|
|
4258
|
+
});
|
|
4259
|
+
|
|
4260
|
+
pi.registerCommand("fix", {
|
|
4261
|
+
description:
|
|
4262
|
+
"Fix findings when the last message is a matching /review result. Otherwise, runs review first. If the last review is stale, /fix warns and continues; if a /fix-started review goes stale mid-run, /fix shows the findings and applies no fixes. Use /review help for modes/options.",
|
|
4263
|
+
getArgumentCompletions: getReviewArgumentCompletions,
|
|
4264
|
+
handler: async (args, ctx) => {
|
|
4265
|
+
const request = parseCommandRequest(pi, args, ctx);
|
|
4266
|
+
if (!request) return;
|
|
4267
|
+
|
|
4268
|
+
const sessionKey = acquireReviewRunLock(
|
|
4269
|
+
ctx,
|
|
4270
|
+
"A /review run is active in this session. Wait for it to finish before /fix.",
|
|
4271
|
+
);
|
|
4272
|
+
if (!sessionKey) return;
|
|
4273
|
+
|
|
4274
|
+
try {
|
|
4275
|
+
let reviewDetails = getLastMessageReviewDetails(ctx);
|
|
4276
|
+
const lastReviewMatchesRequest =
|
|
4277
|
+
!request.rawArgs ||
|
|
4278
|
+
(reviewDetails ? reviewMatchesRequest(reviewDetails, request) : false);
|
|
4279
|
+
const shouldRunFreshReview = !reviewDetails || !lastReviewMatchesRequest;
|
|
4280
|
+
|
|
4281
|
+
if (reviewDetails && !shouldRunFreshReview) {
|
|
4282
|
+
const currentFingerprint = await computeCurrentFingerprint(
|
|
4283
|
+
pi,
|
|
4284
|
+
ctx.cwd,
|
|
4285
|
+
reviewDetails.scope.mode === "working-tree" || reviewDetails.scope.mode === "folder",
|
|
4286
|
+
);
|
|
4287
|
+
const hasStalePayload = reviewDetails.staleness?.status === "stale";
|
|
4288
|
+
const isStaleNow = !fingerprintsEqual(reviewDetails.fingerprint, currentFingerprint);
|
|
4289
|
+
if (hasStalePayload || isStaleNow) {
|
|
4290
|
+
reviewDetails = {
|
|
4291
|
+
...reviewDetails,
|
|
4292
|
+
staleness: reviewDetails.staleness ?? buildStalePayloadStaleness(),
|
|
4293
|
+
};
|
|
4294
|
+
notify(ctx, REVIEW_STALE_REUSE_WARNING, "warning");
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
if (shouldRunFreshReview) {
|
|
4299
|
+
const reviewResult = await runReviewPipeline(pi, ctx, request, "fix");
|
|
4300
|
+
if (!reviewResult.ok) {
|
|
4301
|
+
if (reviewResult.error === REVIEW_CANCELLED_ERROR) {
|
|
4302
|
+
notify(ctx, REVIEW_CANCELLED_ERROR, "error");
|
|
4303
|
+
} else {
|
|
4304
|
+
notify(ctx, `Cannot continue to /fix: ${reviewResult.error}`, "error");
|
|
4305
|
+
}
|
|
4306
|
+
return;
|
|
4307
|
+
}
|
|
4308
|
+
reviewDetails = reviewResult.details;
|
|
4309
|
+
if (reviewDetails.staleness?.status === "stale") {
|
|
4310
|
+
notify(ctx, reviewDetails.staleness.nextStep, "warning");
|
|
4311
|
+
return;
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
if (!reviewDetails) {
|
|
4316
|
+
notify(ctx, "No review payload available for fixing.", "error");
|
|
4317
|
+
return;
|
|
4318
|
+
}
|
|
4319
|
+
|
|
4320
|
+
if (reviewDetails.findings.length === 0) {
|
|
4321
|
+
const failedFocusCount = reviewDetails.focusStatus.filter((focus) => !focus.ok).length;
|
|
4322
|
+
if (failedFocusCount > 0) {
|
|
4323
|
+
notify(
|
|
4324
|
+
ctx,
|
|
4325
|
+
`Latest review had ${failedFocusCount} failed focus run(s) and no findings. Rerun /review before /fix.`,
|
|
4326
|
+
"warning",
|
|
4327
|
+
);
|
|
4328
|
+
return;
|
|
4329
|
+
}
|
|
4330
|
+
|
|
4331
|
+
notify(ctx, "Review produced no findings. Nothing to fix.", "info");
|
|
4332
|
+
return;
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
const fixPrompt = buildFixPrompt(reviewDetails);
|
|
4336
|
+
if (ctx.isIdle()) {
|
|
4337
|
+
pi.sendUserMessage(fixPrompt);
|
|
4338
|
+
} else {
|
|
4339
|
+
pi.sendUserMessage(fixPrompt, { deliverAs: "followUp" });
|
|
4340
|
+
}
|
|
4341
|
+
notify(ctx, "Queued autonomous fix pass from review findings.", "info");
|
|
4342
|
+
} finally {
|
|
4343
|
+
releaseReviewRunLock(sessionKey);
|
|
4344
|
+
}
|
|
4345
|
+
},
|
|
4346
|
+
});
|
|
4347
|
+
}
|