diff --git a/src/resources/extensions/github/formatters.ts b/src/resources/extensions/github/formatters.ts new file mode 100644 index 000000000..239ac2ce8 --- /dev/null +++ b/src/resources/extensions/github/formatters.ts @@ -0,0 +1,207 @@ +/** + * Formatters — produce text summaries for issues, PRs, comments, etc. + * + * Used by both tools (LLM context) and renderers (TUI display). + */ + +import type { GhIssue, GhPullRequest, GhComment, GhReview, GhLabel, GhMilestone } from "./gh-api.js"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function timeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diff = now - then; + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.floor(months / 12)}y ago`; +} + +function stateIcon(state: string, draft?: boolean): string { + if (draft) return "◇"; + switch (state) { + case "open": + return "●"; + case "closed": + return "✓"; + case "merged": + return "⊕"; + default: + return "○"; + } +} + +function truncateBody(body: string | null, maxLines = 10): string { + if (!body) return "(no description)"; + const lines = body.split("\n"); + if (lines.length <= maxLines) return body; + return lines.slice(0, maxLines).join("\n") + `\n... (${lines.length - maxLines} more lines)`; +} + +// ─── Issue formatting ───────────────────────────────────────────────────────── + +export function formatIssueOneLiner(issue: GhIssue): string { + const icon = stateIcon(issue.state); + const labels = issue.labels.map((l) => l.name).join(", "); + const labelStr = labels ? ` [${labels}]` : ""; + const assignee = issue.assignees.length ? ` → ${issue.assignees.map((a) => a.login).join(", ")}` : ""; + return `${icon} #${issue.number} ${issue.title}${labelStr}${assignee} (${timeAgo(issue.updated_at)})`; +} + +export function formatIssueDetail(issue: GhIssue): string { + const lines: string[] = []; + lines.push(`# Issue #${issue.number}: ${issue.title}`); + lines.push(`State: ${issue.state} | Author: @${issue.user.login} | Created: ${timeAgo(issue.created_at)} | Updated: ${timeAgo(issue.updated_at)}`); + + if (issue.assignees.length) { + lines.push(`Assignees: ${issue.assignees.map((a) => `@${a.login}`).join(", ")}`); + } + if (issue.labels.length) { + lines.push(`Labels: ${issue.labels.map((l) => l.name).join(", ")}`); + } + if (issue.milestone) { + lines.push(`Milestone: ${issue.milestone.title}`); + } + lines.push(`Comments: ${issue.comments}`); + lines.push(`URL: ${issue.html_url}`); + lines.push(""); + lines.push(truncateBody(issue.body, 30)); + return lines.join("\n"); +} + +export function formatIssueList(issues: GhIssue[]): string { + if (!issues.length) return "No issues found."; + return issues.map(formatIssueOneLiner).join("\n"); +} + +// ─── PR formatting ──────────────────────────────────────────────────────────── + +export function formatPROneLiner(pr: GhPullRequest): string { + const icon = stateIcon(pr.merged_at ? "merged" : pr.state, pr.draft); + const labels = pr.labels.map((l) => l.name).join(", "); + const labelStr = labels ? ` [${labels}]` : ""; + const draftStr = pr.draft ? " (draft)" : ""; + const reviewers = pr.requested_reviewers.map((r) => r.login).join(", "); + const reviewerStr = reviewers ? ` ⟵ ${reviewers}` : ""; + return `${icon} #${pr.number} ${pr.title}${draftStr}${labelStr}${reviewerStr} (${timeAgo(pr.updated_at)})`; +} + +export function formatPRDetail(pr: GhPullRequest): string { + const lines: string[] = []; + const mergedState = pr.merged_at ? "merged" : pr.state; + lines.push(`# PR #${pr.number}: ${pr.title}`); + lines.push(`State: ${mergedState}${pr.draft ? " (draft)" : ""} | Author: @${pr.user.login} | Created: ${timeAgo(pr.created_at)} | Updated: ${timeAgo(pr.updated_at)}`); + lines.push(`Branch: ${pr.head.ref} → ${pr.base.ref}`); + + if (pr.assignees.length) { + lines.push(`Assignees: ${pr.assignees.map((a) => `@${a.login}`).join(", ")}`); + } + if (pr.labels.length) { + lines.push(`Labels: ${pr.labels.map((l) => l.name).join(", ")}`); + } + if (pr.milestone) { + lines.push(`Milestone: ${pr.milestone.title}`); + } + if (pr.requested_reviewers.length) { + lines.push(`Reviewers: ${pr.requested_reviewers.map((r) => `@${r.login}`).join(", ")}`); + } + + lines.push(`Mergeable: ${pr.mergeable === null ? "checking..." : pr.mergeable ? "yes" : "no"} (${pr.mergeable_state})`); + lines.push(`Comments: ${pr.comments} | Review comments: ${pr.review_comments}`); + lines.push(`URL: ${pr.html_url}`); + lines.push(""); + lines.push(truncateBody(pr.body, 30)); + return lines.join("\n"); +} + +export function formatPRList(prs: GhPullRequest[]): string { + if (!prs.length) return "No pull requests found."; + return prs.map(formatPROneLiner).join("\n"); +} + +// ─── Comment formatting ────────────────────────────────────────────────────── + +export function formatComment(comment: GhComment): string { + return `@${comment.user.login} (${timeAgo(comment.created_at)}):\n${truncateBody(comment.body, 8)}`; +} + +export function formatCommentList(comments: GhComment[]): string { + if (!comments.length) return "No comments."; + return comments.map(formatComment).join("\n\n---\n\n"); +} + +// ─── Review formatting ─────────────────────────────────────────────────────── + +function reviewStateIcon(state: string): string { + switch (state) { + case "APPROVED": + return "✓"; + case "CHANGES_REQUESTED": + return "✗"; + case "COMMENTED": + return "💬"; + case "DISMISSED": + return "—"; + case "PENDING": + return "…"; + default: + return "?"; + } +} + +export function formatReview(review: GhReview): string { + const icon = reviewStateIcon(review.state); + const body = review.body ? `\n${truncateBody(review.body, 5)}` : ""; + return `${icon} @${review.user.login}: ${review.state} (${timeAgo(review.submitted_at)})${body}`; +} + +export function formatReviewList(reviews: GhReview[]): string { + if (!reviews.length) return "No reviews."; + return reviews.map(formatReview).join("\n\n"); +} + +// ─── Label / Milestone formatting ───────────────────────────────────────────── + +export function formatLabel(label: GhLabel): string { + const desc = label.description ? ` — ${label.description}` : ""; + return `• ${label.name} (#${label.color})${desc}`; +} + +export function formatLabelList(labels: GhLabel[]): string { + if (!labels.length) return "No labels."; + return labels.map(formatLabel).join("\n"); +} + +export function formatMilestone(ms: GhMilestone): string { + const progress = ms.open_issues + ms.closed_issues > 0 ? Math.round((ms.closed_issues / (ms.open_issues + ms.closed_issues)) * 100) : 0; + const due = ms.due_on ? ` | Due: ${new Date(ms.due_on).toISOString().split("T")[0]}` : ""; + return `• ${ms.title} (${ms.state}) — ${progress}% complete (${ms.closed_issues}/${ms.open_issues + ms.closed_issues})${due}`; +} + +export function formatMilestoneList(milestones: GhMilestone[]): string { + if (!milestones.length) return "No milestones."; + return milestones.map(formatMilestone).join("\n"); +} + +// ─── File change formatting ─────────────────────────────────────────────────── + +export function formatFileChanges( + files: { filename: string; status: string; additions: number; deletions: number; changes: number }[], +): string { + if (!files.length) return "No files changed."; + const lines = files.map((f) => { + const statusIcon = f.status === "added" ? "+" : f.status === "removed" ? "-" : "~"; + return `${statusIcon} ${f.filename} (+${f.additions} -${f.deletions})`; + }); + const totalAdd = files.reduce((s, f) => s + f.additions, 0); + const totalDel = files.reduce((s, f) => s + f.deletions, 0); + lines.push(`\n${files.length} files changed, +${totalAdd} -${totalDel}`); + return lines.join("\n"); +} diff --git a/src/resources/extensions/github/gh-api.ts b/src/resources/extensions/github/gh-api.ts new file mode 100644 index 000000000..6edc8b174 --- /dev/null +++ b/src/resources/extensions/github/gh-api.ts @@ -0,0 +1,537 @@ +/** + * GitHub API layer — wraps `gh` CLI with fallback to GITHUB_TOKEN + fetch. + * + * All GitHub communication goes through this module. + * Prefers `gh api` when the CLI is available and authenticated. + * Falls back to raw REST API with GITHUB_TOKEN env var. + */ + +import { execSync } from "node:child_process"; + +// ─── Auth detection ─────────────────────────────────────────────────────────── + +let _useGhCli: boolean | null = null; + +function hasGhCli(): boolean { + if (_useGhCli !== null) return _useGhCli; + try { + execSync("gh auth status", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }); + _useGhCli = true; + } catch { + _useGhCli = false; + } + return _useGhCli; +} + +function getToken(): string | undefined { + return process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; +} + +export function isAuthenticated(): boolean { + return hasGhCli() || !!getToken(); +} + +export function authMethod(): string { + if (hasGhCli()) return "gh CLI"; + if (getToken()) return "GITHUB_TOKEN"; + return "none"; +} + +// ─── Repo detection ─────────────────────────────────────────────────────────── + +export interface RepoInfo { + owner: string; + repo: string; + fullName: string; +} + +export function detectRepo(cwd: string): RepoInfo | null { + try { + const remote = execSync("git remote get-url origin", { + cwd, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + // Handle SSH: git@github.com:owner/repo.git + // Handle HTTPS: https://github.com/owner/repo.git + const sshMatch = remote.match(/github\.com[:/]([^/]+)\/([^/.]+)/); + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2], fullName: `${sshMatch[1]}/${sshMatch[2]}` }; + } + + return null; + } catch { + return null; + } +} + +export function getCurrentBranch(cwd: string): string | null { + try { + return execSync("git rev-parse --abbrev-ref HEAD", { + cwd, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { + return null; + } +} + +export function getDefaultBranch(cwd: string): string { + try { + const result = execSync("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo refs/remotes/origin/main", { + cwd, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return result.replace("refs/remotes/origin/", ""); + } catch { + return "main"; + } +} + +// ─── API calls ──────────────────────────────────────────────────────────────── + +/** + * Call the GitHub REST API. Returns parsed JSON. + * + * When method is GET and params are provided, they're appended as query params. + * When method is POST/PUT/PATCH/DELETE, params are sent as JSON body. + */ +export async function ghApi( + endpoint: string, + options: { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + params?: Record; + body?: Record; + cwd?: string; + } = {}, +): Promise { + const method = options.method ?? "GET"; + + if (hasGhCli()) { + return ghCliApi(endpoint, method, options.params, options.body, options.cwd); + } + + const token = getToken(); + if (!token) throw new Error("Not authenticated. Install gh CLI or set GITHUB_TOKEN."); + + return fetchApi(endpoint, method, options.params, options.body, token); +} + +function shellEscape(s: string): string { + // Single-quote wrapping, escaping any existing single quotes + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +function ghCliApi( + endpoint: string, + method: string, + params?: Record, + body?: Record, + cwd?: string, +): T { + const parts = ["gh", "api", shellEscape(endpoint), "--method", method]; + + if (params) { + for (const [key, val] of Object.entries(params)) { + if (val === undefined) continue; + if (Array.isArray(val)) { + for (const v of val) { + parts.push("-f", shellEscape(`${key}[]=${v}`)); + } + } else { + parts.push("-f", shellEscape(`${key}=${String(val)}`)); + } + } + } + + if (body) { + parts.push("--input", "-"); + } + + try { + const result = execSync(parts.join(" "), { + cwd: cwd ?? process.cwd(), + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + input: body ? JSON.stringify(body) : undefined, + }); + if (!result.trim()) return {} as T; + return JSON.parse(result) as T; + } catch (e: unknown) { + const err = e as { stderr?: string; stdout?: string; message?: string }; + const msg = err.stderr?.trim() || err.stdout?.trim() || err.message || String(e); + throw new Error(`gh api error: ${msg}`); + } +} + +async function fetchApi( + endpoint: string, + method: string, + params?: Record, + body?: Record, + token?: string, +): Promise { + let url = endpoint.startsWith("http") ? endpoint : `https://api.github.com${endpoint}`; + + if (method === "GET" && params) { + const qs = new URLSearchParams(); + for (const [key, val] of Object.entries(params)) { + if (val === undefined) continue; + if (Array.isArray(val)) { + for (const v of val) qs.append(key, v); + } else { + qs.set(key, String(val)); + } + } + const qsStr = qs.toString(); + if (qsStr) url += `?${qsStr}`; + } + + const headers: Record = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(url, { + method, + headers, + body: method !== "GET" && body ? JSON.stringify(body) : undefined, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`GitHub API ${res.status}: ${text}`); + } + + const text = await res.text(); + if (!text.trim()) return {} as T; + return JSON.parse(text) as T; +} + +// ─── Typed API wrappers ─────────────────────────────────────────────────────── + +export interface GhIssue { + number: number; + title: string; + state: string; + body: string | null; + user: { login: string }; + labels: { name: string; color: string }[]; + assignees: { login: string }[]; + milestone: { title: string; number: number } | null; + created_at: string; + updated_at: string; + closed_at: string | null; + comments: number; + html_url: string; + pull_request?: { url: string }; +} + +export interface GhPullRequest { + number: number; + title: string; + state: string; + body: string | null; + user: { login: string }; + labels: { name: string; color: string }[]; + assignees: { login: string }[]; + milestone: { title: string; number: number } | null; + head: { ref: string; sha: string }; + base: { ref: string }; + created_at: string; + updated_at: string; + merged_at: string | null; + closed_at: string | null; + comments: number; + review_comments: number; + draft: boolean; + mergeable: boolean | null; + mergeable_state: string; + html_url: string; + diff_url: string; + requested_reviewers: { login: string }[]; +} + +export interface GhComment { + id: number; + body: string; + user: { login: string }; + created_at: string; + updated_at: string; + html_url: string; +} + +export interface GhLabel { + name: string; + color: string; + description: string | null; +} + +export interface GhMilestone { + number: number; + title: string; + description: string | null; + state: string; + open_issues: number; + closed_issues: number; + due_on: string | null; +} + +export interface GhReview { + id: number; + user: { login: string }; + state: string; + body: string | null; + submitted_at: string; + html_url: string; +} + +export interface GhCheckRun { + name: string; + status: string; + conclusion: string | null; + html_url: string; +} + +// ─── Issues ─────────────────────────────────────────────────────────────────── + +export async function listIssues( + repo: RepoInfo, + options: { + state?: "open" | "closed" | "all"; + labels?: string; + assignee?: string; + milestone?: string; + sort?: "created" | "updated" | "comments"; + direction?: "asc" | "desc"; + per_page?: number; + page?: number; + } = {}, +): Promise { + const params: Record = { + state: options.state ?? "open", + sort: options.sort ?? "updated", + direction: options.direction ?? "desc", + per_page: String(options.per_page ?? 30), + page: String(options.page ?? 1), + }; + if (options.labels) params.labels = options.labels; + if (options.assignee) params.assignee = options.assignee; + if (options.milestone) params.milestone = options.milestone; + + const issues = await ghApi(`/repos/${repo.fullName}/issues`, { params }); + // Filter out PRs (GitHub API returns PRs in issues endpoint) + return issues.filter((i) => !i.pull_request); +} + +export async function getIssue(repo: RepoInfo, number: number): Promise { + return ghApi(`/repos/${repo.fullName}/issues/${number}`); +} + +export async function createIssue( + repo: RepoInfo, + data: { title: string; body?: string; labels?: string[]; assignees?: string[]; milestone?: number }, +): Promise { + return ghApi(`/repos/${repo.fullName}/issues`, { + method: "POST", + body: data, + }); +} + +export async function updateIssue( + repo: RepoInfo, + number: number, + data: { title?: string; body?: string; state?: string; labels?: string[]; assignees?: string[]; milestone?: number | null }, +): Promise { + return ghApi(`/repos/${repo.fullName}/issues/${number}`, { + method: "PATCH", + body: data, + }); +} + +export async function addComment(repo: RepoInfo, number: number, body: string): Promise { + return ghApi(`/repos/${repo.fullName}/issues/${number}/comments`, { + method: "POST", + body: { body }, + }); +} + +export async function listComments(repo: RepoInfo, number: number): Promise { + return ghApi(`/repos/${repo.fullName}/issues/${number}/comments`); +} + +// ─── Pull Requests ──────────────────────────────────────────────────────────── + +export async function listPullRequests( + repo: RepoInfo, + options: { + state?: "open" | "closed" | "all"; + sort?: "created" | "updated" | "popularity" | "long-running"; + direction?: "asc" | "desc"; + per_page?: number; + page?: number; + head?: string; + base?: string; + } = {}, +): Promise { + const params: Record = { + state: options.state ?? "open", + sort: options.sort ?? "updated", + direction: options.direction ?? "desc", + per_page: String(options.per_page ?? 30), + page: String(options.page ?? 1), + }; + if (options.head) params.head = options.head; + if (options.base) params.base = options.base; + + return ghApi(`/repos/${repo.fullName}/pulls`, { params }); +} + +export async function getPullRequest(repo: RepoInfo, number: number): Promise { + return ghApi(`/repos/${repo.fullName}/pulls/${number}`); +} + +export async function createPullRequest( + repo: RepoInfo, + data: { title: string; body?: string; head: string; base: string; draft?: boolean }, +): Promise { + return ghApi(`/repos/${repo.fullName}/pulls`, { + method: "POST", + body: data, + }); +} + +export async function updatePullRequest( + repo: RepoInfo, + number: number, + data: { title?: string; body?: string; state?: string; base?: string }, +): Promise { + return ghApi(`/repos/${repo.fullName}/pulls/${number}`, { + method: "PATCH", + body: data, + }); +} + +export async function getPullRequestDiff(repo: RepoInfo, number: number): Promise { + if (hasGhCli()) { + try { + return execSync(`gh pr diff ${number} --repo ${repo.fullName}`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch (e: unknown) { + const err = e as { stderr?: string; message?: string }; + throw new Error(err.stderr?.trim() || err.message || String(e)); + } + } + + const token = getToken(); + const headers: Record = { + Accept: "application/vnd.github.v3.diff", + "X-GitHub-Api-Version": "2022-11-28", + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(`https://api.github.com/repos/${repo.fullName}/pulls/${number}`, { headers }); + if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`); + return res.text(); +} + +export async function listPullRequestFiles( + repo: RepoInfo, + number: number, +): Promise<{ filename: string; status: string; additions: number; deletions: number; changes: number }[]> { + return ghApi(`/repos/${repo.fullName}/pulls/${number}/files`); +} + +// ─── Reviews ────────────────────────────────────────────────────────────────── + +export async function listReviews(repo: RepoInfo, number: number): Promise { + return ghApi(`/repos/${repo.fullName}/pulls/${number}/reviews`); +} + +export async function createReview( + repo: RepoInfo, + number: number, + data: { body?: string; event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT" }, +): Promise { + return ghApi(`/repos/${repo.fullName}/pulls/${number}/reviews`, { + method: "POST", + body: data, + }); +} + +export async function requestReviewers( + repo: RepoInfo, + number: number, + reviewers: string[], +): Promise { + return ghApi(`/repos/${repo.fullName}/pulls/${number}/requested_reviewers`, { + method: "POST", + body: { reviewers }, + }); +} + +// ─── Checks ─────────────────────────────────────────────────────────────────── + +export async function listCheckRuns(repo: RepoInfo, ref: string): Promise<{ check_runs: GhCheckRun[] }> { + return ghApi(`/repos/${repo.fullName}/commits/${ref}/check-runs`); +} + +// ─── Labels & Milestones ────────────────────────────────────────────────────── + +export async function listLabels(repo: RepoInfo): Promise { + return ghApi(`/repos/${repo.fullName}/labels`, { + params: { per_page: "100" }, + }); +} + +export async function createLabel( + repo: RepoInfo, + data: { name: string; color: string; description?: string }, +): Promise { + return ghApi(`/repos/${repo.fullName}/labels`, { + method: "POST", + body: data, + }); +} + +export async function listMilestones(repo: RepoInfo): Promise { + return ghApi(`/repos/${repo.fullName}/milestones`, { + params: { state: "all", per_page: "100" }, + }); +} + +export async function createMilestone( + repo: RepoInfo, + data: { title: string; description?: string; due_on?: string }, +): Promise { + return ghApi(`/repos/${repo.fullName}/milestones`, { + method: "POST", + body: data, + }); +} + +// ─── Search ─────────────────────────────────────────────────────────────────── + +export interface GhSearchResult { + total_count: number; + items: T[]; +} + +export async function searchIssues( + query: string, + options: { per_page?: number; page?: number } = {}, +): Promise> { + return ghApi>("/search/issues", { + params: { + q: query, + per_page: String(options.per_page ?? 30), + page: String(options.page ?? 1), + }, + }); +} diff --git a/src/resources/extensions/github/index.ts b/src/resources/extensions/github/index.ts new file mode 100644 index 000000000..dbd07da2f --- /dev/null +++ b/src/resources/extensions/github/index.ts @@ -0,0 +1,730 @@ +/** + * GitHub Extension — /gh + * + * Full-suite GitHub issues and PR tracker/helper for pi. + * Provides LLM tools + /gh slash command for managing issues, PRs, + * reviews, labels, milestones, and comments. + * + * Auth: gh CLI (preferred) → GITHUB_TOKEN env var (fallback) + * + * Tools: + * github_issues — list, view, create, update, close, search issues + * github_prs — list, view, create, update, diff, files, checks for PRs + * github_comments — list, add comments on issues/PRs + * github_reviews — list, create reviews, request reviewers + * github_labels — list, create labels; list, create milestones + * + * Commands: + * /gh issues [state] — browse issues + * /gh prs [state] — browse PRs + * /gh view — view issue or PR detail + * /gh create issue — create issue interactively + * /gh create pr — create PR from current branch + * /gh labels — list labels + * /gh milestones — list milestones + * /gh status — show auth + repo status + */ + +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Text } from "@mariozechner/pi-tui"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent"; + +import { + isAuthenticated, + authMethod, + detectRepo, + getCurrentBranch, + getDefaultBranch, + type RepoInfo, + listIssues, + getIssue, + createIssue, + updateIssue, + addComment, + listComments, + listPullRequests, + getPullRequest, + createPullRequest, + updatePullRequest, + getPullRequestDiff, + listPullRequestFiles, + listReviews, + createReview, + requestReviewers, + listCheckRuns, + listLabels, + createLabel, + listMilestones, + createMilestone, + searchIssues, +} from "./gh-api.js"; + +import { + formatIssueList, + formatIssueDetail, + formatPRList, + formatPRDetail, + formatCommentList, + formatReviewList, + formatFileChanges, + formatLabelList, + formatMilestoneList, +} from "./formatters.js"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function requireRepo(cwd: string): RepoInfo { + const repo = detectRepo(cwd); + if (!repo) throw new Error("Not in a GitHub repository. Run this from a git repo with a GitHub remote."); + return repo; +} + +function requireAuth(): void { + if (!isAuthenticated()) { + throw new Error("Not authenticated to GitHub. Install and authenticate `gh` CLI, or set GITHUB_TOKEN env var."); + } +} + +function truncateOutput(text: string): string { + const result = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); + if (result.truncated) { + return result.content + `\n\n[Output truncated: showing ${result.outputLines}/${result.totalLines} lines]`; + } + return result.content; +} + +function textResult(text: string, details?: Record) { + return { + content: [{ type: "text" as const, text: truncateOutput(text) }], + ...(details ? { details } : {}), + }; +} + +// ─── Extension ──────────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + // ─── Tool: github_issues ──────────────────────────────────────────────── + + pi.registerTool({ + name: "github_issues", + label: "GitHub Issues", + description: "Manage GitHub issues: list, view, create, update, close, reopen, or search issues in the current repository.", + promptSnippet: "List, view, create, update, close, reopen, or search GitHub issues", + promptGuidelines: [ + "Use github_issues to interact with GitHub issues instead of running `gh` CLI commands directly.", + "When listing issues, default to state='open' and include relevant filters like labels or assignee.", + "When searching, use GitHub search syntax in the query (e.g., 'is:open label:bug').", + ], + parameters: Type.Object({ + action: StringEnum(["list", "view", "create", "update", "close", "reopen", "search"] as const), + number: Type.Optional(Type.Number({ description: "Issue number (for view/update/close/reopen)" })), + title: Type.Optional(Type.String({ description: "Issue title (for create)" })), + body: Type.Optional(Type.String({ description: "Issue body (for create/update)" })), + labels: Type.Optional(Type.String({ description: "Comma-separated labels (for list filter or create/update)" })), + assignee: Type.Optional(Type.String({ description: "Assignee username (for list filter or create/update)" })), + assignees: Type.Optional(Type.String({ description: "Comma-separated assignees (for create/update)" })), + milestone: Type.Optional(Type.String({ description: "Milestone number or title (for list filter)" })), + state: Type.Optional(StringEnum(["open", "closed", "all"] as const)), + query: Type.Optional(Type.String({ description: "Search query using GitHub search syntax (for search action)" })), + per_page: Type.Optional(Type.Number({ description: "Results per page (default 30, max 100)" })), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + requireAuth(); + const repo = requireRepo(ctx.cwd); + + switch (params.action) { + case "list": { + const issues = await listIssues(repo, { + state: params.state, + labels: params.labels, + assignee: params.assignee, + milestone: params.milestone, + per_page: params.per_page, + }); + return textResult( + `Issues in ${repo.fullName} (${params.state ?? "open"}):\n\n${formatIssueList(issues)}`, + { issues: issues.map((i) => ({ number: i.number, title: i.title, state: i.state })) }, + ); + } + case "view": { + if (!params.number) return textResult("Error: 'number' is required for view action."); + const issue = await getIssue(repo, params.number); + const comments = await listComments(repo, params.number); + let text = formatIssueDetail(issue); + if (comments.length) { + text += `\n\n## Comments (${comments.length})\n\n${formatCommentList(comments)}`; + } + return textResult(text, { issue: { number: issue.number, title: issue.title, state: issue.state } }); + } + case "create": { + if (!params.title) return textResult("Error: 'title' is required for create action."); + const newIssue = await createIssue(repo, { + title: params.title, + body: params.body, + labels: params.labels?.split(",").map((l) => l.trim()), + assignees: params.assignees?.split(",").map((a) => a.trim()), + }); + return textResult( + `Created issue #${newIssue.number}: ${newIssue.title}\n${newIssue.html_url}`, + { issue: { number: newIssue.number, title: newIssue.title } }, + ); + } + case "update": { + if (!params.number) return textResult("Error: 'number' is required for update action."); + const updated = await updateIssue(repo, params.number, { + title: params.title, + body: params.body, + labels: params.labels?.split(",").map((l) => l.trim()), + assignees: params.assignees?.split(",").map((a) => a.trim()), + }); + return textResult( + `Updated issue #${updated.number}: ${updated.title}\n${updated.html_url}`, + { issue: { number: updated.number, title: updated.title } }, + ); + } + case "close": { + if (!params.number) return textResult("Error: 'number' is required for close action."); + const closed = await updateIssue(repo, params.number, { state: "closed" }); + return textResult(`Closed issue #${closed.number}: ${closed.title}`, { issue: { number: closed.number } }); + } + case "reopen": { + if (!params.number) return textResult("Error: 'number' is required for reopen action."); + const reopened = await updateIssue(repo, params.number, { state: "open" }); + return textResult(`Reopened issue #${reopened.number}: ${reopened.title}`, { issue: { number: reopened.number } }); + } + case "search": { + if (!params.query) return textResult("Error: 'query' is required for search action."); + const q = `repo:${repo.fullName} ${params.query}`; + const results = await searchIssues(q, { per_page: params.per_page }); + const issuesOnly = results.items.filter((i) => !i.pull_request); + return textResult( + `Search results (${results.total_count} total, showing ${issuesOnly.length}):\n\n${formatIssueList(issuesOnly)}`, + { total: results.total_count }, + ); + } + default: + return textResult(`Unknown action: ${params.action}`); + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("github_issues ")); + text += theme.fg("muted", `${args.action ?? "?"}`); + if (args.number) text += theme.fg("accent", ` #${args.number}`); + if (args.title) text += theme.fg("dim", ` "${args.title}"`); + if (args.query) text += theme.fg("dim", ` "${args.query}"`); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Fetching from GitHub..."), 0, 0); + const content = result.content?.[0]?.type === "text" ? result.content[0].text : ""; + if (!expanded) { + const firstLine = content.split("\n")[0] ?? ""; + return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0); + } + return new Text(content, 0, 0); + }, + }); + + // ─── Tool: github_prs ─────────────────────────────────────────────────── + + pi.registerTool({ + name: "github_prs", + label: "GitHub PRs", + description: "Manage GitHub pull requests: list, view, create, update, get diff, list files, and check CI status.", + promptSnippet: "List, view, create, update, diff, files, and checks for GitHub pull requests", + promptGuidelines: [ + "Use github_prs to interact with GitHub pull requests instead of running `gh` CLI commands directly.", + "Use action='diff' to see the actual code changes in a PR.", + "Use action='files' for a summary of changed files without the full diff.", + "Use action='checks' to see CI/CD status for a PR.", + ], + parameters: Type.Object({ + action: StringEnum(["list", "view", "create", "update", "diff", "files", "checks"] as const), + number: Type.Optional(Type.Number({ description: "PR number (for view/update/diff/files/checks)" })), + title: Type.Optional(Type.String({ description: "PR title (for create)" })), + body: Type.Optional(Type.String({ description: "PR body (for create/update)" })), + head: Type.Optional(Type.String({ description: "Head branch (for create, defaults to current branch)" })), + base: Type.Optional(Type.String({ description: "Base branch (for create, defaults to repo default branch)" })), + draft: Type.Optional(Type.Boolean({ description: "Create as draft PR (for create)" })), + state: Type.Optional(StringEnum(["open", "closed", "all"] as const)), + per_page: Type.Optional(Type.Number({ description: "Results per page (default 30, max 100)" })), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + requireAuth(); + const repo = requireRepo(ctx.cwd); + + switch (params.action) { + case "list": { + const prs = await listPullRequests(repo, { + state: params.state, + per_page: params.per_page, + }); + return textResult( + `Pull requests in ${repo.fullName} (${params.state ?? "open"}):\n\n${formatPRList(prs)}`, + { prs: prs.map((p) => ({ number: p.number, title: p.title, state: p.state, draft: p.draft })) }, + ); + } + case "view": { + if (!params.number) return textResult("Error: 'number' is required for view action."); + const pr = await getPullRequest(repo, params.number); + const reviews = await listReviews(repo, params.number); + let text = formatPRDetail(pr); + if (reviews.length) { + text += `\n\n## Reviews (${reviews.length})\n\n${formatReviewList(reviews)}`; + } + return textResult(text, { pr: { number: pr.number, title: pr.title, state: pr.state } }); + } + case "create": { + if (!params.title) return textResult("Error: 'title' is required for create action."); + const head = params.head ?? getCurrentBranch(ctx.cwd); + if (!head) return textResult("Error: Could not determine current branch. Provide 'head' parameter."); + const base = params.base ?? getDefaultBranch(ctx.cwd); + const newPR = await createPullRequest(repo, { + title: params.title, + body: params.body, + head, + base, + draft: params.draft, + }); + return textResult( + `Created PR #${newPR.number}: ${newPR.title}\n${newPR.head.ref} → ${newPR.base.ref}\n${newPR.html_url}`, + { pr: { number: newPR.number, title: newPR.title } }, + ); + } + case "update": { + if (!params.number) return textResult("Error: 'number' is required for update action."); + const updated = await updatePullRequest(repo, params.number, { + title: params.title, + body: params.body, + base: params.base, + }); + return textResult( + `Updated PR #${updated.number}: ${updated.title}\n${updated.html_url}`, + { pr: { number: updated.number, title: updated.title } }, + ); + } + case "diff": { + if (!params.number) return textResult("Error: 'number' is required for diff action."); + const diff = await getPullRequestDiff(repo, params.number); + return textResult(`Diff for PR #${params.number}:\n\n${diff}`); + } + case "files": { + if (!params.number) return textResult("Error: 'number' is required for files action."); + const files = await listPullRequestFiles(repo, params.number); + return textResult( + `Changed files in PR #${params.number}:\n\n${formatFileChanges(files)}`, + { files: files.map((f) => ({ filename: f.filename, status: f.status })) }, + ); + } + case "checks": { + if (!params.number) return textResult("Error: 'number' is required for checks action."); + const pr = await getPullRequest(repo, params.number); + const checks = await listCheckRuns(repo, pr.head.sha); + if (!checks.check_runs.length) { + return textResult(`No CI checks found for PR #${params.number}.`); + } + const lines = checks.check_runs.map((c) => { + const icon = c.conclusion === "success" ? "✓" : c.conclusion === "failure" ? "✗" : c.status === "in_progress" ? "⟳" : "…"; + return `${icon} ${c.name}: ${c.conclusion ?? c.status}`; + }); + return textResult( + `CI checks for PR #${params.number}:\n\n${lines.join("\n")}`, + { checks: checks.check_runs.map((c) => ({ name: c.name, conclusion: c.conclusion, status: c.status })) }, + ); + } + default: + return textResult(`Unknown action: ${params.action}`); + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("github_prs ")); + text += theme.fg("muted", `${args.action ?? "?"}`); + if (args.number) text += theme.fg("accent", ` #${args.number}`); + if (args.title) text += theme.fg("dim", ` "${args.title}"`); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Fetching from GitHub..."), 0, 0); + const content = result.content?.[0]?.type === "text" ? result.content[0].text : ""; + if (!expanded) { + const firstLine = content.split("\n")[0] ?? ""; + return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0); + } + return new Text(content, 0, 0); + }, + }); + + // ─── Tool: github_comments ────────────────────────────────────────────── + + pi.registerTool({ + name: "github_comments", + label: "GitHub Comments", + description: "List or add comments on GitHub issues and pull requests.", + promptSnippet: "List or add comments on GitHub issues and PRs", + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), + number: Type.Number({ description: "Issue or PR number" }), + body: Type.Optional(Type.String({ description: "Comment body text (for add)" })), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + requireAuth(); + const repo = requireRepo(ctx.cwd); + + switch (params.action) { + case "list": { + const comments = await listComments(repo, params.number); + return textResult( + `Comments on #${params.number} (${comments.length}):\n\n${formatCommentList(comments)}`, + { count: comments.length }, + ); + } + case "add": { + if (!params.body) return textResult("Error: 'body' is required for add action."); + const comment = await addComment(repo, params.number, params.body); + return textResult( + `Added comment on #${params.number}: ${comment.html_url}`, + { comment: { id: comment.id } }, + ); + } + default: + return textResult(`Unknown action: ${params.action}`); + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("github_comments ")); + text += theme.fg("muted", `${args.action ?? "?"}`); + text += theme.fg("accent", ` #${args.number ?? "?"}`); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Fetching..."), 0, 0); + const content = result.content?.[0]?.type === "text" ? result.content[0].text : ""; + if (!expanded) { + const firstLine = content.split("\n")[0] ?? ""; + return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0); + } + return new Text(content, 0, 0); + }, + }); + + // ─── Tool: github_reviews ─────────────────────────────────────────────── + + pi.registerTool({ + name: "github_reviews", + label: "GitHub Reviews", + description: "Manage GitHub PR reviews: list reviews, submit a review (approve/request changes/comment), or request reviewers.", + promptSnippet: "List reviews, submit reviews, or request reviewers on GitHub PRs", + promptGuidelines: [ + "Use event='APPROVE' to approve, 'REQUEST_CHANGES' to request changes, 'COMMENT' for a general review comment.", + "Use action='request_reviewers' to assign reviewers to a PR.", + ], + parameters: Type.Object({ + action: StringEnum(["list", "submit", "request_reviewers"] as const), + number: Type.Number({ description: "PR number" }), + body: Type.Optional(Type.String({ description: "Review body text (for submit)" })), + event: Type.Optional(StringEnum(["APPROVE", "REQUEST_CHANGES", "COMMENT"] as const)), + reviewers: Type.Optional(Type.String({ description: "Comma-separated reviewer usernames (for request_reviewers)" })), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + requireAuth(); + const repo = requireRepo(ctx.cwd); + + switch (params.action) { + case "list": { + const reviews = await listReviews(repo, params.number); + return textResult( + `Reviews on PR #${params.number} (${reviews.length}):\n\n${formatReviewList(reviews)}`, + { count: reviews.length }, + ); + } + case "submit": { + if (!params.event) return textResult("Error: 'event' is required for submit action (APPROVE, REQUEST_CHANGES, or COMMENT)."); + const review = await createReview(repo, params.number, { + body: params.body, + event: params.event, + }); + return textResult( + `Submitted review on PR #${params.number}: ${review.state}\n${review.html_url}`, + { review: { id: review.id, state: review.state } }, + ); + } + case "request_reviewers": { + if (!params.reviewers) return textResult("Error: 'reviewers' is required for request_reviewers action."); + const reviewerList = params.reviewers.split(",").map((r) => r.trim()); + await requestReviewers(repo, params.number, reviewerList); + return textResult( + `Requested reviewers on PR #${params.number}: ${reviewerList.join(", ")}`, + { reviewers: reviewerList }, + ); + } + default: + return textResult(`Unknown action: ${params.action}`); + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("github_reviews ")); + text += theme.fg("muted", `${args.action ?? "?"}`); + text += theme.fg("accent", ` #${args.number ?? "?"}`); + if (args.event) text += theme.fg("dim", ` ${args.event}`); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Processing..."), 0, 0); + const content = result.content?.[0]?.type === "text" ? result.content[0].text : ""; + if (!expanded) { + const firstLine = content.split("\n")[0] ?? ""; + return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0); + } + return new Text(content, 0, 0); + }, + }); + + // ─── Tool: github_labels ──────────────────────────────────────────────── + + pi.registerTool({ + name: "github_labels", + label: "GitHub Labels", + description: "Manage GitHub labels and milestones: list/create labels, list/create milestones.", + promptSnippet: "List or create GitHub labels and milestones", + parameters: Type.Object({ + action: StringEnum(["list_labels", "create_label", "list_milestones", "create_milestone"] as const), + name: Type.Optional(Type.String({ description: "Label or milestone name (for create)" })), + color: Type.Optional(Type.String({ description: "Label hex color without # (for create_label, e.g. 'ff0000')" })), + description: Type.Optional(Type.String({ description: "Description (for create)" })), + due_on: Type.Optional(Type.String({ description: "Milestone due date ISO 8601 (for create_milestone, e.g. '2025-12-31T00:00:00Z')" })), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + requireAuth(); + const repo = requireRepo(ctx.cwd); + + switch (params.action) { + case "list_labels": { + const labels = await listLabels(repo); + return textResult(`Labels in ${repo.fullName}:\n\n${formatLabelList(labels)}`, { count: labels.length }); + } + case "create_label": { + if (!params.name) return textResult("Error: 'name' is required for create_label."); + const label = await createLabel(repo, { + name: params.name, + color: params.color ?? "ededed", + description: params.description, + }); + return textResult(`Created label: ${label.name} (#${label.color})`, { label: { name: label.name } }); + } + case "list_milestones": { + const milestones = await listMilestones(repo); + return textResult(`Milestones in ${repo.fullName}:\n\n${formatMilestoneList(milestones)}`, { count: milestones.length }); + } + case "create_milestone": { + if (!params.name) return textResult("Error: 'name' is required for create_milestone."); + const ms = await createMilestone(repo, { + title: params.name, + description: params.description, + due_on: params.due_on, + }); + return textResult(`Created milestone: ${ms.title} (#${ms.number})`, { milestone: { number: ms.number, title: ms.title } }); + } + default: + return textResult(`Unknown action: ${params.action}`); + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("github_labels ")); + text += theme.fg("muted", `${args.action ?? "?"}`); + if (args.name) text += theme.fg("dim", ` "${args.name}"`); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Fetching..."), 0, 0); + const content = result.content?.[0]?.type === "text" ? result.content[0].text : ""; + if (!expanded) { + const firstLine = content.split("\n")[0] ?? ""; + return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0); + } + return new Text(content, 0, 0); + }, + }); + + // ─── Slash command: /gh ────────────────────────────────────────────────── + + pi.registerCommand("gh", { + description: "GitHub helper: /gh issues|prs|view|create|labels|milestones|status", + + getArgumentCompletions: (prefix: string) => { + const subcommands = ["issues", "prs", "view", "create", "labels", "milestones", "status"]; + const parts = prefix.trim().split(/\s+/); + + if (parts.length <= 1) { + return subcommands + .filter((cmd) => cmd.startsWith(parts[0] ?? "")) + .map((cmd) => ({ value: cmd, label: cmd })); + } + + if (parts[0] === "issues" || parts[0] === "prs") { + const states = ["open", "closed", "all"]; + const statePrefix = parts[1] ?? ""; + return states + .filter((s) => s.startsWith(statePrefix)) + .map((s) => ({ value: `${parts[0]} ${s}`, label: s })); + } + + if (parts[0] === "create") { + const types = ["issue", "pr"]; + const typePrefix = parts[1] ?? ""; + return types + .filter((t) => t.startsWith(typePrefix)) + .map((t) => ({ value: `create ${t}`, label: t })); + } + + return []; + }, + + handler: async (args, ctx) => { + const parts = args.trim().split(/\s+/); + const sub = parts[0]; + const rest = parts.slice(1).join(" "); + + if (!isAuthenticated()) { + ctx.ui.notify("Not authenticated to GitHub. Install `gh` CLI or set GITHUB_TOKEN.", "error"); + return; + } + + const repo = detectRepo(ctx.cwd); + if (!repo && sub !== "status") { + ctx.ui.notify("Not in a GitHub repository.", "error"); + return; + } + + try { + switch (sub) { + case "issues": { + const state = (rest as "open" | "closed" | "all") || "open"; + const issues = await listIssues(repo!, { state }); + const display = `Issues in ${repo!.fullName} (${state}):\n\n${formatIssueList(issues)}`; + pi.sendMessage({ customType: "github", content: display, display: true }); + break; + } + case "prs": { + const state = (rest as "open" | "closed" | "all") || "open"; + const prs = await listPullRequests(repo!, { state }); + const display = `Pull requests in ${repo!.fullName} (${state}):\n\n${formatPRList(prs)}`; + pi.sendMessage({ customType: "github", content: display, display: true }); + break; + } + case "view": { + const num = parseInt(rest, 10); + if (isNaN(num)) { + ctx.ui.notify("Usage: /gh view ", "error"); + return; + } + // Try as issue first, then PR + try { + const issue = await getIssue(repo!, num); + if (issue.pull_request) { + // It's a PR + const pr = await getPullRequest(repo!, num); + const reviews = await listReviews(repo!, num); + let text = formatPRDetail(pr); + if (reviews.length) text += `\n\n## Reviews\n\n${formatReviewList(reviews)}`; + pi.sendMessage({ customType: "github", content: text, display: true }); + } else { + const comments = await listComments(repo!, num); + let text = formatIssueDetail(issue); + if (comments.length) text += `\n\n## Comments\n\n${formatCommentList(comments)}`; + pi.sendMessage({ customType: "github", content: text, display: true }); + } + } catch { + ctx.ui.notify(`Could not find issue or PR #${num}`, "error"); + } + break; + } + case "create": { + const type = parts[1]; + if (type === "issue") { + ctx.ui.notify("Use the agent to create an issue: tell it the title, description, and labels you want.", "info"); + } else if (type === "pr") { + const branch = getCurrentBranch(ctx.cwd); + const base = getDefaultBranch(ctx.cwd); + ctx.ui.notify( + `Current branch: ${branch}\nBase: ${base}\n\nTell the agent the PR title and description to create it.`, + "info", + ); + } else { + ctx.ui.notify("Usage: /gh create issue|pr", "error"); + } + break; + } + case "labels": { + const labels = await listLabels(repo!); + pi.sendMessage({ customType: "github", content: `Labels in ${repo!.fullName}:\n\n${formatLabelList(labels)}`, display: true }); + break; + } + case "milestones": { + const milestones = await listMilestones(repo!); + pi.sendMessage({ + customType: "github", + content: `Milestones in ${repo!.fullName}:\n\n${formatMilestoneList(milestones)}`, + display: true, + }); + break; + } + case "status": { + const auth = authMethod(); + const repoStr = repo ? `${repo.fullName}` : "not detected"; + const branch = repo ? getCurrentBranch(ctx.cwd) ?? "unknown" : "n/a"; + const text = `GitHub Extension Status\n\nAuth: ${auth}\nRepo: ${repoStr}\nBranch: ${branch}`; + pi.sendMessage({ customType: "github", content: text, display: true }); + break; + } + default: + ctx.ui.notify("Usage: /gh issues|prs|view|create|labels|milestones|status", "info"); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + ctx.ui.notify(`GitHub error: ${msg}`, "error"); + } + }, + }); + + // ─── Message renderer ─────────────────────────────────────────────────── + + pi.registerMessageRenderer("github", (message, _options, theme) => { + const content = message.content ?? ""; + // Apply some light styling to the GitHub output + const styled = content + .replace(/^(# .+)$/gm, (m: string) => theme.fg("accent", theme.bold(m))) + .replace(/(●)/g, theme.fg("success", "$1")) + .replace(/(✓)/g, theme.fg("success", "$1")) + .replace(/(✗)/g, theme.fg("error", "$1")) + .replace(/(⊕)/g, theme.fg("accent", "$1")) + .replace(/(◇)/g, theme.fg("dim", "$1")) + .replace(/(https:\/\/github\.com\S+)/g, theme.fg("mdLink", "$1")); + return new Text(styled, 0, 0); + }); + + // ─── Session start notification ───────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + const auth = authMethod(); + if (auth === "none") { + ctx.ui.notify("GitHub extension: not authenticated. Install `gh` CLI or set GITHUB_TOKEN.", "warning"); + } + }); +}