import type { GithubComment, GithubDetail, GithubFile, GithubItem, GithubKind, GithubList, } from "@ace/channel/protocol"; import { githubEnv } from "./keys"; import type { HostRequest } from "./protocol"; const REPO = /^[a-z\d][a-z\d-]*\/[a-z\d_.-]+$/i; const FIELDS = "number,title,url,state,author,createdAt,updatedAt,labels"; type Item = { number: number; title: string; url: string; state: "OPEN" | "CLOSED" | "MERGED"; isDraft?: boolean; author: { login: string } | null; createdAt: string; updatedAt: string; labels: { name: string; color: string }[]; }; type Comment = { id: string; url: string; author: { login: string } | null; body: string; createdAt: string; }; type Review = Omit & { submittedAt: string; state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" | "PENDING"; }; type Detail = Item & { body: string; comments: Comment[]; reviews?: Review[]; baseRefName?: string; headRefName?: string; additions?: number; deletions?: number; changedFiles?: number; reviewDecision?: string; }; type File = { filename: string; previous_filename?: string; status: string; additions: number; deletions: number; patch?: string; }; function repository(value: string): string { if (!REPO.test(value) || [".", ".."].includes(value.split("/")[1]!)) { throw new Error("Choose a GitHub repository in owner/name format"); } return value; } function command(kind: GithubKind): "issue" | "pr" { if (kind === "issues") return "issue"; if (kind === "prs") return "pr"; throw new Error("Choose issues or pull requests"); } function number(value: number): string { if (!Number.isSafeInteger(value) || value < 1) throw new Error("Invalid GitHub item number"); return String(value); } async function gh(args: string[]): Promise { const path = Bun.which("gh", { PATH: process.env.PATH }); if (!path) throw new Error("Install GitHub CLI on this host, then sign in with gh auth login."); const env: NodeJS.ProcessEnv = { ...process.env, ...githubEnv(), GH_HOST: "github.com", GH_PROMPT_DISABLED: "1", GH_PAGER: "cat", }; delete env.GH_REPO; const child = Bun.spawn([path, ...args], { env, stdin: "ignore", stdout: "pipe", stderr: "pipe", timeout: 30_000, }); const [out, error, code] = await Promise.all([ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, ]); if (code === 4) throw new Error("Sign in to GitHub on this host with gh auth login."); if (code !== 0) throw new Error(error.trim() || "GitHub did not respond. Try again."); return JSON.parse(out) as T; } /** Reading a remote needs no GitHub credentials, including on a teammate's host. */ export async function project(path: string): Promise { const child = Bun.spawn([ "git", "-C", path, "config", "--local", "--get-regexp", "^remote\\..*\\.(url|gh-resolved)$", ], { stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: 5000, }); const [out, code] = await Promise.all([new Response(child.stdout).text(), child.exited]); if (code !== 0) return null; const remotes = new Map(); let preferred: string | undefined; for (const line of out.trim().split("\n")) { const match = /^remote\.(.+)\.(url|gh-resolved)\s+(.+)$/.exec(line); if (!match) continue; const name = match[1]!; const field = match[2]!; const value = match[3]!; if (field === "gh-resolved" && value === "base") preferred = name; if (field === "url") remotes.set(name, value); } for (const name of [preferred, "origin", "upstream", ...remotes.keys()]) { const url = name ? remotes.get(name) : undefined; if (!url) continue; const match = /^(?:https?:\/\/(?:[^/@]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com(?::443)?\/)([^/]+\/[^/]+?)\/?$/i .exec(url); if (!match) continue; const repo = match[1]!.replace(/\.git$/i, ""); if (REPO.test(repo)) return repository(repo); } return null; } function item(value: Item, kind: GithubKind): GithubItem { return { kind, number: value.number, title: value.title, url: value.url, state: value.state === "MERGED" ? "merged" : value.state === "CLOSED" ? "closed" : value.isDraft ? "draft" : "open", author: value.author?.login || "ghost", created: value.createdAt, updated: value.updatedAt, labels: value.labels.map(({ name, color }) => ({ name, color })), }; } export async function list( request: Extract, ): Promise { const { repo, kind, state, search, limit } = request; if (!["open", "closed", "all"].includes(state)) throw new Error("Invalid GitHub state filter"); if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) { throw new Error("Load between 1 and 1000 GitHub items"); } const values = await gh([ command(kind), "list", "--repo", repository(repo), "--state", state, "--limit", String(limit + 1), "--search", `${search} sort:updated-desc`.trim(), "--json", FIELDS + (kind === "prs" ? ",isDraft" : ""), ]); return { items: values.slice(0, limit).map((value) => item(value, kind)), more: values.length > limit, }; } export async function detail( request: Extract, ): Promise { const { repo, kind } = request; const value = await gh([ command(kind), "view", number(request.number), "--repo", repository(repo), "--json", FIELDS + ",body,comments" + (kind === "prs" ? ",isDraft,reviews,baseRefName,headRefName,additions,deletions,changedFiles,reviewDecision" : ""), ]); const comments: GithubComment[] = value.comments.map((comment) => ({ id: comment.id, url: comment.url, author: comment.author?.login || "ghost", body: comment.body, created: comment.createdAt, })); for (const review of value.reviews || []) { if (review.state === "PENDING") continue; comments.push({ id: review.id, url: value.url, author: review.author?.login || "ghost", body: review.body, created: review.submittedAt, review: review.state.toLowerCase() as GithubComment["review"], }); } comments.sort((a, b) => a.created.localeCompare(b.created)); return { ...item(value, kind), body: value.body, comments, ...(kind === "prs" ? { pull: { base: value.baseRefName!, head: value.headRefName!, adds: value.additions!, dels: value.deletions!, files: value.changedFiles!, review: value.reviewDecision!, }, } : {}), }; } export async function files(repo: string, id: number): Promise { const pages = await gh([ "api", `repos/${repository(repo)}/pulls/${number(id)}/files?per_page=100`, "--paginate", "--slurp", ]); return pages.flat().map((file) => { const from = file.previous_filename || file.filename; const old = file.status === "added" ? "/dev/null" : `a/${from}`; const next = file.status === "removed" ? "/dev/null" : `b/${file.filename}`; let patch = `diff --git ${JSON.stringify(`a/${from}`)} ${ JSON.stringify(`b/${file.filename}`) }\n`; if (file.status === "renamed") { patch += `rename from ${JSON.stringify(from)}\nrename to ${JSON.stringify(file.filename)}\n`; } patch += `--- ${JSON.stringify(old)}\n+++ ${JSON.stringify(next)}\n${file.patch || ""}\n`; const hasPatch = file.patch !== undefined || (file.status === "renamed" && !file.additions && !file.deletions); return { file: file.filename, from: file.previous_filename, adds: file.additions, dels: file.deletions, signature: new Bun.CryptoHasher("sha256").update(patch).digest("hex"), patch: hasPatch ? patch : undefined, error: hasPatch ? undefined : "GitHub did not include a text diff for this file.", }; }); }