pi-workflow-engine 0.5.0 → 0.7.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 +148 -10
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +205 -23
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +151 -21
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +190 -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 +22 -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 +29 -7
- package/USAGE.md +47 -9
- package/package.json +8 -6
- package/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/assets/preview-candidate-core.png +0 -0
- package/assets/preview-candidate-relic-emblem.png +0 -0
- package/assets/preview-candidate-robot-squad.png +0 -0
|
@@ -5,12 +5,35 @@ import { runAgent, type RunContext } from "./agent-runner.ts";
|
|
|
5
5
|
import { ProgressTracker } from "./progress.ts";
|
|
6
6
|
import { createPerfRecorder, type PerfSnapshot } from "./perf.ts";
|
|
7
7
|
import { defaultConcurrency, resolveWorkflowRunOptions } from "./options.ts";
|
|
8
|
-
import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowRunOptions } from "./types.ts";
|
|
8
|
+
import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
9
9
|
import { WorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
10
10
|
|
|
11
11
|
/** Default global cap on concurrent agents per run. */
|
|
12
12
|
const DEFAULT_CONCURRENCY = defaultConcurrency();
|
|
13
13
|
|
|
14
|
+
/** The workflow-facing slice of the progress tracker (satisfied by `ProgressTracker`). */
|
|
15
|
+
export interface WorkflowProgress {
|
|
16
|
+
phase(title: string): void;
|
|
17
|
+
log(message: string): void;
|
|
18
|
+
event(event: WorkflowProgressEvent): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Per-context knobs threaded through `runWorkflowWithContext` (and re-derived for each sub-workflow). */
|
|
22
|
+
export interface WorkflowContextOptions {
|
|
23
|
+
/** Controller whose `.signal` equals `rc.signal`; aborts in-flight `parallel` siblings on failure. */
|
|
24
|
+
abortController: AbortController;
|
|
25
|
+
/** Eager-submission cap for `parallel`. */
|
|
26
|
+
submissionLimit: number;
|
|
27
|
+
/** Resolve a sub-workflow reference for `api.workflow()`. Omit to disable composition. */
|
|
28
|
+
resolveWorkflow?: (ref: WorkflowRef) => Promise<WorkflowModule>;
|
|
29
|
+
/** Nesting depth. Sub-workflows run at depth >= 1, where `api.workflow()` rejects. */
|
|
30
|
+
depth?: number;
|
|
31
|
+
/** Prefix applied to phase titles so sub-workflow phases nest as "<name> ▸ <title>". */
|
|
32
|
+
progressPrefix?: string;
|
|
33
|
+
/** Namespace applied to child structured progress keys/lanes/logs. */
|
|
34
|
+
progressNamespace?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
14
37
|
/**
|
|
15
38
|
* Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
|
|
16
39
|
* to a shared semaphore + progress tracker), invoke the workflow, return its result.
|
|
@@ -23,6 +46,8 @@ export async function runWorkflow(
|
|
|
23
46
|
): Promise<unknown> {
|
|
24
47
|
const resolvedOptions = resolveWorkflowRunOptions(options);
|
|
25
48
|
const progress = new ProgressTracker(ctx, mod.meta.name);
|
|
49
|
+
const progressSource = { snapshot: () => progress.snapshot() };
|
|
50
|
+
resolvedOptions.onProgressSource?.(progressSource);
|
|
26
51
|
if (resolvedOptions.inspect && ctx.hasUI) {
|
|
27
52
|
void ctx.ui
|
|
28
53
|
.custom<void>(
|
|
@@ -46,36 +71,141 @@ export async function runWorkflow(
|
|
|
46
71
|
perf,
|
|
47
72
|
};
|
|
48
73
|
|
|
49
|
-
|
|
74
|
+
try {
|
|
75
|
+
// perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
|
|
76
|
+
return await perf.time("workflow.total_ms", () =>
|
|
77
|
+
runWorkflowWithContext(rc, progress, mod, args, {
|
|
78
|
+
abortController: runAbortController,
|
|
79
|
+
submissionLimit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
|
|
80
|
+
resolveWorkflow: resolvedOptions.resolveWorkflow,
|
|
81
|
+
depth: 0,
|
|
82
|
+
progressPrefix: "",
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
} finally {
|
|
86
|
+
try {
|
|
87
|
+
const snapshot = perf.snapshot();
|
|
88
|
+
if (resolvedOptions.perf) {
|
|
89
|
+
resolvedOptions.onPerfSnapshot?.(snapshot);
|
|
90
|
+
progress.log(formatPerfSummary(snapshot));
|
|
91
|
+
}
|
|
92
|
+
progress.done();
|
|
93
|
+
resolvedOptions.onProgressSnapshot?.(progress.snapshot());
|
|
94
|
+
} finally {
|
|
95
|
+
resolvedOptions.onProgressSource?.(undefined);
|
|
96
|
+
unlinkContextAbortSignal();
|
|
97
|
+
unlinkOptionAbortSignal();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build the `WorkflowApi` from an existing run context and invoke the module. Reused for both
|
|
104
|
+
* the top-level run and every `api.workflow()` sub-step, so children share the parent's semaphore,
|
|
105
|
+
* abort signal, and perf sink. No setup/teardown of its own — that belongs to `runWorkflow`.
|
|
106
|
+
*/
|
|
107
|
+
export async function runWorkflowWithContext(
|
|
108
|
+
rc: RunContext,
|
|
109
|
+
progress: WorkflowProgress,
|
|
110
|
+
mod: WorkflowModule,
|
|
111
|
+
args: string,
|
|
112
|
+
opts: WorkflowContextOptions,
|
|
113
|
+
): Promise<unknown> {
|
|
114
|
+
const depth = opts.depth ?? 0;
|
|
115
|
+
const scope = createWorkflowScope(progress, opts.progressPrefix ?? "", opts.progressNamespace);
|
|
116
|
+
|
|
117
|
+
const agent = ((prompt: string, agentOpts?: AgentOptions) => runAgent(rc, prompt, scope.agentOptions(agentOpts))) as WorkflowApi["agent"];
|
|
118
|
+
|
|
119
|
+
const workflow: WorkflowApi["workflow"] =
|
|
120
|
+
depth >= 1
|
|
121
|
+
? async () => {
|
|
122
|
+
throw new Error("workflow() can only nest one level deep");
|
|
123
|
+
}
|
|
124
|
+
: async (ref, childArgs) => {
|
|
125
|
+
if (!opts.resolveWorkflow) throw new Error("sub-workflows are not enabled in this context");
|
|
126
|
+
const childMod = await opts.resolveWorkflow(ref);
|
|
127
|
+
// A child controller isolates the child's failures from the parent (so the parent can
|
|
128
|
+
// try/catch a failed sub-workflow), while still cancelling the child when the run aborts.
|
|
129
|
+
const childController = new AbortController();
|
|
130
|
+
const unlink = linkAbortSignal(rc.signal, childController);
|
|
131
|
+
try {
|
|
132
|
+
return await runWorkflowWithContext({ ...rc, signal: childController.signal }, progress, childMod, childArgs ?? "", {
|
|
133
|
+
abortController: childController,
|
|
134
|
+
submissionLimit: opts.submissionLimit,
|
|
135
|
+
resolveWorkflow: opts.resolveWorkflow,
|
|
136
|
+
depth: depth + 1,
|
|
137
|
+
progressPrefix: `${childMod.meta.name} ▸ `,
|
|
138
|
+
progressNamespace: childMod.meta.name,
|
|
139
|
+
});
|
|
140
|
+
} finally {
|
|
141
|
+
unlink();
|
|
142
|
+
scope.restorePhase();
|
|
143
|
+
}
|
|
144
|
+
};
|
|
50
145
|
|
|
51
146
|
const api: WorkflowApi = {
|
|
52
147
|
agent,
|
|
148
|
+
workflow,
|
|
53
149
|
parallel: (thunks) =>
|
|
54
150
|
parallel(thunks, {
|
|
55
|
-
signal:
|
|
56
|
-
abortController:
|
|
57
|
-
limit:
|
|
151
|
+
signal: rc.signal,
|
|
152
|
+
abortController: opts.abortController,
|
|
153
|
+
limit: opts.submissionLimit,
|
|
58
154
|
}),
|
|
59
155
|
pipeline,
|
|
60
|
-
phase:
|
|
61
|
-
log:
|
|
62
|
-
progress:
|
|
156
|
+
phase: scope.phase,
|
|
157
|
+
log: scope.log,
|
|
158
|
+
progress: scope.event,
|
|
63
159
|
args,
|
|
64
|
-
cwd:
|
|
65
|
-
signal:
|
|
160
|
+
cwd: rc.cwd,
|
|
161
|
+
signal: rc.signal,
|
|
66
162
|
};
|
|
67
163
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
164
|
+
return await mod.default(api);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface WorkflowScope {
|
|
168
|
+
agentOptions(opts: AgentOptions | undefined): AgentOptions;
|
|
169
|
+
phase(title: string): void;
|
|
170
|
+
log(message: string): void;
|
|
171
|
+
event(event: WorkflowProgressEvent): void;
|
|
172
|
+
restorePhase(): void;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function createWorkflowScope(progress: WorkflowProgress, prefix: string, namespace: string | undefined): WorkflowScope {
|
|
176
|
+
let currentPhase = `${prefix}Workflow`;
|
|
177
|
+
const display = (title: string) => `${prefix}${title}`;
|
|
178
|
+
return {
|
|
179
|
+
agentOptions(opts) {
|
|
180
|
+
const phase = opts?.phase ? display(opts.phase) : currentPhase;
|
|
181
|
+
return opts ? { ...opts, phase } : { phase };
|
|
182
|
+
},
|
|
183
|
+
phase(title) {
|
|
184
|
+
currentPhase = display(title);
|
|
185
|
+
progress.phase(currentPhase);
|
|
186
|
+
},
|
|
187
|
+
log(message) {
|
|
188
|
+
progress.log(namespace ? `${namespace}: ${message}` : message);
|
|
189
|
+
},
|
|
190
|
+
event(event) {
|
|
191
|
+
progress.event(namespace ? namespaceProgressEvent(namespace, event) : event);
|
|
192
|
+
},
|
|
193
|
+
restorePhase() {
|
|
194
|
+
progress.phase(currentPhase);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function namespaceProgressEvent(namespace: string, event: WorkflowProgressEvent): WorkflowProgressEvent {
|
|
200
|
+
switch (event.type) {
|
|
201
|
+
case "counter":
|
|
202
|
+
return { ...event, key: `${namespace}.${event.key}` };
|
|
203
|
+
case "counter_delta":
|
|
204
|
+
return { ...event, key: `${namespace}.${event.key}` };
|
|
205
|
+
case "lane_item":
|
|
206
|
+
return { ...event, lane: `${namespace} ▸ ${event.lane}` };
|
|
207
|
+
case "summary":
|
|
208
|
+
return { ...event, key: `${namespace}.${event.key}` };
|
|
79
209
|
}
|
|
80
210
|
}
|
|
81
211
|
|
|
@@ -0,0 +1,190 @@
|
|
|
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";
|
|
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 url = stringField(prView.value, "url");
|
|
51
|
+
const baseFromUrl = parsePullRequestUrl(url);
|
|
52
|
+
let owner = baseFromUrl?.owner;
|
|
53
|
+
let repo = baseFromUrl?.repo;
|
|
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
|
+
return { ok: true, context: { owner, repo, number, headSha, url } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildInlineCommentBody(issue: ReviewIssue): string {
|
|
68
|
+
const finding = issue.finding;
|
|
69
|
+
const evidence = finding.evidence.length > 0 ? finding.evidence.map((entry) => `- ${entry}`).join("\n") : "- (none cited)";
|
|
70
|
+
return `**${issue.id}: ${finding.summary}**
|
|
71
|
+
|
|
72
|
+
Severity: ${finding.severity} · Confidence: ${finding.confidence} · Category: ${finding.category}
|
|
73
|
+
|
|
74
|
+
Impact: ${finding.impact}
|
|
75
|
+
|
|
76
|
+
Evidence:
|
|
77
|
+
${evidence}
|
|
78
|
+
|
|
79
|
+
Recommendation: ${finding.recommendation}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function postInlineComment(
|
|
83
|
+
exec: ExecLike,
|
|
84
|
+
cwd: string,
|
|
85
|
+
prContext: GitHubPrContext,
|
|
86
|
+
issue: ReviewIssue,
|
|
87
|
+
): Promise<InlineCommentStatus> {
|
|
88
|
+
if (!isCommentableIssue(issue)) return { issueId: issue.id, status: "skipped", reason: "Finding has no commentable file/line." };
|
|
89
|
+
|
|
90
|
+
const result = await runCommand(exec, cwd, [
|
|
91
|
+
"api",
|
|
92
|
+
`repos/${prContext.owner}/${prContext.repo}/pulls/${prContext.number}/comments`,
|
|
93
|
+
"-f",
|
|
94
|
+
`body=${buildInlineCommentBody(issue)}`,
|
|
95
|
+
"-f",
|
|
96
|
+
`commit_id=${prContext.headSha}`,
|
|
97
|
+
"-f",
|
|
98
|
+
`path=${issue.file}`,
|
|
99
|
+
"-F",
|
|
100
|
+
`line=${issue.line}`,
|
|
101
|
+
"-f",
|
|
102
|
+
"side=RIGHT",
|
|
103
|
+
]);
|
|
104
|
+
if (!result.ok) return { issueId: issue.id, status: "failed", reason: result.reason };
|
|
105
|
+
return { issueId: issue.id, status: "posted", url: parsePostedCommentUrl(result.stdout) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function postInlineComments(
|
|
109
|
+
exec: ExecLike,
|
|
110
|
+
cwd: string,
|
|
111
|
+
prContext: GitHubPrContext,
|
|
112
|
+
issues: readonly ReviewIssue[],
|
|
113
|
+
): Promise<InlineCommentStatus[]> {
|
|
114
|
+
const statuses: InlineCommentStatus[] = [];
|
|
115
|
+
for (const issue of issues) {
|
|
116
|
+
statuses.push(await postInlineComment(exec, cwd, prContext, issue));
|
|
117
|
+
}
|
|
118
|
+
return statuses;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parsePrNumber(diffCommand: string | undefined): number | undefined {
|
|
122
|
+
if (!diffCommand) return undefined;
|
|
123
|
+
const match = /(?:^|\s)gh\s+pr\s+diff\s+(\d+)(?:\s|$)/.exec(diffCommand);
|
|
124
|
+
if (!match) return undefined;
|
|
125
|
+
const parsed = Number(match[1]);
|
|
126
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function runJson(exec: ExecLike, cwd: string, args: string[]): Promise<{ readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly reason: string }> {
|
|
130
|
+
const result = await runCommand(exec, cwd, args);
|
|
131
|
+
if (!result.ok) return result;
|
|
132
|
+
try {
|
|
133
|
+
return { ok: true, value: JSON.parse(result.stdout) };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { ok: false, reason: error instanceof Error ? `Invalid gh JSON: ${error.message}` : "Invalid gh JSON." };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function runCommand(exec: ExecLike, cwd: string, args: string[]): Promise<{ readonly ok: true; readonly stdout: string } | { readonly ok: false; readonly reason: string }> {
|
|
140
|
+
try {
|
|
141
|
+
const result = await exec("gh", args, { cwd, timeout: 30_000 });
|
|
142
|
+
if (result.code !== 0) return { ok: false, reason: result.stderr?.trim() || `gh exited with code ${result.code}` };
|
|
143
|
+
return { ok: true, stdout: result.stdout };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return { ok: false, reason: error instanceof Error ? error.message : "gh command failed." };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function numberField(value: unknown, key: string): number | undefined {
|
|
150
|
+
if (!isRecord(value)) return undefined;
|
|
151
|
+
const field = value[key];
|
|
152
|
+
return typeof field === "number" && Number.isFinite(field) ? field : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function stringField(value: unknown, key: string): string | undefined {
|
|
156
|
+
if (!isRecord(value)) return undefined;
|
|
157
|
+
const field = value[key];
|
|
158
|
+
return typeof field === "string" && field.trim().length > 0 ? field : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseNameWithOwner(value: string | undefined): { readonly owner: string; readonly repo: string } | undefined {
|
|
162
|
+
if (!value) return undefined;
|
|
163
|
+
const [owner, repo, extra] = value.split("/");
|
|
164
|
+
if (!owner || !repo || extra !== undefined) return undefined;
|
|
165
|
+
return { owner, repo };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parsePullRequestUrl(value: string | undefined): { readonly owner: string; readonly repo: string } | undefined {
|
|
169
|
+
if (!value) return undefined;
|
|
170
|
+
try {
|
|
171
|
+
const parsed = new URL(value);
|
|
172
|
+
const [owner, repo, type, number, extra] = parsed.pathname.split("/").filter(Boolean);
|
|
173
|
+
if (!owner || !repo || type !== "pull" || !number || extra !== undefined) return undefined;
|
|
174
|
+
return { owner, repo };
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parsePostedCommentUrl(stdout: string): string | undefined {
|
|
181
|
+
try {
|
|
182
|
+
return stringField(JSON.parse(stdout), "html_url");
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
189
|
+
return typeof value === "object" && value !== null;
|
|
190
|
+
}
|
|
@@ -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
|
+
}
|