diff --git a/packages/cli/src/__tests__/github.test.ts b/packages/cli/src/__tests__/github.test.ts new file mode 100644 index 0000000..45dff2a --- /dev/null +++ b/packages/cli/src/__tests__/github.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { isGitHubRemote, parseGitHubRepo } from "../github/index.js"; + +describe("parseGitHubRepo", () => { + it("parses the SSH shorthand form", () => { + expect(parseGitHubRepo("git@github.com:owner/repo.git")).toEqual({ + owner: "owner", + repo: "repo", + }); + }); + + it("parses the HTTPS form", () => { + expect(parseGitHubRepo("https://github.com/owner/repo.git")).toEqual({ + owner: "owner", + repo: "repo", + }); + }); + + it("parses the ssh:// URL form without a .git suffix", () => { + expect(parseGitHubRepo("ssh://git@github.com/acme/Stage-CLI")).toEqual({ + owner: "acme", + repo: "Stage-CLI", + }); + }); + + it("returns null for non-GitHub hosts", () => { + expect(parseGitHubRepo("git@gitlab.com:owner/repo.git")).toBeNull(); + expect(parseGitHubRepo("https://bitbucket.org/owner/repo.git")).toBeNull(); + }); + + it("returns null for look-alike hosts that merely contain github.com", () => { + expect(parseGitHubRepo("https://notgithub.com.evil.test/owner/repo")).toBeNull(); + }); + + it("returns null when no origin is configured", () => { + expect(parseGitHubRepo(null)).toBeNull(); + }); +}); + +describe("isGitHubRemote", () => { + it("is true for github.com remotes", () => { + expect(isGitHubRemote("git@github.com:owner/repo.git")).toBe(true); + }); + + it("is false for non-GitHub and missing remotes", () => { + expect(isGitHubRemote("git@gitlab.com:owner/repo.git")).toBe(false); + expect(isGitHubRemote(null)).toBe(false); + }); +}); diff --git a/packages/cli/src/__tests__/pull-request.routes.test.ts b/packages/cli/src/__tests__/pull-request.routes.test.ts new file mode 100644 index 0000000..d8a5900 --- /dev/null +++ b/packages/cli/src/__tests__/pull-request.routes.test.ts @@ -0,0 +1,397 @@ +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import type { + ChecksResponse, + MergeStatusResponse, + PullRequestResponse, + ReviewsResponse, +} from "@stagereview/types/pull-request"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeDb, getDb } from "../db/client.js"; +import { chapterRun } from "../db/schema/index.js"; +import { pullRequestRoutes } from "../routes/pull-request.js"; +import { SCOPE_KIND } from "../schema.js"; +import { LOOPBACK_HOST, type ServerHandle, startServer } from "../server.js"; + +let tmpDir: string; +let dbPath: string; +let webDist: string; +let repoRoot: string; +let binDir: string; +let originalPath: string | undefined; +const handles: ServerHandle[] = []; + +const SHA = "a".repeat(40); +const GITHUB_ORIGIN = "git@github.com:owner/repo.git"; + +const PR_JSON = JSON.stringify({ + number: 7, + title: "Add the thing", + url: "https://github.com/owner/repo/pull/7", + state: "OPEN", + isDraft: false, + mergedAt: null, + createdAt: "2026-05-01T00:00:00Z", + author: { login: "octocat", is_bot: false }, + headRefName: "feature", + headRefOid: SHA, + baseRefName: "main", +}); +// REST PR object: drives the author (real avatar/type) and requested_reviewers. +const REST_PR_JSON = JSON.stringify({ + user: { + login: "octocat", + type: "User", + avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4", + html_url: "https://github.com/octocat", + }, + requested_reviewers: [ + { login: "bob", type: "User", avatar_url: "https://avatars.githubusercontent.com/u/2?v=4" }, + ], +}); +// `gh api --paginate --slurp` shape: one array element per page. +// REST reviews: a human approval plus a GitHub App (bot) review with a [bot] login. +const REST_REVIEWS_JSON = JSON.stringify([ + [ + { + user: { + login: "alice", + type: "User", + avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", + }, + state: "APPROVED", + }, + { + user: { + login: "cursor[bot]", + type: "Bot", + avatar_url: "https://avatars.githubusercontent.com/in/1210556?v=4", + }, + state: "COMMENTED", + }, + ], +]); +const CHECKS_JSON = JSON.stringify([ + { + check_runs: [ + { + id: 1, + name: "build", + status: "completed", + conclusion: "success", + started_at: "2026-05-01T00:00:00Z", + completed_at: "2026-05-01T00:01:00Z", + html_url: "https://example.com/run/1", + app: { name: "GitHub Actions", owner: { avatar_url: "https://example.com/a.png" } }, + }, + ], + }, +]); +const MERGE_JSON = JSON.stringify({ + data: { + repository: { + autoMergeAllowed: true, + squashMergeAllowed: true, + mergeCommitAllowed: true, + rebaseMergeAllowed: false, + pullRequest: { + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "APPROVED", + isMergeQueueEnabled: false, + viewerCanEnableAutoMerge: true, + viewerCanDisableAutoMerge: false, + autoMergeRequest: null, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + mergeQueueEntry: null, + }, + }, + }, +}); + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "stage-cli-pr-routes-")); + dbPath = path.join(tmpDir, "db.sqlite"); + webDist = path.join(tmpDir, "web-dist"); + repoRoot = path.join(tmpDir, "repo"); + binDir = path.join(tmpDir, "bin"); + await fs.mkdir(webDist); + await fs.writeFile(path.join(webDist, "index.html"), ""); + await fs.mkdir(repoRoot); + await fs.mkdir(binDir); + originalPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; + closeDb(); +}); + +afterEach(async () => { + while (handles.length > 0) { + const h = handles.pop(); + if (h) await h.close(); + } + closeDb(); + process.env.PATH = originalPath; + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +/** Fake `gh` that dispatches by argv to fixture files; exits 1 when a fixture is absent. */ +async function writeFakeGh(fixtures: { + pr?: string; + restPr?: string; + reviews?: string; + checks?: string; + merge?: string; +}): Promise { + const dir = path.join(binDir, "fixtures"); + await fs.mkdir(dir, { recursive: true }); + const write = async (name: string, value?: string) => { + if (value !== undefined) await fs.writeFile(path.join(dir, name), value); + }; + await Promise.all([ + write("pr.json", fixtures.pr), + write("rest-pr.json", fixtures.restPr), + write("reviews.json", fixtures.reviews), + write("checks.json", fixtures.checks), + write("merge.json", fixtures.merge), + ]); + const script = `#!/bin/sh +dir="${dir}" +emit() { [ -f "$dir/$1" ] && cat "$dir/$1" || exit 1; } +all="$*" +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then emit pr.json +elif [ "$1" = "api" ] && [ "$2" = "graphql" ]; then emit merge.json +elif [ "$1" = "api" ]; then + case "$all" in + *check-runs*) emit checks.json ;; + */reviews*) emit reviews.json ;; + *) emit rest-pr.json ;; + esac +else exit 1; fi +`; + const file = path.join(binDir, "gh"); + await fs.writeFile(file, script); + await fs.chmod(file, 0o755); +} + +function insertRun(originUrl: string | null): string { + const db = getDb({ dbPath }); + const [row] = db + .insert(chapterRun) + .values({ + repoRoot, + originUrl, + scopeKind: SCOPE_KIND.COMMITTED, + workingTreeRef: null, + baseSha: SHA, + headSha: SHA, + mergeBaseSha: SHA, + generatedAt: new Date(), + }) + .returning({ id: chapterRun.id }) + .all(); + if (!row) throw new Error("seed: chapter_run insert returned no row"); + return row.id; +} + +async function start(): Promise { + const db = getDb({ dbPath }); + const handle = await startServer({ webDistPath: webDist, routes: pullRequestRoutes(db) }); + handles.push(handle); + return handle.port; +} + +function request(port: number, p: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { hostname: LOOPBACK_HOST, port, method: "GET", path: p, agent: false }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => + resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8") }), + ); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +describe("pull-request API", () => { + it("maps the gh PR payload onto the REST-shaped wire type", async () => { + await writeFakeGh({ pr: PR_JSON, restPr: REST_PR_JSON }); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), `/api/runs/${runId}/pull-request`); + expect(res.status).toBe(200); + const { pullRequest } = JSON.parse(res.body) as PullRequestResponse; + expect(pullRequest).toEqual({ + number: 7, + title: "Add the thing", + html_url: "https://github.com/owner/repo/pull/7", + state: "open", + draft: false, + merged_at: null, + created_at: "2026-05-01T00:00:00Z", + // Author sourced from REST: real avatar_url, not a fabricated github.com/.png. + user: { + login: "octocat", + type: "User", + avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4", + }, + head: { ref: "feature", sha: SHA }, + base: { ref: "main" }, + }); + }); + + it("returns null when gh finds no PR for the branch", async () => { + await writeFakeGh({}); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), `/api/runs/${runId}/pull-request`); + expect(res.status).toBe(200); + expect((JSON.parse(res.body) as PullRequestResponse).pullRequest).toBeNull(); + }); + + it("returns null for non-GitHub remotes without invoking gh", async () => { + await writeFakeGh({ pr: PR_JSON }); + const runId = insertRun("git@gitlab.com:owner/repo.git"); + const res = await request(await start(), `/api/runs/${runId}/pull-request`); + expect((JSON.parse(res.body) as PullRequestResponse).pullRequest).toBeNull(); + }); + + it("returns 404 for an unknown runId", async () => { + const res = await request( + await start(), + "/api/runs/00000000-0000-0000-0000-000000000000/pull-request", + ); + expect(res.status).toBe(404); + }); + + it("returns mapped CI check items for a valid headSha", async () => { + await writeFakeGh({ checks: CHECKS_JSON }); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request( + await start(), + `/api/runs/${runId}/pull-request/checks?headSha=${SHA}`, + ); + expect(res.status).toBe(200); + const body = JSON.parse(res.body) as ChecksResponse; + expect(body.state).toBe("success"); + expect(body.items).toHaveLength(1); + expect(body.items[0]).toMatchObject({ + source: "check_run", + name: "build", + conclusion: "success", + avatarUrl: "https://example.com/a.png", + appName: "GitHub Actions", + }); + }); + + it("rejects a checks request without a valid headSha", async () => { + await writeFakeGh({ checks: CHECKS_JSON }); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), `/api/runs/${runId}/pull-request/checks?headSha=nope`); + expect(res.status).toBe(400); + }); + + it("maps reviews and requested reviewers, preserving bot identity from REST", async () => { + await writeFakeGh({ reviews: REST_REVIEWS_JSON, restPr: REST_PR_JSON }); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), `/api/runs/${runId}/pull-request/reviews?number=7`); + expect(res.status).toBe(200); + const { reviews } = JSON.parse(res.body) as ReviewsResponse; + expect(reviews?.status).toBe("approved"); + expect(reviews?.reviewers).toEqual([ + { + user: { + login: "alice", + type: "User", + avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", + }, + status: "APPROVED", + }, + { + // Bot reviewer keeps its [bot] login, "Bot" type, and real app avatar. + user: { + login: "cursor[bot]", + type: "Bot", + avatar_url: "https://avatars.githubusercontent.com/in/1210556?v=4", + }, + status: "COMMENTED", + }, + { + user: { + login: "bob", + type: "User", + avatar_url: "https://avatars.githubusercontent.com/u/2?v=4", + }, + status: "REQUESTED", + }, + ]); + }); + + it("treats a re-requested reviewer as awaiting review even if they already reviewed", async () => { + // alice has an APPROVED review AND is in requested_reviewers (re-requested). + const restPr = JSON.stringify({ + user: { login: "octocat", type: "User", avatar_url: "https://example.com/o.png" }, + requested_reviewers: [ + { + login: "alice", + type: "User", + avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", + }, + ], + }); + await writeFakeGh({ reviews: REST_REVIEWS_JSON, restPr }); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), `/api/runs/${runId}/pull-request/reviews?number=7`); + const { reviews } = JSON.parse(res.body) as ReviewsResponse; + const alice = reviews?.reviewers.find((r) => r.user.login === "alice"); + expect(alice?.status).toBe("REQUESTED"); + }); + + it("keeps a re-requested CHANGES_REQUESTED review blocking, not 'awaiting'", async () => { + const reviews = JSON.stringify([ + [ + { + user: { login: "carol", type: "User", avatar_url: "https://example.com/c.png" }, + state: "CHANGES_REQUESTED", + }, + ], + ]); + const restPr = JSON.stringify({ + user: { login: "octocat", type: "User", avatar_url: "https://example.com/o.png" }, + requested_reviewers: [ + { login: "carol", type: "User", avatar_url: "https://example.com/c.png" }, + ], + }); + await writeFakeGh({ reviews, restPr }); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), `/api/runs/${runId}/pull-request/reviews?number=7`); + const body = JSON.parse(res.body) as ReviewsResponse; + expect(body.reviews?.reviewers.find((r) => r.user.login === "carol")?.status).toBe( + "CHANGES_REQUESTED", + ); + expect(body.reviews?.status).toBe("changes_requested"); + }); + + it("maps the merge-status GraphQL response", async () => { + await writeFakeGh({ merge: MERGE_JSON }); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request( + await start(), + `/api/runs/${runId}/pull-request/merge-status?number=7`, + ); + expect(res.status).toBe(200); + const { mergeStatus } = JSON.parse(res.body) as MergeStatusResponse; + expect(mergeStatus).toMatchObject({ + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "APPROVED", + checkRollupState: "SUCCESS", + isInMergeQueue: false, + allowedMergeMethods: ["MERGE", "SQUASH"], + }); + }); +}); diff --git a/packages/cli/src/github/exec.ts b/packages/cli/src/github/exec.ts new file mode 100644 index 0000000..4e4821a --- /dev/null +++ b/packages/cli/src/github/exec.ts @@ -0,0 +1,14 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** Run a read-only `gh` command in `cwd` and return its stdout. */ +export async function gh(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync("gh", args, { + cwd, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + return stdout; +} diff --git a/packages/cli/src/github/index.ts b/packages/cli/src/github/index.ts new file mode 100644 index 0000000..638250f --- /dev/null +++ b/packages/cli/src/github/index.ts @@ -0,0 +1,2 @@ +export { getChecks, getMergeStatus, getPullRequest, getReviews } from "./pull-request.js"; +export { type GitHubRepo, isGitHubRemote, parseGitHubRepo } from "./repo.js"; diff --git a/packages/cli/src/github/pull-request.ts b/packages/cli/src/github/pull-request.ts new file mode 100644 index 0000000..b5914b6 --- /dev/null +++ b/packages/cli/src/github/pull-request.ts @@ -0,0 +1,480 @@ +import { + CHECK_CONCLUSION, + CHECK_ITEM_SOURCE, + type CheckItem, + type ChecksResponse, + type GitHubPullRequest, + type GitHubUser, + type MergeStatusInfo, + PULL_REQUEST_CI_STATUS, + PULL_REQUEST_REVIEW_STATUS, + type PullRequestReviewSummary, + REVIEW_STATE, + REVIEWER_STATUS, + type Reviewer, + type ReviewerStatus, +} from "@stagereview/types/pull-request"; +import { z } from "zod"; +import { gh } from "./exec.js"; +import { type GitHubRepo, parseGitHubRepo } from "./repo.js"; + +// ─── Pull request ───────────────────────────────────────────────────────────── + +const GhAuthorSchema = z + .object({ login: z.string(), is_bot: z.boolean().optional() }) + .nullable() + .optional(); + +const GhPullRequestSchema = z.object({ + number: z.number(), + title: z.string(), + url: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + isDraft: z.boolean(), + mergedAt: z.string().nullable().optional(), + createdAt: z.string(), + author: GhAuthorSchema, + headRefName: z.string(), + headRefOid: z.string(), + baseRefName: z.string(), +}); + +const PR_FIELDS = [ + "number", + "title", + "url", + "state", + "isDraft", + "mergedAt", + "createdAt", + "author", + "headRefName", + "headRefOid", + "baseRefName", +] as const; + +/** GitHub serves a user/org avatar at `https://github.com/.png`. */ +function avatarUrlForLogin(login: string): string { + return `https://github.com/${encodeURIComponent(login)}.png`; +} + +// REST user shape (`.user`, reviewers, etc.). Unlike gh's GraphQL projection, it +// carries `type` ("Bot" for GitHub Apps), the real `avatar_url`, and the `[bot]` +// login suffix — everything getUserDisplay needs to render bot chips and the +// /apps/ profile URL. Sourcing users from REST keeps parity with hosted Stage. +const RestUserSchema = z.object({ + login: z.string(), + avatar_url: z.string(), + type: z.string(), +}); +const RestPullRequestSchema = z.object({ + user: RestUserSchema.nullable(), + requested_reviewers: z.array(RestUserSchema).nullable().optional(), +}); + +/** gh's GraphQL author projection lacks `avatar_url` and the `[bot]` suffix; only used if the REST lookup fails. */ +function ghAuthorFallback( + author: { login: string; is_bot?: boolean } | null | undefined, +): GitHubUser | null { + if (!author?.login) return null; + return { + login: author.login, + avatar_url: avatarUrlForLogin(author.login), + type: author.is_bot ? "Bot" : "User", + }; +} + +async function fetchRestPullRequest( + repoRoot: string, + repo: GitHubRepo, + prNumber: number, +): Promise | null> { + try { + const stdout = await gh( + ["api", `repos/${repo.owner}/${repo.repo}/pulls/${prNumber}`], + repoRoot, + ); + const parsed = RestPullRequestSchema.safeParse(JSON.parse(stdout)); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +/** + * Detect the GitHub PR for the branch currently checked out in `repoRoot`, + * mapped onto the REST-shaped `GitHubPullRequest` the UI consumes. Returns null + * whenever detection isn't possible — non-GitHub remote, `gh` missing or + * unauthenticated, no PR for the branch, or unparseable output — so PR context + * never breaks the review UI. + */ +export async function getPullRequest( + repoRoot: string, + originUrl: string | null, +): Promise { + const repo = parseGitHubRepo(originUrl); + if (!repo) return null; + try { + const stdout = await gh(["pr", "view", "--json", PR_FIELDS.join(",")], repoRoot); + const parsed = GhPullRequestSchema.safeParse(JSON.parse(stdout)); + if (!parsed.success) return null; + const pr = parsed.data; + // Prefer the REST author (real avatar, bot type, [bot] login); fall back to + // gh's leaner author projection only if the REST lookup fails. + const rest = await fetchRestPullRequest(repoRoot, repo, pr.number); + const user = rest?.user ?? ghAuthorFallback(pr.author); + return { + number: pr.number, + title: pr.title, + html_url: pr.url, + // REST `state` is open|closed; merged implies closed. + state: pr.state === "OPEN" ? "open" : "closed", + draft: pr.isDraft, + merged_at: pr.mergedAt && pr.mergedAt.length > 0 ? pr.mergedAt : null, + created_at: pr.createdAt, + user, + head: { ref: pr.headRefName, sha: pr.headRefOid }, + base: { ref: pr.baseRefName }, + }; + } catch { + return null; + } +} + +// ─── CI checks ────────────────────────────────────────────────────────────── + +const GhCheckRunSchema = z.object({ + id: z.number(), + name: z.string(), + status: z.string(), + conclusion: z.string().nullable(), + started_at: z.string().nullable(), + completed_at: z.string().nullable(), + html_url: z.string().nullable(), + app: z + .object({ name: z.string().optional(), owner: z.object({ avatar_url: z.string() }).optional() }) + .nullable() + .optional(), +}); +const GhCheckRunsSchema = z.object({ check_runs: z.array(GhCheckRunSchema) }); + +const COMPLETED_STATUS = "completed"; +const FAILED_CONCLUSIONS = new Set([ + CHECK_CONCLUSION.FAILURE, + CHECK_CONCLUSION.TIMED_OUT, + CHECK_CONCLUSION.STARTUP_FAILURE, + CHECK_CONCLUSION.ACTION_REQUIRED, + CHECK_CONCLUSION.CANCELLED, +]); + +function toCheckItem(run: z.infer): CheckItem { + const status = + run.status === "completed" + ? "completed" + : run.status === "in_progress" + ? "in_progress" + : "queued"; + const conclusion = run.conclusion; + const isKnownConclusion = (value: string | null): value is CheckItem["conclusion"] => + value !== null && Object.values(CHECK_CONCLUSION).includes(value as never); + return { + source: CHECK_ITEM_SOURCE.CHECK_RUN, + id: run.id, + name: run.name, + status, + conclusion: isKnownConclusion(conclusion) ? conclusion : null, + startedAt: run.started_at, + completedAt: run.completed_at, + url: run.html_url, + avatarUrl: run.app?.owner?.avatar_url ?? null, + appName: run.app?.name ?? "", + }; +} + +function deriveCiState(items: CheckItem[]): ChecksResponse["state"] { + if (items.length === 0) return PULL_REQUEST_CI_STATUS.NONE; + let anyPending = false; + for (const item of items) { + if (item.status !== COMPLETED_STATUS) { + anyPending = true; + continue; + } + if (item.conclusion && FAILED_CONCLUSIONS.has(item.conclusion)) { + return PULL_REQUEST_CI_STATUS.FAILURE; + } + } + return anyPending ? PULL_REQUEST_CI_STATUS.PENDING : PULL_REQUEST_CI_STATUS.SUCCESS; +} + +/** + * CI check runs for `headSha`. Deployment links require a GitHub App + * integration the CLI doesn't have, so `deploymentLinks` is always empty here. + */ +export async function getChecks( + repoRoot: string, + repo: GitHubRepo, + headSha: string, +): Promise { + const empty: ChecksResponse = { + state: PULL_REQUEST_CI_STATUS.NONE, + items: [], + deploymentLinks: [], + }; + try { + // `--slurp` wraps every page into one JSON array (`[{page}, {page}, …]`); + // without it, `--paginate` concatenates raw page objects, which isn't valid + // JSON for a multi-page response. + const stdout = await gh( + [ + "api", + `repos/${repo.owner}/${repo.repo}/commits/${headSha}/check-runs`, + "--paginate", + "--slurp", + ], + repoRoot, + ); + const parsed = z.array(GhCheckRunsSchema).safeParse(JSON.parse(stdout)); + if (!parsed.success) return empty; + const items = parsed.data.flatMap((page) => page.check_runs).map(toCheckItem); + return { state: deriveCiState(items), items, deploymentLinks: [] }; + } catch { + return empty; + } +} + +// ─── Reviews ──────────────────────────────────────────────────────────────── + +// REST reviews are returned oldest-first, so iterating and overwriting per login +// yields each reviewer's latest review. +const RestReviewSchema = z.object({ user: RestUserSchema.nullable(), state: z.string() }); + +const KNOWN_REVIEW_STATES = new Set(Object.values(REVIEW_STATE)); + +function reviewerStatusFor(state: string): ReviewerStatus | null { + if (state === "PENDING") return null; + return KNOWN_REVIEW_STATES.has(state) ? (state as ReviewerStatus) : null; +} + +function summarizeReviews(reviewers: Reviewer[]): PullRequestReviewSummary["status"] { + if (reviewers.some((r) => r.status === REVIEWER_STATUS.CHANGES_REQUESTED)) { + return PULL_REQUEST_REVIEW_STATUS.CHANGES_REQUESTED; + } + if (reviewers.some((r) => r.status === REVIEWER_STATUS.APPROVED)) { + return PULL_REQUEST_REVIEW_STATUS.APPROVED; + } + if (reviewers.length > 0) return PULL_REQUEST_REVIEW_STATUS.IN_REVIEW; + return PULL_REQUEST_REVIEW_STATUS.NO_REVIEWS; +} + +export async function getReviews( + repoRoot: string, + repo: GitHubRepo, + prNumber: number, +): Promise { + try { + // `--paginate --slurp` returns one array per page (`[[…], […]]`); flatten to + // the full chronological review list so PRs with >30 reviews aren't truncated. + const stdout = await gh( + [ + "api", + `repos/${repo.owner}/${repo.repo}/pulls/${prNumber}/reviews`, + "--paginate", + "--slurp", + ], + repoRoot, + ); + const parsed = z.array(z.array(RestReviewSchema)).safeParse(JSON.parse(stdout)); + if (!parsed.success) return null; + + const byLogin = new Map(); + for (const review of parsed.data.flat()) { + if (!review.user) continue; + const status = reviewerStatusFor(review.state); + if (!status) continue; + // A later COMMENTED review doesn't supersede a reviewer's standing + // APPROVED/CHANGES_REQUESTED decision, matching GitHub's effective state. + const existing = byLogin.get(review.user.login); + if ( + existing && + status === REVIEWER_STATUS.COMMENTED && + (existing.status === REVIEWER_STATUS.APPROVED || + existing.status === REVIEWER_STATUS.CHANGES_REQUESTED) + ) { + continue; + } + byLogin.set(review.user.login, { user: review.user, status }); + } + + // A currently-requested reviewer is awaiting (re-)review, which supersedes a + // stale approval/comment — re-requesting someone who already approved should + // show "Awaiting review". A standing CHANGES_REQUESTED review is the exception: + // it keeps blocking until the reviewer approves or it's dismissed, so don't + // downgrade it to "requested". + const rest = await fetchRestPullRequest(repoRoot, repo, prNumber); + for (const user of rest?.requested_reviewers ?? []) { + if (byLogin.get(user.login)?.status === REVIEWER_STATUS.CHANGES_REQUESTED) continue; + byLogin.set(user.login, { user, status: REVIEWER_STATUS.REQUESTED }); + } + + const reviewers = [...byLogin.values()]; + return { status: summarizeReviews(reviewers), reviewers }; + } catch { + return null; + } +} + +// ─── Merge status ───────────────────────────────────────────────────────────── + +const MERGE_STATUS_QUERY = `query GetMergeStatus($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + autoMergeAllowed + squashMergeAllowed + mergeCommitAllowed + rebaseMergeAllowed + pullRequest(number: $number) { + mergeable + mergeStateStatus + reviewDecision + isMergeQueueEnabled + viewerCanEnableAutoMerge + viewerCanDisableAutoMerge + autoMergeRequest { enabledAt } + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } + mergeQueueEntry { id position estimatedTimeToMerge } + } + } +}`; + +const GhMergeStatusSchema = z.object({ + data: z.object({ + repository: z.object({ + autoMergeAllowed: z.boolean(), + squashMergeAllowed: z.boolean(), + mergeCommitAllowed: z.boolean(), + rebaseMergeAllowed: z.boolean(), + pullRequest: z.object({ + mergeable: z.string(), + mergeStateStatus: z.string(), + reviewDecision: z.string().nullable(), + isMergeQueueEnabled: z.boolean(), + viewerCanEnableAutoMerge: z.boolean(), + viewerCanDisableAutoMerge: z.boolean(), + autoMergeRequest: z.object({ enabledAt: z.string().nullable() }).nullable(), + commits: z.object({ + nodes: z.array( + z.object({ + commit: z.object({ + statusCheckRollup: z.object({ state: z.string() }).nullable(), + }), + }), + ), + }), + mergeQueueEntry: z + .object({ + id: z.string(), + position: z.number(), + estimatedTimeToMerge: z.number().nullable(), + }) + .nullable(), + }), + }), + }), +}); + +function asEnum>( + obj: T, + value: string, + fallback: T[keyof T], +): T[keyof T] { + return Object.values(obj).includes(value) ? (value as T[keyof T]) : fallback; +} + +export async function getMergeStatus( + repoRoot: string, + repo: GitHubRepo, + prNumber: number, +): Promise { + try { + const stdout = await gh( + [ + "api", + "graphql", + "-f", + `query=${MERGE_STATUS_QUERY}`, + "-F", + `owner=${repo.owner}`, + "-F", + `repo=${repo.repo}`, + "-F", + `number=${prNumber}`, + ], + repoRoot, + ); + const parsed = GhMergeStatusSchema.safeParse(JSON.parse(stdout)); + if (!parsed.success) return null; + const { repository } = parsed.data.data; + const pr = repository.pullRequest; + const allowedMergeMethods: MergeStatusInfo["allowedMergeMethods"] = []; + if (repository.mergeCommitAllowed) allowedMergeMethods.push("MERGE"); + if (repository.squashMergeAllowed) allowedMergeMethods.push("SQUASH"); + if (repository.rebaseMergeAllowed) allowedMergeMethods.push("REBASE"); + const rollupState = pr.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null; + return { + mergeable: asEnum( + { CONFLICTING: "CONFLICTING", MERGEABLE: "MERGEABLE", UNKNOWN: "UNKNOWN" } as const, + pr.mergeable, + "UNKNOWN", + ), + mergeStateStatus: asEnum( + { + BEHIND: "BEHIND", + BLOCKED: "BLOCKED", + CLEAN: "CLEAN", + DIRTY: "DIRTY", + DRAFT: "DRAFT", + HAS_HOOKS: "HAS_HOOKS", + UNKNOWN: "UNKNOWN", + UNSTABLE: "UNSTABLE", + } as const, + pr.mergeStateStatus, + "UNKNOWN", + ), + reviewDecision: + pr.reviewDecision === null + ? null + : asEnum( + { + APPROVED: "APPROVED", + CHANGES_REQUESTED: "CHANGES_REQUESTED", + REVIEW_REQUIRED: "REVIEW_REQUIRED", + } as const, + pr.reviewDecision, + "REVIEW_REQUIRED", + ), + checkRollupState: + rollupState === null + ? null + : asEnum( + { + SUCCESS: "SUCCESS", + PENDING: "PENDING", + FAILURE: "FAILURE", + ERROR: "ERROR", + EXPECTED: "EXPECTED", + } as const, + rollupState, + "PENDING", + ), + autoMergeEnabled: pr.autoMergeRequest !== null, + autoMergeAllowed: repository.autoMergeAllowed, + viewerCanEnableAutoMerge: pr.viewerCanEnableAutoMerge, + viewerCanDisableAutoMerge: pr.viewerCanDisableAutoMerge, + isMergeQueueEnabled: pr.isMergeQueueEnabled, + isInMergeQueue: pr.mergeQueueEntry !== null, + entry: pr.mergeQueueEntry, + allowedMergeMethods, + }; + } catch { + return null; + } +} diff --git a/packages/cli/src/github/repo.ts b/packages/cli/src/github/repo.ts new file mode 100644 index 0000000..b73a33d --- /dev/null +++ b/packages/cli/src/github/repo.ts @@ -0,0 +1,24 @@ +export interface GitHubRepo { + owner: string; + repo: string; +} + +/** + * Parse `owner`/`repo` from a github.com origin URL, or null for non-GitHub + * remotes. Skips the `gh` invocation entirely for GitLab/Bitbucket/self-hosted + * rather than shelling out only to have `gh` fail. Matches the URL shapes git + * emits: git@github.com:owner/repo(.git), https://github.com/owner/repo(.git), + * ssh://git@github.com/owner/repo(.git). + */ +export function parseGitHubRepo(originUrl: string | null): GitHubRepo | null { + if (!originUrl) return null; + const match = originUrl.match(/(?:^|@|\/\/)github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/); + if (!match) return null; + const [, owner, repo] = match; + if (!owner || !repo) return null; + return { owner, repo }; +} + +export function isGitHubRemote(originUrl: string | null): boolean { + return parseGitHubRepo(originUrl) !== null; +} diff --git a/packages/cli/src/routes/pull-request.ts b/packages/cli/src/routes/pull-request.ts new file mode 100644 index 0000000..d6183d6 --- /dev/null +++ b/packages/cli/src/routes/pull-request.ts @@ -0,0 +1,144 @@ +import path from "node:path"; +import type { + ChecksResponse, + MergeStatusResponse, + PullRequestResponse, + ReviewsResponse, +} from "@stagereview/types/pull-request"; +import { eq } from "drizzle-orm"; +import type { StageDb } from "../db/client.js"; +import { chapterRun } from "../db/schema/index.js"; +import { + type GitHubRepo, + getChecks, + getMergeStatus, + getPullRequest, + getReviews, + parseGitHubRepo, +} from "../github/index.js"; +import type { Route, RouteHandler, RouteParams } from "../server.js"; +import { writeJson } from "./json.js"; + +interface RunRepo { + repoRoot: string; + originUrl: string | null; +} + +/** Resolve a run's repo context, writing the matching error response on failure. */ +function resolveRun( + db: StageDb, + params: RouteParams, + res: Parameters[1], +): RunRepo | null { + const runId = params.runId; + if (!runId) { + writeJson(res, 400, { error: "Missing runId" }); + return null; + } + const [run] = db.select().from(chapterRun).where(eq(chapterRun.id, runId)).limit(1).all(); + if (!run) { + writeJson(res, 404, { error: `Run ${runId} not found` }); + return null; + } + const repoRoot = run.repoRoot; + if (!path.isAbsolute(repoRoot) || repoRoot.split(path.sep).includes("..")) { + writeJson(res, 500, { + error: "Run repoRoot is not an absolute path or contains traversal segments", + }); + return null; + } + return { repoRoot, originUrl: run.originUrl }; +} + +function requireRepo(run: RunRepo, res: Parameters[1]): GitHubRepo | null { + const repo = parseGitHubRepo(run.originUrl); + if (!repo) { + writeJson(res, 404, { error: "Run is not associated with a GitHub remote" }); + return null; + } + return repo; +} + +function query(req: Parameters[0], key: string): string | null { + const url = req.url ?? ""; + const qIdx = url.indexOf("?"); + if (qIdx < 0) return null; + return new URLSearchParams(url.slice(qIdx + 1)).get(key); +} + +function parseNumber(value: string | null): number | null { + if (value === null) return null; + const n = Number(value); + return Number.isInteger(n) && n > 0 ? n : null; +} + +const SHA_RE = /^[0-9a-f]{40}$/i; + +export function pullRequestRoutes(db: StageDb): Route[] { + return [ + { + method: "GET", + pattern: "/api/runs/:runId/pull-request", + handler: async (_req, res, params) => { + const run = resolveRun(db, params, res); + if (!run) return; + const pullRequest = await getPullRequest(run.repoRoot, run.originUrl); + const body: PullRequestResponse = { pullRequest }; + writeJson(res, 200, body); + }, + }, + { + method: "GET", + pattern: "/api/runs/:runId/pull-request/checks", + handler: async (req, res, params) => { + const run = resolveRun(db, params, res); + if (!run) return; + const repo = requireRepo(run, res); + if (!repo) return; + const headSha = query(req, "headSha"); + if (!headSha || !SHA_RE.test(headSha)) { + writeJson(res, 400, { error: "Missing or invalid headSha" }); + return; + } + const body: ChecksResponse = await getChecks(run.repoRoot, repo, headSha); + writeJson(res, 200, body); + }, + }, + { + method: "GET", + pattern: "/api/runs/:runId/pull-request/reviews", + handler: async (req, res, params) => { + const run = resolveRun(db, params, res); + if (!run) return; + const repo = requireRepo(run, res); + if (!repo) return; + const number = parseNumber(query(req, "number")); + if (number === null) { + writeJson(res, 400, { error: "Missing or invalid number" }); + return; + } + const reviews = await getReviews(run.repoRoot, repo, number); + const body: ReviewsResponse = { reviews }; + writeJson(res, 200, body); + }, + }, + { + method: "GET", + pattern: "/api/runs/:runId/pull-request/merge-status", + handler: async (req, res, params) => { + const run = resolveRun(db, params, res); + if (!run) return; + const repo = requireRepo(run, res); + if (!repo) return; + const number = parseNumber(query(req, "number")); + if (number === null) { + writeJson(res, 400, { error: "Missing or invalid number" }); + return; + } + const mergeStatus = await getMergeStatus(run.repoRoot, repo, number); + const body: MergeStatusResponse = { mergeStatus }; + writeJson(res, 200, body); + }, + }, + ]; +} diff --git a/packages/cli/src/show.ts b/packages/cli/src/show.ts index a81f1e6..4bb9fe1 100644 --- a/packages/cli/src/show.ts +++ b/packages/cli/src/show.ts @@ -7,6 +7,7 @@ import { parseGitDiff } from "./diff-parser.js"; import { filterFilesForLlm } from "./filter-files.js"; import { type ResolveScopeOptions, readRepoContext, resolveScope } from "./git.js"; import { diffRoutes } from "./routes/diff.js"; +import { pullRequestRoutes } from "./routes/pull-request.js"; import { runRoutes } from "./routes/runs.js"; import { viewStateRoutes } from "./routes/view-state.js"; import { insertChaptersFile } from "./runs/import-chapters.js"; @@ -33,7 +34,7 @@ export async function show( const { runId } = insertChaptersFile(db, chaptersFile, readRepoContext()); const handle = await startServer({ - routes: [...runRoutes(db), ...viewStateRoutes(db), ...diffRoutes(db)], + routes: [...runRoutes(db), ...viewStateRoutes(db), ...diffRoutes(db), ...pullRequestRoutes(db)], }); const { port } = handle; const url = `http://${LOOPBACK_HOST}:${port}/runs/${encodeURIComponent(runId)}`; diff --git a/packages/types/package.json b/packages/types/package.json index a920cc2..a2260c4 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -10,6 +10,7 @@ "./diff": "./src/diff.ts", "./parsed-diff": "./src/parsed-diff.ts", "./prologue": "./src/prologue.ts", + "./pull-request": "./src/pull-request.ts", "./view-state": "./src/view-state.ts" }, "files": [ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ee0225d..715cf6b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -2,4 +2,5 @@ export * from "./chapters.ts"; export * from "./diff.ts"; export * from "./parsed-diff.ts"; export * from "./prologue.ts"; +export * from "./pull-request.ts"; export * from "./view-state.ts"; diff --git a/packages/types/src/pull-request.ts b/packages/types/src/pull-request.ts new file mode 100644 index 0000000..a0b6f9d --- /dev/null +++ b/packages/types/src/pull-request.ts @@ -0,0 +1,231 @@ +import { z } from "zod"; + +// ─── Status enums ─────────────────────────────────────────────────────────── + +export const PULL_REQUEST_STATUS = { + OPEN: "open", + MERGED: "merged", + CLOSED: "closed", + DRAFT: "draft", +} as const; +export type PullRequestStatus = (typeof PULL_REQUEST_STATUS)[keyof typeof PULL_REQUEST_STATUS]; + +export const REVIEW_STATE = { + APPROVED: "APPROVED", + CHANGES_REQUESTED: "CHANGES_REQUESTED", + COMMENTED: "COMMENTED", + DISMISSED: "DISMISSED", + PENDING: "PENDING", +} as const; +export type ReviewState = (typeof REVIEW_STATE)[keyof typeof REVIEW_STATE]; + +export const REVIEWER_STATUS = { + ...REVIEW_STATE, + REQUESTED: "REQUESTED", +} as const; +export type ReviewerStatus = (typeof REVIEWER_STATUS)[keyof typeof REVIEWER_STATUS]; + +export const PULL_REQUEST_REVIEW_STATUS = { + APPROVED: "approved", + CHANGES_REQUESTED: "changes_requested", + IN_REVIEW: "in_review", + NO_REVIEWS: "no_reviews", +} as const; +export type PullRequestReviewStatus = + (typeof PULL_REQUEST_REVIEW_STATUS)[keyof typeof PULL_REQUEST_REVIEW_STATUS]; + +export const PULL_REQUEST_CI_STATUS = { + SUCCESS: "success", + FAILURE: "failure", + PENDING: "pending", + NONE: "none", +} as const; +export type PullRequestCIStatus = + (typeof PULL_REQUEST_CI_STATUS)[keyof typeof PULL_REQUEST_CI_STATUS]; + +export const CHECK_ITEM_SOURCE = { + CHECK_RUN: "check_run", + DEPLOYMENT: "deployment", +} as const; +export type CheckItemSource = (typeof CHECK_ITEM_SOURCE)[keyof typeof CHECK_ITEM_SOURCE]; + +export const CHECK_CONCLUSION = { + SUCCESS: "success", + FAILURE: "failure", + NEUTRAL: "neutral", + CANCELLED: "cancelled", + SKIPPED: "skipped", + TIMED_OUT: "timed_out", + ACTION_REQUIRED: "action_required", + STARTUP_FAILURE: "startup_failure", + STALE: "stale", +} as const; +export type CheckConclusion = (typeof CHECK_CONCLUSION)[keyof typeof CHECK_CONCLUSION]; + +export const CHECK_ITEM_STATUS = { + QUEUED: "queued", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", +} as const; +export type CheckItemStatus = (typeof CHECK_ITEM_STATUS)[keyof typeof CHECK_ITEM_STATUS]; + +export const MERGE_STATE_STATUS = { + BEHIND: "BEHIND", + BLOCKED: "BLOCKED", + CLEAN: "CLEAN", + DIRTY: "DIRTY", + DRAFT: "DRAFT", + HAS_HOOKS: "HAS_HOOKS", + UNKNOWN: "UNKNOWN", + UNSTABLE: "UNSTABLE", +} as const; +export type MergeStateStatus = (typeof MERGE_STATE_STATUS)[keyof typeof MERGE_STATE_STATUS]; + +export const MERGEABLE_STATE = { + CONFLICTING: "CONFLICTING", + MERGEABLE: "MERGEABLE", + UNKNOWN: "UNKNOWN", +} as const; +export type MergeableState = (typeof MERGEABLE_STATE)[keyof typeof MERGEABLE_STATE]; + +export const REVIEW_DECISION = { + APPROVED: "APPROVED", + CHANGES_REQUESTED: "CHANGES_REQUESTED", + REVIEW_REQUIRED: "REVIEW_REQUIRED", +} as const; +export type ReviewDecision = (typeof REVIEW_DECISION)[keyof typeof REVIEW_DECISION]; + +export const CHECK_ROLLUP_STATE = { + SUCCESS: "SUCCESS", + PENDING: "PENDING", + FAILURE: "FAILURE", + ERROR: "ERROR", + EXPECTED: "EXPECTED", +} as const; +export type CheckRollupState = (typeof CHECK_ROLLUP_STATE)[keyof typeof CHECK_ROLLUP_STATE]; + +export const PULL_REQUEST_MERGE_METHOD = { + MERGE: "MERGE", + SQUASH: "SQUASH", + REBASE: "REBASE", +} as const; +export type PullRequestMergeMethod = + (typeof PULL_REQUEST_MERGE_METHOD)[keyof typeof PULL_REQUEST_MERGE_METHOD]; + +// ─── Pull request ───────────────────────────────────────────────────────────── +// REST-shaped subset of GitHub's `pull-request` payload — only the fields the +// header reads. Named/shaped to match hosted Stage's `GitHubPullRequest` so the +// vendored components consume it unchanged. The CLI's gh adapter maps gh's +// GraphQL camelCase output onto this REST snake_case shape. + +const GitHubUserSchema = z.object({ + login: z.string(), + avatar_url: z.string(), + type: z.string().optional(), +}); +export type GitHubUser = z.infer; + +export const PullRequestSchema = z.object({ + number: z.number().int().positive(), + title: z.string(), + html_url: z.string(), + state: z.enum(["open", "closed"]), + draft: z.boolean(), + merged_at: z.string().nullable(), + created_at: z.string(), + user: GitHubUserSchema.nullable(), + head: z.object({ ref: z.string(), sha: z.string() }), + base: z.object({ ref: z.string() }), +}); +export type GitHubPullRequest = z.infer; + +export const PullRequestResponseSchema = z.object({ + /** The PR associated with the repo's current branch, or null when none is found. */ + pullRequest: PullRequestSchema.nullable(), +}); +export type PullRequestResponse = z.infer; + +// ─── CI checks ────────────────────────────────────────────────────────────── + +export const CheckItemSchema = z.object({ + source: z.enum(CHECK_ITEM_SOURCE), + id: z.number(), + name: z.string(), + status: z.enum(CHECK_ITEM_STATUS), + conclusion: z.enum(CHECK_CONCLUSION).nullable(), + startedAt: z.string().nullable(), + completedAt: z.string().nullable(), + url: z.string().nullable(), + avatarUrl: z.string().nullable(), + appName: z.string(), +}); +export type CheckItem = z.infer; + +export const DeploymentLinkSchema = z.object({ + environment: z.string(), + url: z.string(), +}); +export type DeploymentLink = z.infer; + +export const ChecksResponseSchema = z.object({ + state: z.enum(PULL_REQUEST_CI_STATUS), + items: z.array(CheckItemSchema), + deploymentLinks: z.array(DeploymentLinkSchema), +}); +export type ChecksResponse = z.infer; + +// ─── Reviews ──────────────────────────────────────────────────────────────── + +const ReviewUserSchema = z.object({ + login: z.string(), + avatar_url: z.string(), + type: z.string(), +}); +export type ReviewUser = z.infer; + +export const ReviewerSchema = z.object({ + user: ReviewUserSchema, + status: z.enum(REVIEWER_STATUS), +}); +export type Reviewer = z.infer; + +export const PullRequestReviewSummarySchema = z.object({ + status: z.enum(PULL_REQUEST_REVIEW_STATUS), + reviewers: z.array(ReviewerSchema), +}); +export type PullRequestReviewSummary = z.infer; + +export const ReviewsResponseSchema = z.object({ + reviews: PullRequestReviewSummarySchema.nullable(), +}); +export type ReviewsResponse = z.infer; + +// ─── Merge status ───────────────────────────────────────────────────────────── + +export const MergeQueueEntrySchema = z.object({ + id: z.string(), + position: z.number(), + estimatedTimeToMerge: z.number().nullable(), +}); +export type MergeQueueEntry = z.infer; + +export const MergeStatusInfoSchema = z.object({ + mergeable: z.enum(MERGEABLE_STATE), + mergeStateStatus: z.enum(MERGE_STATE_STATUS), + reviewDecision: z.enum(REVIEW_DECISION).nullable(), + checkRollupState: z.enum(CHECK_ROLLUP_STATE).nullable(), + autoMergeEnabled: z.boolean(), + autoMergeAllowed: z.boolean(), + viewerCanEnableAutoMerge: z.boolean(), + viewerCanDisableAutoMerge: z.boolean(), + isMergeQueueEnabled: z.boolean(), + isInMergeQueue: z.boolean(), + entry: MergeQueueEntrySchema.nullable(), + allowedMergeMethods: z.array(z.enum(PULL_REQUEST_MERGE_METHOD)), +}); +export type MergeStatusInfo = z.infer; + +export const MergeStatusResponseSchema = z.object({ + mergeStatus: MergeStatusInfoSchema.nullable(), +}); +export type MergeStatusResponse = z.infer; diff --git a/packages/web/package.json b/packages/web/package.json index 4b69c1d..ee2325d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@pierre/diffs": "^1.0.11", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -24,13 +25,15 @@ "@tanstack/react-router": "^1.169.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.562.0", + "date-fns": "^4.1.0", + "lucide-react": "^0.568.0", "radix-ui": "^1.4.3", "react": "^19.2.3", "react-dom": "^19.2.3", "react-hotkeys-hook": "^5.3.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.18", "tslib": "^2.8.1", diff --git a/packages/web/src/app/__root.tsx b/packages/web/src/app/__root.tsx index 491f497..4344cde 100644 --- a/packages/web/src/app/__root.tsx +++ b/packages/web/src/app/__root.tsx @@ -1,6 +1,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import { KeyboardShortcutsDialog } from "@/components/keyboard/shortcuts-dialog"; +import { Toaster } from "@/components/ui/sonner"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -13,6 +14,7 @@ function RootLayout() {
+
); } diff --git a/packages/web/src/components/pull-request/ci-checks.tsx b/packages/web/src/components/pull-request/ci-checks.tsx new file mode 100644 index 0000000..d4de4da --- /dev/null +++ b/packages/web/src/components/pull-request/ci-checks.tsx @@ -0,0 +1,264 @@ +import type { + CheckConclusion, + CheckItem, + PullRequestCIStatus, +} from "@stagereview/types/pull-request"; +import { CHECK_CONCLUSION, CHECK_ITEM_STATUS } from "@stagereview/types/pull-request"; +import { Check, ChevronDown, Loader2, MinusCircle, X } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { AvatarStack } from "@/components/shared/avatar-stack"; +import { CiStatusIcon } from "@/components/shared/ci-status-icon"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { formatElapsedTime } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +// ─── Visual state derivation from raw Octokit status/conclusion ───────────────── + +const CHECK_VISUAL = { + SUCCESS: "success", + FAILURE: "failure", + PENDING: "pending", + SKIPPED: "skipped", +} as const; +type CheckVisual = (typeof CHECK_VISUAL)[keyof typeof CHECK_VISUAL]; + +function deriveCheckVisual(status: string, conclusion: CheckConclusion | null): CheckVisual { + if (status !== CHECK_ITEM_STATUS.COMPLETED) return CHECK_VISUAL.PENDING; + switch (conclusion) { + case CHECK_CONCLUSION.SUCCESS: + return CHECK_VISUAL.SUCCESS; + case CHECK_CONCLUSION.SKIPPED: + case CHECK_CONCLUSION.NEUTRAL: + case CHECK_CONCLUSION.STALE: + return CHECK_VISUAL.SKIPPED; + default: + return CHECK_VISUAL.FAILURE; + } +} + +const VISUAL_SEVERITY: Record = { + [CHECK_VISUAL.SUCCESS]: 0, + [CHECK_VISUAL.SKIPPED]: 1, + [CHECK_VISUAL.PENDING]: 2, + [CHECK_VISUAL.FAILURE]: 3, +}; + +function CheckVisualIcon({ visual, cls }: { visual: CheckVisual; cls: string }) { + switch (visual) { + case CHECK_VISUAL.SUCCESS: + return ; + case CHECK_VISUAL.FAILURE: + return ; + case CHECK_VISUAL.PENDING: + return ; + case CHECK_VISUAL.SKIPPED: + return ; + } +} + +// ─── Check list (individual check rows + collapsible list) ────────────────────── + +const VISIBLE_COUNT = 5; + +function LiveElapsedTime({ startedAt }: { startedAt: string }) { + const [now, setNow] = useState(() => new Date().toISOString()); + + useEffect(() => { + const id = setInterval(() => setNow(new Date().toISOString()), 1000); + return () => clearInterval(id); + }, []); + + const duration = formatElapsedTime(startedAt, now); + return duration ? ( + {duration} + ) : null; +} + +function CheckRow({ item }: { item: CheckItem }) { + const visual = deriveCheckVisual(item.status, item.conclusion); + const isInProgress = item.status === CHECK_ITEM_STATUS.IN_PROGRESS; + const liveStartedAt = isInProgress ? item.startedAt : null; + const staticDuration = liveStartedAt ? null : formatElapsedTime(item.startedAt, item.completedAt); + const inner = ( + <> + + {item.avatarUrl && ( + {item.appName} + )} +
+ {item.name} + {item.appName && ( + {item.appName} + )} +
+ {liveStartedAt ? ( + + ) : ( + staticDuration && ( + {staticDuration} + ) + )} + + ); + + const rowCls = "flex items-center gap-3 border-b px-4 py-2.5 last:border-b-0"; + + if (item.url) { + return ( + + {inner} + + ); + } + + return
{inner}
; +} + +function ChecksList({ items }: { items: CheckItem[] }) { + const [open, setOpen] = useState(false); + const needsCollapse = items.length > VISIBLE_COUNT; + + if (!needsCollapse) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); + } + + const visible = items.slice(0, VISIBLE_COUNT); + const hidden = items.slice(VISIBLE_COUNT); + + return ( + +
+ {visible.map((item) => ( + + ))} +
+ + {hidden.map((item) => ( + + ))} + + + + {open ? "Show less" : `Show ${hidden.length} more`} + +
+ ); +} + +// ─── App grouping + popover trigger ───────────────────────────────────────────── + +interface AppGroup { + avatarUrl: string; + name: string; + worstVisual: CheckVisual; +} + +function groupChecksByApp(items: CheckItem[]): { + groups: AppGroup[]; + orphanCount: number; +} { + const byAvatar = new Map(); + let orphanCount = 0; + + for (const item of items) { + if (!item.avatarUrl) { + orphanCount++; + continue; + } + const visual = deriveCheckVisual(item.status, item.conclusion); + const existing = byAvatar.get(item.avatarUrl); + if (existing) { + if (VISUAL_SEVERITY[visual] > VISUAL_SEVERITY[existing.worstVisual]) { + existing.worstVisual = visual; + } + } else { + byAvatar.set(item.avatarUrl, { + avatarUrl: item.avatarUrl, + name: item.appName, + worstVisual: visual, + }); + } + } + + return { groups: [...byAvatar.values()], orphanCount }; +} + +function SubBadgeIcon({ visual }: { visual: CheckVisual }) { + return ; +} + +interface CIChecksProps { + state: PullRequestCIStatus; + items: CheckItem[]; +} + +export function CIChecks({ state, items }: CIChecksProps) { + const passedCount = items.filter((item) => { + const v = deriveCheckVisual(item.status, item.conclusion); + return v === CHECK_VISUAL.SUCCESS || v === CHECK_VISUAL.SKIPPED; + }).length; + + // Sort: failures first, then pending, then success, then skipped + const sorted = [...items].sort((a, b) => { + const va = deriveCheckVisual(a.status, a.conclusion); + const vb = deriveCheckVisual(b.status, b.conclusion); + return VISUAL_SEVERITY[vb] - VISUAL_SEVERITY[va]; + }); + + const { groups, orphanCount } = groupChecksByApp(items); + + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + ); +} diff --git a/packages/web/src/components/pull-request/merge-status-summary.ts b/packages/web/src/components/pull-request/merge-status-summary.ts new file mode 100644 index 0000000..0c18eae --- /dev/null +++ b/packages/web/src/components/pull-request/merge-status-summary.ts @@ -0,0 +1,114 @@ +import { + CHECK_ROLLUP_STATE, + MERGE_STATE_STATUS, + MERGEABLE_STATE, + type MergeStatusInfo, +} from "@stagereview/types/pull-request"; +import { AlertTriangle, Check, Loader2, type LucideIcon, XCircle } from "lucide-react"; + +export const MERGE_STATUS = { + CHECKING: "CHECKING", + CONFLICTS: "CONFLICTS", + IN_MERGE_QUEUE: "IN_MERGE_QUEUE", + READY: "READY", + BEHIND: "BEHIND", + BLOCKED: "BLOCKED", +} as const; + +type MergeStatus = (typeof MERGE_STATUS)[keyof typeof MERGE_STATUS]; + +export interface MergeStatusSummary { + status: MergeStatus; + label: string; + icon: LucideIcon; + iconColor: string; + accentColor: string; + pillBg: string; + isTransient: boolean; +} + +function isChecksPending(info: MergeStatusInfo): boolean { + return ( + info.checkRollupState === CHECK_ROLLUP_STATE.PENDING || + info.checkRollupState === CHECK_ROLLUP_STATE.EXPECTED + ); +} + +export function getMergeStatusSummary(info: MergeStatusInfo): MergeStatusSummary { + const { mergeable, mergeStateStatus, isInMergeQueue, entry } = info; + + if (mergeable === MERGEABLE_STATE.UNKNOWN || mergeStateStatus === MERGE_STATE_STATUS.UNKNOWN) { + return { + status: MERGE_STATUS.CHECKING, + label: "Checking…", + icon: Loader2, + iconColor: "text-muted-foreground animate-spin", + accentColor: "text-muted-foreground", + pillBg: "bg-muted", + isTransient: true, + }; + } + + if (mergeable === MERGEABLE_STATE.CONFLICTING || mergeStateStatus === MERGE_STATE_STATUS.DIRTY) { + return { + status: MERGE_STATUS.CONFLICTS, + label: "Conflicts", + icon: XCircle, + iconColor: "text-red-500", + accentColor: "text-red-500", + pillBg: "bg-red-500/10", + isTransient: false, + }; + } + + if (isInMergeQueue && entry) { + return { + status: MERGE_STATUS.IN_MERGE_QUEUE, + label: `In merge queue (#${entry.position})`, + icon: Loader2, + iconColor: "text-blue-500 animate-spin", + accentColor: "text-blue-500", + pillBg: "bg-blue-500/10", + isTransient: true, + }; + } + + // UNSTABLE = non-required checks failing but PR is mergeable. Community tools treat this as ready. + if ( + mergeStateStatus === MERGE_STATE_STATUS.CLEAN || + mergeStateStatus === MERGE_STATE_STATUS.HAS_HOOKS || + mergeStateStatus === MERGE_STATE_STATUS.UNSTABLE + ) { + return { + status: MERGE_STATUS.READY, + label: "Ready to merge", + icon: Check, + iconColor: "text-green-500", + accentColor: "text-green-500", + pillBg: "bg-green-500/10", + isTransient: false, + }; + } + + if (mergeStateStatus === MERGE_STATE_STATUS.BEHIND) { + return { + status: MERGE_STATUS.BEHIND, + label: "Behind base branch", + icon: AlertTriangle, + iconColor: "text-yellow-500", + accentColor: "text-yellow-500", + pillBg: "bg-yellow-500/10", + isTransient: false, + }; + } + + return { + status: MERGE_STATUS.BLOCKED, + label: "Blocked", + icon: XCircle, + iconColor: "text-red-500", + accentColor: "text-red-500", + pillBg: "bg-red-500/10", + isTransient: isChecksPending(info), + }; +} diff --git a/packages/web/src/components/pull-request/pull-request-header-skeleton.tsx b/packages/web/src/components/pull-request/pull-request-header-skeleton.tsx new file mode 100644 index 0000000..a63c488 --- /dev/null +++ b/packages/web/src/components/pull-request/pull-request-header-skeleton.tsx @@ -0,0 +1,32 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Placeholder shown while the PR is being detected, so the real header swaps in + * without a layout shift. Vendored from hosted Stage's PR page. + */ +export function PullRequestHeaderSkeleton() { + return ( +
+
+ +
+ +
+ + + +
+
+
+ + +
+
+
+ + + +
+
+ ); +} diff --git a/packages/web/src/components/pull-request/pull-request-header.tsx b/packages/web/src/components/pull-request/pull-request-header.tsx new file mode 100644 index 0000000..cf26289 --- /dev/null +++ b/packages/web/src/components/pull-request/pull-request-header.tsx @@ -0,0 +1,238 @@ +import type { DeploymentLink } from "@stagereview/types/pull-request"; +import { + type GitHubPullRequest, + MERGE_STATE_STATUS, + MERGEABLE_STATE, + type MergeStatusInfo, + PULL_REQUEST_STATUS, +} from "@stagereview/types/pull-request"; +import { GitBranch, Github, ScanSearch } from "lucide-react"; +import { useCallback } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { CIChecks } from "@/components/pull-request/ci-checks"; +import { getMergeStatusSummary } from "@/components/pull-request/merge-status-summary"; +import { Reviewers } from "@/components/pull-request/reviewers"; +import { DeploymentLinkList } from "@/components/shared/deployment-link-list"; +import { ShortcutTooltip } from "@/components/shared/shortcut-tooltip"; +import { getUserDisplay, UserName } from "@/components/shared/user-name"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { toast } from "@/components/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { formatTimeAgo } from "@/lib/format"; +import { KEYBOARD_SHORTCUTS } from "@/lib/keyboard-shortcuts"; +import { usePullRequestContext } from "@/lib/pull-request-context"; +import { usePullRequestChecks } from "@/lib/use-pull-request"; +import { cn } from "@/lib/utils"; +import { getPullRequestStatusInfo } from "@/lib/utils/pull-request-status"; + +function HeaderDeploymentPopover({ deploymentLinks }: { deploymentLinks: DeploymentLink[] }) { + return ( + + + + + + + + Preview deployments + + + + + + ); +} + +function MergeStatusPill({ mergeInfo }: { mergeInfo: MergeStatusInfo }) { + const summary = getMergeStatusSummary(mergeInfo); + const Icon = summary.icon; + return ( + + + {summary.label} + + ); +} + +export interface PullRequestHeaderProps { + pullRequest: GitHubPullRequest; + mergeInfo?: MergeStatusInfo; +} + +export function PullRequestHeader({ pullRequest, mergeInfo }: PullRequestHeaderProps) { + const { runId } = usePullRequestContext(); + const status = getPullRequestStatusInfo(pullRequest); + const StatusIcon = status.icon; + const authorProfileUrl = pullRequest.user ? getUserDisplay(pullRequest.user).profileUrl : null; + const isOpen = + pullRequest.state === PULL_REQUEST_STATUS.OPEN && !pullRequest.merged_at && !pullRequest.draft; + const isOpenOrDraft = pullRequest.state === PULL_REQUEST_STATUS.OPEN; + const hasMergeData = + isOpen && + mergeInfo !== undefined && + !( + mergeInfo.mergeable === MERGEABLE_STATE.UNKNOWN && + mergeInfo.mergeStateStatus === MERGE_STATE_STATUS.UNKNOWN + ); + + const { data: checksData } = usePullRequestChecks(runId, pullRequest.head.sha, isOpenOrDraft); + const deploymentLinks = checksData?.deploymentLinks ?? []; + const hasChecks = checksData && checksData.items.length > 0; + + const copyToClipboard = useCallback((text: string, label: string) => { + navigator.clipboard.writeText(text).then( + () => toast.success(`Copied ${label} to clipboard`), + () => toast.error("Failed to copy to clipboard"), + ); + }, []); + + const copyBranchName = useCallback(() => { + copyToClipboard(pullRequest.head.ref, "branch name"); + }, [copyToClipboard, pullRequest.head.ref]); + + useHotkeys(KEYBOARD_SHORTCUTS.COPY_BRANCH_NAME.hotkey, copyBranchName); + + const statusPill = ( +
+ + {status.label} +
+ ); + + const externalLinks = ( +
+ {deploymentLinks.length === 1 && deploymentLinks[0] && ( + + + + + Open preview deployment + + )} + {deploymentLinks.length > 1 && } + + + + + Open in GitHub + +
+ ); + + return ( +
+ {/* Row 1: Status + Title + External links */} +
+
+ {statusPill} + {externalLinks} +
+
+
{statusPill}
+

+ {pullRequest.title} + + #{pullRequest.number} + +

+
{externalLinks}
+
+
+ + {/* Row 2: Metadata */} +
+ {pullRequest.user && authorProfileUrl && ( + <> + + + + + {pullRequest.user.login[0]?.toUpperCase()} + + + + + + {" opened "} + {formatTimeAgo(pullRequest.created_at)} + + + + )} +
+
+ ); +} diff --git a/packages/web/src/components/pull-request/reviewers.tsx b/packages/web/src/components/pull-request/reviewers.tsx new file mode 100644 index 0000000..324fe15 --- /dev/null +++ b/packages/web/src/components/pull-request/reviewers.tsx @@ -0,0 +1,132 @@ +import { + REVIEWER_STATUS, + type Reviewer, + type ReviewerStatus, +} from "@stagereview/types/pull-request"; +import { + Check, + ChevronDown, + Circle, + Loader2, + type LucideIcon, + MessageSquare, + Users, + X, +} from "lucide-react"; +import { useState } from "react"; +import { BotBadge } from "@/components/shared/bot-badge"; +import { ReviewerAvatars } from "@/components/shared/reviewer-avatars"; +import { getUserDisplay } from "@/components/shared/user-utils"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { usePullRequestContext } from "@/lib/pull-request-context"; +import { cn } from "@/lib/utils"; + +const STATUS_DESCRIPTIONS: Record = { + [REVIEWER_STATUS.APPROVED]: "This reviewer approved these changes", + [REVIEWER_STATUS.CHANGES_REQUESTED]: "This reviewer requested changes", + [REVIEWER_STATUS.COMMENTED]: "This reviewer left comments", + [REVIEWER_STATUS.DISMISSED]: "This review was dismissed", + [REVIEWER_STATUS.PENDING]: "This reviewer hasn't submitted a review yet", + [REVIEWER_STATUS.REQUESTED]: "Awaiting review from this reviewer", +}; + +const STATUS_ICONS: Record = { + [REVIEWER_STATUS.APPROVED]: { icon: Check, className: "size-3.5 text-green-600" }, + [REVIEWER_STATUS.CHANGES_REQUESTED]: { icon: X, className: "size-3.5 text-destructive" }, + [REVIEWER_STATUS.COMMENTED]: { icon: MessageSquare, className: "size-3 text-muted-foreground" }, + [REVIEWER_STATUS.DISMISSED]: { icon: Circle, className: "size-3 text-muted-foreground" }, + [REVIEWER_STATUS.PENDING]: { icon: Circle, className: "size-3 text-muted-foreground" }, + [REVIEWER_STATUS.REQUESTED]: { icon: Circle, className: "size-3 text-muted-foreground" }, +}; + +function StatusIcon({ status }: { status: ReviewerStatus }) { + const { icon: Icon, className } = STATUS_ICONS[status]; + return ; +} + +function ReviewerRow({ reviewer }: { reviewer: Reviewer }) { + const { isBot, displayName, profileUrl } = getUserDisplay(reviewer.user); + return ( +
+ + + + + + + + {STATUS_DESCRIPTIONS[reviewer.status]} + +
+ ); +} + +export function Reviewers() { + const [open, setOpen] = useState(false); + const { reviews } = usePullRequestContext(); + const reviewers = reviews?.reviewers ?? []; + + return ( + + + + + +
+
+

Reviewers

+
+ {reviewers.length > 0 ? ( +
+ {reviewers.map((reviewer) => ( + + ))} +
+ ) : ( +

No reviewers yet

+ )} +
+
+
+ ); +} diff --git a/packages/web/src/components/shared/avatar-stack.tsx b/packages/web/src/components/shared/avatar-stack.tsx new file mode 100644 index 0000000..28340eb --- /dev/null +++ b/packages/web/src/components/shared/avatar-stack.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from "react"; +import { StatusBadge } from "@/components/shared/status-badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +const SIZE_CONFIG = { + sm: { avatar: "size-4" }, + md: { avatar: "size-5" }, +} as const; + +export type AvatarStackSize = keyof typeof SIZE_CONFIG; + +const MAX_VISIBLE = 3; + +interface AvatarStackItem { + key: string; + avatarUrl: string; + alt: string; + tooltip?: string; + badge?: ReactNode; +} + +interface AvatarStackProps { + items: AvatarStackItem[]; + size?: AvatarStackSize; + /** Number of extra items not represented in `items` (e.g. orphan CI checks). */ + overflowCount?: number; + hoverSpread?: boolean; + className?: string; +} + +export function AvatarStack({ + items, + size = "sm", + overflowCount = 0, + hoverSpread = false, + className, +}: AvatarStackProps) { + if (items.length === 0) return null; + + const config = SIZE_CONFIG[size]; + const visible = items.slice(0, MAX_VISIBLE); + const totalOverflow = overflowCount + Math.max(0, items.length - MAX_VISIBLE); + + const groupClass = hoverSpread ? "group/avatar-stack" : undefined; + + return ( +
+ {visible.map((item, index) => { + const avatarSpan = ( + 0 && "-ml-1.5", + hoverSpread && + index > 0 && + "transition-[margin] duration-200 ease-in-out group-hover/avatar-stack:ml-0.5", + )} + style={{ zIndex: visible.length - index }} + > + {item.alt} + + ); + + if (!item.tooltip) return avatarSpan; + + return ( + + {avatarSpan} + {item.tooltip} + + ); + })} + {totalOverflow > 0 && ( + +{totalOverflow} + )} +
+ ); +} diff --git a/packages/web/src/components/shared/bot-badge.tsx b/packages/web/src/components/shared/bot-badge.tsx new file mode 100644 index 0000000..f1768e8 --- /dev/null +++ b/packages/web/src/components/shared/bot-badge.tsx @@ -0,0 +1,12 @@ +import { Bot } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +export function BotBadge({ className }: { className?: string }) { + return ( + + + bot + + ); +} diff --git a/packages/web/src/components/shared/ci-status-icon.tsx b/packages/web/src/components/shared/ci-status-icon.tsx new file mode 100644 index 0000000..bcbab5f --- /dev/null +++ b/packages/web/src/components/shared/ci-status-icon.tsx @@ -0,0 +1,23 @@ +import type { PullRequestCIStatus } from "@stagereview/types/pull-request"; +import { PULL_REQUEST_CI_STATUS } from "@stagereview/types/pull-request"; +import { Check, Loader2, X } from "lucide-react"; + +interface CiStatusIconProps { + state: PullRequestCIStatus; + size: "xs" | "sm"; +} + +export function CiStatusIcon({ state, size }: CiStatusIconProps) { + const cls = size === "xs" ? "size-3.5" : "size-4"; + + switch (state) { + case PULL_REQUEST_CI_STATUS.SUCCESS: + return ; + case PULL_REQUEST_CI_STATUS.FAILURE: + return ; + case PULL_REQUEST_CI_STATUS.PENDING: + return ; + case PULL_REQUEST_CI_STATUS.NONE: + return null; + } +} diff --git a/packages/web/src/components/shared/deployment-link-list.tsx b/packages/web/src/components/shared/deployment-link-list.tsx new file mode 100644 index 0000000..82fe030 --- /dev/null +++ b/packages/web/src/components/shared/deployment-link-list.tsx @@ -0,0 +1,21 @@ +import type { DeploymentLink } from "@stagereview/types/pull-request"; +import { ExternalLink } from "lucide-react"; + +export function DeploymentLinkList({ deploymentLinks }: { deploymentLinks: DeploymentLink[] }) { + return ( +
+ {deploymentLinks.map((link) => ( + + + {link.environment} + + ))} +
+ ); +} diff --git a/packages/web/src/components/shared/reviewer-avatars.tsx b/packages/web/src/components/shared/reviewer-avatars.tsx new file mode 100644 index 0000000..4f73e8d --- /dev/null +++ b/packages/web/src/components/shared/reviewer-avatars.tsx @@ -0,0 +1,63 @@ +import type { Reviewer, ReviewerStatus } from "@stagereview/types/pull-request"; +import { REVIEWER_STATUS } from "@stagereview/types/pull-request"; +import { Check, Circle, MessageSquare, X } from "lucide-react"; + +import type { AvatarStackSize } from "@/components/shared/avatar-stack"; +import { AvatarStack } from "@/components/shared/avatar-stack"; + +export const REVIEWER_STATUS_LABELS: Record = { + [REVIEWER_STATUS.APPROVED]: "Approved", + [REVIEWER_STATUS.CHANGES_REQUESTED]: "Changes requested", + [REVIEWER_STATUS.COMMENTED]: "Commented", + [REVIEWER_STATUS.DISMISSED]: "Dismissed", + [REVIEWER_STATUS.PENDING]: "Pending", + [REVIEWER_STATUS.REQUESTED]: "Review requested", +}; + +function getStatusIndicator(status: ReviewerStatus) { + switch (status) { + case REVIEWER_STATUS.APPROVED: + return ; + case REVIEWER_STATUS.CHANGES_REQUESTED: + return ; + case REVIEWER_STATUS.COMMENTED: + return ; + default: + return ; + } +} + +/** Filters out the pull request author and sorts bots to the end. */ +export function filterAndSortReviewers(reviewers: Reviewer[], authorLogin?: string): Reviewer[] { + return reviewers + .filter((r) => r.user.login !== authorLogin) + .sort((a, b) => { + const aBot = a.user.type === "Bot" ? 1 : 0; + const bBot = b.user.type === "Bot" ? 1 : 0; + return aBot - bBot; + }); +} + +interface ReviewerAvatarsProps { + reviewers: Reviewer[]; + size?: AvatarStackSize; + className?: string; + hoverSpread?: boolean; +} + +export function ReviewerAvatars({ + reviewers, + size = "md", + className, + hoverSpread = false, +}: ReviewerAvatarsProps) { + const items = reviewers.map((reviewer) => ({ + key: reviewer.user.login, + avatarUrl: reviewer.user.avatar_url, + alt: reviewer.user.login, + tooltip: `${reviewer.user.login} — ${REVIEWER_STATUS_LABELS[reviewer.status]}`, + badge: reviewer.user.type !== "Bot" ? getStatusIndicator(reviewer.status) : undefined, + })); + + return ; +} diff --git a/packages/web/src/components/shared/user-name.tsx b/packages/web/src/components/shared/user-name.tsx new file mode 100644 index 0000000..850e91c --- /dev/null +++ b/packages/web/src/components/shared/user-name.tsx @@ -0,0 +1,26 @@ +import { BotBadge } from "@/components/shared/bot-badge"; +import { type GitHubUser, getUserDisplay } from "@/components/shared/user-utils"; + +export { type GitHubUser, getUserDisplay } from "@/components/shared/user-utils"; + +interface UserNameProps { + user: GitHubUser; +} + +/** Renders a username link with an inline bot badge when applicable. */ +export function UserName({ user }: UserNameProps) { + const { isBot, displayName, profileUrl } = getUserDisplay(user); + return ( + <> + + {displayName} + + {isBot && } + + ); +} diff --git a/packages/web/src/components/shared/user-utils.ts b/packages/web/src/components/shared/user-utils.ts new file mode 100644 index 0000000..0280339 --- /dev/null +++ b/packages/web/src/components/shared/user-utils.ts @@ -0,0 +1,16 @@ +/** Minimal GitHub user shape used by display components. */ +export interface GitHubUser { + login: string; + avatar_url: string; + type?: string; +} + +/** Derive the display name and profile URL from a GitHub user. */ +export function getUserDisplay(user: GitHubUser) { + const isBot = user.type === "Bot"; + const displayName = isBot ? user.login.replace("[bot]", "") : user.login; + const profileUrl = isBot + ? `https://github.com/apps/${displayName}` + : `https://github.com/${user.login}`; + return { isBot, displayName, profileUrl }; +} diff --git a/packages/web/src/components/ui/avatar.tsx b/packages/web/src/components/ui/avatar.tsx new file mode 100644 index 0000000..ddf1bc9 --- /dev/null +++ b/packages/web/src/components/ui/avatar.tsx @@ -0,0 +1,38 @@ +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +function Avatar({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarFallback, AvatarImage }; diff --git a/packages/web/src/components/ui/badge.tsx b/packages/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..37cc28a --- /dev/null +++ b/packages/web/src/components/ui/badge.tsx @@ -0,0 +1,35 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex w-fit shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-full border px-2.5 py-0.5 font-medium text-xs transition-colors [&>svg]:pointer-events-none [&>svg]:size-3", + { + variants: { + variant: { + default: "border-transparent bg-primary/10 text-primary", + secondary: "border-transparent bg-secondary text-secondary-foreground", + destructive: "border-transparent bg-destructive/10 text-destructive", + outline: "border-border text-muted-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/packages/web/src/components/ui/input.tsx b/packages/web/src/components/ui/input.tsx new file mode 100644 index 0000000..83f1892 --- /dev/null +++ b/packages/web/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/packages/web/src/components/ui/sonner.tsx b/packages/web/src/components/ui/sonner.tsx new file mode 100644 index 0000000..87272e7 --- /dev/null +++ b/packages/web/src/components/ui/sonner.tsx @@ -0,0 +1,23 @@ +import type { CSSProperties } from "react"; +import { Toaster as Sonner, type ToasterProps, toast } from "sonner"; +import { useTheme } from "@/lib/theme"; + +export { toast }; + +const defaultStyle: CSSProperties = { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", +} as CSSProperties; + +export function Toaster({ style, ...props }: ToasterProps) { + const { appTheme } = useTheme(); + return ( + + ); +} diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts new file mode 100644 index 0000000..d9fb6d2 --- /dev/null +++ b/packages/web/src/lib/format.ts @@ -0,0 +1,27 @@ +import { formatDistanceToNow } from "date-fns"; + +/** "opened 3 days ago" — matches hosted Stage's relative-time rendering. */ +export function formatTimeAgo(dateString: string): string { + return formatDistanceToNow(new Date(dateString), { addSuffix: true }); +} + +/** Compact elapsed time between two ISO timestamps, e.g. "1m 12s". */ +export function formatElapsedTime( + startedAt: string | null, + completedAt: string | null, +): string | null { + if (!startedAt || !completedAt) return null; + const seconds = Math.round( + (new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000, + ); + if (!Number.isFinite(seconds) || seconds < 0) return null; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) { + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} diff --git a/packages/web/src/lib/keyboard-shortcuts.ts b/packages/web/src/lib/keyboard-shortcuts.ts index f8056e1..62dfa13 100644 --- a/packages/web/src/lib/keyboard-shortcuts.ts +++ b/packages/web/src/lib/keyboard-shortcuts.ts @@ -50,6 +50,13 @@ export const KEYBOARD_SHORTCUTS = { mac: { label: "v", ariaKeyshortcuts: "V" }, nonMac: { label: "v", ariaKeyshortcuts: "V" }, }, + COPY_BRANCH_NAME: { + hotkey: "shift+c", + description: "Copy branch name", + group: "Review", + mac: { label: "⇧ C", ariaKeyshortcuts: "Shift+C" }, + nonMac: { label: "Shift C", ariaKeyshortcuts: "Shift+C" }, + }, } as const; export type ShortcutKey = keyof typeof KEYBOARD_SHORTCUTS; diff --git a/packages/web/src/lib/pull-request-context.tsx b/packages/web/src/lib/pull-request-context.tsx new file mode 100644 index 0000000..93fbf9d --- /dev/null +++ b/packages/web/src/lib/pull-request-context.tsx @@ -0,0 +1,78 @@ +import { + type GitHubPullRequest, + PULL_REQUEST_REVIEW_STATUS, + type PullRequestReviewSummary, +} from "@stagereview/types/pull-request"; +import { createContext, type ReactNode, use, useMemo } from "react"; +import { usePullRequestReviews } from "@/lib/use-pull-request"; + +interface PullRequestContextValue { + runId: string; + owner: string; + repo: string; + number: number; + headSha: string; + pullRequest: GitHubPullRequest; + reviews: PullRequestReviewSummary | null; +} + +const PullRequestContext = createContext(null); + +// Stable reference for the settled-but-empty case, so the useMemo below doesn't +// see a new object every render (which would re-render all context consumers). +const EMPTY_REVIEW_SUMMARY: PullRequestReviewSummary = { + status: PULL_REQUEST_REVIEW_STATUS.NO_REVIEWS, + reviewers: [], +}; + +/** Parse `owner`/`repo` from a PR html_url (`https://github.com/owner/repo/pull/123`). */ +function parseOwnerRepo(htmlUrl: string): { owner: string; repo: string } { + const match = htmlUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\//); + return { owner: match?.[1] ?? "", repo: match?.[2] ?? "" }; +} + +export function PullRequestProvider({ + runId, + pullRequest, + children, +}: { + runId: string; + pullRequest: GitHubPullRequest; + children: ReactNode; +}) { + const { data: reviewsData, isPending: reviewsPending } = usePullRequestReviews( + runId, + pullRequest.number, + ); + const { owner, repo } = parseOwnerRepo(pullRequest.html_url); + + // `null` means "still loading" to consumers (Reviewers shows a spinner). Once the + // query settles — even if gh failed and returned no summary — fall back to an empty + // summary so the UI stops spinning and shows "no reviewers" instead. + const reviews: PullRequestReviewSummary | null = reviewsPending + ? null + : (reviewsData?.reviews ?? EMPTY_REVIEW_SUMMARY); + + const value = useMemo( + () => ({ + runId, + owner, + repo, + number: pullRequest.number, + headSha: pullRequest.head.sha, + pullRequest, + reviews, + }), + [runId, owner, repo, pullRequest, reviews], + ); + + return {children}; +} + +export function usePullRequestContext(): PullRequestContextValue { + const context = use(PullRequestContext); + if (!context) { + throw new Error("usePullRequestContext must be used within a PullRequestProvider"); + } + return context; +} diff --git a/packages/web/src/lib/use-pull-request.ts b/packages/web/src/lib/use-pull-request.ts new file mode 100644 index 0000000..4d4b299 --- /dev/null +++ b/packages/web/src/lib/use-pull-request.ts @@ -0,0 +1,77 @@ +import { + type ChecksResponse, + ChecksResponseSchema, + type MergeStatusResponse, + MergeStatusResponseSchema, + type PullRequestResponse, + PullRequestResponseSchema, + type ReviewsResponse, + ReviewsResponseSchema, +} from "@stagereview/types/pull-request"; +import { skipToken, useQuery } from "@tanstack/react-query"; +import { jsonFetch } from "@/lib/use-view-state"; + +// Live PR data: never auto-refetch on focus/reconnect (the CLI is local and +// the data is cheap to refetch explicitly). Mirrors hosted's live query opts. +const LIVE = { + staleTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +} as const; + +function prPath(runId: string, suffix = ""): string { + return `/api/runs/${encodeURIComponent(runId)}/pull-request${suffix}`; +} + +export function usePullRequest(runId: string | null) { + return useQuery({ + queryKey: ["pull-request", runId], + queryFn: + runId === null + ? skipToken + : async () => PullRequestResponseSchema.parse(await jsonFetch(prPath(runId))), + ...LIVE, + }); +} + +export function usePullRequestChecks(runId: string, headSha: string | null, enabled: boolean) { + return useQuery({ + queryKey: ["pull-request-checks", runId, headSha], + queryFn: + !enabled || headSha === null + ? skipToken + : async () => + ChecksResponseSchema.parse( + await jsonFetch(prPath(runId, `/checks?headSha=${encodeURIComponent(headSha)}`)), + ), + ...LIVE, + }); +} + +export function usePullRequestReviews(runId: string, number: number | null) { + return useQuery({ + queryKey: ["pull-request-reviews", runId, number], + queryFn: + number === null + ? skipToken + : async () => + ReviewsResponseSchema.parse( + await jsonFetch(prPath(runId, `/reviews?number=${number}`)), + ), + ...LIVE, + }); +} + +export function usePullRequestMergeStatus(runId: string, number: number | null, enabled: boolean) { + return useQuery({ + queryKey: ["pull-request-merge-status", runId, number], + queryFn: + !enabled || number === null + ? skipToken + : async () => + MergeStatusResponseSchema.parse( + await jsonFetch(prPath(runId, `/merge-status?number=${number}`)), + ), + ...LIVE, + }); +} diff --git a/packages/web/src/lib/utils/pull-request-status.ts b/packages/web/src/lib/utils/pull-request-status.ts new file mode 100644 index 0000000..73f8a75 --- /dev/null +++ b/packages/web/src/lib/utils/pull-request-status.ts @@ -0,0 +1,58 @@ +import { type GitHubPullRequest, PULL_REQUEST_STATUS } from "@stagereview/types/pull-request"; +import { CircleDashed, GitMerge, GitPullRequest, type LucideIcon, XCircle } from "lucide-react"; + +export interface PullRequestStatusInfo { + icon: LucideIcon; + label: string; + color: string; + bgColor: string; +} + +interface PullRequestStatusOptions { + inMergeQueue?: boolean; + mergeQueuePosition?: number; +} + +export function getPullRequestStatusInfo( + pullRequest: GitHubPullRequest, + options?: PullRequestStatusOptions, +): PullRequestStatusInfo { + if (pullRequest.merged_at) { + return { + icon: GitMerge, + label: "Merged", + color: "text-purple-500", + bgColor: "bg-purple-500/10", + }; + } + if (pullRequest.state === PULL_REQUEST_STATUS.CLOSED) { + return { + icon: XCircle, + label: "Closed", + color: "text-destructive", + bgColor: "bg-destructive/10", + }; + } + if (pullRequest.draft) { + return { + icon: CircleDashed, + label: "Draft", + color: "text-muted-foreground", + bgColor: "bg-muted", + }; + } + if (options?.inMergeQueue) { + return { + icon: GitMerge, + label: "Queued", + color: "text-yellow-600", + bgColor: "bg-yellow-500/10", + }; + } + return { + icon: GitPullRequest, + label: "Open", + color: "text-primary", + bgColor: "bg-primary/10", + }; +} diff --git a/packages/web/src/routes/pull-request-layout.tsx b/packages/web/src/routes/pull-request-layout.tsx index a2b5dd3..56be330 100644 --- a/packages/web/src/routes/pull-request-layout.tsx +++ b/packages/web/src/routes/pull-request-layout.tsx @@ -2,6 +2,8 @@ import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { BookOpen, FileText, FoldVertical, Settings2, UnfoldVertical } from "lucide-react"; import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; import { DiffSettingsForm } from "@/components/diff/diff-settings-form"; +import { PullRequestHeader } from "@/components/pull-request/pull-request-header"; +import { PullRequestHeaderSkeleton } from "@/components/pull-request/pull-request-header-skeleton"; import { SectionLabel } from "@/components/pull-request/section-label"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -9,8 +11,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { ChapterProvider } from "@/lib/chapter-context"; import { CollapseActionsProvider, useCollapseActionsFromNav } from "@/lib/collapse-actions-context"; import { useFileDiffEntries } from "@/lib/parse-diff"; +import { PullRequestProvider } from "@/lib/pull-request-context"; import { useChapters } from "@/lib/use-chapters"; import { useDiffPatch } from "@/lib/use-diff-patch"; +import { usePullRequest, usePullRequestMergeStatus } from "@/lib/use-pull-request"; import { countViewedChapters, useViewStateData } from "@/lib/use-view-state"; import { cn } from "@/lib/utils"; @@ -108,6 +112,18 @@ function ErrorState({ error }: { error: unknown }) { export function PullRequestLayout({ runId }: { runId: string }) { const { data, error } = useChapters(runId); + const { data: prData, isLoading: isPrLoading } = usePullRequest(runId); + const pullRequest = prData?.pullRequest ?? null; + const isPrOpen = + pullRequest !== null && + pullRequest.state === "open" && + !pullRequest.merged_at && + !pullRequest.draft; + const { data: mergeStatusData } = usePullRequestMergeStatus( + runId, + pullRequest?.number ?? null, + isPrOpen, + ); const activeTab = useRouterState({ select: (state): PrTab => { const routeIds = new Set(state.matches.map((match) => match.routeId)); @@ -185,12 +201,27 @@ export function PullRequestLayout({ runId }: { runId: string }) {
-
- Run -

- {data?.run.id ?? runId} -

-
+ {isPrLoading ? ( +
+ +
+ ) : pullRequest ? ( +
+ + + +
+ ) : ( +
+ Run +

+ {data?.run.id ?? runId} +

+
+ )}