@unifan/pi-review-zh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,568 @@
1
+ /**
2
+ * High-level orchestration for `/review` v0.7:
3
+ * 1. Resolve the ReviewTarget (PR URL / local-git / --diff).
4
+ * 2. Acquire an accurate diff + SHA-256 + changed-files via the plugin's
5
+ * own `gh`/`git` calls (never re-fetched by reviewers).
6
+ * 3. Prepare a target workspace (clone for PRs, user's cwd for local).
7
+ * 4. Write the run manifest so reviewers + the report tool can read it.
8
+ * 5. Build + emit the directive (now: single `subagent` call followed by
9
+ * the `pi_review_report` tool).
10
+ *
11
+ * This replaces the old "main agent obtains the diff itself" pattern that
12
+ * silently failed on cross-repo PRs.
13
+ */
14
+ import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+
18
+ import { DEFAULT_CONFIG, loadConfig, resolveModel } from "./config.js";
19
+ import { buildReviewDirective } from "./directive.js";
20
+ import { resolveLeanBudgets } from "./lean-agents.js";
21
+ import { extractPrRef } from "./pr-ref.js";
22
+ import {
23
+ RunManifest,
24
+ WORKSPACE_TTL_MS,
25
+ discoverRulePathsLocal,
26
+ ensureRunDir,
27
+ generateRunId,
28
+ parseChangedFilesFromDiff,
29
+ pruneStaleRuns,
30
+ readManifest,
31
+ resetRunCmd,
32
+ setRunCmd,
33
+ sha256Hex,
34
+ writeDiff,
35
+ writeManifest,
36
+ } from "./review-report.js";
37
+ import {
38
+ prepareWorkspace,
39
+ removeWorkspaceRoot,
40
+ resetTargetWorkspaceCmd,
41
+ setTargetWorkspaceCmd,
42
+ } from "./target-workspace.js";
43
+ import type { ReviewTarget } from "./types.js";
44
+
45
+ export interface PrepareRunInput {
46
+ cwd: string;
47
+ input?: string;
48
+ /** Support `--lite` single-agent mode. */
49
+ lite?: boolean;
50
+ /** Optional per-run gate model override. */
51
+ gateModel?: string;
52
+ /** Set false for dry-runs — pruning is a side effect a dry run must not have. */
53
+ cleanup?: boolean;
54
+ }
55
+
56
+ export interface PreparedRun {
57
+ runId: string;
58
+ manifest: RunManifest;
59
+ directive: string;
60
+ /** Path the main agent will receive as the directive message. */
61
+ directiveText: string;
62
+ }
63
+
64
+ /** Public entrypoint for `index.ts` — prepare a run synchronously. */
65
+ export async function prepareRun(input: PrepareRunInput): Promise<PreparedRun | null> {
66
+ const cwd = input.cwd;
67
+ const { config } = loadConfig();
68
+ const target = await resolveReviewTarget(input.cwd, { input: input.input });
69
+ if (!target) return null;
70
+
71
+ if (input.cleanup !== false) {
72
+ pruneStaleRuns(cwd);
73
+ pruneLegacyFlatArtifacts(cwd);
74
+ pruneStaleWorkspaces();
75
+ }
76
+
77
+ const runId = generateRunId();
78
+ const runDir = ensureRunDir(cwd, runId);
79
+
80
+ const diffResult = await acquireDiff(cwd, target, runDir);
81
+ const changedFiles = parseChangedFilesFromDiff(diffResult.diff);
82
+ const rulePaths = discoverRulePathsLocal(cwd);
83
+ // Pass the diff's head SHA so the workspace checkout can verify it landed
84
+ // on the same commit (guards the force-push-between-calls TOCTOU window).
85
+ let workspaceResult = await prepareWorkspace({
86
+ cwd,
87
+ target: { kind: target.kind, prRef: target.prRef, expectedHeadSha: diffResult.headSha },
88
+ });
89
+ if (workspaceResult.cloned && diffResult.headSha && workspaceResult.workspaceHeadSha &&
90
+ workspaceResult.workspaceHeadSha !== diffResult.headSha) {
91
+ // The first clone's scratch root is garbage now — drop it before
92
+ // retrying so a moving PR does not litter tmpdir with depth-50 clones.
93
+ removeWorkspaceRoot(dirname(workspaceResult.workspacePath));
94
+ workspaceResult = await prepareWorkspace({
95
+ cwd,
96
+ target: { kind: target.kind, prRef: target.prRef, expectedHeadSha: diffResult.headSha },
97
+ });
98
+ if (workspaceResult.workspaceHeadSha && workspaceResult.workspaceHeadSha !== diffResult.headSha) {
99
+ throw new Error(
100
+ `pi-review: workspace HEAD ${workspaceResult.workspaceHeadSha.slice(0, 12)} does not match diff head ${diffResult.headSha.slice(0, 12)} — the PR moved during preparation; re-run /review.`,
101
+ );
102
+ }
103
+ }
104
+
105
+ const manifest: RunManifest = {
106
+ runId,
107
+ targetLabel: target.label,
108
+ targetKind: target.kind,
109
+ prRef: target.prRef,
110
+ diffPath: diffResult.path,
111
+ diffSha256: sha256Hex(diffResult.diff),
112
+ changedFiles: changedFiles.files,
113
+ docsOnly: changedFiles.docsOnly,
114
+ rulePaths,
115
+ historyAvailable: workspaceResult.historyAvailable,
116
+ mode: diffResult.mode,
117
+ baseSha: diffResult.baseSha,
118
+ headSha: diffResult.headSha,
119
+ mergeBase: diffResult.mergeBase,
120
+ workspacePath: workspaceResult.workspacePath,
121
+ workspaceHeadSha: workspaceResult.workspaceHeadSha,
122
+ workspaceWarning: workspaceResult.warning,
123
+ workspaceCloned: workspaceResult.cloned,
124
+ diffWarning: diffResult.warning,
125
+ runDir,
126
+ createdAt: Date.now(),
127
+ };
128
+
129
+ const gateModel = resolveModel(input.gateModel ?? config.gate.model, undefined);
130
+ // Trivial change guard: an empty/placeholder diff (no +/- hunks) is not
131
+ // worth fanning out reviewers. Drop the orphan run dir (only change.diff
132
+ // was written so far) instead of leaving it for the 24h pruner.
133
+ if (changedFiles.additions + changedFiles.deletions === 0) {
134
+ try {
135
+ rmSync(runDir, { recursive: true, force: true });
136
+ } catch {
137
+ /* best effort */
138
+ }
139
+ return null;
140
+ }
141
+ const profile: ChangeProfile = {
142
+ docsOnly: changedFiles.docsOnly,
143
+ rulePaths,
144
+ historyAvailable: workspaceResult.historyAvailable,
145
+ };
146
+ const reviewers = input.lite
147
+ ? [{ id: "lite-review", label: "Lite Review", enabled: true, model: "inherit" }]
148
+ : reviewersForRouting(target, config, profile);
149
+ const skippedReasons = adaptiveSkips(profile);
150
+ // The report tool uses this to reject findings that did not come from this
151
+ // run's roster (stale-artifact contamination guard).
152
+ manifest.reviewerIds = reviewers.map((r) => r.id);
153
+ const workspacePath = manifest.workspacePath;
154
+ const manifestPath = join(runDir, "manifest.json");
155
+ const diffPath = manifest.diffPath;
156
+ // Raw workflowScript text — the directive embeds it as a template literal
157
+ // and points failed-copy retries at this file (no double-escaping, which
158
+ // used to make the main agent's copy/unescape step error-prone).
159
+ const workflowPath = join(runDir, "workflow.js");
160
+ // Build the directive with real paths inlined (JSON.stringify'd into the
161
+ // workflowScript) — no placeholder + replaceAll substitutions. The old
162
+ // replaceAll injected unquoted paths into the JS template, producing
163
+ // invalid JS (`cwd: /var/folders/...` → SyntaxError).
164
+ const directive = buildReviewDirective({
165
+ target,
166
+ reviewers,
167
+ gateModel,
168
+ gateThinking: input.lite ? undefined : config.gate.thinking,
169
+ gateEnabled: config.gate.enabled,
170
+ threshold: config.gate.threshold,
171
+ verdictPolicy: config.gate.verdictPolicy,
172
+ lite: Boolean(input.lite),
173
+ cwd,
174
+ workspacePath,
175
+ manifestPath,
176
+ diffPath,
177
+ workflowPath,
178
+ budgets: resolveLeanBudgets(config.budgets),
179
+ });
180
+ // Remember which lanes adaptive routing dropped so the report can surface
181
+ // them as coverage rather than letting users wonder where a reviewer went.
182
+ if (!input.lite && config.routing.mode === "adaptive" && skippedReasons.length > 0) {
183
+ const skippedByRouting: Array<{ id: string; reason: string }> = [];
184
+ for (const [id, reason] of skippedReasons) {
185
+ if (!reviewers.some((r) => r.id === id)) skippedByRouting.push({ id, reason });
186
+ }
187
+ manifest.skippedReviewers = skippedByRouting;
188
+ }
189
+ writeManifest(runDir, manifest);
190
+
191
+ const directiveText = directive;
192
+
193
+ return { runId, manifest, directive, directiveText };
194
+ }
195
+
196
+ /** Read a previously-prepared manifest from disk. */
197
+ export function loadManifestFor(cwd: string, runId: string): RunManifest {
198
+ const runDir = join(cwd, ".pi", "pi-review", "runs", runId);
199
+ return readManifest(runDir);
200
+ }
201
+
202
+ /* ------------------------------------------------------------------ */
203
+ /* Diff acquisition — replaces `src/obtain-diff.ts`'s bash block. */
204
+ /* ------------------------------------------------------------------ */
205
+
206
+ interface DiffAcquisitionResult {
207
+ path: string;
208
+ diff: string;
209
+ mode: RunManifest["mode"];
210
+ baseSha?: string;
211
+ headSha?: string;
212
+ mergeBase?: string;
213
+ warning?: string;
214
+ }
215
+
216
+ async function acquireDiff(
217
+ cwd: string,
218
+ target: ReviewTarget,
219
+ runDir: string,
220
+ ): Promise<DiffAcquisitionResult> {
221
+ if (target.kind === "pr" && target.prRef) {
222
+ return acquirePrDiff(cwd, target.prRef, runDir);
223
+ }
224
+ if (target.kind === "diff-file" && target.diffPath) {
225
+ const text = safeRead(target.diffPath);
226
+ const path = writeDiff(runDir, text);
227
+ return { path, diff: text, mode: "local-uncommitted" };
228
+ }
229
+ return acquireLocalDiff(cwd, runDir);
230
+ }
231
+
232
+ async function acquirePrDiff(
233
+ cwd: string,
234
+ prRef: string,
235
+ runDir: string,
236
+ ): Promise<DiffAcquisitionResult> {
237
+ // Metadata first (base/head SHAs feed the manifest + workspace checkout
238
+ // reconciliation). A failed view is non-fatal — the diff below is the
239
+ // authority, not the metadata.
240
+ const gh = await _runCmd(
241
+ "gh",
242
+ ["pr", "view", prRef, "--json", "number,baseRefName,baseRefOid,headRefOid,headRepository,headRepositoryOwner"],
243
+ { cwd },
244
+ );
245
+ let prMeta: {
246
+ number?: string;
247
+ baseRefName?: string;
248
+ baseRefOid?: string;
249
+ headRefOid?: string;
250
+ headRepository?: { name?: string };
251
+ headRepositoryOwner?: { login?: string };
252
+ } | null = null;
253
+ if (gh.exitCode === 0 && gh.stdout.trim()) {
254
+ try {
255
+ prMeta = JSON.parse(gh.stdout);
256
+ } catch {
257
+ prMeta = null;
258
+ }
259
+ }
260
+
261
+ // `gh pr diff` is the SINGLE diff authority: it is byte-for-byte what the
262
+ // GitHub web UI renders for the PR (same base/merge-base semantics). A
263
+ // locally computed `git diff origin/main...FETCH_HEAD` can diverge from
264
+ // that (different merge-base, ref timing), which is exactly the
265
+ // "diff does not match GitHub" failure class — so we never substitute our
266
+ // own computation. On failure we stop and tell the user to fix gh.
267
+ const ghDiff = await _runCmd("gh", ["pr", "diff", prRef], { cwd });
268
+ if (ghDiff.exitCode !== 0) {
269
+ throw new Error(
270
+ `pi-review: gh pr diff failed for ${prRef} (${ghDiff.stderr.trim().slice(0, 200)}). gh pr diff is the single diff authority (it matches the GitHub web UI exactly), so there is no local fallback — check gh auth/install and re-run. Details: ${ghDiff.stdout.trim().slice(0, 200)}`,
271
+ );
272
+ }
273
+ const path = writeDiff(runDir, ghDiff.stdout);
274
+ return {
275
+ path,
276
+ diff: ghDiff.stdout,
277
+ mode: "gh-pr-diff",
278
+ baseSha: prMeta?.baseRefOid,
279
+ headSha: prMeta?.headRefOid,
280
+ };
281
+ }
282
+
283
+ async function acquireLocalDiff(cwd: string, runDir: string): Promise<DiffAcquisitionResult> {
284
+ const status = await _runCmd("git", ["status", "--porcelain"], { cwd });
285
+ if (status.stdout.trim().length > 0) {
286
+ // Mixed trees (modified + untracked files) must review BOTH parts —
287
+ // new files are exactly what needs eyes. Combine the tracked diff
288
+ // with synthesized new-file diffs instead of returning early on the
289
+ // first non-empty piece.
290
+ const tracked = (await _runCmd("git", ["diff", "HEAD"], { cwd })).stdout;
291
+ const untracked = (await _runCmd("git", ["ls-files", "--others", "--exclude-standard"], { cwd })).stdout;
292
+ const untrackedParts: string[] = [];
293
+ for (const f of untracked.trim() ? untracked.trim().split("\n") : []) {
294
+ try {
295
+ const buf = readFileSync(join(cwd, f));
296
+ // NUL in the leading bytes marks a binary file — utf-8 coercion
297
+ // would turn it into megabytes of U+FFFD noise.
298
+ if (buf.subarray(0, 8000).includes(0)) {
299
+ untrackedParts.push(`diff --git a/${f} b/${f}\nnew file mode 100644\nBinary file ${f} added (contents not shown)\n`);
300
+ continue;
301
+ }
302
+ const lines = buf.toString("utf-8").split("\n");
303
+ untrackedParts.push(
304
+ `diff --git a/${f} b/${f}\nnew file mode 100644\n--- /dev/null\n+++ b/${f}\n@@ -0,0 +1,${lines.length} @@\n${lines.map((l) => `+${l}`).join("\n")}\n`,
305
+ );
306
+ } catch {
307
+ /* unreadable (permission / deleted race) — skip */
308
+ }
309
+ }
310
+ const text = [tracked.trim(), ...untrackedParts].filter(Boolean).join("\n");
311
+ if (text) {
312
+ const path = writeDiff(runDir, text);
313
+ return { path, diff: text, mode: "local-uncommitted", headSha: await safeRev(cwd, "HEAD") };
314
+ }
315
+ }
316
+
317
+ const base = await detectDefaultBranch(cwd);
318
+ if (!base) {
319
+ const placeholder = `(no diff captured: clean tree, no default branch detected)\n`;
320
+ const path = writeDiff(runDir, placeholder);
321
+ return { path, diff: placeholder, mode: "local-vs-default" };
322
+ }
323
+ const baseFetch = await _runCmd("git", ["fetch", "origin", base, "--quiet"], { cwd });
324
+ // A failed fetch with an existing remote-tracking ref silently diffs
325
+ // against a stale base — surface it instead (the local twin of the PR
326
+ // stale-ref incident; kept non-fatal because offline local review is a
327
+ // legitimate mode and the manifest records whichever base was used).
328
+ let diffWarning: string | undefined;
329
+ if (baseFetch.exitCode !== 0) {
330
+ const hasRemoteRef = (await _runCmd("git", ["rev-parse", "--verify", `refs/remotes/origin/${base}`], { cwd })).exitCode === 0;
331
+ if (hasRemoteRef) {
332
+ diffWarning = `git fetch origin ${base} failed — diffing against possibly-stale origin/${base}`;
333
+ }
334
+ }
335
+ const compare = (await _runCmd("git", ["rev-parse", "--verify", `origin/${base}`], { cwd })).exitCode === 0
336
+ ? `origin/${base}`
337
+ : base;
338
+ const diff = await _runCmd("git", ["diff", `${compare}...HEAD`], { cwd });
339
+ const text = diff.stdout.length > 0 ? diff.stdout : `(no diff vs ${compare})\n`;
340
+ const path = writeDiff(runDir, text);
341
+ return {
342
+ path,
343
+ diff: text,
344
+ mode: "local-vs-default",
345
+ baseSha: await safeRev(cwd, compare),
346
+ headSha: await safeRev(cwd, "HEAD"),
347
+ mergeBase: (await _runCmd("git", ["merge-base", compare, "HEAD"], { cwd })).stdout.trim() || undefined,
348
+ warning: diffWarning,
349
+ };
350
+ }
351
+
352
+ async function safeRev(cwd: string, ref: string): Promise<string | undefined> {
353
+ const r = await _runCmd("git", ["rev-parse", ref], { cwd });
354
+ if (r.exitCode !== 0) return undefined;
355
+ return r.stdout.trim() || undefined;
356
+ }
357
+
358
+ async function detectDefaultBranch(cwd: string): Promise<string | null> {
359
+ const sym = await _runCmd("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd });
360
+ if (sym.exitCode === 0) {
361
+ const v = sym.stdout.trim();
362
+ return v.startsWith("origin/") ? v.slice("origin/".length) : v;
363
+ }
364
+ for (const candidate of ["main", "master"]) {
365
+ const probe = await _runCmd("git", ["rev-parse", "--verify", `refs/heads/${candidate}`], { cwd });
366
+ if (probe.exitCode === 0) return candidate;
367
+ }
368
+ const head = await _runCmd("git", ["symbolic-ref", "--short", "HEAD"], { cwd });
369
+ if (head.exitCode === 0) return head.stdout.trim();
370
+ return null;
371
+ }
372
+
373
+ interface CmdResult {
374
+ stdout: string;
375
+ stderr: string;
376
+ exitCode: number;
377
+ }
378
+
379
+ let _runCmd: (cmd: string, args: string[], opts: { cwd: string }) => Promise<CmdResult>;
380
+ async function defaultRunCmd(
381
+ cmd: string,
382
+ args: string[],
383
+ opts: { cwd: string },
384
+ ): Promise<CmdResult> {
385
+ const { spawn } = await import("node:child_process");
386
+ return new Promise((resolve) => {
387
+ try {
388
+ const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
389
+ let stdout = "";
390
+ let stderr = "";
391
+ child.stdout?.setEncoding("utf-8");
392
+ child.stderr?.setEncoding("utf-8");
393
+ child.stdout?.on("data", (d: string) => (stdout += d));
394
+ child.stderr?.on("data", (d: string) => (stderr += d));
395
+ child.on("error", () => resolve({ stdout, stderr, exitCode: 1 }));
396
+ child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }));
397
+ } catch {
398
+ resolve({ stdout: "", stderr: "spawn failed", exitCode: 1 });
399
+ }
400
+ });
401
+ }
402
+ _runCmd = defaultRunCmd;
403
+
404
+ /** Tests inject a fake. */
405
+ export function setReviewRunCmd(fn: (cmd: string, args: string[], opts: { cwd: string }) => Promise<CmdResult>): void {
406
+ _runCmd = fn;
407
+ }
408
+ export function resetReviewRunCmd(): void {
409
+ _runCmd = defaultRunCmd;
410
+ }
411
+
412
+ /* ------------------------------------------------------------------ */
413
+ /* Reviewer roster — adaptive routing based on target / change-kind. */
414
+ /* ------------------------------------------------------------------ */
415
+
416
+ import type { PiReviewConfig, ReviewerSpec } from "./types.js";
417
+
418
+ export interface ChangeProfile {
419
+ docsOnly: boolean;
420
+ rulePaths: string[];
421
+ historyAvailable: boolean;
422
+ }
423
+
424
+ /**
425
+ * Select enabled reviewers, then — in `adaptive` mode — drop lanes that
426
+ * cannot add signal for THIS change:
427
+ * - no rule files → skip claude-md-compliance
428
+ * - docs-only diff → skip bugbot / security-review (no code to scan)
429
+ * - no git history → skip history-context
430
+ * - docs-only → skip code-comments (nothing but prose touched)
431
+ *
432
+ * The directive still tells reviewer children to return `status: skipped`
433
+ * for these conditions when running in `routing.mode = "all"`, so coverage
434
+ * stays honest even when the lane cannot run.
435
+ */
436
+ export function reviewersForRouting(
437
+ target: ReviewTarget,
438
+ config: PiReviewConfig,
439
+ profile?: ChangeProfile,
440
+ ): ReviewerSpec[] {
441
+ const all = Object.values(config.reviewers).filter((r) => r.enabled);
442
+ if (config.routing.mode !== "adaptive" || !profile) return all;
443
+
444
+ const skippedReasons = adaptiveSkips(profile);
445
+ if (skippedReasons.length === 0) return all;
446
+
447
+ const reasons = new Map(skippedReasons);
448
+ return all.filter((r) => !reasons.has(r.id));
449
+ }
450
+
451
+ /** Return reviewer-id → reason for every lane that adaptive mode should drop. */
452
+ export function adaptiveSkips(profile: ChangeProfile): Array<[string, string]> {
453
+ const out: Array<[string, string]> = [];
454
+ if (profile.rulePaths.length === 0) {
455
+ out.push(["claude-md-compliance", "no rule files (AGENTS.md / CLAUDE.md / .pi rules)"]);
456
+ }
457
+ if (profile.docsOnly) {
458
+ out.push(["bugbot", "docs-only change (no code to scan)"]);
459
+ out.push(["security-review", "docs-only change (no code to scan)"]);
460
+ out.push(["code-comments", "docs-only change (no inline comments to violate)"]);
461
+ }
462
+ if (!profile.historyAvailable) {
463
+ out.push(["history-context", "no git history available in the target workspace"]);
464
+ }
465
+ return out;
466
+ }
467
+
468
+ function safeRead(path: string): string {
469
+ return existsSync(path) ? readFileSync(path, "utf-8") : "";
470
+ }
471
+
472
+ /**
473
+ * One-time-per-run回收 of the v0.5/0.6 flat layout (`.pi/pi-review/*.txt`
474
+ * + `change.diff` at the root). Those leftovers were repeatedly misread as
475
+ * the current run's inputs by later sessions; only known legacy filenames
476
+ * directly under `.pi/pi-review/` are touched — `runs/` is never scanned.
477
+ */
478
+ export function pruneLegacyFlatArtifacts(cwd: string): string[] {
479
+ const root = join(cwd, ".pi", "pi-review");
480
+ const legacyNames = ["change.diff", "changed-files.txt", "change-kind.txt", "diff-meta.txt"];
481
+ const removed: string[] = [];
482
+ for (const name of legacyNames) {
483
+ const p = join(root, name);
484
+ try {
485
+ if (existsSync(p)) {
486
+ rmSync(p, { force: true });
487
+ removed.push(p);
488
+ }
489
+ } catch {
490
+ /* best effort */
491
+ }
492
+ }
493
+ return removed;
494
+ }
495
+
496
+ /** Prune scratch workspace clones older than WORKSPACE_TTL_MS from the tmpdir. */
497
+ export function pruneStaleWorkspaces(now = Date.now()): string[] {
498
+ const removed: string[] = [];
499
+ let entries: string[];
500
+ try {
501
+ entries = readdirSync(tmpdir());
502
+ } catch {
503
+ return removed;
504
+ }
505
+ for (const entry of entries) {
506
+ if (!entry.startsWith("pi-review-ws-")) continue;
507
+ const path = join(tmpdir(), entry);
508
+ try {
509
+ const stat = statSync(path);
510
+ if (now - stat.mtimeMs > WORKSPACE_TTL_MS) {
511
+ rmSync(path, { recursive: true, force: true });
512
+ removed.push(path);
513
+ }
514
+ } catch {
515
+ /* already gone or unreadable */
516
+ }
517
+ }
518
+ return removed;
519
+ }
520
+
521
+ /** Resolve a ReviewTarget locally (mirrors src/git-input.ts without the `gh pr diff` call). */
522
+ async function resolveReviewTarget(
523
+ cwd: string,
524
+ opts: { input?: string },
525
+ ): Promise<ReviewTarget | null> {
526
+ const userContext = opts.input?.trim() || undefined;
527
+ const prRef = userContext ? extractPrRef(userContext) : null;
528
+ if (prRef) {
529
+ return {
530
+ kind: "pr",
531
+ label: prLabel(prRef),
532
+ userContext,
533
+ prRef,
534
+ hint: `Obtain PR ${prRef} yourself via gh and/or git. The plugin already prepared the target repo + diff.`,
535
+ };
536
+ }
537
+ const git = await _runCmd("git", ["rev-parse", "--git-dir"], { cwd }).then((r) => r.exitCode === 0);
538
+ if (!git) return null;
539
+ const status = await _runCmd("git", ["status", "--porcelain"], { cwd });
540
+ const dirty = status.stdout.trim().length > 0;
541
+ const base = await detectDefaultBranch(cwd);
542
+ const baseHint = base ?? "main";
543
+ if (dirty) {
544
+ return {
545
+ kind: "local-git",
546
+ label: "uncommitted changes",
547
+ userContext,
548
+ hint: "Working tree is dirty.",
549
+ };
550
+ }
551
+ return {
552
+ kind: "local-git",
553
+ label: `vs ${baseHint}`,
554
+ userContext,
555
+ hint: `Working tree is clean. Diff vs ${baseHint} is already prepared.`,
556
+ };
557
+ }
558
+
559
+ function prLabel(prRef: string): string {
560
+ const n = prRef.match(/pull\/(\d+)/i)?.[1] ?? prRef.replace(/^#/, "");
561
+ return `PR ${n}`;
562
+ }
563
+
564
+ // Re-exports for tests
565
+ export { setRunCmd, resetRunCmd, setTargetWorkspaceCmd, resetTargetWorkspaceCmd };
566
+
567
+ void DEFAULT_CONFIG;
568
+ void writeFileSync;