/** `github` — gh CLI dispatch: repo views, PRs, searches, Actions run watch. */ import type { ReactNode } from "react"; import { Badge, InvalidArg, Kv, KvGrid, Note, Output, ResultText, Row } from "../parts"; import type { ToolRenderer, ToolRenderProps } from "../types"; import { detailsRecord, isRecord, normalizeWs, num, shortenPath, str, truncate } from "../util"; const SUCCESS_CONCLUSIONS: Record = { success: true, neutral: true, skipped: true }; const FAILURE_CONCLUSIONS: Record = { failure: true, timed_out: true, cancelled: true, action_required: true, startup_failure: true, }; const RUNNING_STATUSES: Record = { in_progress: true }; /** `123`, a PR/issue URL, or a branch name → `#123` or a truncated literal. */ function issueId(value: string): string | null { const trimmed = value.trim(); if (!trimmed) return null; if (/^\d+$/.test(trimmed)) return `#${trimmed}`; const match = trimmed.match(/\/(?:issues|pull)\/(\d+)/); if (match) return `#${match[1]}`; return truncate(trimmed, 40); } function formatPr(pr: unknown): string | null { if (typeof pr === "string") return issueId(pr); if (!Array.isArray(pr)) return null; const parts: string[] = []; for (const item of pr) { if (typeof item !== "string") continue; const id = issueId(item); if (id) parts.push(id); } if (parts.length === 0) return null; if (parts.length > 3) return `${parts.slice(0, 3).join(", ")}, +${parts.length - 3} more`; return parts.join(", "); } function shortSha(sha: string): string { return /^[0-9a-f]{12,}$/i.test(sha) ? sha.slice(0, 7) : sha; } function Salient({ args }: { args: Record }): ReactNode { const op = str(args.op) ?? ""; const repo = str(args.repo); if (op.startsWith("search_")) { const query = str(args.query); return ( <> {query && {truncate(normalizeWs(query), 60)}} {repo && {repo}} ); } if (op === "pr_checkout" || op === "pr_push") { const target = formatPr(args.pr) ?? str(args.branch); return ( <> {target && {target}} {repo && {repo}} ); } if (op === "pr_create") { const title = str(args.title); if (title) return {truncate(normalizeWs(title), 60)}; return args.fill === true ? fill from commits : null; } if (op === "run_watch") { const run = str(args.run); const branch = str(args.branch); if (run) return run {truncate(run, 50)}; return {branch ?? "HEAD"} workflow runs; } const branch = str(args.branch); return ( <> {repo && {repo}} {branch && {branch}} ); } function Summary(props: ToolRenderProps): ReactNode { const op = str(props.args.op); return ( <> {op ? {op} : no op} ); } function argValue(value: unknown): ReactNode { if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) { const parts: string[] = []; for (const item of value) { if (typeof item === "string" || typeof item === "number") parts.push(String(item)); } return parts.join(", "); } return ; } function ArgsGrid({ args }: { args: Record }): ReactNode { const rows: ReactNode[] = []; for (const key in args) { if (key === "op" || key === "body") continue; const value = args[key]; if (value === undefined || value === null) continue; rows.push( {argValue(value)} , ); } return rows.length > 0 ? {rows} : null; } function jobVisual(job: Record): { icon: string; cls: string } { const conclusion = str(job.conclusion); const status = str(job.status); if (conclusion && SUCCESS_CONCLUSIONS[conclusion]) return { icon: "✓", cls: "tv-ok-text" }; if (conclusion && FAILURE_CONCLUSIONS[conclusion]) return { icon: "✕", cls: "tv-err-text" }; if (status && RUNNING_STATUSES[status]) return { icon: "●", cls: "tv-warn-text" }; return { icon: "○", cls: "tv-faint" }; } function RunBlock({ run }: { run: Record }): ReactNode { const label = str(run.workflowName) ?? str(run.displayTitle) ?? "GitHub Actions"; const meta: string[] = []; const branch = str(run.branch); const sha = str(run.headSha); if (branch) meta.push(branch); else if (sha) meta.push(shortSha(sha)); const id = num(run.id); if (id !== null) meta.push(`#${id}`); const conclusion = str(run.conclusion); const status = str(run.status); const tone = conclusion && SUCCESS_CONCLUSIONS[conclusion] ? ("ok" as const) : conclusion && FAILURE_CONCLUSIONS[conclusion] ? ("err" as const) : status && RUNNING_STATUSES[status] ? ("warn" as const) : undefined; const jobs = Array.isArray(run.jobs) ? run.jobs : []; return (
{label} {meta.join(" ")}{" "} {(conclusion || status) && {conclusion ?? status}} {jobs.length === 0 && ( waiting for workflow jobs… )} {jobs.map((job, index) => { if (!isRecord(job)) return null; const visual = jobVisual(job); const duration = num(job.durationSeconds); return ( {visual.icon} {str(job.name) ?? "job"} {duration !== null && {duration}s} ); })}
); } function WatchView({ watch }: { watch: Record }): ReactNode { const repo = str(watch.repo) ?? ""; const watching = str(watch.state) === "watching"; const run = isRecord(watch.run) ? watch.run : null; const runId = run ? num(run.id) : null; let header: string; if (str(watch.mode) === "run" && runId !== null) { header = `${watching ? "watching " : ""}run #${runId} on ${repo}`; } else { const sha = str(watch.headSha); const target = sha ? shortSha(sha) : "this commit"; header = watching ? `watching ${target} on ${repo}` : `workflow runs for ${target} on ${repo}`; } const note = str(watch.note); const runs: Record[] = []; if (run) runs.push(run); else if (Array.isArray(watch.runs)) { for (const item of watch.runs) { if (isRecord(item)) runs.push(item); } } const failedLogs = Array.isArray(watch.failedLogs) ? watch.failedLogs : []; return ( <>
{header}
{note &&
{note}
} {runs.length === 0 &&
waiting for workflow runs…
} {runs.map((item, index) => ( ))} {failedLogs.map((entry, index) => { if (!isRecord(entry)) return null; const jobName = str(entry.jobName) ?? "job"; const workflow = str(entry.workflowName); const failedRunId = num(entry.runId); const context = workflow ?? "run"; const title = `${jobName} — ${context}${failedRunId !== null ? ` #${failedRunId}` : ""}`; const tail = str(entry.tail); if (!tail || entry.available === false) { return ( {title}: log tail unavailable ); } return ; })} ); } function CheckoutRows({ checkouts }: { checkouts: readonly unknown[] }): ReactNode { return (
{checkouts.map((entry, index) => { if (!isRecord(entry)) return null; const prNumber = num(entry.prNumber); const worktree = str(entry.worktreePath); return ( {str(entry.branch) ?? ""} {worktree && {shortenPath(worktree)}} {entry.reused === true && reused} ); })}
); } const DETAIL_KEYS = [ "repo", "branch", "worktreePath", "remote", "remoteBranch", "headSha", "runId", "status", "conclusion", ]; function DetailsGrid({ details }: { details: Record }): ReactNode { const rows: ReactNode[] = []; for (const key of DETAIL_KEYS) { const value = details[key]; if (typeof value === "number") { rows.push( {String(value)} , ); } else if (typeof value === "string" && value) { const text = key === "worktreePath" ? shortenPath(value) : key === "headSha" ? shortSha(value) : value; rows.push( {text} , ); } } if (Array.isArray(details.runIds)) { const ids: string[] = []; for (const id of details.runIds) { if (typeof id === "number") ids.push(`#${id}`); } if (ids.length > 0) { rows.push( {ids.join(", ")} , ); } } if (Array.isArray(details.failedJobs)) { const jobs: string[] = []; for (const job of details.failedJobs) { if (typeof job === "string") jobs.push(job); } if (jobs.length > 0) { rows.push( {jobs.join(", ")} , ); } } return rows.length > 0 ? {rows} : null; } function Body({ args, result }: ToolRenderProps): ReactNode { const details = detailsRecord(result); const watch = details && isRecord(details.watch) ? details.watch : null; const checkouts = details && Array.isArray(details.checkouts) ? details.checkouts : null; const bodyText = str(args.body); return ( <> {bodyText && } {watch && } {checkouts && checkouts.length > 0 && } {details && !watch && } ); } export const githubRenderer: ToolRenderer = { Summary, Body };