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}