pi-workflow-engine 0.4.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.
Files changed (23) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -18
  2. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +2 -33
  3. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -0
  4. package/.pi/extensions/pi-workflow-engine/src/engine.ts +11 -7
  5. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +396 -0
  6. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +185 -0
  7. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +114 -0
  8. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +132 -0
  9. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +90 -0
  10. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +61 -0
  11. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +56 -0
  12. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +240 -0
  13. package/.pi/extensions/pi-workflow-engine/src/types.ts +4 -0
  14. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +22 -4
  15. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +11 -52
  16. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +57 -0
  17. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -3
  18. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +13 -3
  19. package/README.md +61 -12
  20. package/USAGE.md +189 -0
  21. package/package.json +4 -1
  22. package/skills/workflow-code-review-actions/SKILL.md +44 -0
  23. package/.pi/extensions/pi-workflow-engine/workflows/ping.ts +0 -29
@@ -0,0 +1,396 @@
1
+ import { Type } from "typebox";
2
+ import type { WorkflowApi, WorkflowMeta, WorkflowModule } from "./types.ts";
3
+ import { parseWorkflowMeta } from "./workflow-module.ts";
4
+
5
+ /**
6
+ * v1 inline workflow source contract:
7
+ * - The script starts with `export const meta = { ... };` where the object is a pure literal.
8
+ * - The script contains exactly one `export default async ...` workflow function.
9
+ * - Inline scripts must not use `import` or any exports other than `meta` and the default function.
10
+ * - `Type` is injected lexically by the host; scripts must use that `Type`, not import typebox.
11
+ */
12
+
13
+ export class InlineWorkflowCompileError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = "InlineWorkflowCompileError";
17
+ }
18
+ }
19
+
20
+ export interface InlineMetaLiteral {
21
+ readonly literal: string;
22
+ readonly value: unknown;
23
+ readonly endOffset: number;
24
+ }
25
+
26
+ type InlineWorkflowExecutor = (api: WorkflowApi, typebox: typeof Type) => Promise<unknown>;
27
+ type AsyncFunctionConstructor = new (...args: string[]) => InlineWorkflowExecutor;
28
+
29
+ const AsyncFunction = Object.getPrototypeOf(async function inlineWorkflowCompilerSentinel() {}).constructor as AsyncFunctionConstructor;
30
+
31
+ export function compileInlineWorkflow(source: string): WorkflowModule {
32
+ rejectForbiddenModuleSyntax(source);
33
+ const metaLiteral = extractMetaLiteral(source);
34
+ const parsedMeta = parseWorkflowMeta(metaLiteral.value);
35
+ if ("reason" in parsedMeta) throw new InlineWorkflowCompileError(parsedMeta.reason);
36
+
37
+ const defaultExpression = extractDefaultWorkflowExpression(source, metaLiteral.endOffset);
38
+ const executor = compileExecutor(defaultExpression);
39
+ return { meta: parsedMeta.meta, default: (api) => executor(api, Type) };
40
+ }
41
+
42
+ export function extractMetaLiteral(source: string): InlineMetaLiteral {
43
+ const start = skipWhitespace(source, 0);
44
+ const prefix = /export\s+const\s+meta\s*=/y;
45
+ prefix.lastIndex = start;
46
+ const match = prefix.exec(source);
47
+ if (!match || match.index !== start) {
48
+ throw new InlineWorkflowCompileError("inline workflow must start with `export const meta = { ... }`;");
49
+ }
50
+
51
+ const objectStart = skipWhitespace(source, prefix.lastIndex);
52
+ if (source[objectStart] !== "{") {
53
+ throw new InlineWorkflowCompileError("inline workflow meta must be an object literal");
54
+ }
55
+
56
+ const parser = new LiteralParser(source, objectStart);
57
+ const value = parser.parseValue();
58
+ const literalEnd = parser.offset;
59
+ let endOffset = skipWhitespace(source, literalEnd);
60
+ if (source[endOffset] === ";") endOffset++;
61
+
62
+ return { literal: source.slice(objectStart, literalEnd), value, endOffset };
63
+ }
64
+
65
+ function rejectForbiddenModuleSyntax(source: string): void {
66
+ if (/^\s*import\s/m.test(source)) {
67
+ throw new InlineWorkflowCompileError("inline workflows must not contain import statements; use injected Type instead");
68
+ }
69
+ if (/\bimport\s*\(/.test(source)) {
70
+ throw new InlineWorkflowCompileError("inline workflows must not use dynamic import()");
71
+ }
72
+ }
73
+
74
+ function extractDefaultWorkflowExpression(source: string, startOffset: number): string {
75
+ const moduleSource = source.slice(startOffset).trim();
76
+ if (!moduleSource) throw new InlineWorkflowCompileError("inline workflow must export a default async function");
77
+ if (!moduleSource.startsWith("export default")) {
78
+ throw new InlineWorkflowCompileError("inline workflow default export must directly follow the meta declaration");
79
+ }
80
+
81
+ const defaultPrefix = /^export\s+default\s+/;
82
+ const match = defaultPrefix.exec(moduleSource);
83
+ if (!match) throw new InlineWorkflowCompileError("inline workflow must export a default async function");
84
+
85
+ const expressionSource = moduleSource.slice(match[0].length).trimStart();
86
+ const bodyOpen = findDefaultBodyOpen(expressionSource);
87
+ const bodyClose = findMatchingDelimiter(expressionSource, bodyOpen, "{", "}");
88
+ const expression = expressionSource.slice(0, bodyClose + 1);
89
+ const remainder = expressionSource.slice(bodyClose + 1).trim();
90
+ if (remainder !== "" && remainder !== ";") {
91
+ throw new InlineWorkflowCompileError("inline workflow must not contain code after the default export");
92
+ }
93
+ return expression;
94
+ }
95
+
96
+ function findDefaultBodyOpen(expressionSource: string): number {
97
+ const functionMatch = /^async\s+function(?:\s+[A-Za-z_$][\w$]*)?\s*/.exec(expressionSource);
98
+ if (functionMatch) {
99
+ const paramsOpen = skipWhitespace(expressionSource, functionMatch[0].length);
100
+ if (expressionSource[paramsOpen] !== "(") {
101
+ throw new InlineWorkflowCompileError("default async function must declare parameters with parentheses");
102
+ }
103
+ const paramsClose = findMatchingDelimiter(expressionSource, paramsOpen, "(", ")");
104
+ const bodyOpen = skipWhitespace(expressionSource, paramsClose + 1);
105
+ if (expressionSource[bodyOpen] !== "{") {
106
+ throw new InlineWorkflowCompileError("default async function must use a block body");
107
+ }
108
+ return bodyOpen;
109
+ }
110
+
111
+ const arrowMatch = /^async\s*/.exec(expressionSource);
112
+ if (arrowMatch) {
113
+ const paramsOpen = skipWhitespace(expressionSource, arrowMatch[0].length);
114
+ if (expressionSource[paramsOpen] !== "(") {
115
+ throw new InlineWorkflowCompileError("default async arrow workflow must declare parameters with parentheses");
116
+ }
117
+ const paramsClose = findMatchingDelimiter(expressionSource, paramsOpen, "(", ")");
118
+ let cursor = skipWhitespace(expressionSource, paramsClose + 1);
119
+ if (!expressionSource.startsWith("=>", cursor)) {
120
+ throw new InlineWorkflowCompileError("default async arrow workflow must use =>");
121
+ }
122
+ cursor = skipWhitespace(expressionSource, cursor + 2);
123
+ if (expressionSource[cursor] !== "{") {
124
+ throw new InlineWorkflowCompileError("default async arrow workflow must use a block body");
125
+ }
126
+ return cursor;
127
+ }
128
+
129
+ throw new InlineWorkflowCompileError("inline workflow default export must be an async function or async arrow function");
130
+ }
131
+
132
+ function compileExecutor(defaultExpression: string): InlineWorkflowExecutor {
133
+ try {
134
+ return new AsyncFunction("api", "Type", `"use strict";\nconst workflow = ${defaultExpression};\nreturn await workflow(api);`);
135
+ } catch (error) {
136
+ const message = error instanceof Error ? error.message : String(error);
137
+ throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${message}`);
138
+ }
139
+ }
140
+
141
+ class LiteralParser {
142
+ private index: number;
143
+
144
+ constructor(private readonly source: string, offset: number) {
145
+ this.index = offset;
146
+ }
147
+
148
+ get offset(): number {
149
+ return this.index;
150
+ }
151
+
152
+ parseValue(): unknown {
153
+ this.skipWhitespace();
154
+ const char = this.source[this.index];
155
+ if (char === "{") return this.parseObject();
156
+ if (char === "[") return this.parseArray();
157
+ if (char === '"' || char === "'") return this.parseString(char);
158
+ if (char === "-" || isDigit(char)) return this.parseNumber();
159
+ if (this.source.startsWith("true", this.index)) return this.consumeKeyword("true", true);
160
+ if (this.source.startsWith("false", this.index)) return this.consumeKeyword("false", false);
161
+ if (this.source.startsWith("null", this.index)) return this.consumeKeyword("null", null);
162
+ if (char === "`") this.fail("template literals are not allowed in inline workflow meta");
163
+ this.fail(`unexpected token in inline workflow meta: ${char ?? "end of input"}`);
164
+ }
165
+
166
+ private parseObject(): Record<string, unknown> {
167
+ const object: Record<string, unknown> = {};
168
+ this.expect("{");
169
+ this.skipWhitespace();
170
+ if (this.peek() === "}") {
171
+ this.index++;
172
+ return object;
173
+ }
174
+
175
+ while (true) {
176
+ this.skipWhitespace();
177
+ if (this.source.startsWith("...", this.index)) this.fail("spread properties are not allowed in inline workflow meta");
178
+ if (this.peek() === "[") this.fail("computed properties are not allowed in inline workflow meta");
179
+ const key = this.parsePropertyKey();
180
+ this.skipWhitespace();
181
+ if (this.peek() !== ":") this.fail("inline workflow meta properties must use explicit key: value syntax");
182
+ this.index++;
183
+ object[key] = this.parseValue();
184
+ this.skipWhitespace();
185
+ const next = this.peek();
186
+ if (next === ",") {
187
+ this.index++;
188
+ this.skipWhitespace();
189
+ if (this.peek() === "}") {
190
+ this.index++;
191
+ return object;
192
+ }
193
+ continue;
194
+ }
195
+ if (next === "}") {
196
+ this.index++;
197
+ return object;
198
+ }
199
+ this.fail("expected `,` or `}` in inline workflow meta object");
200
+ }
201
+ }
202
+
203
+ private parseArray(): unknown[] {
204
+ const array: unknown[] = [];
205
+ this.expect("[");
206
+ this.skipWhitespace();
207
+ if (this.peek() === "]") {
208
+ this.index++;
209
+ return array;
210
+ }
211
+
212
+ while (true) {
213
+ array.push(this.parseValue());
214
+ this.skipWhitespace();
215
+ const next = this.peek();
216
+ if (next === ",") {
217
+ this.index++;
218
+ this.skipWhitespace();
219
+ if (this.peek() === "]") {
220
+ this.index++;
221
+ return array;
222
+ }
223
+ continue;
224
+ }
225
+ if (next === "]") {
226
+ this.index++;
227
+ return array;
228
+ }
229
+ this.fail("expected `,` or `]` in inline workflow meta array");
230
+ }
231
+ }
232
+
233
+ private parsePropertyKey(): string {
234
+ const char = this.peek();
235
+ if (char === '"' || char === "'") return this.parseString(char);
236
+ if (!isIdentifierStart(char)) this.fail("inline workflow meta property keys must be strings or identifiers");
237
+ const start = this.index;
238
+ this.index++;
239
+ while (isIdentifierPart(this.peek())) this.index++;
240
+ return this.source.slice(start, this.index);
241
+ }
242
+
243
+ private parseString(quote: string): string {
244
+ this.expect(quote);
245
+ let value = "";
246
+ while (this.index < this.source.length) {
247
+ const char = this.source[this.index];
248
+ if (char === quote) {
249
+ this.index++;
250
+ return value;
251
+ }
252
+ if (char === "\\") {
253
+ value += this.parseEscapeSequence();
254
+ continue;
255
+ }
256
+ if (char === "\n" || char === "\r") this.fail("inline workflow meta strings must not contain raw newlines");
257
+ value += char;
258
+ this.index++;
259
+ }
260
+ this.fail("unterminated string in inline workflow meta");
261
+ }
262
+
263
+ private parseEscapeSequence(): string {
264
+ this.expect("\\");
265
+ const escaped = this.source[this.index];
266
+ if (escaped === undefined) this.fail("unterminated escape sequence in inline workflow meta");
267
+ this.index++;
268
+ switch (escaped) {
269
+ case '"':
270
+ case "'":
271
+ case "\\":
272
+ case "/":
273
+ return escaped;
274
+ case "b":
275
+ return "\b";
276
+ case "f":
277
+ return "\f";
278
+ case "n":
279
+ return "\n";
280
+ case "r":
281
+ return "\r";
282
+ case "t":
283
+ return "\t";
284
+ case "u": {
285
+ const hex = this.source.slice(this.index, this.index + 4);
286
+ if (!/^[0-9A-Fa-f]{4}$/.test(hex)) this.fail("invalid unicode escape in inline workflow meta");
287
+ this.index += 4;
288
+ return String.fromCharCode(Number.parseInt(hex, 16));
289
+ }
290
+ default:
291
+ this.fail(`unsupported escape sequence \\${escaped} in inline workflow meta`);
292
+ }
293
+ }
294
+
295
+ private parseNumber(): number {
296
+ const match = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
297
+ match.lastIndex = this.index;
298
+ const parsed = match.exec(this.source);
299
+ if (!parsed) this.fail("invalid number in inline workflow meta");
300
+ this.index = match.lastIndex;
301
+ return Number(parsed[0]);
302
+ }
303
+
304
+ private consumeKeyword<T>(keyword: string, value: T): T {
305
+ this.index += keyword.length;
306
+ if (isIdentifierPart(this.peek())) this.fail(`unexpected identifier after ${keyword} in inline workflow meta`);
307
+ return value;
308
+ }
309
+
310
+ private skipWhitespace(): void {
311
+ this.index = skipWhitespace(this.source, this.index);
312
+ }
313
+
314
+ private peek(): string | undefined {
315
+ return this.source[this.index];
316
+ }
317
+
318
+ private expect(char: string): void {
319
+ if (this.source[this.index] !== char) this.fail(`expected ${char} in inline workflow meta`);
320
+ this.index++;
321
+ }
322
+
323
+ private fail(message: string): never {
324
+ throw new InlineWorkflowCompileError(`${message} at offset ${this.index}`);
325
+ }
326
+ }
327
+
328
+ function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
329
+ let depth = 1;
330
+ let index = openIndex + 1;
331
+ while (index < source.length) {
332
+ const char = source[index];
333
+ if (char === '"' || char === "'" || char === "`") {
334
+ index = skipQuotedSource(source, index, char);
335
+ continue;
336
+ }
337
+ if (char === "/" && source[index + 1] === "/") {
338
+ index = skipLineComment(source, index);
339
+ continue;
340
+ }
341
+ if (char === "/" && source[index + 1] === "*") {
342
+ index = skipBlockComment(source, index);
343
+ continue;
344
+ }
345
+ if (char === open) depth++;
346
+ if (char === close) {
347
+ depth--;
348
+ if (depth === 0) return index;
349
+ }
350
+ index++;
351
+ }
352
+ throw new InlineWorkflowCompileError(`unterminated ${open}${close} block in inline workflow default export`);
353
+ }
354
+
355
+ function skipQuotedSource(source: string, start: number, quote: string): number {
356
+ let index = start + 1;
357
+ while (index < source.length) {
358
+ const char = source[index];
359
+ if (char === "\\") {
360
+ index += 2;
361
+ continue;
362
+ }
363
+ if (char === quote) return index + 1;
364
+ index++;
365
+ }
366
+ throw new InlineWorkflowCompileError("unterminated string in inline workflow default export");
367
+ }
368
+
369
+ function skipLineComment(source: string, start: number): number {
370
+ const newline = source.indexOf("\n", start + 2);
371
+ return newline === -1 ? source.length : newline + 1;
372
+ }
373
+
374
+ function skipBlockComment(source: string, start: number): number {
375
+ const end = source.indexOf("*/", start + 2);
376
+ if (end === -1) throw new InlineWorkflowCompileError("unterminated block comment in inline workflow default export");
377
+ return end + 2;
378
+ }
379
+
380
+ function skipWhitespace(source: string, start: number): number {
381
+ let index = start;
382
+ while (index < source.length && /\s/.test(source[index] ?? "")) index++;
383
+ return index;
384
+ }
385
+
386
+ function isDigit(value: string | undefined): boolean {
387
+ return value !== undefined && value >= "0" && value <= "9";
388
+ }
389
+
390
+ function isIdentifierStart(value: string | undefined): boolean {
391
+ return value !== undefined && /[A-Za-z_$]/.test(value);
392
+ }
393
+
394
+ function isIdentifierPart(value: string | undefined): boolean {
395
+ return value !== undefined && /[A-Za-z0-9_$]/.test(value);
396
+ }
@@ -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
+ }