pi-workflow-engine 0.5.0 → 0.6.1
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 +74 -6
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +3 -3
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +11 -7
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +185 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +114 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +132 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +56 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +240 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +11 -52
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +13 -3
- package/README.md +27 -5
- package/USAGE.md +12 -6
- package/package.json +3 -1
- package/skills/workflow-code-review-actions/SKILL.md +44 -0
|
@@ -2,9 +2,13 @@ import { fileURLToPath } from "node:url";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { WorkflowProgressSnapshot } from "./src/progress.ts";
|
|
5
6
|
import type { WorkflowModule, WorkflowRunOptions } from "./src/types.ts";
|
|
7
|
+
import { WorkflowInspector } from "./src/ui/workflow-inspector.ts";
|
|
6
8
|
import type { PerfSink, PerfSnapshot } from "./src/perf.ts";
|
|
7
9
|
import { registerDynamax } from "./src/dynamax.ts";
|
|
10
|
+
import { handleReviewViewerAction } from "./src/review/review-actions.ts";
|
|
11
|
+
import { decideReviewResultsPresentation, extensionContextMode, maybeShowReviewResultsViewer } from "./src/review/review-results-flow.ts";
|
|
8
12
|
import { isWorkflowResult, renderWorkflowResult, type WorkflowPerfDetails, type WorkflowResultEnvelope } from "./src/ui/workflow-result-renderer.ts";
|
|
9
13
|
|
|
10
14
|
/** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
|
|
@@ -62,6 +66,26 @@ async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSi
|
|
|
62
66
|
|
|
63
67
|
const AUTHOR_TEMP_WORKFLOW_CHOICE = "✍ Author temporary one-shot workflow…";
|
|
64
68
|
|
|
69
|
+
export interface LastWorkflowInspection {
|
|
70
|
+
readonly name: string;
|
|
71
|
+
readonly args: string;
|
|
72
|
+
readonly completedAt: number;
|
|
73
|
+
readonly snapshot: WorkflowProgressSnapshot;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let lastWorkflowInspection: LastWorkflowInspection | undefined;
|
|
77
|
+
|
|
78
|
+
export function getLastWorkflowInspection(): LastWorkflowInspection | undefined {
|
|
79
|
+
return lastWorkflowInspection;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function openWorkflowInspector(ctx: ExtensionCommandContext, inspection: LastWorkflowInspection): Promise<void> {
|
|
83
|
+
await ctx.ui.custom<void>(
|
|
84
|
+
(tui, theme, _keybindings, done) => new WorkflowInspector(() => inspection.snapshot, tui, theme, () => done(undefined)),
|
|
85
|
+
{ overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
export interface WorkflowInvocation {
|
|
66
90
|
name: string;
|
|
67
91
|
args: string;
|
|
@@ -98,6 +122,14 @@ function parseWorkflowOptions(input: string): { args: string; options: WorkflowR
|
|
|
98
122
|
options.perf = true;
|
|
99
123
|
continue;
|
|
100
124
|
}
|
|
125
|
+
if (token === "--result-viewer" || token === "--review-viewer") {
|
|
126
|
+
options.resultViewer = "open";
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (token === "--no-result-viewer" || token === "--no-review-viewer") {
|
|
130
|
+
options.resultViewer = "skip";
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
101
133
|
if (token.startsWith("--concurrency=")) {
|
|
102
134
|
options.concurrency = parseNumericOption(token.slice("--concurrency=".length));
|
|
103
135
|
continue;
|
|
@@ -182,7 +214,7 @@ export function inlineCompileErrorResult(message: string): WorkflowToolErrorResu
|
|
|
182
214
|
return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } };
|
|
183
215
|
}
|
|
184
216
|
|
|
185
|
-
async function pickWorkflow(
|
|
217
|
+
export async function pickWorkflow(
|
|
186
218
|
workflows: ReadonlyMap<string, WorkflowModule>,
|
|
187
219
|
ctx: ExtensionCommandContext,
|
|
188
220
|
): Promise<WorkflowInvocation | undefined> {
|
|
@@ -203,11 +235,10 @@ async function pickWorkflow(
|
|
|
203
235
|
const separator = choice.indexOf(" — ");
|
|
204
236
|
const name = separator === -1 ? choice : choice.slice(0, separator);
|
|
205
237
|
const args = name === "code-review" ? (await ctx.ui.input("Code-review target/instructions", "Blank = auto-detect diff"))?.trim() ?? "" : "";
|
|
206
|
-
|
|
207
|
-
return { name, args, options: { inspect } };
|
|
238
|
+
return { name, args, options: {} };
|
|
208
239
|
}
|
|
209
240
|
|
|
210
|
-
async function sendWorkflowResult(
|
|
241
|
+
export async function sendWorkflowResult(
|
|
211
242
|
pi: ExtensionAPI,
|
|
212
243
|
ctx: ExtensionCommandContext,
|
|
213
244
|
name: string,
|
|
@@ -226,8 +257,24 @@ async function sendWorkflowResult(
|
|
|
226
257
|
perfSnapshot = snapshot;
|
|
227
258
|
options.onPerfSnapshot?.(snapshot);
|
|
228
259
|
},
|
|
260
|
+
onProgressSnapshot(snapshot) {
|
|
261
|
+
lastWorkflowInspection = { name, args, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
|
|
262
|
+
options.onProgressSnapshot?.(snapshot);
|
|
263
|
+
},
|
|
229
264
|
});
|
|
230
265
|
const perf = compactPerfSnapshot(perfSnapshot);
|
|
266
|
+
const reviewDecision = decideReviewResultsPresentation({
|
|
267
|
+
workflowName: name,
|
|
268
|
+
result,
|
|
269
|
+
mode: extensionContextMode(ctx),
|
|
270
|
+
hasUI: ctx.hasUI,
|
|
271
|
+
resultViewer: options.resultViewer,
|
|
272
|
+
invocationKind: "command",
|
|
273
|
+
});
|
|
274
|
+
const reviewAction = await maybeShowReviewResultsViewer(ctx, reviewDecision);
|
|
275
|
+
if (reviewDecision.kind !== "send") {
|
|
276
|
+
await handleReviewViewerAction(pi, ctx, reviewAction, reviewDecision.issues, reviewDecision.report.reviewContext);
|
|
277
|
+
}
|
|
231
278
|
pi.sendMessage(
|
|
232
279
|
{ customType: "workflow-result", content: formatMessageContent(name, result, perf), display: true, details: workflowEnvelope(name, result, perf) },
|
|
233
280
|
{ triggerTurn: false },
|
|
@@ -243,6 +290,27 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
243
290
|
return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
|
|
244
291
|
});
|
|
245
292
|
|
|
293
|
+
pi.registerCommand("workflow:inspector", {
|
|
294
|
+
description: "Open the last completed workflow inspector",
|
|
295
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
296
|
+
const trimmed = args.trim();
|
|
297
|
+
if (trimmed && trimmed !== "last") {
|
|
298
|
+
ctx.ui.notify("Usage: /workflow:inspector [last]", "warning");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!ctx.hasUI) {
|
|
302
|
+
ctx.ui.notify("Workflow inspector requires the TUI", "warning");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const inspection = lastWorkflowInspection;
|
|
306
|
+
if (!inspection) {
|
|
307
|
+
ctx.ui.notify("No completed workflow inspector is available yet", "warning");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await openWorkflowInspector(ctx, inspection);
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
246
314
|
// /workflow <name> [args] — user-invoked.
|
|
247
315
|
pi.registerCommand("workflow", {
|
|
248
316
|
description: "Run a multi-agent workflow: /workflow <name> [args]",
|
|
@@ -279,10 +347,10 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
279
347
|
name: "workflow",
|
|
280
348
|
label: "Workflow",
|
|
281
349
|
description:
|
|
282
|
-
"ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize) and returns its structured result.",
|
|
350
|
+
"ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/workflow:dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize) and returns its structured result.",
|
|
283
351
|
promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
|
|
284
352
|
promptGuidelines: [
|
|
285
|
-
"Use workflow only when the user opted into workflow orchestration via `dynamax`, `/dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
|
|
353
|
+
"Use workflow only when the user opted into workflow orchestration via `dynamax`, `/workflow:dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
|
|
286
354
|
"Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
|
|
287
355
|
"Use workflow with `script` for a new one-off inline workflow; the script must start with `export const meta = { ... }` and default-export an async workflow function.",
|
|
288
356
|
"Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
|
|
@@ -86,8 +86,8 @@ export function registerDynamax(pi: ExtensionAPI): void {
|
|
|
86
86
|
return { messages };
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
pi.registerCommand("dynamax", {
|
|
90
|
-
description: "Toggle dynamax workflow orchestration opt-in: /dynamax on|off|status",
|
|
89
|
+
pi.registerCommand("workflow:dynamax", {
|
|
90
|
+
description: "Toggle dynamax workflow orchestration opt-in: /workflow:dynamax on|off|status",
|
|
91
91
|
handler: async (args, ctx) => {
|
|
92
92
|
const action = args.trim().toLowerCase();
|
|
93
93
|
if (action === "on") {
|
|
@@ -106,7 +106,7 @@ export function registerDynamax(pi: ExtensionAPI): void {
|
|
|
106
106
|
ctx.ui.notify(`dynamax sticky mode is ${sticky}${oneShot}`, "info");
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
|
-
ctx.ui.notify("Usage: /dynamax on|off|status", "warning");
|
|
109
|
+
ctx.ui.notify("Usage: /workflow:dynamax on|off|status", "warning");
|
|
110
110
|
},
|
|
111
111
|
});
|
|
112
112
|
}
|
|
@@ -68,14 +68,18 @@ export async function runWorkflow(
|
|
|
68
68
|
try {
|
|
69
69
|
return await perf.time("workflow.total_ms", () => mod.default(api));
|
|
70
70
|
} finally {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
resolvedOptions.
|
|
74
|
-
|
|
71
|
+
try {
|
|
72
|
+
const snapshot = perf.snapshot();
|
|
73
|
+
if (resolvedOptions.perf) {
|
|
74
|
+
resolvedOptions.onPerfSnapshot?.(snapshot);
|
|
75
|
+
progress.log(formatPerfSummary(snapshot));
|
|
76
|
+
}
|
|
77
|
+
progress.done();
|
|
78
|
+
resolvedOptions.onProgressSnapshot?.(progress.snapshot());
|
|
79
|
+
} finally {
|
|
80
|
+
unlinkContextAbortSignal();
|
|
81
|
+
unlinkOptionAbortSignal();
|
|
75
82
|
}
|
|
76
|
-
unlinkContextAbortSignal();
|
|
77
|
-
unlinkOptionAbortSignal();
|
|
78
|
-
progress.done();
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
85
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { isCommentableIssue, type ReviewContext, type ReviewIssue } from "./review-issues.ts";
|
|
2
|
+
|
|
3
|
+
export interface ExecResultLike {
|
|
4
|
+
readonly stdout: string;
|
|
5
|
+
readonly stderr?: string;
|
|
6
|
+
readonly code: number;
|
|
7
|
+
readonly killed?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ExecLike = (
|
|
11
|
+
command: string,
|
|
12
|
+
args: string[],
|
|
13
|
+
options?: { readonly cwd?: string; readonly timeout?: number; readonly signal?: AbortSignal },
|
|
14
|
+
) => Promise<ExecResultLike>;
|
|
15
|
+
|
|
16
|
+
export interface GitHubPrContext {
|
|
17
|
+
readonly owner: string;
|
|
18
|
+
readonly repo: string;
|
|
19
|
+
readonly number: number;
|
|
20
|
+
readonly headSha: string;
|
|
21
|
+
readonly url?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ResolveGitHubPrContextResult = { readonly ok: true; readonly context: GitHubPrContext } | { readonly ok: false; readonly reason: string };
|
|
25
|
+
|
|
26
|
+
export type InlineCommentStatus =
|
|
27
|
+
| { readonly issueId: string; readonly status: "posted"; readonly url?: string }
|
|
28
|
+
| { readonly issueId: string; readonly status: "skipped"; readonly reason: string }
|
|
29
|
+
| { readonly issueId: string; readonly status: "failed"; readonly reason: string };
|
|
30
|
+
|
|
31
|
+
const PR_VIEW_JSON_FIELDS = "number,headRefOid,url,headRepositoryOwner,headRepository";
|
|
32
|
+
|
|
33
|
+
export async function resolveGitHubPrContext(
|
|
34
|
+
exec: ExecLike,
|
|
35
|
+
cwd: string,
|
|
36
|
+
reviewContext: ReviewContext | undefined,
|
|
37
|
+
): Promise<ResolveGitHubPrContextResult> {
|
|
38
|
+
const parsedNumber = parsePrNumber(reviewContext?.diffCommand);
|
|
39
|
+
const prArgs = parsedNumber
|
|
40
|
+
? ["pr", "view", String(parsedNumber), "--json", PR_VIEW_JSON_FIELDS]
|
|
41
|
+
: ["pr", "view", "--json", PR_VIEW_JSON_FIELDS];
|
|
42
|
+
const prView = await runJson(exec, cwd, prArgs);
|
|
43
|
+
if (!prView.ok) return { ok: false, reason: prView.reason };
|
|
44
|
+
|
|
45
|
+
const number = numberField(prView.value, "number") ?? parsedNumber;
|
|
46
|
+
const headSha = stringField(prView.value, "headRefOid");
|
|
47
|
+
if (number === undefined) return { ok: false, reason: "No GitHub PR number found." };
|
|
48
|
+
if (!headSha) return { ok: false, reason: "GitHub PR head SHA is missing." };
|
|
49
|
+
|
|
50
|
+
const ownerFromPr = nestedStringField(prView.value, "headRepositoryOwner", "login");
|
|
51
|
+
const repoFromPr = nestedStringField(prView.value, "headRepository", "name");
|
|
52
|
+
let owner = ownerFromPr;
|
|
53
|
+
let repo = repoFromPr;
|
|
54
|
+
if (!owner || !repo) {
|
|
55
|
+
const repoView = await runJson(exec, cwd, ["repo", "view", "--json", "nameWithOwner"]);
|
|
56
|
+
if (!repoView.ok) return { ok: false, reason: repoView.reason };
|
|
57
|
+
const nameWithOwner = stringField(repoView.value, "nameWithOwner");
|
|
58
|
+
const parsed = parseNameWithOwner(nameWithOwner);
|
|
59
|
+
owner = owner ?? parsed?.owner;
|
|
60
|
+
repo = repo ?? parsed?.repo;
|
|
61
|
+
}
|
|
62
|
+
if (!owner || !repo) return { ok: false, reason: "GitHub repository owner/name is missing." };
|
|
63
|
+
|
|
64
|
+
const url = stringField(prView.value, "url");
|
|
65
|
+
return { ok: true, context: { owner, repo, number, headSha, url } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildInlineCommentBody(issue: ReviewIssue): string {
|
|
69
|
+
const finding = issue.finding;
|
|
70
|
+
const evidence = finding.evidence.length > 0 ? finding.evidence.map((entry) => `- ${entry}`).join("\n") : "- (none cited)";
|
|
71
|
+
return `**${issue.id}: ${finding.summary}**
|
|
72
|
+
|
|
73
|
+
Severity: ${finding.severity} · Confidence: ${finding.confidence} · Category: ${finding.category}
|
|
74
|
+
|
|
75
|
+
Impact: ${finding.impact}
|
|
76
|
+
|
|
77
|
+
Evidence:
|
|
78
|
+
${evidence}
|
|
79
|
+
|
|
80
|
+
Recommendation: ${finding.recommendation}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function postInlineComment(
|
|
84
|
+
exec: ExecLike,
|
|
85
|
+
cwd: string,
|
|
86
|
+
prContext: GitHubPrContext,
|
|
87
|
+
issue: ReviewIssue,
|
|
88
|
+
): Promise<InlineCommentStatus> {
|
|
89
|
+
if (!isCommentableIssue(issue)) return { issueId: issue.id, status: "skipped", reason: "Finding has no commentable file/line." };
|
|
90
|
+
|
|
91
|
+
const result = await runCommand(exec, cwd, [
|
|
92
|
+
"api",
|
|
93
|
+
`repos/${prContext.owner}/${prContext.repo}/pulls/${prContext.number}/comments`,
|
|
94
|
+
"-f",
|
|
95
|
+
`body=${buildInlineCommentBody(issue)}`,
|
|
96
|
+
"-f",
|
|
97
|
+
`commit_id=${prContext.headSha}`,
|
|
98
|
+
"-f",
|
|
99
|
+
`path=${issue.file}`,
|
|
100
|
+
"-F",
|
|
101
|
+
`line=${issue.line}`,
|
|
102
|
+
"-f",
|
|
103
|
+
"side=RIGHT",
|
|
104
|
+
]);
|
|
105
|
+
if (!result.ok) return { issueId: issue.id, status: "failed", reason: result.reason };
|
|
106
|
+
return { issueId: issue.id, status: "posted", url: parsePostedCommentUrl(result.stdout) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function postInlineComments(
|
|
110
|
+
exec: ExecLike,
|
|
111
|
+
cwd: string,
|
|
112
|
+
prContext: GitHubPrContext,
|
|
113
|
+
issues: readonly ReviewIssue[],
|
|
114
|
+
): Promise<InlineCommentStatus[]> {
|
|
115
|
+
const statuses: InlineCommentStatus[] = [];
|
|
116
|
+
for (const issue of issues) {
|
|
117
|
+
statuses.push(await postInlineComment(exec, cwd, prContext, issue));
|
|
118
|
+
}
|
|
119
|
+
return statuses;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parsePrNumber(diffCommand: string | undefined): number | undefined {
|
|
123
|
+
if (!diffCommand) return undefined;
|
|
124
|
+
const match = /(?:^|\s)gh\s+pr\s+diff\s+(\d+)(?:\s|$)/.exec(diffCommand);
|
|
125
|
+
if (!match) return undefined;
|
|
126
|
+
const parsed = Number(match[1]);
|
|
127
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runJson(exec: ExecLike, cwd: string, args: string[]): Promise<{ readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly reason: string }> {
|
|
131
|
+
const result = await runCommand(exec, cwd, args);
|
|
132
|
+
if (!result.ok) return result;
|
|
133
|
+
try {
|
|
134
|
+
return { ok: true, value: JSON.parse(result.stdout) };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { ok: false, reason: error instanceof Error ? `Invalid gh JSON: ${error.message}` : "Invalid gh JSON." };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function runCommand(exec: ExecLike, cwd: string, args: string[]): Promise<{ readonly ok: true; readonly stdout: string } | { readonly ok: false; readonly reason: string }> {
|
|
141
|
+
try {
|
|
142
|
+
const result = await exec("gh", args, { cwd, timeout: 30_000 });
|
|
143
|
+
if (result.code !== 0) return { ok: false, reason: result.stderr?.trim() || `gh exited with code ${result.code}` };
|
|
144
|
+
return { ok: true, stdout: result.stdout };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return { ok: false, reason: error instanceof Error ? error.message : "gh command failed." };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function numberField(value: unknown, key: string): number | undefined {
|
|
151
|
+
if (!isRecord(value)) return undefined;
|
|
152
|
+
const field = value[key];
|
|
153
|
+
return typeof field === "number" && Number.isFinite(field) ? field : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function stringField(value: unknown, key: string): string | undefined {
|
|
157
|
+
if (!isRecord(value)) return undefined;
|
|
158
|
+
const field = value[key];
|
|
159
|
+
return typeof field === "string" && field.trim().length > 0 ? field : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nestedStringField(value: unknown, objectKey: string, key: string): string | undefined {
|
|
163
|
+
if (!isRecord(value)) return undefined;
|
|
164
|
+
const nested = value[objectKey];
|
|
165
|
+
return stringField(nested, key);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseNameWithOwner(value: string | undefined): { readonly owner: string; readonly repo: string } | undefined {
|
|
169
|
+
if (!value) return undefined;
|
|
170
|
+
const [owner, repo, extra] = value.split("/");
|
|
171
|
+
if (!owner || !repo || extra !== undefined) return undefined;
|
|
172
|
+
return { owner, repo };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parsePostedCommentUrl(stdout: string): string | undefined {
|
|
176
|
+
try {
|
|
177
|
+
return stringField(JSON.parse(stdout), "html_url");
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
184
|
+
return typeof value === "object" && value !== null;
|
|
185
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { postInlineComments, resolveGitHubPrContext, type ExecLike, type InlineCommentStatus } from "./github-pr-comments.ts";
|
|
3
|
+
import { buildCommentHandoffPrompt, buildFixHandoffPrompt } from "./review-handoff.ts";
|
|
4
|
+
import { isCommentableIssue, type ReviewContext, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
5
|
+
|
|
6
|
+
export type ReviewActionPi = Pick<ExtensionAPI, "sendUserMessage" | "exec">;
|
|
7
|
+
export interface ReviewActionContext {
|
|
8
|
+
readonly cwd: string;
|
|
9
|
+
readonly ui: {
|
|
10
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
11
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function handleReviewViewerAction(
|
|
16
|
+
pi: ReviewActionPi,
|
|
17
|
+
ctx: ReviewActionContext,
|
|
18
|
+
action: ReviewIssueSelection | undefined,
|
|
19
|
+
issues: readonly ReviewIssue[],
|
|
20
|
+
context: ReviewContext | undefined,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
if (!action || action.action === "close") return;
|
|
23
|
+
const selected = selectedIssues(issues, action.issueIds);
|
|
24
|
+
if (action.action === "fix") {
|
|
25
|
+
handleFixAction(pi, ctx, selected, context);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await handleCommentAction(pi, ctx, selected, context);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function handleFixAction(pi: ReviewActionPi, ctx: ReviewActionContext, selected: readonly ReviewIssue[], context: ReviewContext | undefined): void {
|
|
32
|
+
if (selected.length === 0) {
|
|
33
|
+
ctx.ui.notify("No selected findings to fix", "warning");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
pi.sendUserMessage(buildFixHandoffPrompt(selected, context), { deliverAs: "followUp" });
|
|
37
|
+
ctx.ui.notify(`Queued fix request for ${selected.length} selected finding(s)`, "info");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function handleCommentAction(
|
|
41
|
+
pi: ReviewActionPi,
|
|
42
|
+
ctx: ReviewActionContext,
|
|
43
|
+
selected: readonly ReviewIssue[],
|
|
44
|
+
context: ReviewContext | undefined,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const commentable = selected.filter(isCommentableIssue);
|
|
47
|
+
if (commentable.length === 0) {
|
|
48
|
+
ctx.ui.notify("No selected findings have file and line information for inline PR comments", "warning");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const confirmed = await ctx.ui.confirm(
|
|
53
|
+
"Post inline PR comments?",
|
|
54
|
+
`Post ${commentable.length} selected finding(s) to the upstream PR using GitHub CLI if available?`,
|
|
55
|
+
);
|
|
56
|
+
if (!confirmed) {
|
|
57
|
+
queueCommentFallback(pi, ctx, commentable, context, "User chose parent-agent fallback instead of direct gh posting.");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const exec = toExecLike(pi);
|
|
62
|
+
const resolved = await resolveGitHubPrContext(exec, ctx.cwd, context);
|
|
63
|
+
if (!resolved.ok) {
|
|
64
|
+
queueCommentFallback(pi, ctx, commentable, context, resolved.reason);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const statuses = await postInlineComments(exec, ctx.cwd, resolved.context, commentable);
|
|
69
|
+
const summary = summarizeStatuses(statuses);
|
|
70
|
+
ctx.ui.notify(`PR comments: ${summary.posted} posted, ${summary.skipped} skipped, ${summary.failed} failed`, summary.failed > 0 ? "warning" : "info");
|
|
71
|
+
if (summary.failed > 0) {
|
|
72
|
+
const failedIds = new Set(statuses.filter((status) => status.status === "failed").map((status) => status.issueId));
|
|
73
|
+
queueCommentFallback(
|
|
74
|
+
pi,
|
|
75
|
+
ctx,
|
|
76
|
+
commentable.filter((issue) => failedIds.has(issue.id)),
|
|
77
|
+
context,
|
|
78
|
+
"Direct gh posting failed for one or more selected findings.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function queueCommentFallback(
|
|
84
|
+
pi: ReviewActionPi,
|
|
85
|
+
ctx: ReviewActionContext,
|
|
86
|
+
issues: readonly ReviewIssue[],
|
|
87
|
+
context: ReviewContext | undefined,
|
|
88
|
+
reason: string,
|
|
89
|
+
): void {
|
|
90
|
+
if (issues.length === 0) return;
|
|
91
|
+
pi.sendUserMessage(buildCommentHandoffPrompt(issues, context, reason), { deliverAs: "followUp" });
|
|
92
|
+
ctx.ui.notify(`Queued PR comment request for ${issues.length} selected finding(s)`, "info");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toExecLike(pi: ReviewActionPi): ExecLike {
|
|
96
|
+
return async (command, args, options) => await pi.exec(command, args, options);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function selectedIssues(issues: readonly ReviewIssue[], issueIds: readonly string[]): ReviewIssue[] {
|
|
100
|
+
const selected = new Set(issueIds);
|
|
101
|
+
return issues.filter((issue) => selected.has(issue.id));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function summarizeStatuses(statuses: readonly InlineCommentStatus[]): { readonly posted: number; readonly skipped: number; readonly failed: number } {
|
|
105
|
+
let posted = 0;
|
|
106
|
+
let skipped = 0;
|
|
107
|
+
let failed = 0;
|
|
108
|
+
for (const status of statuses) {
|
|
109
|
+
if (status.status === "posted") posted++;
|
|
110
|
+
else if (status.status === "skipped") skipped++;
|
|
111
|
+
else failed++;
|
|
112
|
+
}
|
|
113
|
+
return { posted, skipped, failed };
|
|
114
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { truncateDisplay, type WorkflowThemeColor } from "../ui/workflow-format.ts";
|
|
4
|
+
import { formatIssueLocation, type ReviewIssue } from "./review-issues.ts";
|
|
5
|
+
|
|
6
|
+
export interface RenderIssuesTableOptions {
|
|
7
|
+
readonly maxRows?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_ROWS = 12;
|
|
11
|
+
const ID_WIDTH = 4;
|
|
12
|
+
const SEVERITY_WIDTH = 6;
|
|
13
|
+
const CONFIDENCE_WIDTH = 6;
|
|
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)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fieldLines(label: string, value: string, width: number, theme: Theme, valueColor: WorkflowThemeColor = "muted"): string[] {
|
|
69
|
+
const prefix = ` ${theme.fg("dim", `${label}:`)}`;
|
|
70
|
+
const separator = " ";
|
|
71
|
+
const valueText = theme.fg(valueColor, value);
|
|
72
|
+
const available = Math.max(10, width - visibleWidth(`${label}: `) - 2);
|
|
73
|
+
const wrapped = wrapTextWithAnsi(valueText, available);
|
|
74
|
+
if (wrapped.length === 0) return [`${prefix}${separator}`];
|
|
75
|
+
const continuation = " ".repeat(visibleWidth(`${label}: `) + 2);
|
|
76
|
+
return wrapped.map((line, index) => (index === 0 ? `${prefix}${separator}${line}` : `${continuation}${line}`));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderRow(
|
|
80
|
+
cells: readonly [string, string, string, string, string, string],
|
|
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} `);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cell(value: string, width: number): string {
|
|
101
|
+
return truncateDisplay(value, width).padEnd(width, " ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function colorCell(value: string, color: WorkflowThemeColor, theme: Theme): string {
|
|
105
|
+
return theme.fg(color, value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderSeparator(): string {
|
|
109
|
+
return [ID_WIDTH, SEVERITY_WIDTH, CONFIDENCE_WIDTH, CATEGORY_WIDTH, LOCATION_WIDTH, SUMMARY_WIDTH].map((width) => "─".repeat(width)).join("─┼─");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function severityColor(severity: ReviewIssue["finding"]["severity"]): WorkflowThemeColor {
|
|
113
|
+
switch (severity) {
|
|
114
|
+
case "high":
|
|
115
|
+
return "error";
|
|
116
|
+
case "medium":
|
|
117
|
+
return "warning";
|
|
118
|
+
case "low":
|
|
119
|
+
return "muted";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function confidenceColor(confidence: ReviewIssue["finding"]["confidence"]): WorkflowThemeColor {
|
|
124
|
+
switch (confidence) {
|
|
125
|
+
case "high":
|
|
126
|
+
return "success";
|
|
127
|
+
case "medium":
|
|
128
|
+
return "warning";
|
|
129
|
+
case "low":
|
|
130
|
+
return "muted";
|
|
131
|
+
}
|
|
132
|
+
}
|