flake-update-20260505
1/**
2 * CI/Checks action handlers for GitHub extension
3 */
4
5import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
6import type { GhDetails } from "../types";
7import { parseChecks, parseRunList, getErrorMessage, getCheckIcon, getRunStatusIcon, execGh, approvalGate, buildModifyResult, buildRejectResult } from "../utils";
8
9/**
10 * Show check status for a PR
11 */
12export async function handleChecks(
13 pi: ExtensionAPI,
14 params: any,
15 signal: AbortSignal | undefined,
16 onUpdate: any,
17 ctx: ExtensionContext,
18): Promise<any> {
19 if (!params.number) {
20 return {
21 content: [{ type: "text", text: "Error: 'number' parameter is required for checks action" }],
22 details: { action: "checks", error: "missing_number" } as GhDetails,
23 isError: true,
24 };
25 }
26
27 onUpdate?.({ content: [{ type: "text", text: `Fetching checks for PR #${params.number}...` }] });
28
29 const result = await execGh(pi, ctx,
30 ["pr", "view", String(params.number), "--json", "statusCheckRollup"],
31 { signal, timeout: 30000 },
32 );
33
34 if (result.code !== 0) {
35 return {
36 content: [{ type: "text", text: getErrorMessage(result.stderr, "Get checks") }],
37 details: { action: "checks", error: result.stderr, prNumber: params.number } as GhDetails,
38 isError: true,
39 };
40 }
41
42 const checks = parseChecks(result.stdout);
43
44 if (checks.length === 0) {
45 return {
46 content: [{ type: "text", text: `No checks found for PR #${params.number}` }],
47 details: { action: "checks", output: "No checks found", prNumber: params.number } as GhDetails,
48 };
49 }
50
51 const passed = checks.filter((c) => c.conclusion === "SUCCESS").length;
52 const failed = checks.filter((c) => c.conclusion === "FAILURE").length;
53 const pending = checks.filter(
54 (c) => !c.conclusion || c.status === "IN_PROGRESS" || c.status === "QUEUED",
55 ).length;
56 const skipped = checks.filter(
57 (c) => c.conclusion === "SKIPPED" || c.conclusion === "CANCELLED",
58 ).length;
59
60 let output = `PR #${params.number} checks: ${passed} passed, ${failed} failed, ${pending} pending`;
61 if (skipped > 0) output += `, ${skipped} skipped`;
62 output += "\n\n";
63
64 for (const check of checks) {
65 const icon = getCheckIcon(check);
66 output += `${icon} ${check.name} (${check.conclusion || check.status || "pending"})`;
67 if (check.detailsUrl) output += ` ${check.detailsUrl}`;
68 output += "\n";
69 }
70
71 return {
72 content: [{ type: "text", text: output }],
73 details: { action: "checks", output, prNumber: params.number } as GhDetails,
74 };
75}
76
77/**
78 * Get failed check logs
79 * Accepts either runId or PR number
80 */
81export async function handleChecksLog(
82 pi: ExtensionAPI,
83 params: any,
84 signal: AbortSignal | undefined,
85 onUpdate: any,
86 ctx: ExtensionContext,
87): Promise<any> {
88 let runId = params.runId;
89
90 // If no runId but a PR number is provided, get the first failed run
91 if (!runId && params.number) {
92 onUpdate?.({ content: [{ type: "text", text: `Finding failed runs for PR #${params.number}...` }] });
93
94 // Get checks for the PR
95 const checksResult = await execGh(pi, ctx,
96 ["pr", "view", String(params.number), "--json", "statusCheckRollup"],
97 { signal, timeout: 30000 },
98 );
99
100 if (checksResult.code !== 0) {
101 return {
102 content: [{ type: "text", text: getErrorMessage(checksResult.stderr, "Get PR checks") }],
103 details: { action: "checks-log", error: checksResult.stderr, prNumber: params.number } as GhDetails,
104 isError: true,
105 };
106 }
107
108 const checks = parseChecks(checksResult.stdout);
109
110 // Find first failed check with a workflow run ID
111 const failedCheck = checks.find((c) =>
112 c.conclusion === "FAILURE" && c.detailsUrl && c.detailsUrl.includes("/runs/")
113 );
114
115 if (failedCheck && failedCheck.detailsUrl) {
116 // Extract run ID from URL (e.g., https://github.com/owner/repo/actions/runs/123456)
117 const match = failedCheck.detailsUrl.match(/\/runs\/(\d+)/);
118 if (match) {
119 runId = parseInt(match[1], 10);
120 }
121 }
122
123 if (!runId) {
124 return {
125 content: [{ type: "text", text: `No failed workflow runs found for PR #${params.number}` }],
126 details: { action: "checks-log", error: "no_failed_runs", prNumber: params.number } as GhDetails,
127 isError: true,
128 };
129 }
130 }
131
132 if (!runId) {
133 return {
134 content: [{ type: "text", text: "Error: Either 'runId' or 'number' (PR number) parameter is required for checks-log action" }],
135 details: { action: "checks-log", error: "missing_run_id" } as GhDetails,
136 isError: true,
137 };
138 }
139
140 onUpdate?.({ content: [{ type: "text", text: `Fetching logs for run ${runId}...` }] });
141
142 const result = await execGh(pi, ctx, ["run", "view", String(runId), "--log-failed"], {
143 signal,
144 timeout: 60000,
145 });
146
147 if (result.code !== 0) {
148 // If --log-failed has no output, try regular log
149 if (result.stderr.includes("no failed jobs")) {
150 return {
151 content: [{ type: "text", text: `No failed jobs in run ${runId}` }],
152 details: { action: "checks-log", output: "No failed jobs", runId, prNumber: params.number } as GhDetails,
153 };
154 }
155 return {
156 content: [{ type: "text", text: getErrorMessage(result.stderr, "Get run logs") }],
157 details: { action: "checks-log", error: result.stderr, runId, prNumber: params.number } as GhDetails,
158 isError: true,
159 };
160 }
161
162 const logs = result.stdout;
163 const maxLen = 50000;
164 const output =
165 logs.length > maxLen
166 ? logs.slice(logs.length - maxLen) + "\n\n[... logs truncated, showing last 50KB]"
167 : logs;
168
169 return {
170 content: [{ type: "text", text: output }],
171 details: { action: "checks-log", output: `Run ${runId} logs (${logs.length} chars)`, runId, prNumber: params.number } as GhDetails,
172 };
173}
174
175/**
176 * Restart failed checks (requires approval)
177 */
178export async function handleChecksRestart(
179 pi: ExtensionAPI,
180 params: any,
181 signal: AbortSignal | undefined,
182 onUpdate: any,
183 ctx: ExtensionContext,
184): Promise<any> {
185 if (!params.runId) {
186 return {
187 content: [{ type: "text", text: "Error: 'runId' parameter is required for checks-restart action" }],
188 details: { action: "checks-restart", error: "missing_run_id" } as GhDetails,
189 isError: true,
190 };
191 }
192
193 // APPROVAL GATE
194 if (ctx.hasUI) {
195 const approval = await approvalGate(
196 ctx,
197 `Restart run ${params.runId}?`,
198 `This will rerun${params.failedOnly !== false ? " failed jobs in" : ""} workflow run ${params.runId}.`,
199 );
200 if (approval.outcome === "modify") {
201 ctx.ui.notify("Restart paused for modifications", "info");
202 return buildModifyResult("restart run", { action: "checks-restart", runId: params.runId });
203 }
204 if (approval.outcome === "rejected") {
205 ctx.ui.notify("Restart rejected", "info");
206 return buildRejectResult("restart run", { action: "checks-restart", runId: params.runId });
207 }
208 }
209
210 const args = ["run", "rerun", String(params.runId)];
211 if (params.failedOnly !== false) args.push("--failed");
212
213 onUpdate?.({ content: [{ type: "text", text: `Restarting run ${params.runId}...` }] });
214
215 const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
216
217 if (result.code !== 0) {
218 return {
219 content: [{ type: "text", text: getErrorMessage(result.stderr, "Restart run") }],
220 details: { action: "checks-restart", error: result.stderr, runId: params.runId } as GhDetails,
221 isError: true,
222 };
223 }
224
225 return {
226 content: [{ type: "text", text: `Restarted run ${params.runId}${params.failedOnly !== false ? " (failed jobs only)" : ""}` }],
227 details: { action: "checks-restart", output: result.stdout.trim(), runId: params.runId } as GhDetails,
228 };
229}
230
231/**
232 * List workflow runs
233 */
234export async function handleRunList(
235 pi: ExtensionAPI,
236 params: any,
237 signal: AbortSignal | undefined,
238 onUpdate: any,
239 ctx: ExtensionContext,
240): Promise<any> {
241 const args = [
242 "run",
243 "list",
244 "--json",
245 "databaseId,name,displayTitle,status,conclusion,headBranch,event,url,createdAt,updatedAt",
246 ];
247
248 if (params.branch) args.push("--branch", params.branch);
249 if (params.status) args.push("--status", params.status);
250 if (params.workflow) args.push("--workflow", params.workflow);
251 if (params.limit) args.push("--limit", String(params.limit));
252 else args.push("--limit", "20");
253
254 onUpdate?.({ content: [{ type: "text", text: "Fetching workflow runs..." }] });
255
256 const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
257
258 if (result.code !== 0) {
259 return {
260 content: [{ type: "text", text: getErrorMessage(result.stderr, "List runs") }],
261 details: { action: "run-list", error: result.stderr } as GhDetails,
262 isError: true,
263 };
264 }
265
266 const runs = parseRunList(result.stdout);
267
268 if (runs.length === 0) {
269 return {
270 content: [{ type: "text", text: "No workflow runs found." }],
271 details: { action: "run-list", output: "No runs found" } as GhDetails,
272 };
273 }
274
275 let output = `Workflow runs (${runs.length}):\n\n`;
276 for (const run of runs) {
277 const icon = getRunStatusIcon(run);
278 output += `${icon} ${run.databaseId} ${run.name}: ${run.displayTitle}`;
279 output += ` (${run.conclusion || run.status})`;
280 output += ` [${run.headBranch}]`;
281 output += ` ${run.url}`;
282 output += "\n";
283 }
284
285 return {
286 content: [{ type: "text", text: output }],
287 details: { action: "run-list", output } as GhDetails,
288 };
289}
290
291/**
292 * View specific run details
293 */
294export async function handleRunView(
295 pi: ExtensionAPI,
296 params: any,
297 signal: AbortSignal | undefined,
298 onUpdate: any,
299 ctx: ExtensionContext,
300): Promise<any> {
301 if (!params.runId) {
302 return {
303 content: [{ type: "text", text: "Error: 'runId' parameter is required for run-view action" }],
304 details: { action: "run-view", error: "missing_run_id" } as GhDetails,
305 isError: true,
306 };
307 }
308
309 onUpdate?.({ content: [{ type: "text", text: `Fetching run ${params.runId}...` }] });
310
311 const result = await execGh(pi, ctx,
312 [
313 "run",
314 "view",
315 String(params.runId),
316 "--json",
317 "databaseId,name,displayTitle,status,conclusion,headBranch,event,url,createdAt,updatedAt,jobs",
318 ],
319 { signal, timeout: 30000 },
320 );
321
322 if (result.code !== 0) {
323 return {
324 content: [{ type: "text", text: getErrorMessage(result.stderr, "View run") }],
325 details: { action: "run-view", error: result.stderr, runId: params.runId } as GhDetails,
326 isError: true,
327 };
328 }
329
330 let data: any;
331 try {
332 data = JSON.parse(result.stdout);
333 } catch {
334 return {
335 content: [{ type: "text", text: result.stdout }],
336 details: { action: "run-view", output: result.stdout, runId: params.runId } as GhDetails,
337 };
338 }
339
340 let output = `# Run ${data.databaseId}: ${data.name}\n\n`;
341 output += `Title: ${data.displayTitle}\n`;
342 output += `Status: ${data.conclusion || data.status}\n`;
343 output += `Branch: ${data.headBranch}\n`;
344 output += `Event: ${data.event}\n`;
345 output += `URL: ${data.url}\n`;
346
347 const jobs = data.jobs ?? [];
348 if (jobs.length > 0) {
349 output += `\n## Jobs (${jobs.length})\n\n`;
350 for (const job of jobs) {
351 const icon = job.conclusion === "success" ? "✓" : job.conclusion === "failure" ? "✗" : "⏳";
352 output += `${icon} ${job.name} (${job.conclusion || job.status})`;
353 if (job.url) output += ` ${job.url}`;
354 output += "\n";
355 }
356 }
357
358 return {
359 content: [{ type: "text", text: output }],
360 details: { action: "run-view", output, runId: params.runId } as GhDetails,
361 };
362}