Commit 83a161b49f68
Changed files (11)
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;
+}