Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions docs/chat-github-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/app/api/github/runs/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>\]/,
"by-id responses normalize through the same runs[] shape as the list",
);

console.log("github-runs-route.test.ts OK");
30 changes: 23 additions & 7 deletions src/app/api/github/runs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -25,20 +25,28 @@ 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 });
}
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",
Expand All @@ -49,15 +57,23 @@ 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<string, unknown> & { workflow_runs?: unknown[] })
| null;
if (!res.ok || !data || typeof data !== "object") {
return NextResponse.json(
{ ok: false, error: `github error (${res.status})` },
{ status: res.status === 403 ? 403 : 502 },
);
}

const raw = Array.isArray(data.workflow_runs) ? (data.workflow_runs as Array<Record<string, unknown>>) : [];
// 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<string, unknown>]
: Array.isArray(data.workflow_runs)
? (data.workflow_runs as Array<Record<string, unknown>>)
: [];
return NextResponse.json({
ok: true,
authed: Boolean(token),
Expand Down
14 changes: 14 additions & 0 deletions src/components/github-card-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
210 changes: 197 additions & 13 deletions src/components/github-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<CommitState>({ 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<RunState>({ 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" };
Expand Down Expand Up @@ -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;
Comment on lines +772 to +774
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.
Expand All @@ -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");
Expand Down Expand Up @@ -689,16 +855,34 @@ export function GitHubCard({
{item?.draft ? <span>draft</span> : null}
{item?.author?.login ? <span>by {item.author.login}</span> : null}
{updated ? <span>{updated}</span> : null}
{commit ? (
<>
<span>by {commit.authorLogin ?? commit.authorName ?? "unknown"}</span>
{commit.date ? <span>{relativeTime(commit.date)}</span> : null}
<span>
<span className="text-[var(--color-success)]">+{commit.stats.additions}</span>{" "}
<span className="text-[var(--color-warning)]">−{commit.stats.deletions}</span>
</span>
<span>{commit.fileCount === 1 ? "1 file" : `${commit.fileCount} files`}</span>
</>
) : null}
{run ? (
<>
<span>{run.status === "completed" ? (run.conclusion ?? "completed") : run.status.replace(/_/g, " ")}</span>
{run.branch ? <span className="font-mono">{run.branch}</span> : null}
{run.createdAt ? <span>{relativeTime(run.createdAt)}</span> : null}
</>
) : null}
{item && item.comments > 0 ? (
<span className="inline-flex items-center gap-1">
<Icon name="ph:chat-circle-dots" width={11} aria-hidden />
{item.comments}
</span>
) : null}
{checks.phase === "ready" ? <ChecksStrip data={checks.data} /> : null}
{hydratable && state.phase === "loading" ? <span aria-live="polite">loading…</span> : null}
{hydratable && state.phase === "unauth" ? <span>connect GitHub to hydrate</span> : null}
{hydratable && state.phase === "error" ? <span>details unavailable</span> : null}
{detailPhase === "loading" ? <span aria-live="polite">loading…</span> : null}
{detailPhase === "unauth" ? <span>connect GitHub to hydrate</span> : null}
{detailPhase === "error" ? <span>details unavailable</span> : null}
</div>
{item && item.labels.length ? (
<div className="mt-1 flex flex-wrap items-center gap-1">
Expand Down
Loading