From 0d8514db80bdd66063d069e05784316ede5a2652 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 24 Jul 2026 00:50:27 -0500 Subject: [PATCH] feat(chat): hydrate commit/run GitHub cards from their W2a routes (cave-w91n) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit cards now fetch /api/github/commit (first message line as title, author, relative date, +adds/−dels, file count — one shot, commits are immutable). Run cards fetch one exact run via a new /api/github/runs?id= lookup (validated positive int, same normalized runs[] shape) and re-poll on the checks cadence (30s pausable) only while the run is in flight; the leading glyph takes the checks-strip vocabulary (success tint / fail tint via isFailConclusion / neutral spinner). Both kinds share the PR/issue degradation rows (loading… / connect GitHub to hydrate / details unavailable) and prefer hydrated htmlUrl for the link-out. Pins: wiring test covers both hooks, the id param, poll gating, and the degradation gate; runs route test pins id validation + normalization. Doc: /runs table row updated, "Commit/Run card hydration" residual gap removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/chat-github-integration.md | 5 +- src/app/api/github/runs/route.test.ts | 18 ++ src/app/api/github/runs/route.ts | 30 +++- src/components/github-card-wiring.test.ts | 14 ++ src/components/github-card.tsx | 210 ++++++++++++++++++++-- 5 files changed, 253 insertions(+), 24 deletions(-) diff --git a/docs/chat-github-integration.md b/docs/chat-github-integration.md index c5be85131..2fd02606f 100644 --- a/docs/chat-github-integration.md +++ b/docs/chat-github-integration.md @@ -108,7 +108,7 @@ registered in `api-contracts.test.ts`, alphabetical): | `/api/github/merge` | POST | Squash/merge/rebase a PR; surfaces GitHub's own guard errors verbatim | | `/api/github/rerun` | POST | Re-run a check run / failed jobs of a run | | `/api/github/review` | POST | Submit review {repo, number, event: APPROVE\|REQUEST_CHANGES\|COMMENT, body} | -| `/api/github/runs` | GET | List recent Actions runs {repo, branch?} | +| `/api/github/runs` | GET | List recent Actions runs {repo, branch?} or one run {repo, id} | Existing `comment` and `resolve-thread` routes are reused unchanged. @@ -250,9 +250,6 @@ seven-gap summary is fully closed (see the postscript in [the capability map](specs/2026-07-14-chat-github-capability-map.md)). Residual gaps, filed as follow-up beads rather than blocking the epic: -- **Commit/Run card hydration** — `/api/github/commit` and `/runs` exist - (W2a) but the cards still render attrs-only; hydrate them the way PR/issue - cards do. - **Review-thread reply** — thread cards resolve/unresolve but can't reply in place (needs the review-comment reply endpoint); agent `reply` proposals fall back to conversation comments. diff --git a/src/app/api/github/runs/route.test.ts b/src/app/api/github/runs/route.test.ts index 6ce73f1c0..32b241339 100644 --- a/src/app/api/github/runs/route.test.ts +++ b/src/app/api/github/runs/route.test.ts @@ -34,4 +34,22 @@ assert.doesNotMatch( "runs route must not return token material", ); +assert.match( + source, + /\/\^\\d\{1,16\}\$\/\.test\(idParam\)/, + "run id is validated as a positive integer before path interpolation", +); + +assert.match( + source, + /actions\/runs\/\$\{runId\}/, + "an id fetches that exact run instead of scanning the list page", +); + +assert.match( + source, + /runId\s*\?\s*\[data as Record\]/, + "by-id responses normalize through the same runs[] shape as the list", +); + console.log("github-runs-route.test.ts OK"); diff --git a/src/app/api/github/runs/route.ts b/src/app/api/github/runs/route.ts index 4926aeab4..deae9bbd6 100644 --- a/src/app/api/github/runs/route.ts +++ b/src/app/api/github/runs/route.ts @@ -2,9 +2,9 @@ * /api/github/runs * * Recent GitHub Actions workflow runs for a repo (optionally filtered to a - * branch) so chat run cards and the stage layer can hydrate (design: - * docs/chat-github-integration.md §2-3). Read-only; workflow_dispatch lands - * separately with the tier-2 confirm layer (W2b). + * branch), or one exact run by id, so chat run cards and the stage layer can + * hydrate (design: docs/chat-github-integration.md §2-3). Read-only; + * workflow_dispatch lands separately with the tier-2 confirm layer (W2b). * * Auth mirrors /api/github/item: PAT when present, else the public API. */ @@ -25,6 +25,7 @@ export async function GET(req: Request) { const url = new URL(req.url); const repo = (url.searchParams.get("repo") ?? "").trim(); const branch = (url.searchParams.get("branch") ?? "").trim(); + const idParam = (url.searchParams.get("id") ?? "").trim(); if (!REPO_RE.test(repo)) { return NextResponse.json({ ok: false, error: "invalid repo" }, { status: 400 }); @@ -32,13 +33,20 @@ export async function GET(req: Request) { if (branch && !BRANCH_RE.test(branch)) { return NextResponse.json({ ok: false, error: "invalid branch" }, { status: 400 }); } + const runId = idParam ? Number(idParam) : null; + if (idParam && (!/^\d{1,16}$/.test(idParam) || runId === null || !Number.isSafeInteger(runId) || runId <= 0)) { + return NextResponse.json({ ok: false, error: "invalid id" }, { status: 400 }); + } const token = resolveGitHubToken(); try { - // repo passed REPO_RE; branch passed BRANCH_RE and is URL-encoded. + // repo passed REPO_RE; branch passed BRANCH_RE and is URL-encoded; id is a + // validated positive integer. An id fetches that exact run (chat run cards + // hydrate one run, not a page of twenty). const qs = branch ? `?per_page=20&branch=${encodeURIComponent(branch)}` : "?per_page=20"; - const res = await fetch(`${GH}/repos/${repo}/actions/runs${qs}`, { + const endpoint = runId ? `${GH}/repos/${repo}/actions/runs/${runId}` : `${GH}/repos/${repo}/actions/runs${qs}`; + const res = await fetch(endpoint, { headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", @@ -49,7 +57,9 @@ export async function GET(req: Request) { if (res.status === 404) { return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 }); } - const data = (await res.json().catch(() => null)) as { workflow_runs?: unknown[] } | null; + const data = (await res.json().catch(() => null)) as + | (Record & { workflow_runs?: unknown[] }) + | null; if (!res.ok || !data || typeof data !== "object") { return NextResponse.json( { ok: false, error: `github error (${res.status})` }, @@ -57,7 +67,13 @@ export async function GET(req: Request) { ); } - const raw = Array.isArray(data.workflow_runs) ? (data.workflow_runs as Array>) : []; + // A by-id fetch returns the run object itself; the list wraps runs in + // workflow_runs. Both shapes flow through the same normalizer. + const raw = runId + ? [data as Record] + : Array.isArray(data.workflow_runs) + ? (data.workflow_runs as Array>) + : []; return NextResponse.json({ ok: true, authed: Boolean(token), diff --git a/src/components/github-card-wiring.test.ts b/src/components/github-card-wiring.test.ts index 801010eb7..570c05deb 100644 --- a/src/components/github-card-wiring.test.ts +++ b/src/components/github-card-wiring.test.ts @@ -63,6 +63,20 @@ assert.match( ); assert.match(card, /isFailConclusion\(run\.conclusion\)/, "run glyphs share the fail-conclusion source of truth"); +// cave-w91n: commit/run cards hydrate like PR/issue cards. +assert.match(card, /\/api\/github\/commit\?repo=/, "commit cards hydrate from /api/github/commit"); +assert.match(card, /&id=\$\{runId\}/, "run cards hydrate one exact run via the runs id param"); +assert.match( + card, + /usePausablePoll\(\(\) => setTick\(\(t\) => t \+ 1\), 30_000, \{ enabled: enabled && inFlight \}\)/, + "run detail re-polls every 30s only while the run is in flight (hidden tabs pause)", +); +assert.match(card, /commit\.message\.split\("\\n", 1\)\[0\]/, "commit cards title from the first message line"); +assert.match(card, /commit\.stats\.additions/, "commit sub-row surfaces diff stats"); +assert.match(card, /detailPhase === "unauth"/, "commit/run cards share the PR/issue degradation rows"); +assert.match(card, /Workflow run succeeded/, "hydrated run glyph reflects a success conclusion"); +assert.match(card, /Workflow run failed/, "hydrated run glyph reflects a fail conclusion"); + // W2a (cave-fpqx.8): tier-1 actions fire directly from cards. assert.match(card, /fetch\("\/api\/github\/comment"/, "comment action posts through the existing comment route"); assert.match(card, /fetch\("\/api\/github\/issue"/, "close/reopen goes through PATCH /api/github/issue"); diff --git a/src/components/github-card.tsx b/src/components/github-card.tsx index bc5c5aa7d..0ecc20357 100644 --- a/src/components/github-card.tsx +++ b/src/components/github-card.tsx @@ -2,10 +2,10 @@ /** * Inline GitHub cards for chat turns (design: docs/chat-github-integration.md - * §2). W1a scope: IssueCard/PRCard hydrate from /api/github/item; commit / - * run / review-thread descriptors render an attrs-only compact card (their - * hydrated forms land with W1b). Cards degrade to a plain link on any fetch - * failure — never an empty box. + * §2). IssueCard/PRCard hydrate from /api/github/item; commit cards from + * /api/github/commit (message, author, stats); run cards from + * /api/github/runs?id= (status, conclusion — re-polled while in flight). + * Cards degrade to a plain link on any fetch failure — never an empty box. */ import { useEffect, useState } from "react"; @@ -198,10 +198,146 @@ function useGitHubItem( return { ...state, refresh: () => setTick((t) => t + 1) }; } +type CommitDetail = { + sha: string; + message: string; + authorLogin: string | null; + authorName: string | null; + date: string | null; + htmlUrl: string | null; + stats: { additions: number; deletions: number; total: number }; + fileCount: number; +}; + +type CommitState = + | { phase: "loading" } + | { phase: "ready"; commit: CommitDetail } + | { phase: "unauth" } + | { phase: "error" }; + +/** Commit detail for a commit card — /api/github/commit. One shot: commits + * are immutable, so there is nothing to re-poll. */ +function useCommitDetail(repo: string, sha: string | undefined, enabled: boolean): CommitState { + const [state, setState] = useState({ phase: "loading" }); + useEffect(() => { + if (!enabled || !sha) return; + let cancelled = false; + setState({ phase: "loading" }); + (async () => { + try { + const res = await fetch( + `/api/github/commit?repo=${encodeURIComponent(repo)}&sha=${encodeURIComponent(sha)}`, + { cache: "no-store" }, + ); + if (cancelled) return; + if (res.status === 401 || res.status === 403) { + setState({ phase: "unauth" }); + return; + } + const data = (await res.json().catch(() => null)) as + | { ok: true; commit: CommitDetail } + | { ok: false } + | null; + if (cancelled) return; + if (!res.ok || !data || data.ok !== true) { + setState({ phase: "error" }); + return; + } + setState({ phase: "ready", commit: data.commit }); + } catch { + if (!cancelled) setState({ phase: "error" }); + } + })(); + return () => { + cancelled = true; + }; + }, [repo, sha, enabled]); + return state; +} + +type RunDetail = { + id: number; + name: string; + runNumber: number; + status: string; + conclusion: string | null; + branch: string | null; + event: string | null; + createdAt: string | null; + htmlUrl: string | null; +}; + +type RunState = + | { phase: "loading" } + | { phase: "ready"; run: RunDetail } + | { phase: "unauth" } + | { phase: "error" }; + +/** One exact run for a run card — /api/github/runs?id=. Re-polls on the + * checks cadence (30s, hidden tabs pause) only while the run is in flight. */ +function useRunDetail(repo: string, runId: number | undefined, enabled: boolean): RunState { + const [state, setState] = useState({ phase: "loading" }); + const [tick, setTick] = useState(0); + useEffect(() => { + if (!enabled || !runId) return; + let cancelled = false; + setState((prev) => (tick > 0 && prev.phase === "ready" ? prev : { phase: "loading" })); + (async () => { + try { + const res = await fetch( + `/api/github/runs?repo=${encodeURIComponent(repo)}&id=${runId}`, + { cache: "no-store" }, + ); + if (cancelled) return; + if (res.status === 401 || res.status === 403) { + setState({ phase: "unauth" }); + return; + } + const data = (await res.json().catch(() => null)) as + | { ok: true; runs: RunDetail[] } + | { ok: false } + | null; + if (cancelled) return; + if (!res.ok || !data || data.ok !== true || !data.runs[0]) { + // A failed refresh keeps the last good detail. + setState((prev) => (prev.phase === "ready" ? prev : { phase: "error" })); + return; + } + setState({ phase: "ready", run: data.runs[0] }); + } catch { + if (!cancelled) setState((prev) => (prev.phase === "ready" ? prev : { phase: "error" })); + } + })(); + return () => { + cancelled = true; + }; + }, [repo, runId, enabled, tick]); + const inFlight = state.phase === "ready" && state.run.status !== "completed"; + usePausablePoll(() => setTick((t) => t + 1), 30_000, { enabled: enabled && inFlight }); + return state; +} + /** Visual identity per state — icon + accent color for the leading glyph. */ -function stateGlyph(d: GitHubBlockDescriptor, item: ItemDetail | null): { icon: IconName; color: string; label: string } { +function stateGlyph( + d: GitHubBlockDescriptor, + item: ItemDetail | null, + run?: RunDetail | null, +): { icon: IconName; color: string; label: string } { if (d.kind === "commit") return { icon: "ph:git-branch", color: "var(--text-secondary)", label: "Commit" }; - if (d.kind === "run") return { icon: "ph:circle-notch-bold", color: "var(--text-secondary)", label: "Workflow run" }; + if (d.kind === "run") { + // Hydrated runs take the checks-strip vocabulary: success/fail conclusion + // tint, spinner while in flight. Unhydrated stays the neutral spinner. + if (run?.conclusion === "success") { + return { icon: "ph:check-circle", color: "var(--color-success)", label: "Workflow run succeeded" }; + } + if (run && isFailConclusion(run.conclusion)) { + return { icon: "ph:x-circle-fill", color: "var(--color-warning)", label: "Workflow run failed" }; + } + if (run && run.status !== "completed") { + return { icon: "ph:circle-notch-bold", color: "var(--text-secondary)", label: "Workflow run in progress" }; + } + return { icon: "ph:circle-notch-bold", color: "var(--text-secondary)", label: "Workflow run" }; + } if (d.kind === "review-thread") return { icon: "ph:chat-circle-dots", color: "var(--text-secondary)", label: "Review thread" }; const isPull = d.kind === "pr" || Boolean(item?.isPull); if (item?.merged) return { icon: "ph:git-merge", color: "var(--accent-presence)", label: "Merged" }; @@ -630,8 +766,20 @@ export function GitHubCard({ const hydratable = descriptor.kind === "pr" || descriptor.kind === "issue"; const state = useGitHubItem(descriptor.repo, descriptor.number, hydratable); const item = hydratable && state.phase === "ready" ? state.item : null; - const url = item?.htmlUrl ?? descriptorUrl(descriptor); - const glyph = stateGlyph(descriptor, item); + const commitState = useCommitDetail( + descriptor.repo, + descriptor.kind === "commit" ? descriptor.sha : undefined, + descriptor.kind === "commit", + ); + const commit = commitState.phase === "ready" ? commitState.commit : null; + const runState = useRunDetail( + descriptor.repo, + descriptor.kind === "run" ? descriptor.runId : undefined, + descriptor.kind === "run", + ); + const run = runState.phase === "ready" ? runState.run : null; + const url = item?.htmlUrl ?? commit?.htmlUrl ?? run?.htmlUrl ?? descriptorUrl(descriptor); + const glyph = stateGlyph(descriptor, item, run); // Checks strip + expandable run list: OPEN pull requests only — merged and // closed PRs have no live CI story worth a rate-limited fetch. @@ -644,11 +792,29 @@ export function GitHubCard({ descriptor.kind === "commit" ? descriptor.sha?.slice(0, 7) : descriptor.kind === "run" - ? `run ${descriptor.runId}` + ? run + ? `run #${run.runNumber}` + : `run ${descriptor.runId}` : `#${descriptor.number}`; - const title = item?.title ?? descriptor.title ?? url.replace("https://github.com/", ""); + const title = + item?.title ?? + (commit ? commit.message.split("\n", 1)[0] : null) ?? + (run ? run.name : null) ?? + descriptor.title ?? + url.replace("https://github.com/", ""); const updated = item?.updatedAt ? relativeTime(item.updatedAt) : ""; + // Degradation rows share one gate: every hydrating kind reports its fetch + // state in the sub-row (review-thread reports inside its own body). + const detailPhase = + descriptor.kind === "commit" + ? commitState.phase + : descriptor.kind === "run" + ? runState.phase + : hydratable + ? state.phase + : null; + const open = () => { if (onOpenUrl) onOpenUrl(url); else window.open(url, "_blank", "noopener,noreferrer"); @@ -689,6 +855,24 @@ export function GitHubCard({ {item?.draft ? draft : null} {item?.author?.login ? by {item.author.login} : null} {updated ? {updated} : null} + {commit ? ( + <> + by {commit.authorLogin ?? commit.authorName ?? "unknown"} + {commit.date ? {relativeTime(commit.date)} : null} + + +{commit.stats.additions}{" "} + −{commit.stats.deletions} + + {commit.fileCount === 1 ? "1 file" : `${commit.fileCount} files`} + + ) : null} + {run ? ( + <> + {run.status === "completed" ? (run.conclusion ?? "completed") : run.status.replace(/_/g, " ")} + {run.branch ? {run.branch} : null} + {run.createdAt ? {relativeTime(run.createdAt)} : null} + + ) : null} {item && item.comments > 0 ? ( @@ -696,9 +880,9 @@ export function GitHubCard({ ) : null} {checks.phase === "ready" ? : null} - {hydratable && state.phase === "loading" ? loading… : null} - {hydratable && state.phase === "unauth" ? connect GitHub to hydrate : null} - {hydratable && state.phase === "error" ? details unavailable : null} + {detailPhase === "loading" ? loading… : null} + {detailPhase === "unauth" ? connect GitHub to hydrate : null} + {detailPhase === "error" ? details unavailable : null} {item && item.labels.length ? (