Commit 83a161b49f68

Vincent Demeester <vincent@sbr.pm>
2026-02-10 16:24:08
feat: add pi GitHub extension
Added gh CLI-based pi extension for managing PRs, issues, CI checks, and workflow runs. Includes 23 tool actions, 6 slash commands, auto-detection of GitHub URLs, approval-gated writes, custom TUI rendering, and 62 tests.
1 parent 7fc97cb
dots/pi/agent/extensions/github/actions/checks.ts
@@ -0,0 +1,317 @@
+/**
+ * CI/Checks action handlers for GitHub extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { GhDetails } from "../types";
+import { parseChecks, parseRunList, getErrorMessage, getCheckIcon, getRunStatusIcon } from "../utils";
+
+/**
+ * Show check status for a PR
+ */
+export async function handleChecks(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for checks action" }],
+			details: { action: "checks", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Fetching checks for PR #${params.number}...` }] });
+
+	const result = await pi.exec(
+		"gh",
+		["pr", "view", String(params.number), "--json", "statusCheckRollup"],
+		{ signal, timeout: 30000 },
+	);
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Get checks") }],
+			details: { action: "checks", error: result.stderr, prNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const checks = parseChecks(result.stdout);
+
+	if (checks.length === 0) {
+		return {
+			content: [{ type: "text", text: `No checks found for PR #${params.number}` }],
+			details: { action: "checks", output: "No checks found", prNumber: params.number } as GhDetails,
+		};
+	}
+
+	const passed = checks.filter((c) => c.conclusion === "SUCCESS").length;
+	const failed = checks.filter((c) => c.conclusion === "FAILURE").length;
+	const pending = checks.filter(
+		(c) => !c.conclusion || c.status === "IN_PROGRESS" || c.status === "QUEUED",
+	).length;
+	const skipped = checks.filter(
+		(c) => c.conclusion === "SKIPPED" || c.conclusion === "CANCELLED",
+	).length;
+
+	let output = `PR #${params.number} checks: ${passed} passed, ${failed} failed, ${pending} pending`;
+	if (skipped > 0) output += `, ${skipped} skipped`;
+	output += "\n\n";
+
+	for (const check of checks) {
+		const icon = getCheckIcon(check);
+		output += `${icon} ${check.name} (${check.conclusion || check.status || "pending"})`;
+		if (check.detailsUrl) output += ` ${check.detailsUrl}`;
+		output += "\n";
+	}
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "checks", output, prNumber: params.number } as GhDetails,
+	};
+}
+
+/**
+ * Get failed check logs
+ */
+export async function handleChecksLog(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.runId) {
+		return {
+			content: [{ type: "text", text: "Error: 'runId' parameter is required for checks-log action" }],
+			details: { action: "checks-log", error: "missing_run_id" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Fetching logs for run ${params.runId}...` }] });
+
+	const result = await pi.exec("gh", ["run", "view", String(params.runId), "--log-failed"], {
+		signal,
+		timeout: 60000,
+	});
+
+	if (result.code !== 0) {
+		// If --log-failed has no output, try regular log
+		if (result.stderr.includes("no failed jobs")) {
+			return {
+				content: [{ type: "text", text: `No failed jobs in run ${params.runId}` }],
+				details: { action: "checks-log", output: "No failed jobs", runId: params.runId } as GhDetails,
+			};
+		}
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Get run logs") }],
+			details: { action: "checks-log", error: result.stderr, runId: params.runId } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const logs = result.stdout;
+	const maxLen = 50000;
+	const output =
+		logs.length > maxLen
+			? logs.slice(logs.length - maxLen) + "\n\n[... logs truncated, showing last 50KB]"
+			: logs;
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "checks-log", output: `Run ${params.runId} logs (${logs.length} chars)`, runId: params.runId } as GhDetails,
+	};
+}
+
+/**
+ * Restart failed checks (requires approval)
+ */
+export async function handleChecksRestart(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.runId) {
+		return {
+			content: [{ type: "text", text: "Error: 'runId' parameter is required for checks-restart action" }],
+			details: { action: "checks-restart", error: "missing_run_id" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmed = await ctx.ui.confirm(
+			`Restart run ${params.runId}?`,
+			`This will rerun${params.failedOnly !== false ? " failed jobs in" : ""} workflow run ${params.runId}.`,
+		);
+		if (!confirmed) {
+			ctx.ui.notify("Restart cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Restart cancelled by user" }],
+				details: { action: "checks-restart", cancelled: true, runId: params.runId } as GhDetails,
+			};
+		}
+	}
+
+	const args = ["run", "rerun", String(params.runId)];
+	if (params.failedOnly !== false) args.push("--failed");
+
+	onUpdate?.({ content: [{ type: "text", text: `Restarting run ${params.runId}...` }] });
+
+	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Restart run") }],
+			details: { action: "checks-restart", error: result.stderr, runId: params.runId } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Restarted run ${params.runId}${params.failedOnly !== false ? " (failed jobs only)" : ""}` }],
+		details: { action: "checks-restart", output: result.stdout.trim(), runId: params.runId } as GhDetails,
+	};
+}
+
+/**
+ * List workflow runs
+ */
+export async function handleRunList(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	const args = [
+		"run",
+		"list",
+		"--json",
+		"databaseId,name,displayTitle,status,conclusion,headBranch,event,url,createdAt,updatedAt",
+	];
+
+	if (params.branch) args.push("--branch", params.branch);
+	if (params.status) args.push("--status", params.status);
+	if (params.workflow) args.push("--workflow", params.workflow);
+	if (params.limit) args.push("--limit", String(params.limit));
+	else args.push("--limit", "20");
+
+	onUpdate?.({ content: [{ type: "text", text: "Fetching workflow runs..." }] });
+
+	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "List runs") }],
+			details: { action: "run-list", error: result.stderr } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const runs = parseRunList(result.stdout);
+
+	if (runs.length === 0) {
+		return {
+			content: [{ type: "text", text: "No workflow runs found." }],
+			details: { action: "run-list", output: "No runs found" } as GhDetails,
+		};
+	}
+
+	let output = `Workflow runs (${runs.length}):\n\n`;
+	for (const run of runs) {
+		const icon = getRunStatusIcon(run);
+		output += `${icon} ${run.databaseId} ${run.name}: ${run.displayTitle}`;
+		output += ` (${run.conclusion || run.status})`;
+		output += ` [${run.headBranch}]`;
+		output += ` ${run.url}`;
+		output += "\n";
+	}
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "run-list", output } as GhDetails,
+	};
+}
+
+/**
+ * View specific run details
+ */
+export async function handleRunView(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.runId) {
+		return {
+			content: [{ type: "text", text: "Error: 'runId' parameter is required for run-view action" }],
+			details: { action: "run-view", error: "missing_run_id" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Fetching run ${params.runId}...` }] });
+
+	const result = await pi.exec(
+		"gh",
+		[
+			"run",
+			"view",
+			String(params.runId),
+			"--json",
+			"databaseId,name,displayTitle,status,conclusion,headBranch,event,url,createdAt,updatedAt,jobs",
+		],
+		{ signal, timeout: 30000 },
+	);
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "View run") }],
+			details: { action: "run-view", error: result.stderr, runId: params.runId } as GhDetails,
+			isError: true,
+		};
+	}
+
+	let data: any;
+	try {
+		data = JSON.parse(result.stdout);
+	} catch {
+		return {
+			content: [{ type: "text", text: result.stdout }],
+			details: { action: "run-view", output: result.stdout, runId: params.runId } as GhDetails,
+		};
+	}
+
+	let output = `# Run ${data.databaseId}: ${data.name}\n\n`;
+	output += `Title: ${data.displayTitle}\n`;
+	output += `Status: ${data.conclusion || data.status}\n`;
+	output += `Branch: ${data.headBranch}\n`;
+	output += `Event: ${data.event}\n`;
+	output += `URL: ${data.url}\n`;
+
+	const jobs = data.jobs ?? [];
+	if (jobs.length > 0) {
+		output += `\n## Jobs (${jobs.length})\n\n`;
+		for (const job of jobs) {
+			const icon = job.conclusion === "success" ? "✓" : job.conclusion === "failure" ? "✗" : "⏳";
+			output += `${icon} ${job.name} (${job.conclusion || job.status})`;
+			if (job.url) output += ` ${job.url}`;
+			output += "\n";
+		}
+	}
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "run-view", output, runId: params.runId } as GhDetails,
+	};
+}
dots/pi/agent/extensions/github/actions/issue.ts
@@ -0,0 +1,443 @@
+/**
+ * Issue action handlers for GitHub extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { GhDetails } from "../types";
+import {
+	parseIssueList,
+	getErrorMessage,
+	extractIssueNumber,
+	extractIssueUrl,
+	buildIssueCreateConfirmation,
+	buildCommentConfirmation,
+	truncate,
+} from "../utils";
+
+const ISSUE_LIST_FIELDS = "number,title,state,author,url,labels,assignees,createdAt,updatedAt,body,comments";
+
+/**
+ * List issues
+ */
+export async function handleIssueList(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+	currentUser: string,
+): Promise<any> {
+	const args = ["issue", "list", "--json", ISSUE_LIST_FIELDS];
+
+	if (params.state) args.push("--state", params.state);
+	if (params.label) args.push("--label", params.label);
+	if (params.assignee) {
+		args.push("--assignee", params.assignee === "me" ? (currentUser || "@me") : params.assignee);
+	}
+	if (params.milestone) args.push("--milestone", params.milestone);
+	if (params.limit) args.push("--limit", String(params.limit));
+	else args.push("--limit", "20");
+
+	onUpdate?.({ content: [{ type: "text", text: "Fetching issues..." }] });
+
+	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "List issues") }],
+			details: { action: "issue-list", error: result.stderr } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const issues = parseIssueList(result.stdout);
+
+	let output = "";
+	if (issues.length === 0) {
+		output = "No issues found.";
+	} else {
+		output = issues
+			.map((issue) => {
+				const labels = issue.labels.length > 0 ? ` [${issue.labels.join(", ")}]` : "";
+				const assignees = issue.assignees.length > 0 ? ` @${issue.assignees.join(", @")}` : "";
+				return `#${issue.number} ${issue.title}${labels}${assignees} (${issue.state})`;
+			})
+			.join("\n");
+	}
+
+	const issueNumbers = issues.map((i) => i.number);
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "issue-list", output, issueNumbers } as GhDetails,
+	};
+}
+
+/**
+ * View issue details
+ */
+export async function handleIssueView(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for issue-view action" }],
+			details: { action: "issue-view", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Fetching issue #${params.number}...` }] });
+
+	const result = await pi.exec(
+		"gh",
+		["issue", "view", String(params.number), "--json", `${ISSUE_LIST_FIELDS},milestone`],
+		{ signal, timeout: 20000 },
+	);
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "View issue") }],
+			details: { action: "issue-view", error: result.stderr, issueNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	let data: any;
+	try {
+		data = JSON.parse(result.stdout);
+	} catch {
+		return {
+			content: [{ type: "text", text: result.stdout }],
+			details: { action: "issue-view", output: result.stdout, issueNumber: params.number } as GhDetails,
+		};
+	}
+
+	let output = "";
+	output += `# Issue #${data.number}: ${data.title}\n\n`;
+	output += `State: ${data.state}\n`;
+	output += `Author: @${data.author?.login ?? "?"}\n`;
+	output += `URL: ${data.url}\n`;
+
+	if (data.labels?.length > 0) {
+		output += `Labels: ${data.labels.map((l: any) => l.name ?? l).join(", ")}\n`;
+	}
+	if (data.assignees?.length > 0) {
+		output += `Assignees: ${data.assignees.map((a: any) => `@${a.login ?? a}`).join(", ")}\n`;
+	}
+	if (data.milestone) {
+		output += `Milestone: ${data.milestone.title ?? data.milestone}\n`;
+	}
+
+	if (data.body) {
+		output += `\n## Description\n\n${data.body}\n`;
+	}
+
+	// Get comments if any
+	const commentsCount = data.comments?.totalCount ?? data.comments ?? 0;
+	if (commentsCount > 0) {
+		output += `\n## Comments (${commentsCount})\n`;
+		// Fetch comments separately via gh issue view --comments
+		const commentsResult = await pi.exec(
+			"gh",
+			["issue", "view", String(params.number), "--comments", "--json", "comments"],
+			{ signal, timeout: 20000 },
+		);
+		if (commentsResult.code === 0) {
+			try {
+				const commentsData = JSON.parse(commentsResult.stdout);
+				const comments = commentsData.comments ?? [];
+				for (const comment of comments) {
+					output += `\n@${comment.author?.login ?? "?"} (${comment.createdAt ?? ""}):\n${comment.body}\n`;
+				}
+			} catch {
+				output += "\n(Could not parse comments)\n";
+			}
+		}
+	}
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: {
+			action: "issue-view",
+			output,
+			issueNumber: data.number,
+			issueUrl: data.url,
+		} as GhDetails,
+	};
+}
+
+/**
+ * Create issue (requires approval)
+ */
+export async function handleIssueCreate(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.title) {
+		return {
+			content: [{ type: "text", text: "Error: 'title' parameter is required for issue-create action" }],
+			details: { action: "issue-create", error: "missing_title" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildIssueCreateConfirmation(params);
+		const confirmed = await ctx.ui.confirm("Create Issue?", confirmMessage);
+		if (!confirmed) {
+			ctx.ui.notify("Issue creation cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Issue creation cancelled by user" }],
+				details: { action: "issue-create", cancelled: true } as GhDetails,
+			};
+		}
+	}
+
+	const args = ["issue", "create", "--title", params.title];
+
+	if (params.body) args.push("--body", params.body);
+	if (params.labels?.length) {
+		for (const label of params.labels) args.push("--label", label);
+	}
+	if (params.assignees?.length) {
+		for (const assignee of params.assignees) args.push("--assignee", assignee);
+	}
+	if (params.milestone) args.push("--milestone", params.milestone);
+
+	onUpdate?.({ content: [{ type: "text", text: "Creating issue..." }] });
+
+	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Create issue") }],
+			details: { action: "issue-create", error: result.stderr } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const issueNumber = extractIssueNumber(result.stdout);
+	const issueUrl = extractIssueUrl(result.stdout) || result.stdout.trim();
+
+	return {
+		content: [{ type: "text", text: `Created issue${issueNumber ? ` #${issueNumber}` : ""}: ${issueUrl}` }],
+		details: {
+			action: "issue-create",
+			output: result.stdout.trim(),
+			issueNumber: issueNumber ?? undefined,
+			issueUrl: issueUrl ?? undefined,
+		} as GhDetails,
+	};
+}
+
+/**
+ * Close issue (requires approval)
+ */
+export async function handleIssueClose(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for issue-close action" }],
+			details: { action: "issue-close", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const reason = params.reason || "completed";
+		const confirmed = await ctx.ui.confirm(
+			`Close issue #${params.number}?`,
+			`This will close the issue as "${reason}".`,
+		);
+		if (!confirmed) {
+			ctx.ui.notify("Close cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Close cancelled by user" }],
+				details: { action: "issue-close", cancelled: true, issueNumber: params.number } as GhDetails,
+			};
+		}
+	}
+
+	const args = ["issue", "close", String(params.number)];
+	if (params.reason) args.push("--reason", params.reason);
+
+	const result = await pi.exec("gh", args, { signal, timeout: 20000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Close issue") }],
+			details: { action: "issue-close", error: result.stderr, issueNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Closed issue #${params.number}` }],
+		details: { action: "issue-close", output: result.stdout.trim(), issueNumber: params.number } as GhDetails,
+	};
+}
+
+/**
+ * Comment on issue (requires approval)
+ */
+export async function handleIssueComment(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for issue-comment action" }],
+			details: { action: "issue-comment", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.body) {
+		return {
+			content: [{ type: "text", text: "Error: 'body' parameter is required for issue-comment action" }],
+			details: { action: "issue-comment", error: "missing_body" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildCommentConfirmation("Issue", params.number, params.body);
+		const confirmed = await ctx.ui.confirm(`Comment on issue #${params.number}?`, confirmMessage);
+		if (!confirmed) {
+			ctx.ui.notify("Comment cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Comment cancelled by user" }],
+				details: { action: "issue-comment", cancelled: true, issueNumber: params.number } as GhDetails,
+			};
+		}
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Adding comment to issue #${params.number}...` }] });
+
+	const result = await pi.exec(
+		"gh",
+		["issue", "comment", String(params.number), "--body", params.body],
+		{ signal, timeout: 20000 },
+	);
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Comment on issue") }],
+			details: { action: "issue-comment", error: result.stderr, issueNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Added comment to issue #${params.number}` }],
+		details: { action: "issue-comment", output: result.stdout.trim(), issueNumber: params.number } as GhDetails,
+	};
+}
+
+/**
+ * Edit issue (requires approval)
+ */
+export async function handleIssueEdit(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for issue-edit action" }],
+			details: { action: "issue-edit", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// Build description of what we're editing
+	const changes: string[] = [];
+	if (params.title) changes.push(`Title: "${params.title}"`);
+	if (params.body) changes.push(`Body: "${truncate(params.body, 100)}"`);
+	if (params.addLabels?.length) changes.push(`Add labels: ${params.addLabels.join(", ")}`);
+	if (params.removeLabels?.length) changes.push(`Remove labels: ${params.removeLabels.join(", ")}`);
+	if (params.addAssignees?.length) changes.push(`Add assignees: ${params.addAssignees.join(", ")}`);
+	if (params.removeAssignees?.length) changes.push(`Remove assignees: ${params.removeAssignees.join(", ")}`);
+	if (params.milestone) changes.push(`Milestone: ${params.milestone}`);
+
+	if (changes.length === 0) {
+		return {
+			content: [{ type: "text", text: "Error: No fields to update specified" }],
+			details: { action: "issue-edit", error: "no_changes" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = `Issue: #${params.number}\n\nChanges:\n${changes.join("\n")}\n\nThis will modify the issue.`;
+		const confirmed = await ctx.ui.confirm(`Edit issue #${params.number}?`, confirmMessage);
+		if (!confirmed) {
+			ctx.ui.notify("Edit cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Edit cancelled by user" }],
+				details: { action: "issue-edit", cancelled: true, issueNumber: params.number } as GhDetails,
+			};
+		}
+	}
+
+	const args = ["issue", "edit", String(params.number)];
+
+	if (params.title) args.push("--title", params.title);
+	if (params.body) args.push("--body", params.body);
+	if (params.addLabels?.length) {
+		for (const label of params.addLabels) args.push("--add-label", label);
+	}
+	if (params.removeLabels?.length) {
+		for (const label of params.removeLabels) args.push("--remove-label", label);
+	}
+	if (params.addAssignees?.length) {
+		for (const assignee of params.addAssignees) args.push("--add-assignee", assignee);
+	}
+	if (params.removeAssignees?.length) {
+		for (const assignee of params.removeAssignees) args.push("--remove-assignee", assignee);
+	}
+	if (params.milestone) args.push("--milestone", params.milestone);
+
+	onUpdate?.({ content: [{ type: "text", text: `Editing issue #${params.number}...` }] });
+
+	const result = await pi.exec("gh", args, { signal, timeout: 20000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Edit issue") }],
+			details: { action: "issue-edit", error: result.stderr, issueNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Updated issue #${params.number}: ${changes.join("; ")}` }],
+		details: {
+			action: "issue-edit",
+			output: result.stdout.trim(),
+			issueNumber: params.number,
+		} as GhDetails,
+	};
+}
dots/pi/agent/extensions/github/actions/pr.ts
@@ -0,0 +1,610 @@
+/**
+ * Pull Request action handlers for GitHub extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { GhDetails } from "../types";
+import {
+	parsePRList,
+	parsePRItem,
+	getErrorMessage,
+	extractPRNumber,
+	extractPRUrl,
+	buildPRCreateConfirmation,
+	buildPRMergeConfirmation,
+	buildReviewConfirmation,
+	buildCommentConfirmation,
+	truncate,
+	getReviewDecisionText,
+} from "../utils";
+
+const PR_LIST_FIELDS = "number,title,state,author,headRefName,baseRefName,url,isDraft,labels,reviewDecision,additions,deletions,changedFiles,createdAt,updatedAt";
+
+/**
+ * List pull requests
+ */
+export async function handlePRList(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+	currentUser: string,
+): Promise<any> {
+	const args = ["pr", "list", "--json", PR_LIST_FIELDS];
+
+	if (params.state) args.push("--state", params.state);
+	if (params.author) {
+		args.push("--author", params.author === "me" ? (currentUser || "@me") : params.author);
+	}
+	if (params.label) args.push("--label", params.label);
+	if (params.base) args.push("--base", params.base);
+	if (params.limit) args.push("--limit", String(params.limit));
+	else args.push("--limit", "20");
+
+	onUpdate?.({ content: [{ type: "text", text: "Fetching PRs..." }] });
+
+	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "List PRs") }],
+			details: { action: "pr-list", error: result.stderr } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const prs = parsePRList(result.stdout);
+
+	let output = "";
+	if (prs.length === 0) {
+		output = "No pull requests found.";
+	} else {
+		output = prs
+			.map((pr) => {
+				const draft = pr.isDraft ? " [draft]" : "";
+				const review = pr.reviewDecision ? ` (${getReviewDecisionText(pr.reviewDecision)})` : "";
+				const changes = `+${pr.additions}/-${pr.deletions}`;
+				return `#${pr.number} ${pr.title}${draft}${review} (${pr.branch} → ${pr.base}) ${changes} @${pr.author}`;
+			})
+			.join("\n");
+	}
+
+	const prNumbers = prs.map((p) => p.number);
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "pr-list", output, prNumbers } as GhDetails,
+	};
+}
+
+/**
+ * View pull request details
+ */
+export async function handlePRView(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for pr-view action" }],
+			details: { action: "pr-view", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Fetching PR #${params.number}...` }] });
+
+	const fields = `${PR_LIST_FIELDS},body,mergeStateStatus,statusCheckRollup,reviews,comments`;
+	const result = await pi.exec("gh", ["pr", "view", String(params.number), "--json", fields], {
+		signal,
+		timeout: 30000,
+	});
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "View PR") }],
+			details: { action: "pr-view", error: result.stderr, prNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	let data: any;
+	try {
+		data = JSON.parse(result.stdout);
+	} catch {
+		return {
+			content: [{ type: "text", text: result.stdout }],
+			details: { action: "pr-view", output: result.stdout, prNumber: params.number } as GhDetails,
+		};
+	}
+
+	const pr = parsePRItem(data);
+
+	// Build readable output
+	let output = "";
+	output += `# PR #${pr.number}: ${pr.title}\n\n`;
+	output += `State: ${pr.state}${pr.isDraft ? " (draft)" : ""}\n`;
+	output += `Author: @${pr.author}\n`;
+	output += `Branch: ${pr.branch} → ${pr.base}\n`;
+	output += `Review: ${getReviewDecisionText(pr.reviewDecision)}\n`;
+	output += `Changes: ${pr.changedFiles} files (+${pr.additions}/-${pr.deletions})\n`;
+	output += `URL: ${pr.url}\n`;
+
+	if (pr.labels.length > 0) {
+		output += `Labels: ${pr.labels.join(", ")}\n`;
+	}
+
+	if (data.body) {
+		output += `\n## Description\n\n${data.body}\n`;
+	}
+
+	// Checks summary
+	const checks = data.statusCheckRollup ?? [];
+	if (checks.length > 0) {
+		const passed = checks.filter((c: any) => c.conclusion === "SUCCESS").length;
+		const failed = checks.filter((c: any) => c.conclusion === "FAILURE").length;
+		const pending = checks.filter((c: any) => !c.conclusion || c.status === "IN_PROGRESS" || c.status === "QUEUED").length;
+		output += `\n## Checks: ${passed} passed, ${failed} failed, ${pending} pending\n\n`;
+		for (const check of checks) {
+			const icon = check.conclusion === "SUCCESS" ? "✓" : check.conclusion === "FAILURE" ? "✗" : "⏳";
+			output += `${icon} ${check.name ?? check.context ?? "?"} (${check.conclusion || check.status || "pending"})\n`;
+		}
+	}
+
+	// Reviews summary
+	const reviews = data.reviews ?? [];
+	if (reviews.length > 0) {
+		output += `\n## Reviews\n\n`;
+		for (const review of reviews) {
+			output += `@${review.author?.login ?? "?"}: ${review.state}`;
+			if (review.body) output += ` - ${truncate(review.body, 100)}`;
+			output += "\n";
+		}
+	}
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "pr-view", output, prNumber: pr.number, prUrl: pr.url } as GhDetails,
+	};
+}
+
+/**
+ * View PR diff
+ */
+export async function handlePRDiff(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for pr-diff action" }],
+			details: { action: "pr-diff", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Fetching diff for PR #${params.number}...` }] });
+
+	const result = await pi.exec("gh", ["pr", "diff", String(params.number)], { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "PR diff") }],
+			details: { action: "pr-diff", error: result.stderr, prNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const diff = result.stdout;
+	// Truncate if very large
+	const maxLen = 50000;
+	const output = diff.length > maxLen ? diff.slice(0, maxLen) + "\n\n[... diff truncated, use `gh pr diff` for full output]" : diff;
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "pr-diff", output: `Diff for PR #${params.number} (${diff.length} chars)`, prNumber: params.number } as GhDetails,
+	};
+}
+
+/**
+ * Create pull request (requires approval)
+ */
+export async function handlePRCreate(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.title) {
+		return {
+			content: [{ type: "text", text: "Error: 'title' parameter is required for pr-create action" }],
+			details: { action: "pr-create", error: "missing_title" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildPRCreateConfirmation(params);
+		const confirmed = await ctx.ui.confirm("Create Pull Request?", confirmMessage);
+		if (!confirmed) {
+			ctx.ui.notify("PR creation cancelled", "info");
+			return {
+				content: [{ type: "text", text: "PR creation cancelled by user" }],
+				details: { action: "pr-create", cancelled: true } as GhDetails,
+			};
+		}
+	}
+
+	const args = ["pr", "create", "--title", params.title];
+
+	if (params.body) args.push("--body", params.body);
+	if (params.base) args.push("--base", params.base);
+	if (params.draft) args.push("--draft");
+	if (params.labels?.length) {
+		for (const label of params.labels) args.push("--label", label);
+	}
+	if (params.reviewers?.length) {
+		for (const reviewer of params.reviewers) args.push("--reviewer", reviewer);
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: "Creating PR..." }] });
+
+	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Create PR") }],
+			details: { action: "pr-create", error: result.stderr } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const prNumber = extractPRNumber(result.stdout);
+	const prUrl = extractPRUrl(result.stdout) || result.stdout.trim();
+
+	return {
+		content: [{ type: "text", text: `Created PR${prNumber ? ` #${prNumber}` : ""}: ${prUrl}` }],
+		details: { action: "pr-create", output: result.stdout.trim(), prNumber: prNumber ?? undefined, prUrl: prUrl ?? undefined } as GhDetails,
+	};
+}
+
+/**
+ * Checkout PR locally
+ */
+export async function handlePRCheckout(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for pr-checkout action" }],
+			details: { action: "pr-checkout", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Checking out PR #${params.number}...` }] });
+
+	const result = await pi.exec("gh", ["pr", "checkout", String(params.number)], { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Checkout PR") }],
+			details: { action: "pr-checkout", error: result.stderr, prNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Checked out PR #${params.number}\n${result.stdout.trim()}` }],
+		details: { action: "pr-checkout", output: result.stdout.trim(), prNumber: params.number } as GhDetails,
+	};
+}
+
+/**
+ * Merge pull request (requires approval)
+ */
+export async function handlePRMerge(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for pr-merge action" }],
+			details: { action: "pr-merge", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildPRMergeConfirmation(params);
+		const confirmed = await ctx.ui.confirm(`Merge PR #${params.number}?`, confirmMessage);
+		if (!confirmed) {
+			ctx.ui.notify("Merge cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Merge cancelled by user" }],
+				details: { action: "pr-merge", cancelled: true, prNumber: params.number } as GhDetails,
+			};
+		}
+	}
+
+	const args = ["pr", "merge", String(params.number)];
+
+	const method = params.method || "merge";
+	if (method === "squash") args.push("--squash");
+	else if (method === "rebase") args.push("--rebase");
+	else args.push("--merge");
+
+	if (params.deleteBranch) args.push("--delete-branch");
+
+	onUpdate?.({ content: [{ type: "text", text: `Merging PR #${params.number}...` }] });
+
+	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Merge PR") }],
+			details: { action: "pr-merge", error: result.stderr, prNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Merged PR #${params.number} (${method})\n${result.stdout.trim()}` }],
+		details: { action: "pr-merge", output: result.stdout.trim(), prNumber: params.number, mergeMethod: method } as GhDetails,
+	};
+}
+
+/**
+ * Submit PR review (requires approval)
+ */
+export async function handlePRReview(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for pr-review action" }],
+			details: { action: "pr-review", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.reviewAction) {
+		return {
+			content: [{ type: "text", text: "Error: 'reviewAction' parameter is required (approve, request-changes, comment)" }],
+			details: { action: "pr-review", error: "missing_review_action" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildReviewConfirmation(params);
+		const confirmed = await ctx.ui.confirm(`Submit review on PR #${params.number}?`, confirmMessage);
+		if (!confirmed) {
+			ctx.ui.notify("Review cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Review cancelled by user" }],
+				details: { action: "pr-review", cancelled: true, prNumber: params.number } as GhDetails,
+			};
+		}
+	}
+
+	const args = ["pr", "review", String(params.number)];
+
+	switch (params.reviewAction) {
+		case "approve":
+			args.push("--approve");
+			break;
+		case "request-changes":
+			args.push("--request-changes");
+			break;
+		case "comment":
+			args.push("--comment");
+			break;
+	}
+
+	if (params.body) args.push("--body", params.body);
+
+	onUpdate?.({ content: [{ type: "text", text: `Submitting review on PR #${params.number}...` }] });
+
+	const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Submit review") }],
+			details: { action: "pr-review", error: result.stderr, prNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Submitted ${params.reviewAction} review on PR #${params.number}` }],
+		details: {
+			action: "pr-review",
+			output: result.stdout.trim(),
+			prNumber: params.number,
+			reviewAction: params.reviewAction,
+		} as GhDetails,
+	};
+}
+
+/**
+ * Comment on a PR (requires approval)
+ */
+export async function handlePRComment(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for pr-comment action" }],
+			details: { action: "pr-comment", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	if (!params.body) {
+		return {
+			content: [{ type: "text", text: "Error: 'body' parameter is required for pr-comment action" }],
+			details: { action: "pr-comment", error: "missing_body" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmMessage = buildCommentConfirmation("PR", params.number, params.body);
+		const confirmed = await ctx.ui.confirm(`Comment on PR #${params.number}?`, confirmMessage);
+		if (!confirmed) {
+			ctx.ui.notify("Comment cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Comment cancelled by user" }],
+				details: { action: "pr-comment", cancelled: true, prNumber: params.number } as GhDetails,
+			};
+		}
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Adding comment to PR #${params.number}...` }] });
+
+	const result = await pi.exec("gh", ["pr", "comment", String(params.number), "--body", params.body], {
+		signal,
+		timeout: 20000,
+	});
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Comment on PR") }],
+			details: { action: "pr-comment", error: result.stderr, prNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Added comment to PR #${params.number}` }],
+		details: { action: "pr-comment", output: result.stdout.trim(), prNumber: params.number } as GhDetails,
+	};
+}
+
+/**
+ * Mark draft PR as ready for review (requires approval)
+ */
+export async function handlePRReady(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for pr-ready action" }],
+			details: { action: "pr-ready", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmed = await ctx.ui.confirm(
+			`Mark PR #${params.number} as ready?`,
+			"This will mark the draft PR as ready for review.",
+		);
+		if (!confirmed) {
+			ctx.ui.notify("Cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Operation cancelled by user" }],
+				details: { action: "pr-ready", cancelled: true, prNumber: params.number } as GhDetails,
+			};
+		}
+	}
+
+	onUpdate?.({ content: [{ type: "text", text: `Marking PR #${params.number} as ready...` }] });
+
+	const result = await pi.exec("gh", ["pr", "ready", String(params.number)], { signal, timeout: 20000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Mark PR ready") }],
+			details: { action: "pr-ready", error: result.stderr, prNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `PR #${params.number} marked as ready for review` }],
+		details: { action: "pr-ready", output: result.stdout.trim(), prNumber: params.number } as GhDetails,
+	};
+}
+
+/**
+ * Close a PR (requires approval)
+ */
+export async function handlePRClose(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	if (!params.number) {
+		return {
+			content: [{ type: "text", text: "Error: 'number' parameter is required for pr-close action" }],
+			details: { action: "pr-close", error: "missing_number" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	// APPROVAL GATE
+	if (ctx.hasUI) {
+		const confirmed = await ctx.ui.confirm(
+			`Close PR #${params.number}?`,
+			"This will close the pull request without merging.",
+		);
+		if (!confirmed) {
+			ctx.ui.notify("Cancelled", "info");
+			return {
+				content: [{ type: "text", text: "Close cancelled by user" }],
+				details: { action: "pr-close", cancelled: true, prNumber: params.number } as GhDetails,
+			};
+		}
+	}
+
+	const result = await pi.exec("gh", ["pr", "close", String(params.number)], { signal, timeout: 20000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "Close PR") }],
+			details: { action: "pr-close", error: result.stderr, prNumber: params.number } as GhDetails,
+			isError: true,
+		};
+	}
+
+	return {
+		content: [{ type: "text", text: `Closed PR #${params.number}` }],
+		details: { action: "pr-close", output: result.stdout.trim(), prNumber: params.number } as GhDetails,
+	};
+}
dots/pi/agent/extensions/github/actions/repo.ts
@@ -0,0 +1,123 @@
+/**
+ * Repository action handlers for GitHub extension
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import type { GhDetails } from "../types";
+import { parseRepo, parseReleaseList, getErrorMessage, formatRelativeDate } from "../utils";
+
+/**
+ * View repository info
+ */
+export async function handleRepoView(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	onUpdate?.({ content: [{ type: "text", text: "Fetching repo info..." }] });
+
+	const result = await pi.exec(
+		"gh",
+		[
+			"repo",
+			"view",
+			"--json",
+			"nameWithOwner,description,defaultBranchRef,visibility,url,stargazerCount,forkCount,isArchived",
+		],
+		{ signal, timeout: 20000 },
+	);
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "View repo") }],
+			details: { action: "repo-view", error: result.stderr } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const repo = parseRepo(result.stdout);
+	if (!repo) {
+		return {
+			content: [{ type: "text", text: "Could not parse repository information" }],
+			details: { action: "repo-view", error: "parse_error" } as GhDetails,
+			isError: true,
+		};
+	}
+
+	let output = `# ${repo.nameWithOwner}\n\n`;
+	if (repo.description) output += `${repo.description}\n\n`;
+	output += `Visibility: ${repo.visibility}\n`;
+	output += `Default branch: ${repo.defaultBranch}\n`;
+	output += `Stars: ${repo.stargazerCount} | Forks: ${repo.forkCount}\n`;
+	output += `URL: ${repo.url}\n`;
+	if (repo.isArchived) output += `⚠️ This repository is archived\n`;
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "repo-view", output } as GhDetails,
+	};
+}
+
+/**
+ * List releases
+ */
+export async function handleReleaseList(
+	pi: ExtensionAPI,
+	params: any,
+	signal: AbortSignal | undefined,
+	onUpdate: any,
+	ctx: ExtensionContext,
+): Promise<any> {
+	const args = [
+		"release",
+		"list",
+		"--json",
+		"tagName,name,isDraft,isPrerelease,publishedAt",
+	];
+
+	if (params.limit) args.push("--limit", String(params.limit));
+	else args.push("--limit", "10");
+
+	onUpdate?.({ content: [{ type: "text", text: "Fetching releases..." }] });
+
+	const result = await pi.exec("gh", args, { signal, timeout: 20000 });
+
+	if (result.code !== 0) {
+		return {
+			content: [{ type: "text", text: getErrorMessage(result.stderr, "List releases") }],
+			details: { action: "release-list", error: result.stderr } as GhDetails,
+			isError: true,
+		};
+	}
+
+	const releases = parseReleaseList(result.stdout);
+
+	if (releases.length === 0) {
+		return {
+			content: [{ type: "text", text: "No releases found." }],
+			details: { action: "release-list", output: "No releases found" } as GhDetails,
+		};
+	}
+
+	let output = `Releases (${releases.length}):\n\n`;
+	for (const release of releases) {
+		const flags: string[] = [];
+		if (release.isDraft) flags.push("draft");
+		if (release.isPrerelease) flags.push("pre-release");
+		const flagStr = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
+		const date = formatRelativeDate(release.publishedAt);
+
+		output += `${release.tagName}`;
+		if (release.name && release.name !== release.tagName) output += ` - ${release.name}`;
+		output += `${flagStr}`;
+		if (date) output += ` (${date})`;
+		output += "\n";
+	}
+
+	return {
+		content: [{ type: "text", text: output }],
+		details: { action: "release-list", output } as GhDetails,
+	};
+}
dots/pi/agent/extensions/github/github.test.ts
@@ -0,0 +1,655 @@
+/**
+ * Tests for GitHub extension
+ *
+ * Run with: bun test github.test.ts
+ */
+
+import { describe, expect, test } from "bun:test";
+import {
+	parsePRList,
+	parsePRItem,
+	parseIssueList,
+	parseIssueItem,
+	parseChecks,
+	parseRunList,
+	parseReviews,
+	parseReleaseList,
+	parseRepo,
+	truncate,
+	formatDate,
+	formatRelativeDate,
+	getPRStateIcon,
+	getCheckIcon,
+	getRunStatusIcon,
+	getReviewDecisionText,
+	buildPRCreateConfirmation,
+	buildPRMergeConfirmation,
+	buildReviewConfirmation,
+	buildIssueCreateConfirmation,
+	buildCommentConfirmation,
+	isAuthError,
+	isNotFoundError,
+	isRepoError,
+	getErrorMessage,
+	extractPRNumber,
+	extractIssueNumber,
+	extractPRUrl,
+	extractIssueUrl,
+} from "./utils";
+
+// ============================================================================
+// Parsing Tests
+// ============================================================================
+
+describe("PR Parsing", () => {
+	test("parsePRList parses JSON array", () => {
+		const json = JSON.stringify([
+			{
+				number: 123,
+				title: "feat: add feature",
+				state: "OPEN",
+				author: { login: "alice" },
+				headRefName: "feat/feature",
+				baseRefName: "main",
+				url: "https://github.com/org/repo/pull/123",
+				isDraft: false,
+				labels: [{ name: "enhancement" }],
+				reviewDecision: "APPROVED",
+				additions: 50,
+				deletions: 10,
+				changedFiles: 3,
+				createdAt: "2025-01-01T00:00:00Z",
+				updatedAt: "2025-01-02T00:00:00Z",
+			},
+		]);
+
+		const prs = parsePRList(json);
+		expect(prs.length).toBe(1);
+		expect(prs[0].number).toBe(123);
+		expect(prs[0].title).toBe("feat: add feature");
+		expect(prs[0].author).toBe("alice");
+		expect(prs[0].branch).toBe("feat/feature");
+		expect(prs[0].base).toBe("main");
+		expect(prs[0].isDraft).toBe(false);
+		expect(prs[0].labels).toEqual(["enhancement"]);
+		expect(prs[0].reviewDecision).toBe("APPROVED");
+		expect(prs[0].additions).toBe(50);
+		expect(prs[0].deletions).toBe(10);
+		expect(prs[0].changedFiles).toBe(3);
+	});
+
+	test("parsePRList handles empty array", () => {
+		expect(parsePRList("[]")).toEqual([]);
+	});
+
+	test("parsePRList handles invalid JSON", () => {
+		expect(parsePRList("not json")).toEqual([]);
+	});
+
+	test("parsePRItem handles missing fields gracefully", () => {
+		const pr = parsePRItem({});
+		expect(pr.number).toBe(0);
+		expect(pr.title).toBe("");
+		expect(pr.author).toBe("");
+		expect(pr.isDraft).toBe(false);
+		expect(pr.labels).toEqual([]);
+		expect(pr.additions).toBe(0);
+	});
+
+	test("parsePRList handles draft PRs", () => {
+		const json = JSON.stringify([
+			{
+				number: 456,
+				title: "WIP: draft PR",
+				state: "OPEN",
+				author: { login: "bob" },
+				isDraft: true,
+				headRefName: "wip",
+				baseRefName: "main",
+			},
+		]);
+
+		const prs = parsePRList(json);
+		expect(prs[0].isDraft).toBe(true);
+	});
+
+	test("parsePRList handles merged PRs", () => {
+		const json = JSON.stringify([
+			{
+				number: 789,
+				title: "Merged PR",
+				state: "MERGED",
+				author: { login: "carol" },
+				headRefName: "merged-branch",
+				baseRefName: "main",
+			},
+		]);
+
+		const prs = parsePRList(json);
+		expect(prs[0].state).toBe("MERGED");
+	});
+});
+
+describe("Issue Parsing", () => {
+	test("parseIssueList parses JSON array", () => {
+		const json = JSON.stringify([
+			{
+				number: 42,
+				title: "Bug: login broken",
+				state: "OPEN",
+				author: { login: "alice" },
+				url: "https://github.com/org/repo/issues/42",
+				labels: [{ name: "bug" }, { name: "priority:high" }],
+				assignees: [{ login: "bob" }],
+				createdAt: "2025-01-01T00:00:00Z",
+				updatedAt: "2025-01-02T00:00:00Z",
+				body: "Login is broken on mobile",
+				comments: { totalCount: 5 },
+			},
+		]);
+
+		const issues = parseIssueList(json);
+		expect(issues.length).toBe(1);
+		expect(issues[0].number).toBe(42);
+		expect(issues[0].title).toBe("Bug: login broken");
+		expect(issues[0].state).toBe("OPEN");
+		expect(issues[0].labels).toEqual(["bug", "priority:high"]);
+		expect(issues[0].assignees).toEqual(["bob"]);
+		expect(issues[0].comments).toBe(5);
+	});
+
+	test("parseIssueList handles empty array", () => {
+		expect(parseIssueList("[]")).toEqual([]);
+	});
+
+	test("parseIssueList handles invalid JSON", () => {
+		expect(parseIssueList("invalid")).toEqual([]);
+	});
+
+	test("parseIssueItem handles missing fields", () => {
+		const issue = parseIssueItem({});
+		expect(issue.number).toBe(0);
+		expect(issue.title).toBe("");
+		expect(issue.labels).toEqual([]);
+		expect(issue.assignees).toEqual([]);
+		expect(issue.comments).toBe(0);
+	});
+
+	test("parseIssueItem handles numeric comments", () => {
+		const issue = parseIssueItem({ comments: 10 });
+		expect(issue.comments).toBe(10);
+	});
+});
+
+describe("Checks Parsing", () => {
+	test("parseChecks parses statusCheckRollup object", () => {
+		const json = JSON.stringify({
+			statusCheckRollup: [
+				{
+					name: "CI Tests",
+					status: "COMPLETED",
+					conclusion: "SUCCESS",
+					startedAt: "2025-01-01T00:00:00Z",
+					completedAt: "2025-01-01T00:05:00Z",
+					detailsUrl: "https://github.com/org/repo/actions/runs/123",
+				},
+				{
+					name: "Lint",
+					status: "COMPLETED",
+					conclusion: "FAILURE",
+					startedAt: "2025-01-01T00:00:00Z",
+					completedAt: "2025-01-01T00:02:00Z",
+				},
+				{
+					name: "E2E",
+					status: "IN_PROGRESS",
+					conclusion: "",
+				},
+			],
+		});
+
+		const checks = parseChecks(json);
+		expect(checks.length).toBe(3);
+		expect(checks[0].name).toBe("CI Tests");
+		expect(checks[0].conclusion).toBe("SUCCESS");
+		expect(checks[1].name).toBe("Lint");
+		expect(checks[1].conclusion).toBe("FAILURE");
+		expect(checks[2].name).toBe("E2E");
+		expect(checks[2].status).toBe("IN_PROGRESS");
+	});
+
+	test("parseChecks handles plain array", () => {
+		const json = JSON.stringify([
+			{ name: "test", status: "COMPLETED", conclusion: "SUCCESS" },
+		]);
+
+		const checks = parseChecks(json);
+		expect(checks.length).toBe(1);
+		expect(checks[0].name).toBe("test");
+	});
+
+	test("parseChecks handles context field (status checks)", () => {
+		const json = JSON.stringify({
+			statusCheckRollup: [
+				{ context: "ci/jenkins", status: "COMPLETED", conclusion: "SUCCESS", targetUrl: "https://ci.example.com" },
+			],
+		});
+
+		const checks = parseChecks(json);
+		expect(checks[0].name).toBe("ci/jenkins");
+		expect(checks[0].detailsUrl).toBe("https://ci.example.com");
+	});
+
+	test("parseChecks handles empty", () => {
+		expect(parseChecks("{}")).toEqual([]);
+		expect(parseChecks("invalid")).toEqual([]);
+	});
+});
+
+describe("Run Parsing", () => {
+	test("parseRunList parses JSON array", () => {
+		const json = JSON.stringify([
+			{
+				databaseId: 12345,
+				name: "CI",
+				displayTitle: "feat: add feature",
+				status: "completed",
+				conclusion: "success",
+				headBranch: "main",
+				event: "push",
+				url: "https://github.com/org/repo/actions/runs/12345",
+				createdAt: "2025-01-01T00:00:00Z",
+				updatedAt: "2025-01-01T00:05:00Z",
+			},
+		]);
+
+		const runs = parseRunList(json);
+		expect(runs.length).toBe(1);
+		expect(runs[0].databaseId).toBe(12345);
+		expect(runs[0].name).toBe("CI");
+		expect(runs[0].conclusion).toBe("success");
+		expect(runs[0].headBranch).toBe("main");
+	});
+
+	test("parseRunList handles empty", () => {
+		expect(parseRunList("[]")).toEqual([]);
+		expect(parseRunList("invalid")).toEqual([]);
+	});
+});
+
+describe("Review Parsing", () => {
+	test("parseReviews parses reviews object", () => {
+		const json = JSON.stringify({
+			reviews: [
+				{
+					author: { login: "alice" },
+					state: "APPROVED",
+					body: "LGTM",
+					submittedAt: "2025-01-01T00:00:00Z",
+				},
+				{
+					author: { login: "bob" },
+					state: "CHANGES_REQUESTED",
+					body: "Please fix the error handling",
+					submittedAt: "2025-01-01T01:00:00Z",
+				},
+			],
+		});
+
+		const reviews = parseReviews(json);
+		expect(reviews.length).toBe(2);
+		expect(reviews[0].author).toBe("alice");
+		expect(reviews[0].state).toBe("APPROVED");
+		expect(reviews[1].author).toBe("bob");
+		expect(reviews[1].state).toBe("CHANGES_REQUESTED");
+	});
+
+	test("parseReviews handles empty", () => {
+		expect(parseReviews("{}")).toEqual([]);
+	});
+});
+
+describe("Release Parsing", () => {
+	test("parseReleaseList parses JSON", () => {
+		const json = JSON.stringify([
+			{
+				tagName: "v1.0.0",
+				name: "Release 1.0.0",
+				isDraft: false,
+				isPrerelease: false,
+				publishedAt: "2025-01-01T00:00:00Z",
+				url: "https://github.com/org/repo/releases/tag/v1.0.0",
+			},
+		]);
+
+		const releases = parseReleaseList(json);
+		expect(releases.length).toBe(1);
+		expect(releases[0].tagName).toBe("v1.0.0");
+		expect(releases[0].name).toBe("Release 1.0.0");
+		expect(releases[0].isDraft).toBe(false);
+	});
+
+	test("parseReleaseList handles empty", () => {
+		expect(parseReleaseList("[]")).toEqual([]);
+		expect(parseReleaseList("invalid")).toEqual([]);
+	});
+});
+
+describe("Repo Parsing", () => {
+	test("parseRepo parses repo JSON", () => {
+		const json = JSON.stringify({
+			nameWithOwner: "org/repo",
+			description: "A test repo",
+			defaultBranchRef: { name: "main" },
+			visibility: "PUBLIC",
+			url: "https://github.com/org/repo",
+			stargazerCount: 100,
+			forkCount: 20,
+			isArchived: false,
+		});
+
+		const repo = parseRepo(json);
+		expect(repo).not.toBeNull();
+		expect(repo!.nameWithOwner).toBe("org/repo");
+		expect(repo!.description).toBe("A test repo");
+		expect(repo!.defaultBranch).toBe("main");
+		expect(repo!.visibility).toBe("PUBLIC");
+		expect(repo!.stargazerCount).toBe(100);
+		expect(repo!.isArchived).toBe(false);
+	});
+
+	test("parseRepo handles defaultBranch string", () => {
+		const json = JSON.stringify({ defaultBranch: "develop" });
+		const repo = parseRepo(json);
+		expect(repo!.defaultBranch).toBe("develop");
+	});
+
+	test("parseRepo handles invalid JSON", () => {
+		expect(parseRepo("invalid")).toBeNull();
+	});
+});
+
+// ============================================================================
+// Formatting Tests
+// ============================================================================
+
+describe("Formatting", () => {
+	test("truncate shortens long text", () => {
+		expect(truncate("This is a very long text", 15)).toBe("This is a ve...");
+		expect(truncate("This is a very long text", 15).length).toBe(15);
+	});
+
+	test("truncate preserves short text", () => {
+		expect(truncate("Short", 20)).toBe("Short");
+	});
+
+	test("truncate handles exact length", () => {
+		expect(truncate("Exactly 10", 10)).toBe("Exactly 10");
+	});
+
+	test("formatDate formats ISO date", () => {
+		const result = formatDate("2025-06-15T10:30:00Z");
+		expect(result).toBeTruthy();
+		expect(result).not.toBe("");
+	});
+
+	test("formatDate handles empty", () => {
+		expect(formatDate("")).toBe("");
+	});
+
+	test("formatRelativeDate returns relative time", () => {
+		const now = new Date();
+		const fiveMinAgo = new Date(now.getTime() - 5 * 60000).toISOString();
+		expect(formatRelativeDate(fiveMinAgo)).toBe("5m ago");
+
+		const twoHoursAgo = new Date(now.getTime() - 2 * 3600000).toISOString();
+		expect(formatRelativeDate(twoHoursAgo)).toBe("2h ago");
+
+		const threeDaysAgo = new Date(now.getTime() - 3 * 86400000).toISOString();
+		expect(formatRelativeDate(threeDaysAgo)).toBe("3d ago");
+	});
+
+	test("formatRelativeDate handles just now", () => {
+		const now = new Date().toISOString();
+		const result = formatRelativeDate(now);
+		expect(result === "just now" || result === "1m ago").toBe(true);
+	});
+
+	test("formatRelativeDate handles empty", () => {
+		expect(formatRelativeDate("")).toBe("");
+	});
+});
+
+// ============================================================================
+// Icon/Status Tests
+// ============================================================================
+
+describe("Status Icons", () => {
+	test("getPRStateIcon returns correct icons", () => {
+		expect(getPRStateIcon({ state: "MERGED" } as any)).toBe("⏣");
+		expect(getPRStateIcon({ state: "CLOSED" } as any)).toBe("✗");
+		expect(getPRStateIcon({ state: "OPEN", isDraft: true } as any)).toBe("◌");
+		expect(getPRStateIcon({ state: "OPEN", isDraft: false } as any)).toBe("●");
+	});
+
+	test("getCheckIcon returns correct icons", () => {
+		expect(getCheckIcon({ conclusion: "SUCCESS" } as any)).toBe("✓");
+		expect(getCheckIcon({ conclusion: "FAILURE" } as any)).toBe("✗");
+		expect(getCheckIcon({ conclusion: "CANCELLED" } as any)).toBe("⊘");
+		expect(getCheckIcon({ conclusion: "SKIPPED" } as any)).toBe("⊘");
+		expect(getCheckIcon({ status: "IN_PROGRESS", conclusion: "" } as any)).toBe("⏳");
+		expect(getCheckIcon({ status: "QUEUED", conclusion: "" } as any)).toBe("⏳");
+	});
+
+	test("getRunStatusIcon returns correct icons", () => {
+		expect(getRunStatusIcon({ conclusion: "success" } as any)).toBe("✓");
+		expect(getRunStatusIcon({ conclusion: "failure" } as any)).toBe("✗");
+		expect(getRunStatusIcon({ conclusion: "cancelled" } as any)).toBe("⊘");
+		expect(getRunStatusIcon({ status: "in_progress", conclusion: "" } as any)).toBe("⏳");
+	});
+
+	test("getReviewDecisionText returns human-readable text", () => {
+		expect(getReviewDecisionText("APPROVED")).toBe("✓ Approved");
+		expect(getReviewDecisionText("CHANGES_REQUESTED")).toBe("✗ Changes requested");
+		expect(getReviewDecisionText("REVIEW_REQUIRED")).toBe("⏳ Review required");
+		expect(getReviewDecisionText("")).toBe("No reviews");
+	});
+});
+
+// ============================================================================
+// Confirmation Builder Tests
+// ============================================================================
+
+describe("Confirmation Builders", () => {
+	test("buildPRCreateConfirmation includes all fields", () => {
+		const msg = buildPRCreateConfirmation({
+			title: "feat: add feature",
+			body: "Description",
+			base: "main",
+			draft: true,
+			labels: ["enhancement"],
+			reviewers: ["alice"],
+		});
+
+		expect(msg).toContain("feat: add feature");
+		expect(msg).toContain("Base: main");
+		expect(msg).toContain("Body: Description");
+		expect(msg).toContain("Draft: yes");
+		expect(msg).toContain("Labels: enhancement");
+		expect(msg).toContain("Reviewers: alice");
+		expect(msg).toContain("create a new pull request");
+	});
+
+	test("buildPRCreateConfirmation handles minimal", () => {
+		const msg = buildPRCreateConfirmation({ title: "fix: bug" });
+		expect(msg).toContain("fix: bug");
+		expect(msg).not.toContain("Base:");
+		expect(msg).not.toContain("Draft:");
+	});
+
+	test("buildPRCreateConfirmation truncates long body", () => {
+		const msg = buildPRCreateConfirmation({
+			title: "test",
+			body: "a".repeat(300),
+		});
+		expect(msg).toContain("...");
+	});
+
+	test("buildPRMergeConfirmation includes method", () => {
+		const msg = buildPRMergeConfirmation({ number: 123, method: "squash", deleteBranch: true });
+		expect(msg).toContain("#123");
+		expect(msg).toContain("squash");
+		expect(msg).toContain("Delete branch: yes");
+	});
+
+	test("buildReviewConfirmation includes action", () => {
+		const msg = buildReviewConfirmation({ number: 456, reviewAction: "approve", body: "LGTM" });
+		expect(msg).toContain("#456");
+		expect(msg).toContain("approve");
+		expect(msg).toContain("LGTM");
+	});
+
+	test("buildIssueCreateConfirmation includes all fields", () => {
+		const msg = buildIssueCreateConfirmation({
+			title: "Bug report",
+			body: "Steps to reproduce",
+			labels: ["bug"],
+			assignees: ["alice"],
+		});
+
+		expect(msg).toContain("Bug report");
+		expect(msg).toContain("Steps to reproduce");
+		expect(msg).toContain("bug");
+		expect(msg).toContain("alice");
+	});
+
+	test("buildCommentConfirmation includes preview", () => {
+		const msg = buildCommentConfirmation("PR", 123, "Great work!");
+		expect(msg).toContain("PR: #123");
+		expect(msg).toContain("Great work!");
+		expect(msg).toContain("public comment");
+	});
+
+	test("buildCommentConfirmation truncates long comment", () => {
+		const msg = buildCommentConfirmation("Issue", 42, "a".repeat(300));
+		expect(msg).toContain("...");
+	});
+});
+
+// ============================================================================
+// Error Handling Tests
+// ============================================================================
+
+describe("Error Handling", () => {
+	test("isAuthError detects auth failures", () => {
+		expect(isAuthError("authentication required")).toBe(true);
+		expect(isAuthError("unauthorized")).toBe(true);
+		expect(isAuthError("not logged in to any github hosts")).toBe(true);
+		expect(isAuthError("try: gh auth login")).toBe(true);
+		expect(isAuthError("network timeout")).toBe(false);
+	});
+
+	test("isNotFoundError detects not found", () => {
+		expect(isNotFoundError("not found")).toBe(true);
+		expect(isNotFoundError("could not resolve to a repository")).toBe(true);
+		expect(isNotFoundError("authentication failed")).toBe(false);
+	});
+
+	test("isRepoError detects repo errors", () => {
+		expect(isRepoError("not a git repository")).toBe(true);
+		expect(isRepoError("no git remotes found")).toBe(true);
+		expect(isRepoError("authentication failed")).toBe(false);
+	});
+
+	test("getErrorMessage returns helpful messages", () => {
+		expect(getErrorMessage("not logged in", "list")).toContain("gh auth login");
+		expect(getErrorMessage("not a git repository", "view")).toContain("Not in a GitHub repository");
+		expect(getErrorMessage("not found", "view")).toContain("not found");
+		expect(getErrorMessage("something else", "create")).toBe("something else");
+	});
+});
+
+// ============================================================================
+// Extraction Tests
+// ============================================================================
+
+describe("URL/Number Extraction", () => {
+	test("extractPRNumber from URL", () => {
+		expect(extractPRNumber("https://github.com/org/repo/pull/123")).toBe(123);
+	});
+
+	test("extractPRNumber from hash format", () => {
+		expect(extractPRNumber("Created PR #456")).toBe(456);
+	});
+
+	test("extractPRNumber returns null for no match", () => {
+		expect(extractPRNumber("no number here")).toBeNull();
+	});
+
+	test("extractIssueNumber from URL", () => {
+		expect(extractIssueNumber("https://github.com/org/repo/issues/42")).toBe(42);
+	});
+
+	test("extractIssueNumber from hash format", () => {
+		expect(extractIssueNumber("Created issue #99")).toBe(99);
+	});
+
+	test("extractIssueNumber returns null for no match", () => {
+		expect(extractIssueNumber("no number here")).toBeNull();
+	});
+
+	test("extractPRUrl extracts GitHub PR URL", () => {
+		const url = extractPRUrl("Created https://github.com/org/repo/pull/123 successfully");
+		expect(url).toBe("https://github.com/org/repo/pull/123");
+	});
+
+	test("extractPRUrl returns null for no match", () => {
+		expect(extractPRUrl("no url here")).toBeNull();
+	});
+
+	test("extractIssueUrl extracts GitHub issue URL", () => {
+		const url = extractIssueUrl("Created https://github.com/org/repo/issues/42 successfully");
+		expect(url).toBe("https://github.com/org/repo/issues/42");
+	});
+
+	test("extractIssueUrl returns null for no match", () => {
+		expect(extractIssueUrl("no url here")).toBeNull();
+	});
+});
+
+// ============================================================================
+// Auto-detection Pattern Tests
+// ============================================================================
+
+describe("Auto-detection Patterns", () => {
+	test("GitHub PR URL pattern matches", () => {
+		const pattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/pull\/(\d+)\/?$/;
+
+		expect("https://github.com/org/repo/pull/123".match(pattern)?.[1]).toBe("123");
+		expect("https://github.com/org/repo/pull/123/".match(pattern)?.[1]).toBe("123");
+		expect("https://github.com/my-org/my-repo/pull/456".match(pattern)?.[1]).toBe("456");
+	});
+
+	test("GitHub PR URL pattern doesn't match non-PRs", () => {
+		const pattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/pull\/(\d+)\/?$/;
+
+		expect("https://github.com/org/repo/issues/123".match(pattern)).toBeNull();
+		expect("https://github.com/org/repo/pull/".match(pattern)).toBeNull();
+		expect("https://github.com/org/repo".match(pattern)).toBeNull();
+		expect("not a url".match(pattern)).toBeNull();
+	});
+
+	test("GitHub issue URL pattern matches", () => {
+		const pattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)\/?$/;
+
+		expect("https://github.com/org/repo/issues/42".match(pattern)?.[1]).toBe("42");
+		expect("https://github.com/org/repo/issues/42/".match(pattern)?.[1]).toBe("42");
+	});
+
+	test("GitHub issue URL pattern doesn't match non-issues", () => {
+		const pattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)\/?$/;
+
+		expect("https://github.com/org/repo/pull/123".match(pattern)).toBeNull();
+		expect("https://github.com/org/repo/issues/".match(pattern)).toBeNull();
+	});
+});
dots/pi/agent/extensions/github/index.ts
@@ -0,0 +1,933 @@
+/**
+ * Pi Extension: GitHub Management
+ *
+ * Provides GitHub integration via the gh CLI with:
+ * - Read operations: PR list/view/diff, issue list/view, checks, runs, repo, releases
+ * - Write operations (with approval): PR create/merge/review/comment/close/ready,
+ *   issue create/close/comment/edit, checks restart
+ * - Custom rendering for PRs, issues, checks
+ * - Slash commands for instant results
+ * - Auto-detection of GitHub PR/issue URLs
+ *
+ * Requirements:
+ *   - gh CLI: https://cli.github.com/
+ *   - Authenticated: gh auth login
+ */
+
+import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
+import { Text } from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+
+import type { GhDetails } from "./types";
+import {
+	handlePRList,
+	handlePRView,
+	handlePRDiff,
+	handlePRCreate,
+	handlePRCheckout,
+	handlePRMerge,
+	handlePRReview,
+	handlePRComment,
+	handlePRReady,
+	handlePRClose,
+} from "./actions/pr";
+import {
+	handleChecks,
+	handleChecksLog,
+	handleChecksRestart,
+	handleRunList,
+	handleRunView,
+} from "./actions/checks";
+import {
+	handleIssueList,
+	handleIssueView,
+	handleIssueCreate,
+	handleIssueClose,
+	handleIssueComment,
+	handleIssueEdit,
+} from "./actions/issue";
+import { handleRepoView, handleReleaseList } from "./actions/repo";
+import {
+	parsePRList,
+	parseIssueList,
+	parseChecks,
+	truncate,
+	getPRStateIcon,
+	getCheckIcon,
+	getRunStatusIcon,
+	getReviewDecisionText,
+	formatRelativeDate,
+} from "./utils";
+
+export default function (pi: ExtensionAPI) {
+	// ========================================================================
+	// State Management
+	// ========================================================================
+
+	let currentUser = "";
+	let recentPRs: { number: number; title: string }[] = [];
+	let recentIssues: { number: number; title: string }[] = [];
+
+	const reconstructState = (ctx: ExtensionContext) => {
+		currentUser = "";
+		recentPRs = [];
+		recentIssues = [];
+
+		for (const entry of ctx.sessionManager.getBranch()) {
+			if (entry.type !== "message") continue;
+			const msg = entry.message;
+			if (msg.role !== "toolResult" || msg.toolName !== "github") continue;
+
+			const details = msg.details as GhDetails | undefined;
+			if (!details) continue;
+
+			// Track recent PR numbers
+			if (details.prNumber && !recentPRs.find((p) => p.number === details.prNumber)) {
+				recentPRs.push({ number: details.prNumber, title: "" });
+			}
+			if (details.prNumbers) {
+				for (const n of details.prNumbers) {
+					if (!recentPRs.find((p) => p.number === n)) {
+						recentPRs.push({ number: n, title: "" });
+					}
+				}
+			}
+
+			// Track recent issue numbers
+			if (details.issueNumber && !recentIssues.find((i) => i.number === details.issueNumber)) {
+				recentIssues.push({ number: details.issueNumber, title: "" });
+			}
+			if (details.issueNumbers) {
+				for (const n of details.issueNumbers) {
+					if (!recentIssues.find((i) => i.number === n)) {
+						recentIssues.push({ number: n, title: "" });
+					}
+				}
+			}
+		}
+
+		// Keep only last 20
+		if (recentPRs.length > 20) recentPRs = recentPRs.slice(-20);
+		if (recentIssues.length > 20) recentIssues = recentIssues.slice(-20);
+	};
+
+	pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
+	pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
+	pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
+	pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
+
+	// Helper: fetch current user lazily
+	async function ensureCurrentUser(signal?: AbortSignal): Promise<string> {
+		if (currentUser) return currentUser;
+		const result = await pi.exec("gh", ["api", "user", "--jq", ".login"], { signal, timeout: 10000 });
+		if (result.code === 0) {
+			currentUser = result.stdout.trim();
+		}
+		return currentUser;
+	}
+
+	// ========================================================================
+	// Tool Registration
+	// ========================================================================
+
+	pi.registerTool({
+		name: "github",
+		label: "GitHub",
+		description:
+			"Manage GitHub PRs, issues, checks, and runs via gh CLI. " +
+			"Actions: pr-list, pr-view, pr-diff, pr-create, pr-checkout, pr-merge, " +
+			"pr-review, pr-comment, pr-ready, pr-close, " +
+			"checks, checks-log, checks-restart, run-list, run-view, " +
+			"issue-list, issue-view, issue-create, issue-close, issue-comment, issue-edit, " +
+			"repo-view, release-list. " +
+			"Write operations (create, merge, review, comment, close, restart, edit) require user approval.",
+
+		parameters: Type.Object({
+			action: StringEnum([
+				"pr-list",
+				"pr-view",
+				"pr-diff",
+				"pr-create",
+				"pr-checkout",
+				"pr-merge",
+				"pr-review",
+				"pr-comment",
+				"pr-ready",
+				"pr-close",
+				"checks",
+				"checks-log",
+				"checks-restart",
+				"run-list",
+				"run-view",
+				"issue-list",
+				"issue-view",
+				"issue-create",
+				"issue-close",
+				"issue-comment",
+				"issue-edit",
+				"repo-view",
+				"release-list",
+			] as const),
+
+			// PR/Issue number
+			number: Type.Optional(Type.Number({ description: "PR or issue number" })),
+
+			// PR list filters
+			state: Type.Optional(Type.String({ description: "Filter by state: open, closed, merged, all" })),
+			author: Type.Optional(Type.String({ description: "Filter by author (username or 'me')" })),
+			label: Type.Optional(Type.String({ description: "Filter by label" })),
+			base: Type.Optional(Type.String({ description: "Filter PRs by base branch" })),
+			limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
+
+			// PR create
+			title: Type.Optional(Type.String({ description: "PR or issue title" })),
+			body: Type.Optional(Type.String({ description: "PR/issue body or comment text" })),
+			draft: Type.Optional(Type.Boolean({ description: "Create as draft PR" })),
+			reviewers: Type.Optional(Type.Array(Type.String(), { description: "PR reviewers to request" })),
+			labels: Type.Optional(Type.Array(Type.String(), { description: "Labels to add" })),
+
+			// PR merge
+			method: Type.Optional(Type.String({ description: "Merge method: merge, squash, rebase" })),
+			deleteBranch: Type.Optional(Type.Boolean({ description: "Delete branch after merge" })),
+
+			// Review
+			reviewAction: Type.Optional(Type.String({ description: "Review action: approve, request-changes, comment" })),
+
+			// Checks/Runs
+			runId: Type.Optional(Type.Number({ description: "Workflow run ID" })),
+			failedOnly: Type.Optional(Type.Boolean({ description: "Restart only failed jobs (default true)" })),
+			branch: Type.Optional(Type.String({ description: "Filter runs by branch" })),
+			status: Type.Optional(Type.String({ description: "Filter runs by status" })),
+			workflow: Type.Optional(Type.String({ description: "Filter runs by workflow name" })),
+
+			// Issue filters
+			assignee: Type.Optional(Type.String({ description: "Filter issues by assignee (or 'me')" })),
+			milestone: Type.Optional(Type.String({ description: "Filter issues by milestone" })),
+
+			// Issue edit
+			addLabels: Type.Optional(Type.Array(Type.String(), { description: "Labels to add" })),
+			removeLabels: Type.Optional(Type.Array(Type.String(), { description: "Labels to remove" })),
+			addAssignees: Type.Optional(Type.Array(Type.String(), { description: "Assignees to add" })),
+			removeAssignees: Type.Optional(Type.Array(Type.String(), { description: "Assignees to remove" })),
+
+			// Issue close
+			reason: Type.Optional(Type.String({ description: "Close reason: completed, not planned" })),
+		}),
+
+		async execute(toolCallId, params, signal, onUpdate, ctx) {
+			try {
+				switch (params.action) {
+					// PR actions
+					case "pr-list":
+						return await handlePRList(pi, params, signal, onUpdate, ctx, currentUser);
+					case "pr-view":
+						return await handlePRView(pi, params, signal, onUpdate, ctx);
+					case "pr-diff":
+						return await handlePRDiff(pi, params, signal, onUpdate, ctx);
+					case "pr-create":
+						return await handlePRCreate(pi, params, signal, onUpdate, ctx);
+					case "pr-checkout":
+						return await handlePRCheckout(pi, params, signal, onUpdate, ctx);
+					case "pr-merge":
+						return await handlePRMerge(pi, params, signal, onUpdate, ctx);
+					case "pr-review":
+						return await handlePRReview(pi, params, signal, onUpdate, ctx);
+					case "pr-comment":
+						return await handlePRComment(pi, params, signal, onUpdate, ctx);
+					case "pr-ready":
+						return await handlePRReady(pi, params, signal, onUpdate, ctx);
+					case "pr-close":
+						return await handlePRClose(pi, params, signal, onUpdate, ctx);
+
+					// Check/Run actions
+					case "checks":
+						return await handleChecks(pi, params, signal, onUpdate, ctx);
+					case "checks-log":
+						return await handleChecksLog(pi, params, signal, onUpdate, ctx);
+					case "checks-restart":
+						return await handleChecksRestart(pi, params, signal, onUpdate, ctx);
+					case "run-list":
+						return await handleRunList(pi, params, signal, onUpdate, ctx);
+					case "run-view":
+						return await handleRunView(pi, params, signal, onUpdate, ctx);
+
+					// Issue actions
+					case "issue-list":
+						return await handleIssueList(pi, params, signal, onUpdate, ctx, currentUser);
+					case "issue-view":
+						return await handleIssueView(pi, params, signal, onUpdate, ctx);
+					case "issue-create":
+						return await handleIssueCreate(pi, params, signal, onUpdate, ctx);
+					case "issue-close":
+						return await handleIssueClose(pi, params, signal, onUpdate, ctx);
+					case "issue-comment":
+						return await handleIssueComment(pi, params, signal, onUpdate, ctx);
+					case "issue-edit":
+						return await handleIssueEdit(pi, params, signal, onUpdate, ctx);
+
+					// Repo actions
+					case "repo-view":
+						return await handleRepoView(pi, params, signal, onUpdate, ctx);
+					case "release-list":
+						return await handleReleaseList(pi, params, signal, onUpdate, ctx);
+
+					default:
+						return {
+							content: [{ type: "text", text: `Unknown action: ${params.action}` }],
+							details: { action: params.action, error: "unknown_action" } as GhDetails,
+							isError: true,
+						};
+				}
+			} catch (error) {
+				return {
+					content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
+					details: { action: params.action, error: String(error) } as GhDetails,
+					isError: true,
+				};
+			}
+		},
+
+		// ====================================================================
+		// Custom Rendering
+		// ====================================================================
+
+		renderCall(args, theme) {
+			let text = theme.fg("toolTitle", theme.bold("github "));
+			text += theme.fg("muted", args.action);
+
+			if (args.number) {
+				text += " " + theme.fg("accent", `#${args.number}`);
+			}
+			if (args.runId) {
+				text += " " + theme.fg("accent", String(args.runId));
+			}
+			if (args.title) {
+				text += " " + theme.fg("dim", `"${truncate(args.title, 50)}"`);
+			}
+
+			return new Text(text, 0, 0);
+		},
+
+		renderResult(result, { expanded }, theme) {
+			const details = result.details as GhDetails | undefined;
+
+			if (!details) {
+				const text = result.content[0];
+				return new Text(text?.type === "text" ? text.text : "", 0, 0);
+			}
+
+			if (details.error) {
+				return new Text(theme.fg("error", `✗ Error: ${details.error}`), 0, 0);
+			}
+
+			if (details.cancelled) {
+				return new Text(theme.fg("warning", "✗ Cancelled by user"), 0, 0);
+			}
+
+			switch (details.action) {
+				case "pr-list":
+					return renderPRList(details, expanded, theme);
+				case "pr-view":
+					return renderLongOutput(details, expanded, theme, "PR");
+				case "pr-diff":
+					return renderDiff(details, expanded, theme);
+				case "pr-create":
+					return renderCreated(details, theme, "PR", details.prNumber, details.prUrl);
+				case "pr-checkout":
+					return new Text(theme.fg("success", `✓ Checked out PR #${details.prNumber}`), 0, 0);
+				case "pr-merge":
+					return new Text(
+						theme.fg("success", "✓ Merged ") +
+							theme.fg("accent", `#${details.prNumber}`) +
+							theme.fg("muted", ` (${details.mergeMethod || "merge"})`),
+						0,
+						0,
+					);
+				case "pr-review":
+					return new Text(
+						theme.fg("success", `✓ ${details.reviewAction} `) +
+							theme.fg("accent", `#${details.prNumber}`),
+						0,
+						0,
+					);
+				case "pr-comment":
+					return new Text(theme.fg("success", "✓ Comment added to ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
+				case "pr-ready":
+					return new Text(theme.fg("success", "✓ PR ready for review: ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
+				case "pr-close":
+					return new Text(theme.fg("success", "✓ Closed ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
+
+				case "checks":
+					return renderChecks(details, expanded, theme);
+				case "checks-log":
+					return renderLongOutput(details, expanded, theme, "Logs");
+				case "checks-restart":
+					return new Text(theme.fg("success", `✓ Restarted run ${details.runId}`), 0, 0);
+				case "run-list":
+					return renderRunList(details, expanded, theme);
+				case "run-view":
+					return renderLongOutput(details, expanded, theme, "Run");
+
+				case "issue-list":
+					return renderIssueList(details, expanded, theme);
+				case "issue-view":
+					return renderLongOutput(details, expanded, theme, "Issue");
+				case "issue-create":
+					return renderCreated(details, theme, "Issue", details.issueNumber, details.issueUrl);
+				case "issue-close":
+					return new Text(theme.fg("success", "✓ Closed issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
+				case "issue-comment":
+					return new Text(theme.fg("success", "✓ Comment added to issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
+				case "issue-edit":
+					return new Text(theme.fg("success", "✓ Updated issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
+
+				case "repo-view":
+				case "release-list":
+					return renderLongOutput(details, expanded, theme, "");
+
+				default:
+					return new Text(details.output || "", 0, 0);
+			}
+		},
+	});
+
+	// ========================================================================
+	// Slash Commands
+	// ========================================================================
+
+	// /gh - Show my open PRs
+	pi.registerCommand("gh", {
+		description: "Show my open PRs in this repo",
+		handler: async (_args, ctx) => {
+			if (!ctx.hasUI) {
+				ctx.ui.notify("/gh requires interactive mode", "error");
+				return;
+			}
+
+			const user = await ensureCurrentUser();
+			const result = await pi.exec(
+				"gh",
+				[
+					"pr",
+					"list",
+					"--author",
+					user || "@me",
+					"--state",
+					"open",
+					"--json",
+					"number,title,state,headRefName,baseRefName,isDraft,reviewDecision,additions,deletions,url",
+				],
+				{ timeout: 30000 },
+			);
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			const prs = parsePRList(result.stdout);
+
+			// Track
+			for (const pr of prs) {
+				if (!recentPRs.find((p) => p.number === pr.number)) {
+					recentPRs.push({ number: pr.number, title: pr.title });
+				}
+			}
+			if (recentPRs.length > 20) recentPRs.splice(0, recentPRs.length - 20);
+
+			const lines: string[] = [];
+			lines.push("## My Open PRs");
+			lines.push("");
+
+			if (prs.length === 0) {
+				lines.push("*No open PRs* ✨");
+			} else {
+				lines.push("| # | Title | Branch | Review | Changes |");
+				lines.push("|---|-------|--------|--------|---------|");
+				for (const pr of prs) {
+					const draft = pr.isDraft ? " 📝" : "";
+					const review = getReviewDecisionText(pr.reviewDecision);
+					const changes = `+${pr.additions}/-${pr.deletions}`;
+					lines.push(`| #${pr.number}${draft} | ${truncate(pr.title, 50)} | ${pr.branch} → ${pr.base} | ${review} | ${changes} |`);
+				}
+			}
+
+			pi.sendMessage({
+				customType: "gh-prs",
+				content: lines.join("\n"),
+				display: true,
+			});
+		},
+	});
+
+	// /gh-prs - Show all open PRs
+	pi.registerCommand("gh-prs", {
+		description: "Show all open PRs in this repo",
+		handler: async (_args, ctx) => {
+			if (!ctx.hasUI) {
+				ctx.ui.notify("/gh-prs requires interactive mode", "error");
+				return;
+			}
+
+			const result = await pi.exec(
+				"gh",
+				[
+					"pr",
+					"list",
+					"--state",
+					"open",
+					"--json",
+					"number,title,state,author,headRefName,baseRefName,isDraft,labels,reviewDecision,additions,deletions,url",
+					"--limit",
+					"20",
+				],
+				{ timeout: 30000 },
+			);
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			const prs = parsePRList(result.stdout);
+
+			// Track
+			for (const pr of prs) {
+				if (!recentPRs.find((p) => p.number === pr.number)) {
+					recentPRs.push({ number: pr.number, title: pr.title });
+				}
+			}
+			if (recentPRs.length > 20) recentPRs.splice(0, recentPRs.length - 20);
+
+			const lines: string[] = [];
+			lines.push("## Open Pull Requests");
+			lines.push("");
+
+			if (prs.length === 0) {
+				lines.push("*No open PRs* ✨");
+			} else {
+				lines.push("| # | Title | Author | Branch | Review | Changes |");
+				lines.push("|---|-------|--------|--------|--------|---------|");
+				for (const pr of prs) {
+					const draft = pr.isDraft ? " 📝" : "";
+					const review = getReviewDecisionText(pr.reviewDecision);
+					const changes = `+${pr.additions}/-${pr.deletions}`;
+					lines.push(`| #${pr.number}${draft} | ${truncate(pr.title, 40)} | @${pr.author} | ${pr.branch} → ${pr.base} | ${review} | ${changes} |`);
+				}
+			}
+
+			pi.sendMessage({
+				customType: "gh-prs",
+				content: lines.join("\n"),
+				display: true,
+			});
+		},
+	});
+
+	// /gh-pr <number> - View specific PR
+	pi.registerCommand("gh-pr", {
+		description: "View a PR (e.g., /gh-pr 123)",
+		getArgumentCompletions: (prefix: string) => {
+			if (recentPRs.length === 0) return null;
+			const items = recentPRs.map((p) => ({
+				value: String(p.number),
+				label: `#${p.number}${p.title ? ` - ${truncate(p.title, 50)}` : ""}`,
+			}));
+			if (!prefix.trim()) return items;
+			const filtered = items.filter((i) => i.value.startsWith(prefix.trim()));
+			return filtered.length > 0 ? filtered : null;
+		},
+		handler: async (args, ctx) => {
+			if (!args?.trim()) {
+				ctx.ui.notify("Usage: /gh-pr <number>", "error");
+				return;
+			}
+
+			const number = parseInt(args.trim(), 10);
+			if (isNaN(number)) {
+				ctx.ui.notify(`Invalid PR number: ${args}`, "error");
+				return;
+			}
+
+			const result = await pi.exec(
+				"gh",
+				["pr", "view", String(number), "--json", "number,title,state,author,headRefName,baseRefName,isDraft,url,reviewDecision,additions,deletions,changedFiles,body,statusCheckRollup"],
+				{ timeout: 30000 },
+			);
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			let data: any;
+			try {
+				data = JSON.parse(result.stdout);
+			} catch {
+				ctx.ui.notify("Could not parse PR data", "error");
+				return;
+			}
+
+			// Track
+			if (!recentPRs.find((p) => p.number === data.number)) {
+				recentPRs.push({ number: data.number, title: data.title });
+			}
+
+			const checks = data.statusCheckRollup ?? [];
+			const passed = checks.filter((c: any) => c.conclusion === "SUCCESS").length;
+			const failed = checks.filter((c: any) => c.conclusion === "FAILURE").length;
+			const pending = checks.filter((c: any) => !c.conclusion).length;
+
+			const lines: string[] = [];
+			lines.push(`## PR #${data.number}: ${data.title}`);
+			lines.push("");
+			lines.push(`- **State:** ${data.state}${data.isDraft ? " (draft)" : ""}`);
+			lines.push(`- **Author:** @${data.author?.login ?? "?"}`);
+			lines.push(`- **Branch:** ${data.headRefName} → ${data.baseRefName}`);
+			lines.push(`- **Review:** ${getReviewDecisionText(data.reviewDecision ?? "")}`);
+			lines.push(`- **Changes:** ${data.changedFiles} files (+${data.additions}/-${data.deletions})`);
+			if (checks.length > 0) {
+				lines.push(`- **Checks:** ${passed}✓ ${failed}✗ ${pending}⏳`);
+			}
+			lines.push(`- **URL:** ${data.url}`);
+
+			if (data.body) {
+				lines.push("");
+				lines.push("### Description");
+				lines.push("");
+				lines.push(data.body);
+			}
+
+			pi.sendMessage({
+				customType: "gh-pr-view",
+				content: lines.join("\n"),
+				display: true,
+			});
+		},
+	});
+
+	// /gh-checks <number> - Show check status
+	pi.registerCommand("gh-checks", {
+		description: "Show check status for a PR (e.g., /gh-checks 123)",
+		getArgumentCompletions: (prefix: string) => {
+			if (recentPRs.length === 0) return null;
+			const items = recentPRs.map((p) => ({
+				value: String(p.number),
+				label: `#${p.number}${p.title ? ` - ${truncate(p.title, 50)}` : ""}`,
+			}));
+			if (!prefix.trim()) return items;
+			const filtered = items.filter((i) => i.value.startsWith(prefix.trim()));
+			return filtered.length > 0 ? filtered : null;
+		},
+		handler: async (args, ctx) => {
+			if (!args?.trim()) {
+				ctx.ui.notify("Usage: /gh-checks <number>", "error");
+				return;
+			}
+
+			const number = parseInt(args.trim(), 10);
+			if (isNaN(number)) {
+				ctx.ui.notify(`Invalid PR number: ${args}`, "error");
+				return;
+			}
+
+			const result = await pi.exec(
+				"gh",
+				["pr", "view", String(number), "--json", "statusCheckRollup"],
+				{ timeout: 30000 },
+			);
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			const checks = parseChecks(result.stdout);
+
+			const lines: string[] = [];
+			lines.push(`## Checks for PR #${number}`);
+			lines.push("");
+
+			if (checks.length === 0) {
+				lines.push("*No checks found*");
+			} else {
+				const passed = checks.filter((c) => c.conclusion === "SUCCESS").length;
+				const failed = checks.filter((c) => c.conclusion === "FAILURE").length;
+				const pending = checks.filter((c) => !c.conclusion || c.status === "IN_PROGRESS" || c.status === "QUEUED").length;
+
+				lines.push(`**Summary:** ${passed} passed, ${failed} failed, ${pending} pending`);
+				lines.push("");
+				lines.push("| Status | Name | Details |");
+				lines.push("|--------|------|---------|");
+				for (const check of checks) {
+					const icon = getCheckIcon(check);
+					lines.push(`| ${icon} | ${check.name} | ${check.conclusion || check.status || "pending"} |`);
+				}
+			}
+
+			pi.sendMessage({
+				customType: "gh-checks",
+				content: lines.join("\n"),
+				display: true,
+			});
+		},
+	});
+
+	// /gh-issues - Show open issues
+	pi.registerCommand("gh-issues", {
+		description: "Show open issues in this repo",
+		handler: async (_args, ctx) => {
+			if (!ctx.hasUI) {
+				ctx.ui.notify("/gh-issues requires interactive mode", "error");
+				return;
+			}
+
+			const result = await pi.exec(
+				"gh",
+				["issue", "list", "--state", "open", "--json", "number,title,state,labels,assignees,url", "--limit", "20"],
+				{ timeout: 30000 },
+			);
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			const issues = parseIssueList(result.stdout);
+
+			// Track
+			for (const issue of issues) {
+				if (!recentIssues.find((i) => i.number === issue.number)) {
+					recentIssues.push({ number: issue.number, title: issue.title });
+				}
+			}
+			if (recentIssues.length > 20) recentIssues.splice(0, recentIssues.length - 20);
+
+			const lines: string[] = [];
+			lines.push("## Open Issues");
+			lines.push("");
+
+			if (issues.length === 0) {
+				lines.push("*No open issues* ✨");
+			} else {
+				lines.push("| # | Title | Labels | Assignees |");
+				lines.push("|---|-------|--------|-----------|");
+				for (const issue of issues) {
+					const labels = issue.labels.length > 0 ? issue.labels.join(", ") : "-";
+					const assignees = issue.assignees.length > 0 ? issue.assignees.map((a) => `@${a}`).join(", ") : "-";
+					lines.push(`| #${issue.number} | ${truncate(issue.title, 50)} | ${labels} | ${assignees} |`);
+				}
+			}
+
+			pi.sendMessage({
+				customType: "gh-issues",
+				content: lines.join("\n"),
+				display: true,
+			});
+		},
+	});
+
+	// /gh-runs - Show recent workflow runs
+	pi.registerCommand("gh-runs", {
+		description: "Show recent workflow runs",
+		handler: async (_args, ctx) => {
+			if (!ctx.hasUI) {
+				ctx.ui.notify("/gh-runs requires interactive mode", "error");
+				return;
+			}
+
+			const result = await pi.exec(
+				"gh",
+				["run", "list", "--json", "databaseId,name,displayTitle,status,conclusion,headBranch,url,createdAt", "--limit", "15"],
+				{ timeout: 30000 },
+			);
+
+			if (result.code !== 0) {
+				ctx.ui.notify(`Error: ${result.stderr}`, "error");
+				return;
+			}
+
+			let runs: any[];
+			try {
+				runs = JSON.parse(result.stdout);
+			} catch {
+				ctx.ui.notify("Could not parse run data", "error");
+				return;
+			}
+
+			const lines: string[] = [];
+			lines.push("## Recent Workflow Runs");
+			lines.push("");
+
+			if (runs.length === 0) {
+				lines.push("*No recent runs*");
+			} else {
+				lines.push("| Status | ID | Workflow | Title | Branch | Age |");
+				lines.push("|--------|-----|----------|-------|--------|-----|");
+				for (const run of runs) {
+					const icon = run.conclusion === "success" ? "✓" : run.conclusion === "failure" ? "✗" : "⏳";
+					const age = formatRelativeDate(run.createdAt);
+					lines.push(`| ${icon} | ${run.databaseId} | ${run.name} | ${truncate(run.displayTitle, 35)} | ${run.headBranch} | ${age} |`);
+				}
+			}
+
+			pi.sendMessage({
+				customType: "gh-runs",
+				content: lines.join("\n"),
+				display: true,
+			});
+		},
+	});
+
+	// ========================================================================
+	// Auto-detection: GitHub PR/Issue URLs
+	// ========================================================================
+
+	pi.on("input", async (event, ctx) => {
+		if (event.source !== "interactive") return { action: "continue" as const };
+
+		const text = event.text.trim();
+
+		// Detect GitHub PR URLs
+		const prUrlMatch = text.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+\/pull\/(\d+)\/?$/);
+		if (prUrlMatch) {
+			return { action: "transform" as const, text: `View GitHub PR #${prUrlMatch[1]}` };
+		}
+
+		// Detect GitHub issue URLs
+		const issueUrlMatch = text.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)\/?$/);
+		if (issueUrlMatch) {
+			return { action: "transform" as const, text: `View GitHub issue #${issueUrlMatch[1]}` };
+		}
+
+		return { action: "continue" as const };
+	});
+}
+
+// ============================================================================
+// Rendering Functions
+// ============================================================================
+
+function renderPRList(details: GhDetails, expanded: boolean, theme: Theme): Text {
+	if (!details.output) return new Text(theme.fg("dim", "No PRs found"), 0, 0);
+
+	const lines = details.output.split("\n").filter((l) => l.trim());
+	if (lines.length === 0) return new Text(theme.fg("dim", "No PRs found"), 0, 0);
+
+	let text = theme.fg("muted", `${lines.length} PR(s):`);
+
+	const display = expanded ? lines : lines.slice(0, 5);
+	for (const line of display) {
+		text += `\n${theme.fg("text", line)}`;
+	}
+
+	if (!expanded && lines.length > 5) {
+		text += `\n${theme.fg("dim", `... ${lines.length - 5} more (expand for all)`)}`;
+	}
+
+	return new Text(text, 0, 0);
+}
+
+function renderIssueList(details: GhDetails, expanded: boolean, theme: Theme): Text {
+	if (!details.output) return new Text(theme.fg("dim", "No issues found"), 0, 0);
+
+	const lines = details.output.split("\n").filter((l) => l.trim());
+	if (lines.length === 0) return new Text(theme.fg("dim", "No issues found"), 0, 0);
+
+	let text = theme.fg("muted", `${lines.length} issue(s):`);
+
+	const display = expanded ? lines : lines.slice(0, 5);
+	for (const line of display) {
+		text += `\n${theme.fg("text", line)}`;
+	}
+
+	if (!expanded && lines.length > 5) {
+		text += `\n${theme.fg("dim", `... ${lines.length - 5} more (expand for all)`)}`;
+	}
+
+	return new Text(text, 0, 0);
+}
+
+function renderChecks(details: GhDetails, expanded: boolean, theme: Theme): Text {
+	if (!details.output) return new Text(theme.fg("dim", "No checks"), 0, 0);
+
+	const lines = details.output.split("\n").filter((l) => l.trim());
+	if (lines.length === 0) return new Text(theme.fg("dim", "No checks"), 0, 0);
+
+	// First line is the summary
+	let text = theme.fg("muted", lines[0]);
+
+	const checkLines = lines.slice(1);
+	const display = expanded ? checkLines : checkLines.slice(0, 8);
+	for (const line of display) {
+		if (line.startsWith("✓")) text += `\n${theme.fg("success", line)}`;
+		else if (line.startsWith("✗")) text += `\n${theme.fg("error", line)}`;
+		else if (line.startsWith("⏳")) text += `\n${theme.fg("warning", line)}`;
+		else text += `\n${theme.fg("text", line)}`;
+	}
+
+	if (!expanded && checkLines.length > 8) {
+		text += `\n${theme.fg("dim", `... ${checkLines.length - 8} more (expand for all)`)}`;
+	}
+
+	return new Text(text, 0, 0);
+}
+
+function renderRunList(details: GhDetails, expanded: boolean, theme: Theme): Text {
+	if (!details.output) return new Text(theme.fg("dim", "No runs"), 0, 0);
+
+	const lines = details.output.split("\n").filter((l) => l.trim());
+	if (lines.length === 0) return new Text(theme.fg("dim", "No runs"), 0, 0);
+
+	// First line is the summary
+	let text = theme.fg("muted", lines[0]);
+
+	const runLines = lines.slice(1);
+	const display = expanded ? runLines : runLines.slice(0, 8);
+	for (const line of display) {
+		if (line.startsWith("✓")) text += `\n${theme.fg("success", line)}`;
+		else if (line.startsWith("✗")) text += `\n${theme.fg("error", line)}`;
+		else if (line.startsWith("⏳")) text += `\n${theme.fg("warning", line)}`;
+		else text += `\n${theme.fg("text", line)}`;
+	}
+
+	if (!expanded && runLines.length > 8) {
+		text += `\n${theme.fg("dim", `... ${runLines.length - 8} more (expand for all)`)}`;
+	}
+
+	return new Text(text, 0, 0);
+}
+
+function renderLongOutput(details: GhDetails, expanded: boolean, theme: Theme, prefix: string): Text {
+	if (!details.output) return new Text(theme.fg("dim", `No ${prefix.toLowerCase()} data`), 0, 0);
+
+	if (expanded) {
+		return new Text(details.output, 0, 0);
+	}
+
+	const lines = details.output.split("\n");
+	const preview = lines.slice(0, 15).join("\n");
+	let text = preview;
+
+	if (lines.length > 15) {
+		text += `\n${theme.fg("dim", `... ${lines.length - 15} more lines (expand for full view)`)}`;
+	}
+
+	return new Text(text, 0, 0);
+}
+
+function renderDiff(details: GhDetails, expanded: boolean, theme: Theme): Text {
+	const summary = details.output || "Diff fetched";
+	if (expanded) {
+		return new Text(summary, 0, 0);
+	}
+	return new Text(theme.fg("muted", summary), 0, 0);
+}
+
+function renderCreated(details: GhDetails, theme: Theme, kind: string, number?: number, url?: string): Text {
+	let text = theme.fg("success", `✓ Created ${kind} `);
+	if (number) text += theme.fg("accent", theme.bold(`#${number}`));
+	if (url) text += theme.fg("dim", ` ${url}`);
+	return new Text(text, 0, 0);
+}
dots/pi/agent/extensions/github/Makefile
@@ -0,0 +1,21 @@
+.PHONY: test test-watch help
+
+# Run tests
+test:
+	@echo "Running tests..."
+	@bun test github.test.ts
+
+# Run tests in watch mode
+test-watch:
+	@echo "Running tests in watch mode..."
+	@bun test --watch github.test.ts
+
+# Help
+help:
+	@echo "Available targets:"
+	@echo "  test        - Run tests once"
+	@echo "  test-watch  - Run tests in watch mode"
+	@echo "  help        - Show this help message"
+
+# Default target
+.DEFAULT_GOAL := help
dots/pi/agent/extensions/github/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "github-extension",
+  "version": "1.0.0",
+  "type": "module",
+  "scripts": {
+    "test": "bun test github.test.ts"
+  },
+  "devDependencies": {
+    "bun-types": "^1.0.0"
+  }
+}
dots/pi/agent/extensions/github/README.md
@@ -0,0 +1,211 @@
+# GitHub Extension for Pi
+
+Manage GitHub PRs, issues, CI checks, and workflow runs directly from pi using the `gh` CLI.
+
+## Features
+
+### Read Operations (No Approval Required)
+- **`pr-list`** - List PRs with filters (state, author, label, base)
+- **`pr-view`** - View PR details (metadata, diff summary, checks, reviews)
+- **`pr-diff`** - View PR diff
+- **`pr-checkout`** - Checkout PR locally
+- **`checks`** - Show check status for a PR
+- **`checks-log`** - Get failed check logs
+- **`run-list`** - List workflow runs (filter by branch, status, workflow)
+- **`run-view`** - View specific run details with jobs
+- **`issue-list`** - List issues with filters (state, label, assignee)
+- **`issue-view`** - View issue details + comments
+- **`repo-view`** - View current repo info
+- **`release-list`** - List releases
+
+### Write Operations (Require User Approval)
+- **`pr-create`** - Create PR
+- **`pr-merge`** - Merge PR (merge, squash, or rebase)
+- **`pr-review`** - Submit review (approve, request-changes, comment)
+- **`pr-comment`** - Comment on PR
+- **`pr-ready`** - Mark draft as ready for review
+- **`pr-close`** - Close PR without merging
+- **`checks-restart`** - Restart failed workflow runs
+- **`issue-create`** - Create issue
+- **`issue-close`** - Close issue
+- **`issue-comment`** - Comment on issue
+- **`issue-edit`** - Edit issue (title, body, labels, assignees, milestone)
+
+All write operations show a confirmation dialog before executing.
+
+## Prerequisites
+
+### 1. gh CLI
+
+Install the GitHub CLI: https://cli.github.com/
+
+### 2. Authentication
+
+```bash
+gh auth login
+```
+
+Verify:
+```bash
+gh auth status
+```
+
+## Slash Commands (Instant, No LLM)
+
+| Command | Description |
+|---------|-------------|
+| `/gh` | Show my open PRs |
+| `/gh-prs` | Show all open PRs in this repo |
+| `/gh-pr <number>` | View PR details |
+| `/gh-checks <number>` | Show check status for PR |
+| `/gh-issues` | Show open issues |
+| `/gh-runs` | Show recent workflow runs |
+
+All commands execute directly via `gh` CLI — no LLM roundtrip!
+
+**Tab auto-completion** for PR/issue numbers from your session.
+
+## Auto-Detection
+
+The extension detects GitHub URLs pasted into the input:
+
+```
+https://github.com/org/repo/pull/123
+→ Automatically transforms to: "View GitHub PR #123"
+
+https://github.com/org/repo/issues/42
+→ Automatically transforms to: "View GitHub issue #42"
+```
+
+## Usage Examples
+
+### PR Workflows
+
+```
+"Show my open PRs"
+→ github pr-list, author=me, state=open
+
+"View PR 123"
+→ github pr-view, number=123
+
+"Show the diff for PR 123"
+→ github pr-diff, number=123
+
+"Create a PR for this branch"
+→ github pr-create (approval dialog)
+
+"Merge PR 123 with squash"
+→ github pr-merge, number=123, method=squash (approval dialog)
+
+"Approve PR 123 with LGTM"
+→ github pr-review, number=123, reviewAction=approve, body="LGTM" (approval dialog)
+```
+
+### CI/Checks
+
+```
+"Check the status of PR 123"
+→ github checks, number=123
+
+"Show the failed logs for run 456789"
+→ github checks-log, runId=456789
+
+"Restart the failed checks for run 456789"
+→ github checks-restart, runId=456789 (approval dialog)
+
+"List recent workflow runs"
+→ github run-list
+
+"Show failed runs on main"
+→ github run-list, branch=main, status=failure
+```
+
+### Issues
+
+```
+"Show open issues"
+→ github issue-list, state=open
+
+"Create an issue for the login bug"
+→ github issue-create (approval dialog)
+
+"Close issue 42"
+→ github issue-close, number=42 (approval dialog)
+
+"Add a comment to issue 42"
+→ github issue-comment, number=42, body="..." (approval dialog)
+```
+
+### Repository
+
+```
+"Show repo info"
+→ github repo-view
+
+"List recent releases"
+→ github release-list
+```
+
+## Architecture
+
+```
+github/
+├── index.ts              # Main extension: tool, commands, rendering, state
+├── actions/
+│   ├── pr.ts             # PR action handlers
+│   ├── checks.ts         # CI/checks action handlers
+│   ├── issue.ts          # Issue action handlers
+│   └── repo.ts           # Repo/release action handlers
+├── types.ts              # TypeScript type definitions
+├── utils.ts              # Parsing, formatting, error helpers
+├── github.test.ts        # Tests (62 tests)
+├── package.json          # Package config
+├── Makefile              # Test runner
+└── README.md             # This file
+```
+
+## Development
+
+### Running Tests
+
+```bash
+cd dots/pi/agent/extensions/github
+make test
+```
+
+Watch mode:
+```bash
+make test-watch
+```
+
+### Adding New Actions
+
+1. Add action string to the `StringEnum` in `index.ts`
+2. Add handler function in the appropriate `actions/*.ts` file
+3. Add routing in the `execute()` switch in `index.ts`
+4. Add rendering case in `renderResult()` in `index.ts`
+5. Add any new parameters to the tool schema
+6. Add tests in `github.test.ts`
+7. Update this README
+
+## State Management
+
+The extension tracks:
+- **Current GitHub user** (fetched lazily via `gh api user`)
+- **Recent PR numbers** (for slash command auto-completion)
+- **Recent issue numbers** (for slash command auto-completion)
+
+State is reconstructed from session on load/fork/tree navigation.
+
+## Custom Rendering
+
+The extension provides themed TUI rendering:
+- **PR list**: Compact with draft/review status
+- **Checks**: Color-coded pass ✓ / fail ✗ / pending ⏳
+- **Run list**: Status icons with branch and age
+- **Write ops**: Success confirmations with links
+- **Long output**: Collapsed by default, expandable
+
+## License
+
+Same as the homelab repository.
dots/pi/agent/extensions/github/types.ts
@@ -0,0 +1,124 @@
+/**
+ * Type definitions for GitHub extension
+ */
+
+// ============================================================================
+// Tool Details (persisted in session for rendering & state reconstruction)
+// ============================================================================
+
+export interface GhDetails {
+	action: string;
+	output?: string;
+	cancelled?: boolean;
+	error?: string;
+	// PR-specific
+	prNumber?: number;
+	prNumbers?: number[];
+	prUrl?: string;
+	// Issue-specific
+	issueNumber?: number;
+	issueNumbers?: number[];
+	issueUrl?: string;
+	// Run-specific
+	runId?: number;
+	// Review
+	reviewAction?: string;
+	// Merge
+	mergeMethod?: string;
+	// Generic
+	field?: string;
+	newValue?: string;
+}
+
+// ============================================================================
+// Parsed Data Types (from gh CLI JSON output)
+// ============================================================================
+
+export interface GhPR {
+	number: number;
+	title: string;
+	state: string;
+	author: string;
+	branch: string;
+	base: string;
+	url: string;
+	isDraft: boolean;
+	labels: string[];
+	reviewDecision: string;
+	additions: number;
+	deletions: number;
+	changedFiles: number;
+	createdAt: string;
+	updatedAt: string;
+}
+
+export interface GhIssue {
+	number: number;
+	title: string;
+	state: string;
+	author: string;
+	url: string;
+	labels: string[];
+	assignees: string[];
+	createdAt: string;
+	updatedAt: string;
+	body: string;
+	comments: number;
+}
+
+export interface GhCheck {
+	name: string;
+	status: string;
+	conclusion: string;
+	startedAt: string;
+	completedAt: string;
+	detailsUrl: string;
+}
+
+export interface GhRun {
+	databaseId: number;
+	name: string;
+	displayTitle: string;
+	status: string;
+	conclusion: string;
+	headBranch: string;
+	event: string;
+	url: string;
+	createdAt: string;
+	updatedAt: string;
+}
+
+export interface GhReview {
+	author: string;
+	state: string;
+	body: string;
+	submittedAt: string;
+}
+
+export interface GhReviewComment {
+	author: string;
+	body: string;
+	path: string;
+	line: number;
+	createdAt: string;
+}
+
+export interface GhRelease {
+	tagName: string;
+	name: string;
+	isDraft: boolean;
+	isPrerelease: boolean;
+	publishedAt: string;
+	url: string;
+}
+
+export interface GhRepo {
+	nameWithOwner: string;
+	description: string;
+	defaultBranch: string;
+	visibility: string;
+	url: string;
+	stargazerCount: number;
+	forkCount: number;
+	isArchived: boolean;
+}
dots/pi/agent/extensions/github/utils.ts
@@ -0,0 +1,409 @@
+/**
+ * Utility functions for GitHub extension
+ */
+
+import type { Theme } from "@mariozechner/pi-coding-agent";
+import type { GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhRelease, GhRepo } from "./types";
+
+// ============================================================================
+// Parsing: gh CLI JSON output → typed objects
+// ============================================================================
+
+export function parsePRList(json: string): GhPR[] {
+	try {
+		const data = JSON.parse(json);
+		if (!Array.isArray(data)) return [];
+		return data.map(parsePRItem);
+	} catch {
+		return [];
+	}
+}
+
+export function parsePRItem(item: any): GhPR {
+	return {
+		number: item.number ?? 0,
+		title: item.title ?? "",
+		state: item.state ?? "OPEN",
+		author: item.author?.login ?? "",
+		branch: item.headRefName ?? "",
+		base: item.baseRefName ?? "",
+		url: item.url ?? "",
+		isDraft: item.isDraft ?? false,
+		labels: (item.labels ?? []).map((l: any) => l.name ?? l),
+		reviewDecision: item.reviewDecision ?? "",
+		additions: item.additions ?? 0,
+		deletions: item.deletions ?? 0,
+		changedFiles: item.changedFiles ?? 0,
+		createdAt: item.createdAt ?? "",
+		updatedAt: item.updatedAt ?? "",
+	};
+}
+
+export function parseIssueList(json: string): GhIssue[] {
+	try {
+		const data = JSON.parse(json);
+		if (!Array.isArray(data)) return [];
+		return data.map(parseIssueItem);
+	} catch {
+		return [];
+	}
+}
+
+export function parseIssueItem(item: any): GhIssue {
+	return {
+		number: item.number ?? 0,
+		title: item.title ?? "",
+		state: item.state ?? "OPEN",
+		author: item.author?.login ?? "",
+		url: item.url ?? "",
+		labels: (item.labels ?? []).map((l: any) => l.name ?? l),
+		assignees: (item.assignees ?? []).map((a: any) => a.login ?? a),
+		createdAt: item.createdAt ?? "",
+		updatedAt: item.updatedAt ?? "",
+		body: item.body ?? "",
+		comments: item.comments?.totalCount ?? item.comments ?? 0,
+	};
+}
+
+export function parseChecks(json: string): GhCheck[] {
+	try {
+		const data = JSON.parse(json);
+		// gh pr view --json statusCheckRollup returns { statusCheckRollup: [...] }
+		const checks = data.statusCheckRollup ?? data;
+		if (!Array.isArray(checks)) return [];
+		return checks.map((item: any) => ({
+			name: item.name ?? item.context ?? "",
+			status: item.status ?? "",
+			conclusion: item.conclusion ?? "",
+			startedAt: item.startedAt ?? "",
+			completedAt: item.completedAt ?? "",
+			detailsUrl: item.detailsUrl ?? item.targetUrl ?? "",
+		}));
+	} catch {
+		return [];
+	}
+}
+
+export function parseRunList(json: string): GhRun[] {
+	try {
+		const data = JSON.parse(json);
+		if (!Array.isArray(data)) return [];
+		return data.map((item: any) => ({
+			databaseId: item.databaseId ?? 0,
+			name: item.name ?? "",
+			displayTitle: item.displayTitle ?? "",
+			status: item.status ?? "",
+			conclusion: item.conclusion ?? "",
+			headBranch: item.headBranch ?? "",
+			event: item.event ?? "",
+			url: item.url ?? "",
+			createdAt: item.createdAt ?? "",
+			updatedAt: item.updatedAt ?? "",
+		}));
+	} catch {
+		return [];
+	}
+}
+
+export function parseReviews(json: string): GhReview[] {
+	try {
+		const data = JSON.parse(json);
+		// gh pr view --json reviews returns { reviews: [...] }
+		const reviews = data.reviews ?? data;
+		if (!Array.isArray(reviews)) return [];
+		return reviews.map((item: any) => ({
+			author: item.author?.login ?? "",
+			state: item.state ?? "",
+			body: item.body ?? "",
+			submittedAt: item.submittedAt ?? "",
+		}));
+	} catch {
+		return [];
+	}
+}
+
+export function parseReviewComments(json: string): GhReviewComment[] {
+	try {
+		const data = JSON.parse(json);
+		// gh pr view --json reviewComments isn't directly available,
+		// but gh api returns array of comments
+		const comments = data.comments ?? data;
+		if (!Array.isArray(comments)) return [];
+		return comments.map((item: any) => ({
+			author: item.author?.login ?? item.user?.login ?? "",
+			body: item.body ?? "",
+			path: item.path ?? "",
+			line: item.line ?? item.original_line ?? 0,
+			createdAt: item.createdAt ?? item.created_at ?? "",
+		}));
+	} catch {
+		return [];
+	}
+}
+
+export function parseReleaseList(json: string): GhRelease[] {
+	try {
+		const data = JSON.parse(json);
+		if (!Array.isArray(data)) return [];
+		return data.map((item: any) => ({
+			tagName: item.tagName ?? "",
+			name: item.name ?? "",
+			isDraft: item.isDraft ?? false,
+			isPrerelease: item.isPrerelease ?? false,
+			publishedAt: item.publishedAt ?? "",
+			url: item.url ?? "",
+		}));
+	} catch {
+		return [];
+	}
+}
+
+export function parseRepo(json: string): GhRepo | null {
+	try {
+		const data = JSON.parse(json);
+		return {
+			nameWithOwner: data.nameWithOwner ?? "",
+			description: data.description ?? "",
+			defaultBranch: data.defaultBranchRef?.name ?? data.defaultBranch ?? "",
+			visibility: data.visibility ?? "",
+			url: data.url ?? "",
+			stargazerCount: data.stargazerCount ?? 0,
+			forkCount: data.forkCount ?? 0,
+			isArchived: data.isArchived ?? false,
+		};
+	} catch {
+		return null;
+	}
+}
+
+// ============================================================================
+// Formatting helpers
+// ============================================================================
+
+export function truncate(text: string, maxLength: number): string {
+	if (text.length <= maxLength) return text;
+	return text.slice(0, maxLength - 3) + "...";
+}
+
+export function formatDate(dateStr: string): string {
+	if (!dateStr) return "";
+	try {
+		const date = new Date(dateStr);
+		return date.toLocaleDateString();
+	} catch {
+		return dateStr;
+	}
+}
+
+export function formatRelativeDate(dateStr: string): string {
+	if (!dateStr) return "";
+	try {
+		const date = new Date(dateStr);
+		const now = new Date();
+		const diffMs = now.getTime() - date.getTime();
+		const diffMins = Math.floor(diffMs / 60000);
+		const diffHours = Math.floor(diffMs / 3600000);
+		const diffDays = Math.floor(diffMs / 86400000);
+
+		if (diffMins < 1) return "just now";
+		if (diffMins < 60) return `${diffMins}m ago`;
+		if (diffHours < 24) return `${diffHours}h ago`;
+		if (diffDays < 30) return `${diffDays}d ago`;
+		return formatDate(dateStr);
+	} catch {
+		return dateStr;
+	}
+}
+
+// ============================================================================
+// Rendering helpers
+// ============================================================================
+
+export function getPRStateIcon(pr: GhPR): string {
+	if (pr.state === "MERGED") return "⏣";
+	if (pr.state === "CLOSED") return "✗";
+	if (pr.isDraft) return "◌";
+	return "●";
+}
+
+export function getPRStateColor(pr: GhPR, theme: Theme): string {
+	const icon = getPRStateIcon(pr);
+	if (pr.state === "MERGED") return theme.fg("accent", icon);
+	if (pr.state === "CLOSED") return theme.fg("error", icon);
+	if (pr.isDraft) return theme.fg("dim", icon);
+	return theme.fg("success", icon);
+}
+
+export function getCheckIcon(check: GhCheck): string {
+	if (check.conclusion === "SUCCESS") return "✓";
+	if (check.conclusion === "FAILURE") return "✗";
+	if (check.conclusion === "CANCELLED") return "⊘";
+	if (check.conclusion === "SKIPPED") return "⊘";
+	if (check.status === "IN_PROGRESS" || check.status === "QUEUED") return "⏳";
+	return "?";
+}
+
+export function getCheckColor(check: GhCheck, theme: Theme): string {
+	const icon = getCheckIcon(check);
+	if (check.conclusion === "SUCCESS") return theme.fg("success", icon);
+	if (check.conclusion === "FAILURE") return theme.fg("error", icon);
+	if (check.conclusion === "CANCELLED" || check.conclusion === "SKIPPED") return theme.fg("dim", icon);
+	if (check.status === "IN_PROGRESS" || check.status === "QUEUED") return theme.fg("warning", icon);
+	return theme.fg("muted", icon);
+}
+
+export function getRunStatusIcon(run: GhRun): string {
+	if (run.conclusion === "success") return "✓";
+	if (run.conclusion === "failure") return "✗";
+	if (run.conclusion === "cancelled") return "⊘";
+	if (run.status === "in_progress" || run.status === "queued") return "⏳";
+	return "?";
+}
+
+export function getRunStatusColor(run: GhRun, theme: Theme): string {
+	const icon = getRunStatusIcon(run);
+	if (run.conclusion === "success") return theme.fg("success", icon);
+	if (run.conclusion === "failure") return theme.fg("error", icon);
+	if (run.conclusion === "cancelled") return theme.fg("dim", icon);
+	if (run.status === "in_progress" || run.status === "queued") return theme.fg("warning", icon);
+	return theme.fg("muted", icon);
+}
+
+export function getReviewDecisionText(decision: string): string {
+	switch (decision) {
+		case "APPROVED": return "✓ Approved";
+		case "CHANGES_REQUESTED": return "✗ Changes requested";
+		case "REVIEW_REQUIRED": return "⏳ Review required";
+		default: return decision || "No reviews";
+	}
+}
+
+// ============================================================================
+// Confirmation builders
+// ============================================================================
+
+export function buildPRCreateConfirmation(params: any): string {
+	let msg = "";
+	msg += `Title: "${params.title}"\n`;
+	if (params.base) msg += `Base: ${params.base}\n`;
+	if (params.body) {
+		const preview = params.body.length > 200 ? params.body.slice(0, 197) + "..." : params.body;
+		msg += `Body: ${preview}\n`;
+	}
+	if (params.draft) msg += `Draft: yes\n`;
+	if (params.labels?.length) msg += `Labels: ${params.labels.join(", ")}\n`;
+	if (params.reviewers?.length) msg += `Reviewers: ${params.reviewers.join(", ")}\n`;
+	msg += "\nThis will create a new pull request on GitHub.";
+	return msg;
+}
+
+export function buildPRMergeConfirmation(params: any): string {
+	let msg = `PR: #${params.number}\n`;
+	msg += `Method: ${params.method || "merge"}\n`;
+	if (params.deleteBranch) msg += `Delete branch: yes\n`;
+	msg += "\nThis will merge the pull request.";
+	return msg;
+}
+
+export function buildReviewConfirmation(params: any): string {
+	let msg = `PR: #${params.number}\n`;
+	msg += `Action: ${params.reviewAction}\n`;
+	if (params.body) {
+		const preview = params.body.length > 200 ? params.body.slice(0, 197) + "..." : params.body;
+		msg += `Comment: ${preview}\n`;
+	}
+	msg += "\nThis will submit a review on the pull request.";
+	return msg;
+}
+
+export function buildIssueCreateConfirmation(params: any): string {
+	let msg = "";
+	msg += `Title: "${params.title}"\n`;
+	if (params.body) {
+		const preview = params.body.length > 200 ? params.body.slice(0, 197) + "..." : params.body;
+		msg += `Body: ${preview}\n`;
+	}
+	if (params.labels?.length) msg += `Labels: ${params.labels.join(", ")}\n`;
+	if (params.assignees?.length) msg += `Assignees: ${params.assignees.join(", ")}\n`;
+	msg += "\nThis will create a new issue on GitHub.";
+	return msg;
+}
+
+export function buildCommentConfirmation(kind: string, number: number, comment: string): string {
+	const preview = comment.length > 200 ? comment.slice(0, 197) + "..." : comment;
+	let msg = `${kind}: #${number}\n\n`;
+	msg += `Comment preview:\n"${preview}"\n\n`;
+	msg += "This will post a public comment.";
+	return msg;
+}
+
+// ============================================================================
+// Error helpers
+// ============================================================================
+
+export function isAuthError(stderr: string): boolean {
+	const lower = stderr.toLowerCase();
+	return (
+		lower.includes("authentication") ||
+		lower.includes("unauthorized") ||
+		lower.includes("not logged in") ||
+		lower.includes("gh auth login")
+	);
+}
+
+export function isNotFoundError(stderr: string): boolean {
+	const lower = stderr.toLowerCase();
+	return lower.includes("not found") || lower.includes("could not resolve");
+}
+
+export function isRepoError(stderr: string): boolean {
+	const lower = stderr.toLowerCase();
+	return lower.includes("not a git repository") || lower.includes("no git remotes");
+}
+
+export function getErrorMessage(stderr: string, action: string): string {
+	if (isAuthError(stderr)) {
+		return "Authentication failed. Run: gh auth login";
+	}
+	if (isRepoError(stderr)) {
+		return "Not in a GitHub repository or no git remotes found.";
+	}
+	if (isNotFoundError(stderr)) {
+		return `${action}: Resource not found`;
+	}
+	return stderr.trim();
+}
+
+// ============================================================================
+// URL/number extraction
+// ============================================================================
+
+export function extractPRNumber(output: string): number | null {
+	// Match GitHub PR URL
+	const urlMatch = output.match(/\/pull\/(\d+)/);
+	if (urlMatch) return parseInt(urlMatch[1], 10);
+	// Match bare number
+	const numMatch = output.match(/#(\d+)/);
+	if (numMatch) return parseInt(numMatch[1], 10);
+	return null;
+}
+
+export function extractIssueNumber(output: string): number | null {
+	// Match GitHub issue URL
+	const urlMatch = output.match(/\/issues\/(\d+)/);
+	if (urlMatch) return parseInt(urlMatch[1], 10);
+	// Match bare number
+	const numMatch = output.match(/#(\d+)/);
+	if (numMatch) return parseInt(numMatch[1], 10);
+	return null;
+}
+
+export function extractPRUrl(output: string): string | null {
+	const match = output.match(/(https:\/\/github\.com\/[^\s]+\/pull\/\d+)/);
+	return match ? match[1] : null;
+}
+
+export function extractIssueUrl(output: string): string | null {
+	const match = output.match(/(https:\/\/github\.com\/[^\s]+\/issues\/\d+)/);
+	return match ? match[1] : null;
+}