import { type FSWatcher, watch } from "node:fs"; import { basename, dirname, resolve } from "node:path"; import type { ExecResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; export type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | "UNKNOWN"; export type CheckState = "pass" | "fail" | "pending" | "none"; export type PullRequestState = "OPEN" | "CLOSED" | "MERGED"; type JsonRecord = Record; export interface CheckSummary { passed: number; failed: number; pending: number; total: number; } export interface ReviewSummary { decision: ReviewDecision; approvedBy: string[]; changesRequestedBy: string[]; commentedBy: string[]; total: number; } export interface CommentSummary { issue: number; reviews: number; total: number; } export interface PullRequestStatus { number: number; url: string; state: PullRequestState; closedAt?: string; mergedAt?: string; isDraft: boolean; review: ReviewSummary; checks: CheckSummary; comments: CommentSummary; } const STATUS_KEY = "github-pr"; const GH_TIMEOUT_MS = 10_000; const GIT_TIMEOUT_MS = 5_000; const BRANCH_REFRESH_DEBOUNCE_MS = 100; const PR_REFRESH_INTERVAL_MS = 60_000; const TERMINAL_PR_LIFETIME_MS = 24 * 60 * 60 * 1000; const GH_PR_FIELDS = [ "number", "isDraft", "url", "state", "closedAt", "mergedAt", "reviewDecision", "latestReviews", "statusCheckRollup", ]; const GH_PR_COUNT_QUERY = ` query PullRequestCounts($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { comments { totalCount } reviews { totalCount } } } } `; interface GithubPrOptions { refreshIntervalMs?: number; } export default function githubPr(pi: ExtensionAPI, options: GithubPrOptions = {}) { const refreshIntervalMs = options.refreshIntervalMs ?? PR_REFRESH_INTERVAL_MS; if (!Number.isFinite(refreshIntervalMs) || refreshIntervalMs <= 0) { throw new RangeError("refreshIntervalMs must be a positive finite number"); } const branchWatch: BranchWatchState = { generation: 0, request: 0, session: 0 }; const refreshStatus = async (ctx: ExtensionContext, signal?: AbortSignal, generation = branchWatch.generation) => { branchWatch.request += 1; const request = branchWatch.request; // A turn that is already cancelled gets no `gh` spawn at all, and its rendered // status and expiry timer are left exactly as they are. if (signal?.aborted) return request; try { const status = await runGhPrView(pi, ctx.cwd, signal); // A late abort says nothing about data already in hand; generation and request // are what decide whether this answer is still the current one. if (generation === branchWatch.generation && request === branchWatch.request) { renderStatus(ctx, status, branchWatch, generation); } } catch (error) { // Only an abort-shaped rejection is the cancellation itself. A real `gh` fault that // merely races the abort still has to surface, or Ctrl+C hides a broken gh install. if (signal?.aborted && isAbortError(error)) return request; if (generation === branchWatch.generation && request === branchWatch.request) { clearExpiryTimer(branchWatch); renderAmbientFailure(ctx, error); } } return request; }; const schedulePeriodicRefresh = (ctx: ExtensionContext, session: number) => { cancelPeriodicRefresh(branchWatch); branchWatch.refreshTimer = setTimeout(async () => { branchWatch.refreshTimer = undefined; if (session !== branchWatch.session) return; const controller = new AbortController(); branchWatch.refreshController = controller; const request = await refreshStatus(ctx, controller.signal); if (branchWatch.refreshController === controller) { branchWatch.refreshController = undefined; } if (session === branchWatch.session && request === branchWatch.request) { schedulePeriodicRefresh(ctx, session); } }, refreshIntervalMs); branchWatch.refreshTimer.unref?.(); }; const scheduleBranchRefresh = (ctx: ExtensionContext, session: number) => { branchWatch.generation += 1; const generation = branchWatch.generation; cancelPeriodicRefresh(branchWatch); clearExpiryTimer(branchWatch); clearStatus(ctx); if (branchWatch.timer) clearTimeout(branchWatch.timer); branchWatch.timer = setTimeout(async () => { branchWatch.timer = undefined; if (generation !== branchWatch.generation) return; const request = await refreshStatus(ctx, ctx.signal, generation); if (session === branchWatch.session && request === branchWatch.request) { schedulePeriodicRefresh(ctx, session); } }, BRANCH_REFRESH_DEBOUNCE_MS); }; const closeBranchWatcher = () => { if (branchWatch.timer) clearTimeout(branchWatch.timer); branchWatch.timer = undefined; cancelPeriodicRefresh(branchWatch); clearExpiryTimer(branchWatch); branchWatch.watcher?.close(); branchWatch.watcher = undefined; }; pi.on("session_start", async (_event, ctx) => { branchWatch.generation += 1; branchWatch.session += 1; branchWatch.sessionManager = ctx.sessionManager; const session = branchWatch.session; closeBranchWatcher(); const watcher = await createBranchWatcher(pi, ctx.cwd, ctx.signal, () => { if (session === branchWatch.session) scheduleBranchRefresh(ctx, session); }); if (session !== branchWatch.session) { watcher?.close(); return; } branchWatch.watcher = watcher; const request = await refreshStatus(ctx, ctx.signal); if (session === branchWatch.session && request === branchWatch.request) { schedulePeriodicRefresh(ctx, session); } }); pi.on("agent_end", async (_event, ctx) => { if (ctx.sessionManager !== branchWatch.sessionManager || ctx.signal?.aborted) return; const session = branchWatch.session; cancelPeriodicRefresh(branchWatch); const request = await refreshStatus(ctx, ctx.signal); if (session === branchWatch.session && request === branchWatch.request) { schedulePeriodicRefresh(ctx, session); } }); pi.on("session_shutdown", (_event, ctx) => { if (ctx.sessionManager !== branchWatch.sessionManager) return; branchWatch.sessionManager = undefined; branchWatch.generation += 1; branchWatch.session += 1; closeBranchWatcher(); clearStatus(ctx); }); } interface BranchWatchState { generation: number; request: number; session: number; sessionManager?: ExtensionContext["sessionManager"]; watcher?: FSWatcher; timer?: ReturnType; refreshTimer?: ReturnType; refreshController?: AbortController; expiryTimer?: ReturnType; } async function createBranchWatcher( pi: Pick, cwd: string, signal: AbortSignal | undefined, onChange: () => void, ): Promise { try { const result = await pi.exec("git", ["rev-parse", "--git-path", "HEAD"], { cwd, signal, timeout: GIT_TIMEOUT_MS, }); if (result.killed || result.code !== 0) return undefined; const gitHead = result.stdout.trim(); if (!gitHead) return undefined; const headPath = resolve(cwd, gitHead); const headFileName = basename(headPath); const watcher = watch(dirname(headPath), { persistent: false }, (_event, fileName) => { if (!fileName || fileName.toString() === headFileName) onChange(); }); watcher.on("error", () => watcher.close()); return watcher; } catch { return undefined; } } export async function runGhPrView( pi: Pick, cwd: string, signal?: AbortSignal, ): Promise { const invocation = ghPrViewInvocation(); const result = await execGh(pi, invocation.command, invocation.args, cwd, signal, "gh pr view"); if (result.killed) throw new Error("gh pr view timed out or was cancelled."); if (result.code !== 0) throw new Error(formatGhFailure("gh pr view", result)); let pr: JsonRecord; try { pr = objectRecord(JSON.parse(result.stdout)); } catch (error) { throw new Error(`Failed to parse gh pr view output: ${formatError(error)}`); } const counts = await runGhPrCountQuery(pi, cwd, pr, signal); return normalizeGhPrView({ ...pr, ...counts }); } export function normalizeGhPrView(value: unknown): PullRequestStatus { const pr = objectRecord(value); const reviews = arrayValue(pr.reviews); const latestReviews = arrayValue(pr.latestReviews); const comments = summarizeComments(pr.comments, countValue(pr.reviews)); return { number: requiredNumber(pr.number, "number"), url: optionalString(pr.url) ?? "", state: pullRequestState(pr.state), closedAt: optionalString(pr.closedAt), mergedAt: optionalString(pr.mergedAt), isDraft: pr.isDraft === true, review: summarizeReviews(pr.reviewDecision, latestReviews.length > 0 ? latestReviews : reviews), checks: summarizeChecks(pr.statusCheckRollup), comments, }; } function summarizeChecks(value: unknown): CheckSummary { const checks = arrayValue(value); const summary: CheckSummary = { passed: 0, failed: 0, pending: 0, total: checks.length }; for (const check of checks) { const state = checkState(check); if (state === "pass") summary.passed += 1; else if (state === "fail") summary.failed += 1; else summary.pending += 1; } return summary; } function checkState(value: unknown): Exclude { const check = objectRecord(value); const state = optionalString(check.state)?.toUpperCase(); const status = optionalString(check.status)?.toUpperCase(); const conclusion = optionalString(check.conclusion)?.toUpperCase(); if (state === "SUCCESS") return "pass"; if (state === "FAILURE" || state === "ERROR") return "fail"; if (state === "PENDING" || state === "EXPECTED") return "pending"; if (status && status !== "COMPLETED") return "pending"; if (conclusion === "SUCCESS" || conclusion === "SKIPPED" || conclusion === "NEUTRAL") { return "pass"; } if ( conclusion === "FAILURE" || conclusion === "CANCELLED" || conclusion === "TIMED_OUT" || conclusion === "ACTION_REQUIRED" || conclusion === "STARTUP_FAILURE" ) { return "fail"; } return "pending"; } function summarizeReviews(decisionValue: unknown, reviewValues: unknown[]): ReviewSummary { const latestByAuthor = new Map(); let anonymousIndex = 0; for (const reviewValue of reviewValues) { const review = objectRecord(reviewValue); const author = authorLogin(review) ?? `review-${anonymousIndex++}`; latestByAuthor.set(author, review); } const summary: ReviewSummary = { decision: reviewDecision(decisionValue), approvedBy: [], changesRequestedBy: [], commentedBy: [], total: reviewValues.length, }; for (const [author, review] of latestByAuthor) { const state = optionalString(review.state)?.toUpperCase(); if (state === "APPROVED") summary.approvedBy.push(author); else if (state === "CHANGES_REQUESTED") summary.changesRequestedBy.push(author); else if (state === "COMMENTED") summary.commentedBy.push(author); } return summary; } function summarizeComments(commentsValue: unknown, reviewCount: number): CommentSummary { const issue = countValue(commentsValue); return { issue, reviews: reviewCount, total: issue + reviewCount }; } function countValue(value: unknown): number { if (Array.isArray(value)) return value.length; const object = objectRecord(value); const totalCount = object.totalCount; if (typeof totalCount === "number") return totalCount; const nodes = object.nodes; return Array.isArray(nodes) ? nodes.length : 0; } function reviewDecision(value: unknown): ReviewDecision { if (value === "APPROVED" || value === "CHANGES_REQUESTED" || value === "REVIEW_REQUIRED") { return value; } return "UNKNOWN"; } function pullRequestState(value: unknown): PullRequestState { if (value === "OPEN" || value === "CLOSED" || value === "MERGED") return value; throw new Error("Missing valid PR state"); } function authorLogin(review: JsonRecord): string | undefined { const author = objectRecord(review.author); return optionalString(author.login); } function checkOverall(checks: CheckSummary): CheckState { if (checks.total === 0) return "none"; if (checks.failed > 0) return "fail"; if (checks.pending > 0) return "pending"; return "pass"; } export function formatCompactStatus(status: PullRequestStatus): string { if (status.state === "MERGED") return `PR #${status.number}: merged`; if (status.state === "CLOSED") return `PR #${status.number}: closed`; return `PR #${status.number}: ${[ formatCheckCompact(status.checks), formatReviewCompact(status), formatCommentCompact(status.comments), ].join(", ")}`; } function formatCheckCompact(checks: CheckSummary): string { switch (checkOverall(checks)) { case "pass": return "checks passing"; case "fail": return `checks failing (${checks.failed})`; case "pending": return `checks pending (${checks.pending})`; case "none": return "no checks"; } } function formatCommentCompact(comments: CommentSummary): string { const count = comments.total; if (count === 0) return "no comments"; return `${count} ${count === 1 ? "comment" : "comments"}`; } function formatReviewCompact(status: PullRequestStatus): string { if (status.isDraft) return "draft"; const review = status.review; switch (review.decision) { case "APPROVED": return "approved"; case "CHANGES_REQUESTED": return "changes requested"; case "REVIEW_REQUIRED": return "review required"; case "UNKNOWN": return review.commentedBy.length > 0 ? "commented" : "review ?"; } } function renderStatus( ctx: ExtensionContext, status: PullRequestStatus, branchWatch: BranchWatchState, generation: number, ) { clearExpiryTimer(branchWatch); const now = Date.now(); const expiresAt = pullRequestExpiresAt(status); if (!isPullRequestVisible(status, now)) { clearStatus(ctx); return; } ctx.ui.setStatus(STATUS_KEY, formatLinkedStatus(status)); if (expiresAt === undefined) return; branchWatch.expiryTimer = setTimeout(() => { branchWatch.expiryTimer = undefined; if (generation === branchWatch.generation) clearStatus(ctx); }, expiresAt - now); } export function isPullRequestVisible(status: PullRequestStatus, now = Date.now()): boolean { if (status.state === "OPEN") return true; const expiresAt = pullRequestExpiresAt(status); return expiresAt !== undefined && now < expiresAt; } function pullRequestExpiresAt(status: PullRequestStatus): number | undefined { if (status.state === "OPEN") return undefined; const timestamp = status.state === "MERGED" ? status.mergedAt : status.closedAt; if (!timestamp) return undefined; const terminalAt = Date.parse(timestamp); return Number.isFinite(terminalAt) ? terminalAt + TERMINAL_PR_LIFETIME_MS : undefined; } function cancelPeriodicRefresh(branchWatch: BranchWatchState) { if (branchWatch.refreshTimer) clearTimeout(branchWatch.refreshTimer); branchWatch.refreshTimer = undefined; branchWatch.refreshController?.abort(); branchWatch.refreshController = undefined; } function clearExpiryTimer(branchWatch: BranchWatchState) { if (branchWatch.expiryTimer) clearTimeout(branchWatch.expiryTimer); branchWatch.expiryTimer = undefined; } export function formatLinkedStatus(status: PullRequestStatus): string { const text = formatCompactStatus(status); if (!status.url) return text; const label = `#${status.number}`; return text.replace(label, osc8Link(status.url, label)); } function stripTerminalControlChars(value: string): string { let sanitized = ""; for (const character of value) { const codePoint = character.codePointAt(0); if (codePoint !== undefined && codePoint > 0x1f && codePoint !== 0x7f) sanitized += character; } return sanitized; } function osc8Link(url: string, text: string): string { const safeText = stripTerminalControlChars(text); try { const parsed = new URL(url); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return safeText; const safeUrl = stripTerminalControlChars(parsed.toString()); return `\x1b]8;;${safeUrl}\x07${safeText}\x1b]8;;\x07`; } catch { return safeText; } } function clearStatus(ctx: ExtensionContext) { ctx.ui.setStatus(STATUS_KEY, undefined); } function renderAmbientFailure(ctx: ExtensionContext, error: unknown) { const message = formatError(error); const lower = message.toLowerCase(); if (isGhExecutableMissingMessage(lower)) { ctx.ui.setStatus(STATUS_KEY, "PR gh missing"); return; } if (/not authenticated|auth login|authentication/.test(lower)) { ctx.ui.setStatus(STATUS_KEY, "PR gh auth"); return; } clearStatus(ctx); } async function runGhPrCountQuery( pi: Pick, cwd: string, pr: JsonRecord, signal?: AbortSignal, ): Promise> { const { host, owner, name, number } = parsePrCoordinates(pr); const result = await execGh( pi, "gh", [ "api", "graphql", "--hostname", host, "-f", `query=${GH_PR_COUNT_QUERY}`, "-F", `owner=${owner}`, "-F", `name=${name}`, "-F", `number=${number}`, ], cwd, signal, "gh api graphql", ); if (result.killed) throw new Error("gh api graphql timed out or was cancelled."); if (result.code !== 0) throw new Error(formatGhFailure("gh api graphql", result)); try { const payload = objectRecord(JSON.parse(result.stdout)); const data = objectRecord(payload.data); const repository = objectRecord(data.repository); const pullRequest = objectRecord(repository.pullRequest); return { comments: pullRequest.comments, reviews: pullRequest.reviews }; } catch (error) { throw new Error(`Failed to parse gh api graphql output: ${formatError(error)}`); } } export function ghPrViewInvocation( ghHost = process.env.GH_HOST, platform: NodeJS.Platform = process.platform, comSpec = process.env.ComSpec, ): { command: string; args: string[] } { const args = ["pr", "view", "--json", GH_PR_FIELDS.join(",")]; if (!ghHost) return { command: "gh", args }; if (platform === "win32") { return { command: comSpec ?? "cmd.exe", args: ["/d", "/s", "/c", `set "GH_HOST=" && gh ${args.join(" ")}`], }; } return { command: "env", args: ["-u", "GH_HOST", "gh", ...args] }; } async function execGh( pi: Pick, executable: string, args: string[], cwd: string, signal: AbortSignal | undefined, command: string, ): Promise { try { return await pi.exec(executable, args, { cwd, signal, timeout: GH_TIMEOUT_MS }); } catch (error) { const message = formatError(error); if (isGhExecutableMissingMessage(message.toLowerCase())) { throw new Error(`GitHub CLI not found. Install gh and run: gh auth login. ${message}`); } throw new Error(`${command} could not start: ${message}`); } } function formatGhFailure(command: string, result: ExecResult): string { const output = (result.stderr || result.stdout).trim(); const lower = output.toLowerCase(); if (isGhExecutableMissingMessage(lower)) { return "GitHub CLI not found. Install gh and run: gh auth login."; } if (/not logged in|authentication|auth login|gh auth/.test(lower)) { return `GitHub CLI is not authenticated. Run: gh auth login. ${output}`; } if (/no pull requests|could not resolve|not a github repository/.test(lower)) { return `No GitHub pull request found. ${output}`; } return `${command} failed (${result.code}): ${output || "no output"}`; } function isGhExecutableMissingMessage(lowerMessage: string): boolean { return ( /\bgithub cli (?:not available|not found)\b/.test(lowerMessage) || /\b(?:gh|gh\.exe)\b.*\benoent\b|\benoent\b.*\b(?:gh|gh\.exe)\b/.test(lowerMessage) || /\b(?:gh|gh\.exe): (?:command )?not found\b/.test(lowerMessage) || /\bcommand not found: (?:gh|gh\.exe)\b/.test(lowerMessage) || /\benv:\s+['"‘’]?(?:gh|gh\.exe)['"‘’]?: no such file or directory\b/.test(lowerMessage) || /['"‘’]?(?:gh|gh\.exe)['"‘’]? is not recognized as an internal or external command\b/.test(lowerMessage) || /\b(?:gh|gh\.exe): no such file or directory\b/.test(lowerMessage) ); } function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } /** * `pi.exec` surfaces a cancelled child through several spellings — a DOMException-style * AbortError, `The operation was aborted`, or this extension's own "timed out or was * cancelled" wrapper — so match the shape rather than one library's exact error. */ function isAbortError(error: unknown): boolean { if (error instanceof Error && error.name === "AbortError") return true; return /\baborted?\b|\bcancell?ed\b/i.test(formatError(error)); } function parsePrCoordinates(pr: JsonRecord): { host: string; owner: string; name: string; number: number; } { const number = requiredNumber(pr.number, "number"); const url = optionalString(pr.url); if (!url) throw new Error("Missing PR url"); let parsed: URL; try { parsed = new URL(url); } catch (error) { throw new Error(`Invalid PR url: ${formatError(error)}`); } const match = /^\/([^/]+)\/([^/]+)\/pull\/\d+\/?$/.exec(parsed.pathname); if (!match) throw new Error(`Unsupported PR url: ${url}`); return { host: parsed.host, owner: match[1], name: match[2], number }; } function arrayValue(value: unknown): unknown[] { if (Array.isArray(value)) return value; const object = objectRecord(value); return Array.isArray(object.nodes) ? object.nodes : []; } function objectRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } function requiredNumber(value: unknown, name: string): number { if (typeof value === "number" && Number.isFinite(value)) return value; throw new Error(`Missing numeric PR ${name}`); } function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; }