pi-workflow-engine 0.12.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/USAGE.md +90 -17
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- package/.pi/settings.json +0 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { throwIfAborted } from "../cancellation.ts";
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
2
|
+
import { isFiniteNumber, isRecord } from "../guards.ts";
|
|
3
|
+
import type { CommentableReviewIssue, ReviewIssue } from "./review-issues.ts";
|
|
4
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
4
5
|
|
|
5
6
|
export interface ExecResultLike {
|
|
6
7
|
readonly stdout: string;
|
|
@@ -30,24 +31,16 @@ export type InlineCommentStatus =
|
|
|
30
31
|
| { readonly issueId: string; readonly status: "skipped"; readonly reason: string }
|
|
31
32
|
| { readonly issueId: string; readonly status: "failed"; readonly reason: string };
|
|
32
33
|
|
|
33
|
-
const PR_VIEW_JSON_FIELDS = "number,headRefOid,url";
|
|
34
|
-
|
|
35
34
|
export async function resolveGitHubPrContext(
|
|
36
35
|
exec: ExecLike,
|
|
37
36
|
cwd: string,
|
|
38
|
-
|
|
37
|
+
number: number,
|
|
39
38
|
signal?: AbortSignal,
|
|
40
39
|
): Promise<ResolveGitHubPrContextResult> {
|
|
41
|
-
const
|
|
42
|
-
const prArgs = parsedNumber
|
|
43
|
-
? ["pr", "view", String(parsedNumber), "--json", PR_VIEW_JSON_FIELDS]
|
|
44
|
-
: ["pr", "view", "--json", PR_VIEW_JSON_FIELDS];
|
|
45
|
-
const prView = await runJson(exec, cwd, prArgs, signal);
|
|
40
|
+
const prView = await runJson(exec, cwd, ["pr", "view", String(number), "--json", "headRefOid,url"], signal);
|
|
46
41
|
if (!prView.ok) return { ok: false, reason: prView.reason };
|
|
47
42
|
|
|
48
|
-
const number = numberField(prView.value, "number") ?? parsedNumber;
|
|
49
43
|
const headSha = stringField(prView.value, "headRefOid");
|
|
50
|
-
if (number === undefined) return { ok: false, reason: "No GitHub PR number found." };
|
|
51
44
|
if (!headSha) return { ok: false, reason: "GitHub PR head SHA is missing." };
|
|
52
45
|
|
|
53
46
|
const url = stringField(prView.value, "url");
|
|
@@ -82,15 +75,13 @@ ${evidence}
|
|
|
82
75
|
Recommendation: ${finding.recommendation}`;
|
|
83
76
|
}
|
|
84
77
|
|
|
85
|
-
|
|
78
|
+
async function postInlineComment(
|
|
86
79
|
exec: ExecLike,
|
|
87
80
|
cwd: string,
|
|
88
81
|
prContext: GitHubPrContext,
|
|
89
|
-
issue:
|
|
82
|
+
issue: CommentableReviewIssue,
|
|
90
83
|
signal?: AbortSignal,
|
|
91
84
|
): Promise<InlineCommentStatus> {
|
|
92
|
-
if (!isCommentableIssue(issue)) return { issueId: issue.id, status: "skipped", reason: "Finding has no commentable file/line." };
|
|
93
|
-
|
|
94
85
|
const result = await runCommand(exec, cwd, [
|
|
95
86
|
"api",
|
|
96
87
|
`repos/${prContext.owner}/${prContext.repo}/pulls/${prContext.number}/comments`,
|
|
@@ -113,7 +104,7 @@ export async function postInlineComments(
|
|
|
113
104
|
exec: ExecLike,
|
|
114
105
|
cwd: string,
|
|
115
106
|
prContext: GitHubPrContext,
|
|
116
|
-
issues: readonly
|
|
107
|
+
issues: readonly CommentableReviewIssue[],
|
|
117
108
|
signal?: AbortSignal,
|
|
118
109
|
): Promise<InlineCommentStatus[]> {
|
|
119
110
|
throwIfAborted(signal);
|
|
@@ -125,14 +116,14 @@ export async function postInlineComments(
|
|
|
125
116
|
const statuses: InlineCommentStatus[] = [];
|
|
126
117
|
for (const issue of issues) {
|
|
127
118
|
throwIfAborted(signal);
|
|
128
|
-
const key =
|
|
129
|
-
if (
|
|
119
|
+
const key = commentKey(buildInlineCommentBody(issue), issue.file, issue.line, prContext.headSha);
|
|
120
|
+
if (existing.keys.has(key)) {
|
|
130
121
|
statuses.push({ issueId: issue.id, status: "skipped", reason: "An identical inline comment already exists on this PR head." });
|
|
131
122
|
continue;
|
|
132
123
|
}
|
|
133
124
|
const status = await postInlineComment(exec, cwd, prContext, issue, signal);
|
|
134
125
|
statuses.push(status);
|
|
135
|
-
if (
|
|
126
|
+
if (status.status === "posted") existing.keys.add(key);
|
|
136
127
|
}
|
|
137
128
|
return statuses;
|
|
138
129
|
}
|
|
@@ -161,7 +152,7 @@ async function runJson(
|
|
|
161
152
|
try {
|
|
162
153
|
return { ok: true, value: JSON.parse(result.stdout) };
|
|
163
154
|
} catch (error) {
|
|
164
|
-
return { ok: false, reason:
|
|
155
|
+
return { ok: false, reason: `Invalid gh JSON: ${unknownErrorMessage(error)}` };
|
|
165
156
|
}
|
|
166
157
|
}
|
|
167
158
|
|
|
@@ -179,7 +170,7 @@ async function runCommand(
|
|
|
179
170
|
return { ok: true, stdout: result.stdout };
|
|
180
171
|
} catch (error) {
|
|
181
172
|
throwIfAborted(signal);
|
|
182
|
-
return { ok: false, reason: error
|
|
173
|
+
return { ok: false, reason: unknownErrorMessage(error) };
|
|
183
174
|
}
|
|
184
175
|
}
|
|
185
176
|
|
|
@@ -197,10 +188,6 @@ function parseExistingInlineCommentKeys(value: unknown): Set<string> | undefined
|
|
|
197
188
|
return keys;
|
|
198
189
|
}
|
|
199
190
|
|
|
200
|
-
function inlineCommentKey(issue: ReviewIssue, headSha: string): string | undefined {
|
|
201
|
-
return isCommentableIssue(issue) ? commentKey(buildInlineCommentBody(issue), issue.file, issue.line, headSha) : undefined;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
191
|
function commentKey(body: string, path: string, line: number, headSha: string): string {
|
|
205
192
|
return JSON.stringify([body, path, line, headSha]);
|
|
206
193
|
}
|
|
@@ -208,7 +195,7 @@ function commentKey(body: string, path: string, line: number, headSha: string):
|
|
|
208
195
|
function numberField(value: unknown, key: string): number | undefined {
|
|
209
196
|
if (!isRecord(value)) return undefined;
|
|
210
197
|
const field = value[key];
|
|
211
|
-
return
|
|
198
|
+
return isFiniteNumber(field) ? field : undefined;
|
|
212
199
|
}
|
|
213
200
|
|
|
214
201
|
function stringField(value: unknown, key: string): string | undefined {
|
|
@@ -243,7 +230,3 @@ function parsePostedCommentUrl(stdout: string): string | undefined {
|
|
|
243
230
|
return undefined;
|
|
244
231
|
}
|
|
245
232
|
}
|
|
246
|
-
|
|
247
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
248
|
-
return typeof value === "object" && value !== null;
|
|
249
|
-
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { throwIfAborted } from "../cancellation.ts";
|
|
4
|
+
import { runBoundedProcess, type BoundedProcessResult } from "../process-runner.ts";
|
|
5
|
+
import { WorktreeRegistry, type WorktreeBaseline } from "../worktree.ts";
|
|
6
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
7
|
+
import { fingerprintReviewWorktreeBaseline } from "./review-snapshot.ts";
|
|
8
|
+
|
|
9
|
+
export const PatchEvaluationSchema = Type.Object({
|
|
10
|
+
outcome: Type.Union([Type.Literal("accepted"), Type.Literal("rejected"), Type.Literal("blocked")]),
|
|
11
|
+
reason: Type.String(),
|
|
12
|
+
checks: Type.Array(Type.Object({
|
|
13
|
+
file: Type.String({ description: "Executable for a focused validation command, not a shell command string." }),
|
|
14
|
+
args: Type.Array(Type.String()),
|
|
15
|
+
required: Type.Boolean(),
|
|
16
|
+
regression: Type.Optional(Type.Object({
|
|
17
|
+
baselinePatch: Type.String({ description: "Test-only patch to apply to the original baseline to reproduce the bug; exclude the repair." }),
|
|
18
|
+
expectedFailure: Type.String({ description: "Specific failure text proving the intended defect, not a missing dependency or setup error." }),
|
|
19
|
+
})),
|
|
20
|
+
}), { maxItems: 6 }),
|
|
21
|
+
});
|
|
22
|
+
export type PatchEvaluation = Static<typeof PatchEvaluationSchema>;
|
|
23
|
+
export type PatchCandidateStatus = "verified" | "rejected" | "blocked" | "no-patch";
|
|
24
|
+
export interface PatchValidation {
|
|
25
|
+
status: PatchCandidateStatus;
|
|
26
|
+
baselineFingerprint: string;
|
|
27
|
+
baselineOid?: string;
|
|
28
|
+
patchHash: string;
|
|
29
|
+
checks: { file: string; args: string[]; required: boolean; stage: "candidate" | "baseline"; result: BoundedProcessResult }[];
|
|
30
|
+
evaluation?: PatchEvaluation;
|
|
31
|
+
reason: string;
|
|
32
|
+
cleanupError?: string;
|
|
33
|
+
}
|
|
34
|
+
export function initialPatchValidation(baseline: WorktreeBaseline, baselineOid: string | undefined, patch: string): PatchValidation {
|
|
35
|
+
return { status: patch.trim() ? "blocked" : "no-patch", baselineFingerprint: fingerprintReviewWorktreeBaseline(baseline), ...(baselineOid ? { baselineOid } : {}),
|
|
36
|
+
patchHash: createHash("sha256").update(patch).digest("hex"), checks: [], reason: patch.trim() ? "Independent evaluation has not completed." : "The implementer produced no patch; this does not establish that no change is needed." };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PatchValidationOptions {
|
|
40
|
+
cwd: string; baseline: WorktreeBaseline; baselineOid: string; patch: string;
|
|
41
|
+
evaluation: PatchEvaluation; signal?: AbortSignal;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reconstruct the exact reviewed baseline and retain evidence even if cleanup fails. */
|
|
45
|
+
export async function validateCandidatePatch(options: PatchValidationOptions): Promise<PatchValidation> {
|
|
46
|
+
const validation = initialPatchValidation(options.baseline, options.baselineOid, options.patch);
|
|
47
|
+
validation.evaluation = options.evaluation;
|
|
48
|
+
if (!options.patch.trim()) return validation;
|
|
49
|
+
const worktrees = new WorktreeRegistry(options.cwd);
|
|
50
|
+
let result = validation;
|
|
51
|
+
try {
|
|
52
|
+
result = await evaluateCandidateInWorktrees(worktrees, options, validation);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throwIfAborted(options.signal);
|
|
55
|
+
result = { ...validation, status: "blocked", reason: unknownErrorMessage(error) };
|
|
56
|
+
} finally {
|
|
57
|
+
try {
|
|
58
|
+
await worktrees.removeAll();
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const cleanupError = unknownErrorMessage(error);
|
|
61
|
+
result = {
|
|
62
|
+
...result,
|
|
63
|
+
status: result.status === "verified" ? "blocked" : result.status,
|
|
64
|
+
cleanupError,
|
|
65
|
+
reason: `${result.reason}\nCleanup failed: ${cleanupError}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
throwIfAborted(options.signal);
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function evaluateCandidateInWorktrees(
|
|
74
|
+
worktrees: WorktreeRegistry, options: PatchValidationOptions, validation: PatchValidation,
|
|
75
|
+
): Promise<PatchValidation> {
|
|
76
|
+
const candidate = await worktrees.add(options.signal, options.baseline);
|
|
77
|
+
if ("error" in candidate) return { ...validation, reason: candidate.error };
|
|
78
|
+
if (candidate.baselineOid !== options.baselineOid) return { ...validation, status: "rejected", reason: "Candidate was produced from a different baseline." };
|
|
79
|
+
const applied = await worktrees.applyPatch(candidate.path, options.patch, options.signal);
|
|
80
|
+
if (!applied.ok) return { ...validation, status: "rejected", reason: applied.error };
|
|
81
|
+
const rejected = options.evaluation.outcome === "rejected" ? [options.evaluation.reason] : [];
|
|
82
|
+
const blocked = options.evaluation.outcome === "blocked" ? [options.evaluation.reason] : [];
|
|
83
|
+
if (!options.evaluation.checks.some((check) => check.required)) blocked.push("No required behavior check was selected.");
|
|
84
|
+
for (const check of options.evaluation.checks) {
|
|
85
|
+
const result = await runCheck(check, candidate.path, options.signal);
|
|
86
|
+
validation.checks.push({ ...check, stage: "candidate", result });
|
|
87
|
+
throwIfAborted(options.signal);
|
|
88
|
+
if (!result.ok) {
|
|
89
|
+
if (result.failure.kind === "exit") rejected.push(`${check.file}: ${result.failure.message}`);
|
|
90
|
+
else if (check.required) blocked.push(`${check.file}: ${result.failure.message}`);
|
|
91
|
+
}
|
|
92
|
+
if (check.regression) {
|
|
93
|
+
const original = await worktrees.add(options.signal, options.baseline);
|
|
94
|
+
if ("error" in original) { blocked.push(`Baseline worktree setup failed: ${original.error}`); continue; }
|
|
95
|
+
const testApplied = await worktrees.applyPatch(original.path, check.regression.baselinePatch, options.signal);
|
|
96
|
+
if (!testApplied.ok) { blocked.push(`Baseline regression setup failed: ${testApplied.error}`); continue; }
|
|
97
|
+
const baselineResult = await runCheck(check, original.path, options.signal);
|
|
98
|
+
validation.checks.push({ ...check, stage: "baseline", result: baselineResult });
|
|
99
|
+
throwIfAborted(options.signal);
|
|
100
|
+
if (baselineResult.ok || baselineResult.failure.kind !== "exit" || !check.regression.expectedFailure.trim() ||
|
|
101
|
+
!(baselineResult.stdout + baselineResult.stderr).includes(check.regression.expectedFailure)) {
|
|
102
|
+
blocked.push(`${check.file}: baseline did not reproduce the expected failure: ${check.regression.expectedFailure}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Tests must not silently replace the candidate under evaluation.
|
|
107
|
+
const after = await worktrees.capturePatch(candidate.path, candidate.baselineOid, options.signal);
|
|
108
|
+
if ("error" in after) blocked.push(`Candidate capture failed: ${after.error}`);
|
|
109
|
+
else if (after.patch !== options.patch) blocked.push("Validation checks changed the candidate patch.");
|
|
110
|
+
if (rejected.length > 0) return { ...validation, status: "rejected", reason: [...rejected, ...blocked].join("\n") };
|
|
111
|
+
if (blocked.length > 0) return { ...validation, status: "blocked", reason: blocked.join("\n") };
|
|
112
|
+
return { ...validation, status: "verified", reason: "Independent evaluation and all required checks passed." };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function runCheck(check: PatchEvaluation["checks"][number], cwd: string, signal?: AbortSignal): Promise<BoundedProcessResult> {
|
|
116
|
+
return runBoundedProcess({ file: check.file, args: check.args, cwd, signal, timeoutMs: 120_000, maxBufferBytes: 1 << 20,
|
|
117
|
+
abortError: "Validation aborted", timeoutError: "Validation timed out", maxBufferError: "Validation output exceeded its limit",
|
|
118
|
+
exitError: (stderr, code) => stderr.trim() || `Validation exited with ${code}` });
|
|
119
|
+
}
|
|
@@ -104,7 +104,7 @@ async function handleCommentAction(
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
const exec = toExecLike(pi);
|
|
107
|
-
const resolved = await resolveGitHubPrContext(exec, ctx.cwd, context, ctx.signal);
|
|
107
|
+
const resolved = await resolveGitHubPrContext(exec, ctx.cwd, context.diffTarget.number, ctx.signal);
|
|
108
108
|
if (!resolved.ok) {
|
|
109
109
|
await queueCommentFallback(pi, ctx, commentable, context, baseline.ref, resolved.reason);
|
|
110
110
|
return;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { repoPathCandidates } from "../advisory-evidence.ts";
|
|
2
|
+
import type { AdvisoryLocation } from "../advisory-schema.ts";
|
|
3
|
+
|
|
4
|
+
const C_QUOTE_ESCAPES: Readonly<Record<string, number>> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 };
|
|
5
|
+
|
|
6
|
+
/** Undo git's C-style quoting of unusual path names, e.g. `"b/caf\303\251.txt"`. */
|
|
7
|
+
function unquoteGitPath(path: string): string {
|
|
8
|
+
if (path.length < 2 || !path.startsWith('"') || !path.endsWith('"')) return path;
|
|
9
|
+
const bytes: number[] = [];
|
|
10
|
+
for (const [, escape, literal] of path.slice(1, -1).matchAll(/\\([0-7]{3}|.)|([^\\]+)/g)) {
|
|
11
|
+
if (literal !== undefined) bytes.push(...Buffer.from(literal));
|
|
12
|
+
else if (escape !== undefined) bytes.push(escape.length === 3 ? parseInt(escape, 8) : (C_QUOTE_ESCAPES[escape] ?? escape.charCodeAt(0)));
|
|
13
|
+
}
|
|
14
|
+
return Buffer.from(bytes).toString("utf8");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Parse a unified diff into the set of added/changed new-file line numbers per file. */
|
|
18
|
+
export function changedLines(diff: string): Map<string, Set<number>> {
|
|
19
|
+
const byFile = new Map<string, Set<number>>();
|
|
20
|
+
let file: string | null = null;
|
|
21
|
+
let newLine = 0;
|
|
22
|
+
for (const raw of diff.split("\n")) {
|
|
23
|
+
if (raw.startsWith("+++ ")) {
|
|
24
|
+
const path = unquoteGitPath(raw.slice(4).trim());
|
|
25
|
+
file = path === "/dev/null" ? null : path.replace(/^b\//, "");
|
|
26
|
+
if (file && !byFile.has(file)) byFile.set(file, new Set());
|
|
27
|
+
} else if (raw.startsWith("@@")) {
|
|
28
|
+
const match = /\+(\d+)/.exec(raw);
|
|
29
|
+
newLine = match ? Number(match[1]) : 0;
|
|
30
|
+
} else if (file === null || raw.startsWith("---") || raw.startsWith("\\")) {
|
|
31
|
+
// file header, deletion, or "No newline" marker — record nothing
|
|
32
|
+
} else if (raw.startsWith("+")) {
|
|
33
|
+
byFile.get(file)!.add(newLine++);
|
|
34
|
+
} else if (!raw.startsWith("-")) {
|
|
35
|
+
newLine++; // context line advances the new-file counter; deletions do not
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return byFile;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The location re-keyed to its repo-relative diff path when it is inside the diff, else undefined.
|
|
43
|
+
* The exact path wins; a stray `./`, `a/` or `b/` prefix is tolerated. File-level locations count
|
|
44
|
+
* if the file changed; lines get ±1 of fuzz.
|
|
45
|
+
*/
|
|
46
|
+
export function diffAnchor(changed: ReadonlyMap<string, ReadonlySet<number>>, location: AdvisoryLocation): AdvisoryLocation | undefined {
|
|
47
|
+
for (const file of repoPathCandidates(location.file)) {
|
|
48
|
+
const lines = changed.get(file);
|
|
49
|
+
if (!lines) continue;
|
|
50
|
+
const { line } = location;
|
|
51
|
+
return line == null || lines.has(line) || lines.has(line - 1) || lines.has(line + 1) ? { ...location, file } : undefined;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { initialPatchValidation, PatchEvaluationSchema, validateCandidatePatch, type PatchValidation } from "./patch-validation.ts";
|
|
2
|
+
import { isFatalWorkflowError } from "../cancellation.ts";
|
|
3
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
1
4
|
import type { ParallelSettledError } from "../concurrency.ts";
|
|
2
|
-
import type { LoadedWorkflow, WorkflowApi, WorkflowModule } from "../types.ts";
|
|
5
|
+
import type { IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowModule } from "../types.ts";
|
|
3
6
|
import type { WorktreeBaseline } from "../worktree.ts";
|
|
4
7
|
import { loadWorkflow } from "../workflow-module.ts";
|
|
5
8
|
import { serializeReviewIssue, type ReviewIssue } from "./review-issues.ts";
|
|
@@ -14,6 +17,7 @@ export interface ReviewFixPreview {
|
|
|
14
17
|
readonly result: string;
|
|
15
18
|
readonly patch: string;
|
|
16
19
|
readonly changed: boolean;
|
|
20
|
+
readonly validation: PatchValidation;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export interface ReviewFixFailure {
|
|
@@ -28,7 +32,7 @@ export interface ReviewFixWorkflowResult {
|
|
|
28
32
|
readonly fixes: readonly ReviewFixOutcome[];
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "parallel" | "phase">;
|
|
35
|
+
export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "parallel" | "phase" | "cwd" | "signal">;
|
|
32
36
|
|
|
33
37
|
/** Build an ephemeral workflow that generates one isolated patch preview per finding. */
|
|
34
38
|
export function createReviewFixWorkflow(
|
|
@@ -40,9 +44,9 @@ export function createReviewFixWorkflow(
|
|
|
40
44
|
meta: {
|
|
41
45
|
name: "code-review-fix-previews",
|
|
42
46
|
description: "Generate isolated patch previews for selected code-review findings.",
|
|
43
|
-
phases: [{ title: REVIEW_FIX_PHASE }],
|
|
47
|
+
phases: [{ title: REVIEW_FIX_PHASE }, { title: "Validate patch previews" }],
|
|
44
48
|
},
|
|
45
|
-
default: (api) => runReviewFixWorkflow(api, issues, context),
|
|
49
|
+
default: (api) => runReviewFixWorkflow(api, issues, context, baseline),
|
|
46
50
|
};
|
|
47
51
|
return loadWorkflow(
|
|
48
52
|
module,
|
|
@@ -76,6 +80,7 @@ export async function runReviewFixWorkflow(
|
|
|
76
80
|
api: ReviewFixWorkflowApi,
|
|
77
81
|
issues: readonly ReviewIssue[],
|
|
78
82
|
context: ReviewContext | undefined,
|
|
83
|
+
baseline: WorktreeBaseline,
|
|
79
84
|
): Promise<ReviewFixWorkflowResult> {
|
|
80
85
|
api.phase(REVIEW_FIX_PHASE);
|
|
81
86
|
const settled = await api.parallel(
|
|
@@ -84,16 +89,18 @@ export async function runReviewFixWorkflow(
|
|
|
84
89
|
isolation: "worktree",
|
|
85
90
|
label: `fix:${issue.id}`,
|
|
86
91
|
phase: REVIEW_FIX_PHASE,
|
|
87
|
-
|
|
92
|
+
profile: "medium",
|
|
88
93
|
cacheKey: `review-fix:${issue.id}`,
|
|
89
94
|
tools: [...REVIEW_FIX_TOOLS],
|
|
90
95
|
toolHints: ["search"],
|
|
91
96
|
});
|
|
97
|
+
const validation = await evaluateReviewFix(api, { issue, baseline, isolated });
|
|
92
98
|
return {
|
|
93
99
|
findingId: issue.id,
|
|
94
100
|
result: isolated.result,
|
|
95
101
|
patch: isolated.patch,
|
|
96
102
|
changed: isolated.changed,
|
|
103
|
+
validation,
|
|
97
104
|
};
|
|
98
105
|
}),
|
|
99
106
|
{ settled: true },
|
|
@@ -103,11 +110,11 @@ export async function runReviewFixWorkflow(
|
|
|
103
110
|
entry.ok ? entry.value : { findingId: issues[index]!.id, error: entry.error },
|
|
104
111
|
);
|
|
105
112
|
const successful = fixes.filter(isReviewFixPreview);
|
|
106
|
-
const
|
|
113
|
+
const count = (status: PatchValidation["status"]) => successful.filter((fix) => fix.validation.status === status).length;
|
|
107
114
|
const failed = fixes.length - successful.length;
|
|
108
115
|
|
|
109
116
|
return {
|
|
110
|
-
summary:
|
|
117
|
+
summary: `${count("verified")} verified candidate(s); ${count("rejected")} rejected; ${count("blocked")} blocked; ${count("no-patch")} no-patch; ${failed} attempt(s) failed.`,
|
|
111
118
|
fixes,
|
|
112
119
|
};
|
|
113
120
|
}
|
|
@@ -115,3 +122,30 @@ export async function runReviewFixWorkflow(
|
|
|
115
122
|
function isReviewFixPreview(outcome: ReviewFixOutcome): outcome is ReviewFixPreview {
|
|
116
123
|
return "patch" in outcome;
|
|
117
124
|
}
|
|
125
|
+
|
|
126
|
+
/** `baseline` was already revalidated against the reviewed snapshot identity by resolveReviewWorktreeBaseline. */
|
|
127
|
+
async function evaluateReviewFix(api: ReviewFixWorkflowApi, input: {
|
|
128
|
+
issue: ReviewIssue; baseline: WorktreeBaseline; isolated: IsolatedAgentResult<string>;
|
|
129
|
+
}): Promise<PatchValidation> {
|
|
130
|
+
const { issue, baseline, isolated } = input;
|
|
131
|
+
const validation = initialPatchValidation(baseline, isolated.baselineOid, isolated.patch);
|
|
132
|
+
if (!isolated.patch.trim()) return validation;
|
|
133
|
+
if (!isolated.baselineOid) return { ...validation, reason: "Candidate has no recorded baseline identity." };
|
|
134
|
+
try {
|
|
135
|
+
const evaluated = await api.agent(
|
|
136
|
+
`Independently evaluate a candidate repair. Your fresh worktree contains the exact reviewed baseline plus the captured patch. The implementer's report is not validation evidence. Inspect the finding, callers and tests. Reject incorrect repairs; return blocked if required validation is unavailable. Select at most six focused deterministic checks with executable and argument arrays. Require at least one meaningful behavior check. If a regression test is applicable, supply a test-only baselinePatch and specific expectedFailure so the engine can prove it fails before the repair and passes after. Do not edit, install dependencies, commit or change branches. The engine will execute checks independently.\nFinding: ${JSON.stringify(serializeReviewIssue(issue))}\nBaseline: ${isolated.baselineOid}\nPatch SHA-256: ${validation.patchHash}\nPatch:\n${isolated.patch}`,
|
|
137
|
+
{ isolation: "worktree", candidatePatch: { baselineOid: isolated.baselineOid, patch: isolated.patch },
|
|
138
|
+
label: `evaluate:${issue.id}`, phase: "Validate patch previews", profile: "medium", resume: "off",
|
|
139
|
+
tools: ["read", "bash", "grep", "find", "ls"], toolHints: ["search"], schema: PatchEvaluationSchema },
|
|
140
|
+
);
|
|
141
|
+
// Drift here is usually a test byproduct in the evaluator's worktree, not evidence against the repair.
|
|
142
|
+
if (evaluated.baselineOid !== isolated.baselineOid || evaluated.patch !== isolated.patch) {
|
|
143
|
+
return { ...validation, status: "blocked", reason: "Evaluator session changed its workspace (candidate patch or baseline drifted); independent checks were not run.", evaluation: evaluated.result };
|
|
144
|
+
}
|
|
145
|
+
return await validateCandidatePatch({ cwd: api.cwd, baseline,
|
|
146
|
+
baselineOid: isolated.baselineOid, patch: isolated.patch, evaluation: evaluated.result, signal: api.signal });
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (isFatalWorkflowError(error, api.signal)) throw error;
|
|
149
|
+
return { ...validation, status: "blocked", reason: unknownErrorMessage(error) };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -1,132 +1,67 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { visibleWidth
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { dot, fit, GLYPH, hangingWrap, severityBadge, severityRank, truncatePath } from "../ui/kit.ts";
|
|
4
|
+
import type { ReviewIssue } from "./review-issues.ts";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/** Most severe first; report order (and so the stable R-number) breaks ties. */
|
|
7
|
+
export function sortIssuesForDisplay(issues: readonly ReviewIssue[]): ReviewIssue[] {
|
|
8
|
+
return issues
|
|
9
|
+
.map((issue, index) => ({ issue, index }))
|
|
10
|
+
.sort((a, b) => severityRank(a.issue.finding.severity) - severityRank(b.issue.finding.severity) || a.index - b.index)
|
|
11
|
+
.map(({ issue }) => issue);
|
|
8
12
|
}
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const CATEGORY_WIDTH = 8;
|
|
15
|
-
const LOCATION_WIDTH = 30;
|
|
16
|
-
const SUMMARY_WIDTH = 58;
|
|
17
|
-
|
|
18
|
-
export function renderIssuesTable(issues: readonly ReviewIssue[], theme: Theme, options: RenderIssuesTableOptions = {}): string {
|
|
19
|
-
const maxRows = options.maxRows ?? DEFAULT_MAX_ROWS;
|
|
20
|
-
const visible = maxRows >= 0 ? issues.slice(0, maxRows) : issues;
|
|
21
|
-
const lines = [
|
|
22
|
-
renderRow(["ID", "Sev", "Conf", "Cat", "Location", "Summary"], theme.fg("dim", "│"), theme, true),
|
|
23
|
-
theme.fg("dim", renderSeparator()),
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
for (const issue of visible) {
|
|
27
|
-
lines.push(
|
|
28
|
-
renderRow(
|
|
29
|
-
[
|
|
30
|
-
issue.id,
|
|
31
|
-
issue.finding.severity,
|
|
32
|
-
issue.finding.confidence,
|
|
33
|
-
issue.finding.category,
|
|
34
|
-
formatIssueLocation(issue),
|
|
35
|
-
issue.finding.summary,
|
|
36
|
-
],
|
|
37
|
-
theme.fg("dim", "│"),
|
|
38
|
-
theme,
|
|
39
|
-
false,
|
|
40
|
-
issue,
|
|
41
|
-
),
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (issues.length > visible.length) {
|
|
46
|
-
lines.push(theme.fg("dim", `… ${issues.length - visible.length} more finding(s)`));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return lines.join("\n");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function renderIssueDetails(issue: ReviewIssue, theme: Theme): string {
|
|
53
|
-
return renderIssueDetailLines(issue, theme, 120).join("\n");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function renderIssueDetailLines(issue: ReviewIssue, theme: Theme, width: number): string[] {
|
|
57
|
-
const finding = issue.finding;
|
|
58
|
-
const metadata = `${finding.category} · severity ${finding.severity} · confidence ${finding.confidence}`;
|
|
59
|
-
const lines = [`${theme.fg("accent", issue.id)} ${theme.fg("text", finding.summary)}`];
|
|
60
|
-
lines.push(...fieldLines("Metadata", metadata, width, theme));
|
|
61
|
-
lines.push(...fieldLines("Location", formatIssueLocation(issue), width, theme, "accent"));
|
|
62
|
-
lines.push(...fieldLines("Impact", finding.impact, width, theme));
|
|
63
|
-
lines.push(...fieldLines("Evidence", finding.evidence.join("; ") || "(none cited)", width, theme));
|
|
64
|
-
lines.push(...fieldLines("Recommendation", finding.recommendation, width, theme));
|
|
65
|
-
return lines.flatMap((line) => wrapTextWithAnsi(line, Math.max(10, width)));
|
|
14
|
+
/** `src/cache.ts:13`, without the symbol; the detail view shows the symbol on its own. */
|
|
15
|
+
export function issuePath(issue: ReviewIssue): string {
|
|
16
|
+
if (!issue.file) return "no location";
|
|
17
|
+
return issue.line === undefined ? issue.file : `${issue.file}:${issue.line}`;
|
|
66
18
|
}
|
|
67
19
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
20
|
+
/**
|
|
21
|
+
* One aligned row: `HIGH R003 src/cache.ts:13 The parser accepts an empty value…`.
|
|
22
|
+
* `pathColumn` (from `findingPathColumn`) narrows the location column to the paths actually listed.
|
|
23
|
+
*/
|
|
24
|
+
export function renderFindingRow(issue: ReviewIssue, width: number, theme: Theme, pathColumn?: number): string {
|
|
25
|
+
const head = `${severityBadge(issue.finding.severity, theme)} ${theme.fg("dim", issue.id)}`;
|
|
26
|
+
const room = Math.max(0, width - visibleWidth(head) - 2);
|
|
27
|
+
const pathWidth = Math.min(pathColumn ?? Number.POSITIVE_INFINITY, maxPathWidth(room));
|
|
28
|
+
if (room < pathWidth + 10) return fit(`${head} ${theme.fg("text", issue.finding.summary)}`, width);
|
|
29
|
+
const path = theme.fg("accent", fit(truncatePath(issuePath(issue), pathWidth), pathWidth));
|
|
30
|
+
return fit(`${head} ${path} ${theme.fg("text", issue.finding.summary)}`, width);
|
|
77
31
|
}
|
|
78
32
|
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
separator: string,
|
|
82
|
-
theme: Theme,
|
|
83
|
-
header: boolean,
|
|
84
|
-
issue?: ReviewIssue,
|
|
85
|
-
): string {
|
|
86
|
-
const rendered = [
|
|
87
|
-
cell(cells[0], ID_WIDTH),
|
|
88
|
-
colorCell(cell(cells[1], SEVERITY_WIDTH), header ? "dim" : severityColor(issue?.finding.severity ?? "low"), theme),
|
|
89
|
-
colorCell(cell(cells[2], CONFIDENCE_WIDTH), header ? "dim" : confidenceColor(issue?.finding.confidence ?? "low"), theme),
|
|
90
|
-
cell(cells[3], CATEGORY_WIDTH),
|
|
91
|
-
cell(cells[4], LOCATION_WIDTH),
|
|
92
|
-
cell(cells[5], SUMMARY_WIDTH),
|
|
93
|
-
];
|
|
94
|
-
if (header) {
|
|
95
|
-
return rendered.map((entry) => theme.fg("dim", entry)).join(` ${separator} `);
|
|
96
|
-
}
|
|
97
|
-
return rendered.join(` ${separator} `);
|
|
33
|
+
function maxPathWidth(room: number): number {
|
|
34
|
+
return Math.min(Math.max(16, Math.floor(room * 0.38)), 34);
|
|
98
35
|
}
|
|
99
36
|
|
|
100
|
-
|
|
101
|
-
|
|
37
|
+
/** A location column as wide as the longest listed path, never below 12 columns. */
|
|
38
|
+
export function findingPathColumn(issues: readonly ReviewIssue[]): number {
|
|
39
|
+
return Math.max(12, ...issues.map((issue) => visibleWidth(issuePath(issue))));
|
|
102
40
|
}
|
|
103
41
|
|
|
104
|
-
|
|
105
|
-
return theme.fg(color, value);
|
|
106
|
-
}
|
|
42
|
+
const LABEL_WIDTH = 10;
|
|
107
43
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
44
|
+
/** The full finding: headline, wrapped summary, then aligned Location / Impact / Evidence / Fix sections. */
|
|
45
|
+
export function renderFindingDetail(issue: ReviewIssue, width: number, theme: Theme): string[] {
|
|
46
|
+
const finding = issue.finding;
|
|
47
|
+
const meta = [finding.category, `confidence ${finding.confidence}`, finding.verdict?.toLowerCase()].filter(Boolean).join(dot(theme));
|
|
48
|
+
const lines = [
|
|
49
|
+
`${severityBadge(finding.severity, theme)} ${theme.fg("accent", theme.bold(issue.id))}${dot(theme)}${theme.fg("muted", meta)}`,
|
|
50
|
+
...hangingWrap(theme.bold(finding.summary), width),
|
|
51
|
+
"",
|
|
52
|
+
];
|
|
53
|
+
const location = [theme.fg("accent", issuePath(issue)), issue.symbol ? theme.fg("muted", issue.symbol) : undefined].filter(Boolean).join(dot(theme));
|
|
54
|
+
lines.push(...labelledLines("Location", [location], width, theme));
|
|
55
|
+
lines.push(...labelledLines("Impact", [finding.impact], width, theme));
|
|
56
|
+
const evidence = finding.evidence.length > 0 ? finding.evidence.map((item) => `${GLYPH.bullet} ${item}`) : [theme.fg("dim", "none cited")];
|
|
57
|
+
lines.push(...labelledLines("Evidence", evidence, width, theme, " "));
|
|
58
|
+
lines.push(...labelledLines("Fix", [finding.recommendation], width, theme));
|
|
59
|
+
return lines;
|
|
121
60
|
}
|
|
122
61
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return "warning";
|
|
129
|
-
case "low":
|
|
130
|
-
return "muted";
|
|
131
|
-
}
|
|
62
|
+
/** A dim label column (10 wide) with each item wrapped beside it, so result sections align with finding details. */
|
|
63
|
+
export function labelledLines(label: string, items: readonly string[], width: number, theme: Theme, itemIndent = ""): string[] {
|
|
64
|
+
const first = theme.fg("dim", label.padEnd(LABEL_WIDTH, " "));
|
|
65
|
+
const rest = " ".repeat(LABEL_WIDTH);
|
|
66
|
+
return items.flatMap((item, index) => hangingWrap(item, width, index === 0 ? first : rest, rest + itemIndent));
|
|
132
67
|
}
|
|
@@ -23,7 +23,7 @@ Instructions:
|
|
|
23
23
|
- Do not edit files or make code changes.
|
|
24
24
|
- Prefer installed GitHub MCP/tools if present; otherwise use the GitHub CLI (gh).
|
|
25
25
|
- Before posting, resolve the current PR head and require it to equal the verified reviewed head \`${verifiedHead}\`; stop if it differs.
|
|
26
|
-
- With gh, resolve the PR using \`gh pr view\` and
|
|
26
|
+
- With gh, resolve the PR using \`gh pr view <context.diffTarget.number> --json headRefOid,url\` and take owner/repo from the PR \`url\` (the base repository, so fork PRs are commented upstream), then call \`gh api repos/{owner}/{repo}/pulls/{number}/comments\` with \`commit_id\`, \`path\`, \`line\`, and \`side=RIGHT\`.
|
|
27
27
|
- Do not post duplicate comments.
|
|
28
28
|
- Do not post line-less findings as inline comments.
|
|
29
29
|
- Ask the user if the upstream PR cannot be identified.
|