diff --git a/packages/cli/src/__tests__/review.routes.test.ts b/packages/cli/src/__tests__/review.routes.test.ts new file mode 100644 index 0000000..9e5c7ee --- /dev/null +++ b/packages/cli/src/__tests__/review.routes.test.ts @@ -0,0 +1,511 @@ +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import type { ReviewResponse } from "@stagereview/types/review"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeDb, getDb } from "../db/client.js"; +import { chapterRun, comment, commentThread } from "../db/schema/index.js"; +import { reviewRoutes } from "../routes/review.js"; +import { SCOPE_KIND, WORKING_TREE_REF } 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 BASE = "b".repeat(40); +const HEAD = "a".repeat(40); +const MERGE_BASE = "c".repeat(40); +const SCOPE_KEY = `committed:${BASE}:${HEAD}:${MERGE_BASE}`; +const GITHUB_ORIGIN = "git@github.com:owner/repo.git"; + +// One submitted thread (comment 1) and one pending thread (comment 2, viewer's draft). +const REVIEW_QUERY_RESULT = { + data: { + repository: { + pullRequest: { + id: "PR_node", + viewerDidAuthor: false, + headRefOid: HEAD, + reviews: { nodes: [{ id: "REVIEW_pending" }] }, + reviewThreads: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: "THREAD_sub", + isResolved: false, + comments: { + nodes: [ + { + databaseId: 1, + id: "COMMENT_sub", + url: "https://github.com/owner/repo/pull/5#discussion_r1", + path: "src/foo.ts", + body: "Submitted comment", + bodyHTML: "

Submitted comment

", + createdAt: "2026-01-01T00:00:00Z", + line: 10, + startLine: null, + diffSide: "RIGHT", + startDiffSide: null, + author: { login: "octocat", avatarUrl: "https://x/o.png" }, + pullRequestReview: { state: "COMMENTED" }, + }, + ], + }, + }, + { + id: "THREAD_pending", + isResolved: false, + comments: { + nodes: [ + { + databaseId: 2, + id: "COMMENT_pending", + url: "https://github.com/owner/repo/pull/5#discussion_r2", + path: "src/bar.ts", + body: "Draft comment", + bodyHTML: "

Draft comment

", + createdAt: "2026-01-02T00:00:00Z", + line: 4, + startLine: null, + diffSide: "LEFT", + startDiffSide: null, + author: { login: "octocat", avatarUrl: "https://x/o.png" }, + pullRequestReview: { state: "PENDING" }, + }, + ], + }, + }, + ], + }, + }, + }, + }, +}; + +async function writeGhShim( + reviewResult: unknown, + opts: { failAddThread?: boolean; failAddReply?: boolean } = {}, +): Promise { + await fs.writeFile(path.join(tmpDir, "review.json"), JSON.stringify(reviewResult)); + const shim = `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +const query = (args.find((a) => a.startsWith("query=")) || ""); +// Field args only (the GraphQL query itself is multi-line and would break line-based log parsing). +const fields = args.filter((a) => !a.startsWith("query=") && a !== "-f" && a !== "-F" && a !== "api" && a !== "graphql").join(" "); +const log = ${JSON.stringify(path.join(tmpDir, "gh-log.txt"))}; +function emit(o) { process.stdout.write(JSON.stringify(o)); } +if (query.includes("query GetReview")) { + emit(JSON.parse(fs.readFileSync(${JSON.stringify(path.join(tmpDir, "review.json"))}, "utf8"))); +} else if (query.includes("mutation CreatePendingReview")) { + fs.appendFileSync(log, "create-review\\n"); + emit({ data: { addPullRequestReview: { pullRequestReview: { id: "REVIEW_new" } } } }); +} else if (query.includes("mutation AddReviewThread")) { + fs.appendFileSync(log, "add-thread " + fields + "\\n"); + if (${opts.failAddThread ? "true" : "false"}) { process.stderr.write("gh: line not in diff\\n"); process.exit(1); } + emit({ data: { addPullRequestReviewThread: { thread: { id: "THREAD_new" } } } }); +} else if (query.includes("mutation DiscardReview")) { + fs.appendFileSync(log, "discard-review\\n"); + emit({ data: { deletePullRequestReview: { pullRequestReview: { id: "REVIEW_new" } } } }); +} else if (query.includes("mutation AddReviewReply")) { + fs.appendFileSync(log, "reply\\n"); + if (${opts.failAddReply ? "true" : "false"}) { process.stderr.write("gh: reply failed\\n"); process.exit(1); } + emit({ data: { addPullRequestReviewThreadReply: { comment: { id: "C" } } } }); +} else if (query.includes("mutation SubmitReview")) { + fs.appendFileSync(log, "submit " + fields + "\\n"); + emit({ data: { submitPullRequestReview: { pullRequestReview: { id: "R" } } } }); +} else { + emit({ data: {} }); +} +`; + await fs.writeFile(path.join(binDir, "gh"), shim); + await fs.chmod(path.join(binDir, "gh"), 0o755); +} + +const EMPTY_REVIEW = { + data: { + repository: { + pullRequest: { + id: "PR_node", + viewerDidAuthor: false, + headRefOid: HEAD, + reviews: { nodes: [] }, + reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }, + }, + }, +}; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "stage-cli-review-")); + 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 }); +}); + +function insertRun(originUrl: string | null, committed = true, headSha: string = HEAD): string { + const db = getDb({ dbPath }); + const [row] = db + .insert(chapterRun) + .values({ + repoRoot, + originUrl, + prNumber: 5, + scopeKind: committed ? SCOPE_KIND.COMMITTED : SCOPE_KIND.WORKING_TREE, + workingTreeRef: committed ? null : WORKING_TREE_REF.WORK, + baseSha: BASE, + headSha, + mergeBaseSha: MERGE_BASE, + generatedAt: new Date(), + }) + .returning({ id: chapterRun.id }) + .all(); + if (!row) throw new Error("seed: chapter_run insert returned no row"); + return row.id; +} + +function seedLocalThread(): string { + const db = getDb({ dbPath }); + const [thread] = db + .insert(commentThread) + .values({ + scopeKey: SCOPE_KEY, + filePath: "src/foo.ts", + side: "additions", + startLine: 3, + endLine: 3, + }) + .returning({ id: commentThread.id }) + .all(); + if (!thread) throw new Error("seed: thread insert returned no row"); + db.insert(comment).values({ threadId: thread.id, body: "Local note" }).run(); + return thread.id; +} + +// A local thread with a root + one reply, both oldest-first by createdAt. +function seedLocalThreadWithReply(): string { + const db = getDb({ dbPath }); + const [thread] = db + .insert(commentThread) + .values({ + scopeKey: SCOPE_KEY, + filePath: "src/foo.ts", + side: "additions", + startLine: 3, + endLine: 3, + }) + .returning({ id: commentThread.id }) + .all(); + if (!thread) throw new Error("seed: thread insert returned no row"); + db.insert(comment) + .values({ threadId: thread.id, body: "Root", createdAt: new Date(1) }) + .run(); + db.insert(comment) + .values({ threadId: thread.id, body: "Reply", createdAt: new Date(2) }) + .run(); + return thread.id; +} + +async function start(): Promise { + const db = getDb({ dbPath }); + const handle = await startServer({ webDistPath: webDist, routes: reviewRoutes(db) }); + handles.push(handle); + return handle.port; +} + +function request( + port: number, + method: string, + p: string, + body?: unknown, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const payload = body === undefined ? undefined : JSON.stringify(body); + const req = http.request( + { + hostname: LOOPBACK_HOST, + port, + method, + path: p, + agent: false, + headers: + payload === undefined + ? {} + : { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, + }, + (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(payload); + }); +} + +describe("review API — read", () => { + it("returns local-only with github:none when there's no GitHub remote", async () => { + const runId = insertRun(null); + seedLocalThread(); + const res = await request(await start(), "GET", `/api/runs/${runId}/review`); + expect(res.status).toBe(200); + const review = JSON.parse(res.body) as ReviewResponse; + expect(review.github).toBe("none"); + expect(review.threads).toHaveLength(1); + expect(review.threads[0]?.source).toBe("local"); + expect(review.threads[0]?.comments[0]?.state).toBe("local"); + }); + + it("merges local threads with the PR's pending and submitted GitHub threads", async () => { + await writeGhShim(REVIEW_QUERY_RESULT); + const runId = insertRun(GITHUB_ORIGIN); + seedLocalThread(); + const res = await request(await start(), "GET", `/api/runs/${runId}/review`); + const review = JSON.parse(res.body) as ReviewResponse; + expect(review.github).toBe("available"); + expect(review.pendingCommentCount).toBe(1); + expect(review.hasPendingReview).toBe(true); + // Committed scope, clean tree, HEAD matches the PR head → pushable. + expect(review.canPushToReview).toBe(true); + expect(review.isOwnPullRequest).toBe(false); + + const states = review.threads.map((t) => t.comments[0]?.state).sort(); + expect(states).toEqual(["local", "pending", "submitted"]); + // LEFT diff side maps to the deletions side locally. + const pending = review.threads.find((t) => t.comments[0]?.state === "pending"); + expect(pending?.side).toBe("deletions"); + }); + + it("reports github:offline when gh fails", async () => { + // No gh shim on PATH → the review query errors out → offline (local still renders). + await fs.writeFile(path.join(binDir, "gh"), "#!/bin/sh\nexit 1\n"); + await fs.chmod(path.join(binDir, "gh"), 0o755); + const runId = insertRun(GITHUB_ORIGIN); + seedLocalThread(); + const res = await request(await start(), "GET", `/api/runs/${runId}/review`); + const review = JSON.parse(res.body) as ReviewResponse; + expect(review.github).toBe("offline"); + expect(review.threads).toHaveLength(1); + }); + + it("reports github:offline (not available) when the PR can't be resolved", async () => { + // A null pullRequest (stale/unknown number) must not yield an available review + // with an empty PR node id that write actions would post against. + await writeGhShim({ data: { repository: { pullRequest: null } } }); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), "GET", `/api/runs/${runId}/review`); + const review = JSON.parse(res.body) as ReviewResponse; + expect(review.github).toBe("offline"); + expect(review.canPushToReview).toBe(false); + }); + + it("shows local-only (github:none) when the run's diff doesn't match the PR head", async () => { + // The run is committed but its head differs from the PR head, so the PR's live + // thread anchors wouldn't align with this run's diff — surface local only. + await writeGhShim(REVIEW_QUERY_RESULT); + const runId = insertRun(GITHUB_ORIGIN, true, "d".repeat(40)); + const res = await request(await start(), "GET", `/api/runs/${runId}/review`); + const review = JSON.parse(res.body) as ReviewResponse; + expect(review.github).toBe("none"); + expect(review.canPushToReview).toBe(false); + // No GitHub threads overlaid — their anchors are from the PR head, not this diff. + expect(review.threads.every((t) => t.source === "local")).toBe(true); + }); +}); + +describe("review API — actions", () => { + it("promotes a local thread to a pending review comment and removes the local copy", async () => { + await writeGhShim(EMPTY_REVIEW); + const runId = insertRun(GITHUB_ORIGIN); + const localThreadId = seedLocalThread(); + const res = await request(await start(), "POST", `/api/runs/${runId}/review/add`, { + localThreadId, + }); + expect(res.status).toBe(200); + + const db = getDb({ dbPath }); + expect(db.select().from(commentThread).all()).toHaveLength(0); + const lines = (await fs.readFile(path.join(tmpDir, "gh-log.txt"), "utf8")).split("\n"); + expect(lines.filter((l) => l === "create-review")).toHaveLength(1); + expect(lines.filter((l) => l.startsWith("add-thread"))).toHaveLength(1); + }); + + it("creates a pending comment directly on the PR without storing it locally", async () => { + await writeGhShim(EMPTY_REVIEW); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), "POST", `/api/runs/${runId}/review/comment`, { + filePath: "src/foo.ts", + side: "additions", + startLine: 3, + endLine: 3, + body: "On the PR", + }); + expect(res.status).toBe(200); + + const db = getDb({ dbPath }); + expect(db.select().from(commentThread).all()).toHaveLength(0); + const lines = (await fs.readFile(path.join(tmpDir, "gh-log.txt"), "utf8")).split("\n"); + expect(lines.filter((l) => l === "create-review")).toHaveLength(1); + expect(lines.filter((l) => l.startsWith("add-thread"))).toHaveLength(1); + }); + + it("submits the pending review with the chosen event", async () => { + await writeGhShim(REVIEW_QUERY_RESULT); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), "POST", `/api/runs/${runId}/review/submit`, { + event: "APPROVE", + body: "LGTM", + }); + expect(res.status).toBe(200); + const lines = (await fs.readFile(path.join(tmpDir, "gh-log.txt"), "utf8")).split("\n"); + const submit = lines.find((l) => l.startsWith("submit")); + expect(submit).toContain("event=APPROVE"); + }); + + it("rejects an empty Comment review (no body, no pending comments)", async () => { + await writeGhShim(EMPTY_REVIEW); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), "POST", `/api/runs/${runId}/review/submit`, { + event: "COMMENT", + body: " ", + }); + expect(res.status).toBe(400); + // No review was opened or submitted. + const log = await fs.readFile(path.join(tmpDir, "gh-log.txt"), "utf8").catch(() => ""); + expect(log).not.toMatch(/create-review|submit/); + }); + + it("counts pending drafts on anchorless threads, so an empty-body Comment submit isn't falsely rejected", async () => { + // A pending draft whose root has no current line is dropped from the rendered + // threads but must still count, so the empty-review guard doesn't false-reject. + const anchorlessPending = { + data: { + repository: { + pullRequest: { + id: "PR_node", + viewerDidAuthor: false, + headRefOid: HEAD, + reviews: { nodes: [{ id: "REVIEW_pending" }] }, + reviewThreads: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: "THREAD_outdated", + isResolved: false, + comments: { + nodes: [ + { + databaseId: 9, + id: "COMMENT_outdated", + url: "https://github.com/owner/repo/pull/5#d9", + path: "src/foo.ts", + body: "Outdated draft", + bodyHTML: "

Outdated draft

", + createdAt: "2026-01-03T00:00:00Z", + line: null, + startLine: null, + diffSide: "RIGHT", + startDiffSide: null, + author: { login: "octocat", avatarUrl: "https://x/o.png" }, + pullRequestReview: { state: "PENDING" }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + }; + await writeGhShim(anchorlessPending); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), "POST", `/api/runs/${runId}/review/submit`, { + event: "COMMENT", + body: "", + }); + expect(res.status).toBe(200); + const log = await fs.readFile(path.join(tmpDir, "gh-log.txt"), "utf8"); + expect(log).toMatch(/submit/); + }); + + it("rejects commenting on the PR from a working-tree scope (push guardrail)", async () => { + await writeGhShim(REVIEW_QUERY_RESULT); + const runId = insertRun(GITHUB_ORIGIN, false); // working-tree scope + const res = await request(await start(), "POST", `/api/runs/${runId}/review/comment`, { + filePath: "src/foo.ts", + side: "additions", + startLine: 3, + endLine: 3, + body: "On the PR", + }); + expect(res.status).toBe(409); + expect(JSON.parse(res.body).error).toMatch(/committed diff/i); + }); + + it("discards a freshly-opened pending review when the comment fails to post", async () => { + // No pending review exists, so the comment path opens one; the add then fails + // (line not in diff) and the empty review must be discarded, not left behind. + await writeGhShim(EMPTY_REVIEW, { failAddThread: true }); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request(await start(), "POST", `/api/runs/${runId}/review/comment`, { + filePath: "src/foo.ts", + side: "additions", + startLine: 3, + endLine: 3, + body: "On the PR", + }); + expect(res.status).toBe(500); + const lines = (await fs.readFile(path.join(tmpDir, "gh-log.txt"), "utf8")).split("\n"); + expect(lines.filter((l) => l === "create-review")).toHaveLength(1); + expect(lines.filter((l) => l === "discard-review")).toHaveLength(1); + }); + + it("keeps unposted replies local when a reply fails mid-promotion (no silent loss)", async () => { + await writeGhShim(EMPTY_REVIEW, { failAddReply: true }); + const runId = insertRun(GITHUB_ORIGIN); + const localThreadId = seedLocalThreadWithReply(); + const res = await request(await start(), "POST", `/api/runs/${runId}/review/add`, { + localThreadId, + }); + expect(res.status).toBe(500); + + const db = getDb({ dbPath }); + // Root was promoted (deleted locally); the failed reply stays local; thread remains. + const bodies = db + .select() + .from(comment) + .all() + .map((c) => c.body); + expect(bodies).toEqual(["Reply"]); + expect(db.select().from(commentThread).all()).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/db/schema/comment.ts b/packages/cli/src/db/schema/comment.ts index 1a9783c..85fa9cf 100644 --- a/packages/cli/src/db/schema/comment.ts +++ b/packages/cli/src/db/schema/comment.ts @@ -3,6 +3,8 @@ import { LOCAL_USER_ID } from "../local-user.js"; import { baseColumns } from "./columns.js"; import { commentThread } from "./comment-thread.js"; +// Local (CLI-only) review comments. GitHub review comments are never mirrored +// here — they're fetched live (see the `review` server layer). export const comment = sqliteTable( "comment", { diff --git a/packages/cli/src/github/exec.ts b/packages/cli/src/github/exec.ts index 7c20968..d279fc9 100644 --- a/packages/cli/src/github/exec.ts +++ b/packages/cli/src/github/exec.ts @@ -13,6 +13,20 @@ export async function gh(args: string[], cwd: string): Promise { return stdout; } +/** + * Run a `gh` command and return stdout, surfacing failures as a clean Error with + * gh's stderr message. Use for user-initiated actions (reads and writes alike) + * where the failure reason should reach the user, unlike the passive PR-context + * adapters that degrade to empty. + */ +export async function ghOrThrow(args: string[], cwd: string): Promise { + try { + return await gh(args, cwd); + } catch (err) { + throw new Error(ghErrorMessage(err)); + } +} + /** * Extract the most useful message from a failed `gh`/`git` exec: prefer the * command's stderr (where these tools write human-readable failures), falling diff --git a/packages/cli/src/github/review.ts b/packages/cli/src/github/review.ts new file mode 100644 index 0000000..8c09b85 --- /dev/null +++ b/packages/cli/src/github/review.ts @@ -0,0 +1,430 @@ +import { z } from "zod"; +import { ghOrThrow } from "./exec.js"; +import type { GitHubRepo } from "./repo.js"; + +/** + * GitHub's diff sides. `LEFT` is the base/deletion side, `RIGHT` the head/addition + * side; they map onto the local `DIFF_SIDE` (deletions/additions) in the review layer. + */ +export const GITHUB_DIFF_SIDE = { + LEFT: "LEFT", + RIGHT: "RIGHT", +} as const; +export type GitHubDiffSide = (typeof GITHUB_DIFF_SIDE)[keyof typeof GITHUB_DIFF_SIDE]; + +/** The three events a review can be submitted with, mirroring GitHub's own model. */ +export const REVIEW_EVENT = { + COMMENT: "COMMENT", + APPROVE: "APPROVE", + REQUEST_CHANGES: "REQUEST_CHANGES", +} as const; +export type ReviewEvent = (typeof REVIEW_EVENT)[keyof typeof REVIEW_EVENT]; + +// ─── Read: the PR's review state in one paginated query ───────────────────────── + +// A single GraphQL query gives everything we render: the PR node id (needed by the +// write mutations), the viewer's pending-review node id, and every review thread +// with its comments. Each comment's `pullRequestReview.state` distinguishes a +// PENDING (draft, viewer-only) comment from a submitted one — no REST list or +// local mirror required. +const REVIEW_QUERY = `query GetReview($owner: String!, $repo: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + viewerDidAuthor + headRefOid + reviews(states: PENDING, first: 1) { nodes { id } } + reviewThreads(first: 50, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + isResolved + comments(first: 100) { + nodes { + databaseId + id + url + path + body + bodyHTML + createdAt + line + startLine + diffSide + startDiffSide + author { login avatarUrl } + pullRequestReview { state } + } + } + } + } + } + } +}`; + +const GqlActorSchema = z.object({ login: z.string(), avatarUrl: z.string() }).nullable(); + +const GqlReviewCommentSchema = z.object({ + databaseId: z.number().nullable(), + id: z.string(), + url: z.string(), + path: z.string(), + body: z.string(), + bodyHTML: z.string(), + createdAt: z.string(), + line: z.number().nullable(), + startLine: z.number().nullable(), + diffSide: z.enum(GITHUB_DIFF_SIDE).nullable(), + startDiffSide: z.enum(GITHUB_DIFF_SIDE).nullable(), + author: GqlActorSchema, + pullRequestReview: z.object({ state: z.string() }).nullable(), +}); + +const ReviewQuerySchema = z.object({ + data: z.object({ + repository: z + .object({ + pullRequest: z + .object({ + id: z.string(), + viewerDidAuthor: z.boolean(), + headRefOid: z.string(), + reviews: z.object({ nodes: z.array(z.object({ id: z.string() })) }), + reviewThreads: z.object({ + pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }), + nodes: z.array( + z.object({ + id: z.string(), + isResolved: z.boolean(), + comments: z.object({ nodes: z.array(GqlReviewCommentSchema) }), + }), + ), + }), + }) + .nullable(), + }) + .nullable(), + }), +}); + +/** A comment within a review thread, tagged with whether it's a draft (pending) or published. */ +export interface ReviewComment { + databaseId: number | null; + nodeId: string; + htmlUrl: string; + body: string; + /** GitHub's server-rendered HTML (resolves @mentions, #refs, emoji). */ + bodyHtml: string; + createdAt: string; + authorLogin: string; + authorAvatarUrl: string; + /** Pending = part of the viewer's unsubmitted review (only they see it). */ + isPending: boolean; +} + +/** A line-anchored review thread on the PR, with its comments oldest-first. */ +export interface ReviewThread { + threadNodeId: string; + isResolved: boolean; + path: string; + line: number | null; + startLine: number | null; + side: GitHubDiffSide; + startSide: GitHubDiffSide | null; + comments: ReviewComment[]; +} + +export interface GitHubReview { + /** GraphQL node id of the PR, required by the write mutations. */ + pullRequestNodeId: string; + /** True when the viewer opened the PR (GitHub forbids approving your own PR). */ + viewerDidAuthor: boolean; + /** The PR's current head commit — comments anchor to this commit's diff. */ + headRefOid: string; + /** The viewer's open pending review, or null when they have none. */ + pendingReviewNodeId: string | null; + /** Viewer's pending (draft) comments across all threads, including anchorless ones. */ + pendingCommentCount: number; + threads: ReviewThread[]; +} + +const PENDING_STATE = "PENDING"; + +/** + * The PR's review threads (pending + submitted) as the viewer sees them, plus the + * ids the write mutations need. Threads with no anchorable line (outdated or + * whole-file) are dropped — the review UI is line-anchored. + */ +export async function getReview( + repoRoot: string, + repo: GitHubRepo, + prNumber: number, +): Promise { + let pullRequestNodeId = ""; + let viewerDidAuthor = false; + let headRefOid = ""; + let pendingReviewNodeId: string | null = null; + let pendingCommentCount = 0; + const threads: ReviewThread[] = []; + let cursor: string | null = null; + + do { + // `-f` keeps string GraphQL variables as strings; `-F` does typed coercion, which + // would mangle a repo/owner literally named `123`, `true`, or `null` into the wrong + // GraphQL type. Only `number` (Int!) uses `-F`. + const args = [ + "api", + "graphql", + "-f", + `query=${REVIEW_QUERY}`, + "-f", + `owner=${repo.owner}`, + "-f", + `repo=${repo.repo}`, + "-F", + `number=${prNumber}`, + ]; + if (cursor !== null) args.push("-f", `cursor=${cursor}`); + const parsed = ReviewQuerySchema.safeParse(JSON.parse(await ghOrThrow(args, repoRoot))); + if (!parsed.success) throw new Error("Unexpected response shape from GitHub review query"); + const pr = parsed.data.data.repository?.pullRequest; + if (!pr) break; + pullRequestNodeId = pr.id; + viewerDidAuthor = pr.viewerDidAuthor; + headRefOid = pr.headRefOid; + pendingReviewNodeId = pr.reviews.nodes[0]?.id ?? null; + + for (const node of pr.reviewThreads.nodes) { + // Count pending (draft) comments across every thread, including outdated/whole-file + // ones dropped below — so the tray count and the empty-review check don't undercount. + for (const c of node.comments.nodes) { + if (c.pullRequestReview?.state === PENDING_STATE) pendingCommentCount++; + } + const root = node.comments.nodes[0]; + if (!root || root.line === null) continue; + threads.push({ + threadNodeId: node.id, + isResolved: node.isResolved, + path: root.path, + line: root.line, + startLine: root.startLine, + side: root.diffSide ?? GITHUB_DIFF_SIDE.RIGHT, + startSide: root.startDiffSide, + comments: node.comments.nodes.map(toReviewComment), + }); + } + cursor = pr.reviewThreads.pageInfo.hasNextPage ? pr.reviewThreads.pageInfo.endCursor : null; + } while (cursor !== null); + + // No `pullRequest` in the response (stale/unknown PR number, or repo no longer + // resolves) — treat as unavailable rather than handing back an empty node id that + // later write mutations would post against. + if (pullRequestNodeId === "") throw new Error("Pull request not found on GitHub"); + + return { + pullRequestNodeId, + viewerDidAuthor, + headRefOid, + pendingReviewNodeId, + pendingCommentCount, + threads, + }; +} + +function toReviewComment(c: z.infer): ReviewComment { + return { + databaseId: c.databaseId, + nodeId: c.id, + htmlUrl: c.url, + body: c.body, + bodyHtml: c.bodyHTML, + createdAt: c.createdAt, + authorLogin: c.author?.login ?? "ghost", + authorAvatarUrl: c.author?.avatarUrl ?? "", + isPending: c.pullRequestReview?.state === PENDING_STATE, + }; +} + +// ─── Write: pending-review lifecycle ──────────────────────────────────────────── + +const CREATE_PENDING_REVIEW = `mutation CreatePendingReview($pullRequestId: ID!) { + addPullRequestReview(input: { pullRequestId: $pullRequestId }) { + pullRequestReview { id } + } +}`; + +const ADD_REVIEW_THREAD = `mutation AddReviewThread($pullRequestId: ID!, $reviewId: ID!, $path: String!, $body: String!, $line: Int!, $startLine: Int, $side: DiffSide!, $startSide: DiffSide) { + addPullRequestReviewThread(input: { pullRequestId: $pullRequestId, pullRequestReviewId: $reviewId, path: $path, body: $body, line: $line, startLine: $startLine, side: $side, startSide: $startSide }) { + thread { id } + } +}`; + +const ADD_REVIEW_REPLY = `mutation AddReviewReply($threadId: ID!, $reviewId: ID, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, pullRequestReviewId: $reviewId, body: $body }) { + comment { id } + } +}`; + +const UPDATE_REVIEW_COMMENT = `mutation UpdateReviewComment($commentId: ID!, $body: String!) { + updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $commentId, body: $body }) { + pullRequestReviewComment { id } + } +}`; + +const DELETE_REVIEW_COMMENT = `mutation DeleteReviewComment($commentId: ID!) { + deletePullRequestReviewComment(input: { id: $commentId }) { + pullRequestReviewComment { id } + } +}`; + +const SUBMIT_REVIEW = `mutation SubmitReview($pullRequestId: ID!, $reviewId: ID!, $event: PullRequestReviewEvent!, $body: String) { + submitPullRequestReview(input: { pullRequestId: $pullRequestId, pullRequestReviewId: $reviewId, event: $event, body: $body }) { + pullRequestReview { id } + } +}`; + +const DISCARD_REVIEW = `mutation DiscardReview($reviewId: ID!) { + deletePullRequestReview(input: { pullRequestReviewId: $reviewId }) { + pullRequestReview { id } + } +}`; + +const RESOLVE_THREAD = `mutation ResolveThread($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { thread { id } } +}`; + +const UNRESOLVE_THREAD = `mutation UnresolveThread($threadId: ID!) { + unresolveReviewThread(input: { threadId: $threadId }) { thread { id } } +}`; + +function gqlArgs(query: string, fields: Record): string[] { + const args = ["api", "graphql", "-f", `query=${query}`]; + for (const [key, value] of Object.entries(fields)) { + // Omit null fields so GraphQL applies its own null default (e.g. single-line + // comments send no startLine/startSide). + if (value === null) continue; + // `-f` for strings, `-F` for the numeric line fields (typed JSON values). + args.push(typeof value === "number" ? "-F" : "-f", `${key}=${value}`); + } + return args; +} + +const CreatedReviewSchema = z.object({ + data: z.object({ + addPullRequestReview: z.object({ pullRequestReview: z.object({ id: z.string() }) }), + }), +}); + +/** Open an empty pending review on the PR, returning its node id. */ +export async function createPendingReview( + repoRoot: string, + pullRequestNodeId: string, +): Promise { + const stdout = await ghOrThrow( + gqlArgs(CREATE_PENDING_REVIEW, { pullRequestId: pullRequestNodeId }), + repoRoot, + ); + return CreatedReviewSchema.parse(JSON.parse(stdout)).data.addPullRequestReview.pullRequestReview + .id; +} + +export interface AddReviewThreadInput { + pullRequestNodeId: string; + reviewNodeId: string; + path: string; + body: string; + line: number; + side: GitHubDiffSide; + startLine: number | null; + startSide: GitHubDiffSide | null; +} + +const AddedThreadSchema = z.object({ + data: z.object({ + addPullRequestReviewThread: z.object({ thread: z.object({ id: z.string() }) }), + }), +}); + +/** Add a line-anchored comment (a new thread) to a pending review, returning the thread's node id. */ +export async function addReviewThread( + repoRoot: string, + input: AddReviewThreadInput, +): Promise { + const stdout = await ghOrThrow( + gqlArgs(ADD_REVIEW_THREAD, { + pullRequestId: input.pullRequestNodeId, + reviewId: input.reviewNodeId, + path: input.path, + body: input.body, + line: input.line, + startLine: input.startLine, + side: input.side, + startSide: input.startSide, + }), + repoRoot, + ); + return AddedThreadSchema.parse(JSON.parse(stdout)).data.addPullRequestReviewThread.thread.id; +} + +/** Reply to an existing thread, attaching the reply to a pending review when one is open. */ +export async function addReviewReply( + repoRoot: string, + threadNodeId: string, + body: string, + reviewNodeId: string | null, +): Promise { + await ghOrThrow( + gqlArgs(ADD_REVIEW_REPLY, { threadId: threadNodeId, reviewId: reviewNodeId, body }), + repoRoot, + ); +} + +/** Edit a review comment by node id (works for pending and submitted comments). */ +export async function updateReviewComment( + repoRoot: string, + commentNodeId: string, + body: string, +): Promise { + await ghOrThrow(gqlArgs(UPDATE_REVIEW_COMMENT, { commentId: commentNodeId, body }), repoRoot); +} + +/** Delete a review comment by node id (used for pending comments). */ +export async function deleteReviewComment(repoRoot: string, commentNodeId: string): Promise { + await ghOrThrow(gqlArgs(DELETE_REVIEW_COMMENT, { commentId: commentNodeId }), repoRoot); +} + +/** Submit the pending review with the chosen event (Comment / Approve / Request changes). */ +export async function submitReview( + repoRoot: string, + pullRequestNodeId: string, + reviewNodeId: string, + event: ReviewEvent, + body: string, +): Promise { + await ghOrThrow( + gqlArgs(SUBMIT_REVIEW, { + pullRequestId: pullRequestNodeId, + reviewId: reviewNodeId, + event, + body: body.length > 0 ? body : null, + }), + repoRoot, + ); +} + +/** Throw away the pending review and all its draft comments. */ +export async function discardReview(repoRoot: string, reviewNodeId: string): Promise { + await ghOrThrow(gqlArgs(DISCARD_REVIEW, { reviewId: reviewNodeId }), repoRoot); +} + +/** Resolve or reopen a review thread by its node id. */ +export async function setThreadResolved( + repoRoot: string, + threadNodeId: string, + resolved: boolean, +): Promise { + await ghOrThrow( + gqlArgs(resolved ? RESOLVE_THREAD : UNRESOLVE_THREAD, { threadId: threadNodeId }), + repoRoot, + ); +} diff --git a/packages/cli/src/routes/comments.ts b/packages/cli/src/routes/comments.ts index d171c51..ff11bf2 100644 --- a/packages/cli/src/routes/comments.ts +++ b/packages/cli/src/routes/comments.ts @@ -104,6 +104,8 @@ export function commentRoutes(db: StageDb): Route[] { }, }, { + // Resolve/reopen a local thread. GitHub threads resolve via the separate + // review-resolve route, so this stays unscoped — it only touches local rows. method: "PATCH", pattern: "/api/comment-threads/:threadId", handler: async (req, res, params) => { diff --git a/packages/cli/src/routes/review.ts b/packages/cli/src/routes/review.ts new file mode 100644 index 0000000..5b5ade6 --- /dev/null +++ b/packages/cli/src/routes/review.ts @@ -0,0 +1,163 @@ +import { CreateCommentThreadBodySchema } from "@stagereview/types/comments"; +import { + AddToReviewBodySchema, + GitHubCommentDeleteBodySchema, + GitHubCommentEditBodySchema, + GitHubReplyBodySchema, + GitHubResolveBodySchema, + SubmitReviewBodySchema, +} from "@stagereview/types/review"; +import { eq } from "drizzle-orm"; +import type { z } from "zod"; +import type { StageDb } from "../db/client.js"; +import { type ChapterRunRow, chapterRun } from "../db/schema/index.js"; +import { + addLocalThreadToReview, + addPendingComment, + deleteGitHubComment, + discardRunReview, + editGitHubComment, + getReviewForRun, + ReviewError, + replyToGitHubThread, + resolveGitHubThread, + submitRunReview, +} from "../runs/review.js"; +import type { Route, RouteHandler } from "../server.js"; +import { parseJsonBody, writeJson } from "./json.js"; +import { enforceSameOrigin } from "./pull-request-shared.js"; + +type Req = Parameters[0]; +type Res = Parameters[1]; + +export function reviewRoutes(db: StageDb): Route[] { + return [ + { + method: "GET", + pattern: "/api/runs/:runId/review", + handler: (_req, res, params) => + withRun(db, params.runId, res, (run) => getReviewForRun(db, run)), + }, + { + method: "POST", + pattern: "/api/runs/:runId/review/add", + handler: (req, res, params) => + withRunBody(db, req, res, params.runId, AddToReviewBodySchema, (run, body) => + addLocalThreadToReview(db, run, body.localThreadId), + ), + }, + { + // Create a comment directly on the PR as a pending (draft) review comment. + method: "POST", + pattern: "/api/runs/:runId/review/comment", + handler: (req, res, params) => + withRunBody(db, req, res, params.runId, CreateCommentThreadBodySchema, (run, body) => + addPendingComment(run, body), + ), + }, + { + method: "POST", + pattern: "/api/runs/:runId/review/submit", + handler: (req, res, params) => + withRunBody(db, req, res, params.runId, SubmitReviewBodySchema, (run, body) => + submitRunReview(run, body.event, body.body), + ), + }, + { + method: "POST", + pattern: "/api/runs/:runId/review/discard", + handler: (req, res, params) => + withRun(db, params.runId, res, (run) => discardRunReview(run), { sameOrigin: req }), + }, + { + method: "POST", + pattern: "/api/runs/:runId/review/reply", + handler: (req, res, params) => + withRunBody(db, req, res, params.runId, GitHubReplyBodySchema, (run, body) => + replyToGitHubThread(run, body.threadNodeId, body.body, body.pending), + ), + }, + { + method: "POST", + pattern: "/api/runs/:runId/review/comment/edit", + handler: (req, res, params) => + withRunBody(db, req, res, params.runId, GitHubCommentEditBodySchema, (run, body) => + editGitHubComment(run, body.nodeId, body.body), + ), + }, + { + method: "POST", + pattern: "/api/runs/:runId/review/comment/delete", + handler: (req, res, params) => + withRunBody(db, req, res, params.runId, GitHubCommentDeleteBodySchema, (run, body) => + deleteGitHubComment(run, body.nodeId), + ), + }, + { + method: "POST", + pattern: "/api/runs/:runId/review/resolve", + handler: (req, res, params) => + withRunBody(db, req, res, params.runId, GitHubResolveBodySchema, (run, body) => + resolveGitHubThread(run, body.threadNodeId, body.resolved), + ), + }, + ]; +} + +function loadRun(db: StageDb, runId: string | undefined, res: Res): ChapterRunRow | null { + 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; + } + return run; +} + +async function respond(res: Res, action: () => Promise): Promise { + try { + writeJson(res, 200, (await action()) ?? {}); + } catch (err) { + if (err instanceof ReviewError) { + writeJson(res, err.status, { error: err.message }); + return; + } + writeJson(res, 500, { + error: err instanceof Error ? err.message : "Review action failed", + }); + } +} + +/** GET-style handler: load the run and run the action. Pass `sameOrigin` to also guard a write. */ +async function withRun( + db: StageDb, + runId: string | undefined, + res: Res, + action: (run: ChapterRunRow) => Promise, + opts: { sameOrigin?: Req } = {}, +): Promise { + if (opts.sameOrigin && !enforceSameOrigin(opts.sameOrigin, res)) return; + const run = loadRun(db, runId, res); + if (!run) return; + await respond(res, () => action(run)); +} + +/** POST-style handler: enforce same-origin, parse + validate the body, then run the action. */ +async function withRunBody( + db: StageDb, + req: Req, + res: Res, + runId: string | undefined, + schema: z.ZodType, + action: (run: ChapterRunRow, body: T) => Promise, +): Promise { + if (!enforceSameOrigin(req, res)) return; + const run = loadRun(db, runId, res); + if (!run) return; + const body = await parseJsonBody(req, res, schema); + if (body === null) return; + await respond(res, () => action(run, body)); +} diff --git a/packages/cli/src/runs/review.ts b/packages/cli/src/runs/review.ts new file mode 100644 index 0000000..582ef1c --- /dev/null +++ b/packages/cli/src/runs/review.ts @@ -0,0 +1,449 @@ +import { + COMMENT_STATE, + GITHUB_REVIEW_STATUS, + REVIEW_EVENT, + type ReviewComment as ReviewCommentDto, + type ReviewEvent, + type ReviewResponse, + type ReviewThread as ReviewThreadDto, + THREAD_SOURCE, +} from "@stagereview/types/review"; +import { asc, eq } from "drizzle-orm"; +import type { StageDb } from "../db/client.js"; +import { type ChapterRunRow, comment, commentThread } from "../db/schema/index.js"; +import { type GitHubRepo, getPullRequest, parseGitHubRepo } from "../github/index.js"; +import { + addReviewReply, + addReviewThread, + createPendingReview, + deleteReviewComment, + discardReview, + GITHUB_DIFF_SIDE, + type GitHubDiffSide, + type GitHubReview, + type ReviewThread as GitHubReviewThread, + getReview, + setThreadResolved, + submitReview, + updateReviewComment, +} from "../github/review.js"; +import { DIFF_SIDE, type DiffSide, SCOPE_KIND } from "../schema.js"; +import { deriveScopeKey } from "./scope-key.js"; + +/** A review action failure with a user-facing message and the route's HTTP status. */ +export class ReviewError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "ReviewError"; + } +} + +// LEFT is GitHub's base/deletion side, RIGHT the head/addition side. +function toGitHubSide(side: DiffSide): GitHubDiffSide { + return side === DIFF_SIDE.DELETIONS ? GITHUB_DIFF_SIDE.LEFT : GITHUB_DIFF_SIDE.RIGHT; +} + +function fromGitHubSide(side: GitHubDiffSide): DiffSide { + return side === GITHUB_DIFF_SIDE.LEFT ? DIFF_SIDE.DELETIONS : DIFF_SIDE.ADDITIONS; +} + +/** + * Whether the run's diff IS the PR's current diff. GitHub anchors review comments to + * the PR head-commit's diff, and a run's comment anchors are line numbers from its + * own `base..head`. So the two align only when the run is a committed diff (working- + * tree line numbers aren't the PR's) whose head is the PR head. This is the load- + * bearing invariant for both showing live PR threads and adding comments to the PR; + * the live worktree state is irrelevant — a committed run's anchors are fixed by its + * recorded SHAs. + */ +function runMatchesPrDiff(run: ChapterRunRow, headRefOid: string): boolean { + return run.scopeKind === SCOPE_KIND.COMMITTED && run.headSha === headRefOid; +} + +function assertPushable(run: ChapterRunRow, review: GitHubReview): void { + if (runMatchesPrDiff(run, review.headRefOid)) return; + const reason = + run.scopeKind !== SCOPE_KIND.COMMITTED + ? "Only comments on a committed diff can be added to the PR — working-tree comments aren't anchored to the PR's commits." + : "This run's diff doesn't match the current PR head. Re-run against the latest PR commit to comment on it."; + throw new ReviewError(reason, 409); +} + +// ─── Read: merged local + GitHub review ───────────────────────────────────────── + +function loadLocalThreads(db: StageDb, scopeKey: string): ReviewThreadDto[] { + const threads = db + .select() + .from(commentThread) + .where(eq(commentThread.scopeKey, scopeKey)) + .orderBy(asc(commentThread.createdAt)) + .all(); + return threads.map((thread): ReviewThreadDto => { + const comments = db + .select() + .from(comment) + .where(eq(comment.threadId, thread.id)) + .orderBy(asc(comment.createdAt)) + .all(); + return { + id: thread.id, + source: THREAD_SOURCE.LOCAL, + threadNodeId: null, + filePath: thread.filePath, + side: thread.side, + startLine: thread.startLine, + endLine: thread.endLine, + isResolved: thread.resolvedAt !== null, + comments: comments.map( + (c): ReviewCommentDto => ({ + id: c.id, + state: COMMENT_STATE.LOCAL, + body: c.body, + bodyHtml: null, + author: null, + nodeId: null, + htmlUrl: null, + createdAt: c.createdAt.toISOString(), + }), + ), + }; + }); +} + +function toGitHubThreadDto(t: GitHubReviewThread): ReviewThreadDto { + // `line` is non-null (getReview drops anchorless threads); start defaults to line. + const endLine = t.line ?? t.startLine ?? 1; + return { + id: t.threadNodeId, + source: THREAD_SOURCE.GITHUB, + threadNodeId: t.threadNodeId, + filePath: t.path, + side: fromGitHubSide(t.side), + startLine: t.startLine ?? endLine, + endLine, + isResolved: t.isResolved, + comments: t.comments.map( + (c): ReviewCommentDto => ({ + id: c.nodeId, + state: c.isPending ? COMMENT_STATE.PENDING : COMMENT_STATE.SUBMITTED, + body: c.body, + bodyHtml: c.bodyHtml, + author: { login: c.authorLogin, avatarUrl: c.authorAvatarUrl || null }, + nodeId: c.nodeId, + htmlUrl: c.htmlUrl, + createdAt: c.createdAt, + }), + ), + }; +} + +/** + * The run's full review: local threads from the DB merged with the PR's live + * GitHub threads (pending + submitted). GitHub failures degrade to `offline` + * (local comments still render) rather than throwing — the read backs passive + * rendering, so it never blanks the review. + */ +export async function getReviewForRun(db: StageDb, run: ChapterRunRow): Promise { + const localThreads = loadLocalThreads(db, deriveScopeKey(run)); + const base = { + threads: localThreads, + pendingCommentCount: 0, + hasPendingReview: false, + isOwnPullRequest: false, + canPushToReview: false, + }; + + const repo = parseGitHubRepo(run.originUrl); + if (!repo) return { ...base, github: GITHUB_REVIEW_STATUS.NONE }; + + let prNumber = run.prNumber; + if (prNumber === null) { + const pr = await getPullRequest(run.repoRoot, run.originUrl, null); + prNumber = pr?.number ?? null; + } + if (prNumber === null) return { ...base, github: GITHUB_REVIEW_STATUS.NONE }; + + let review: GitHubReview; + try { + review = await getReview(run.repoRoot, repo, prNumber); + } catch { + return { ...base, github: GITHUB_REVIEW_STATUS.OFFLINE }; + } + + // The PR's live threads anchor to its head-commit diff. If this run isn't that + // exact diff, overlaying them would mis-anchor comments on unrelated lines, so we + // surface only local comments — the GitHub review isn't meaningful for this diff. + if (!runMatchesPrDiff(run, review.headRefOid)) { + return { ...base, github: GITHUB_REVIEW_STATUS.NONE }; + } + + const githubThreads = review.threads.map(toGitHubThreadDto); + return { + github: GITHUB_REVIEW_STATUS.AVAILABLE, + threads: [...localThreads, ...githubThreads], + // Raw count from getReview includes pending drafts on anchorless threads we don't render. + pendingCommentCount: review.pendingCommentCount, + hasPendingReview: review.pendingReviewNodeId !== null, + isOwnPullRequest: review.viewerDidAuthor, + canPushToReview: true, + }; +} + +// ─── Write: review actions ────────────────────────────────────────────────────── + +interface ReviewTarget { + repo: GitHubRepo; + prNumber: number; + review: GitHubReview; +} + +/** Resolve the run's PR and load its live review, throwing a user-facing error when unavailable. */ +async function loadTarget(run: ChapterRunRow): Promise { + const repo = parseGitHubRepo(run.originUrl); + if (!repo) throw new ReviewError("This run isn't associated with a GitHub remote.", 404); + let prNumber = run.prNumber; + if (prNumber === null) { + const pr = await getPullRequest(run.repoRoot, run.originUrl, null); + prNumber = pr?.number ?? null; + } + if (prNumber === null) { + throw new ReviewError("No GitHub pull request found for this run.", 404); + } + const review = await getReview(run.repoRoot, repo, prNumber); + return { repo, prNumber, review }; +} + +/** The viewer's pending review node id, opening an empty pending review if none is open. */ +async function openPendingReview( + run: ChapterRunRow, + review: GitHubReview, +): Promise<{ reviewNodeId: string; created: boolean }> { + if (review.pendingReviewNodeId !== null) { + return { reviewNodeId: review.pendingReviewNodeId, created: false }; + } + return { + reviewNodeId: await createPendingReview(run.repoRoot, review.pullRequestNodeId), + created: true, + }; +} + +/** + * Run an action against the viewer's pending review, opening one if needed. If we + * had to open the review and the action then fails (e.g. an out-of-diff line), the + * just-created empty review is discarded so it doesn't linger on the PR as a stray + * "review to submit". A pre-existing review is never discarded. + */ +async function withPendingReview( + run: ChapterRunRow, + review: GitHubReview, + action: (reviewNodeId: string) => Promise, +): Promise { + const { reviewNodeId, created } = await openPendingReview(run, review); + try { + return await action(reviewNodeId); + } catch (err) { + if (created) await discardReview(run.repoRoot, reviewNodeId).catch(() => {}); + throw err; + } +} + +// Local thread ids currently mid-promotion. The server is a single process, so this +// in-memory set serializes concurrent /review/add calls for the same thread (e.g. a +// double-click): the second request is rejected instead of racing to create a +// duplicate pending GitHub thread before the first deletes the local row. +const promotingThreads = new Set(); + +/** + * Promote a local comment thread to the viewer's pending GitHub review: the root + * becomes a new review thread, replies become pending replies, and the local thread + * is removed (it now lives on GitHub as pending). GitHub anchors the comment to the + * PR's current diff, so a line not in that diff is rejected and surfaced as an error. + */ +export async function addLocalThreadToReview( + db: StageDb, + run: ChapterRunRow, + localThreadId: string, +): Promise { + if (promotingThreads.has(localThreadId)) { + throw new ReviewError("This comment is already being added to the review.", 409); + } + promotingThreads.add(localThreadId); + try { + await promoteLocalThread(db, run, localThreadId); + } finally { + promotingThreads.delete(localThreadId); + } +} + +async function promoteLocalThread( + db: StageDb, + run: ChapterRunRow, + localThreadId: string, +): Promise { + const [thread] = db + .select() + .from(commentThread) + .where(eq(commentThread.id, localThreadId)) + .limit(1) + .all(); + if (!thread) throw new ReviewError(`Thread ${localThreadId} not found`, 404); + // The thread must belong to this run's diff scope; its anchor was computed + // against that diff, so promoting one from another scope would mis-anchor. + if (thread.scopeKey !== deriveScopeKey(run)) { + throw new ReviewError("This comment doesn't belong to this run's diff.", 400); + } + const comments = db + .select() + .from(comment) + .where(eq(comment.threadId, localThreadId)) + .orderBy(asc(comment.createdAt)) + .all(); + const root = comments[0]; + if (!root) throw new ReviewError("Thread has no comments to add to the review.", 400); + + const { review } = await loadTarget(run); + assertPushable(run, review); + const { reviewNodeId, created } = await openPendingReview(run, review); + const side = toGitHubSide(thread.side); + const startLine = thread.endLine !== thread.startLine ? thread.startLine : null; + + let threadNodeId: string; + try { + threadNodeId = await addReviewThread(run.repoRoot, { + pullRequestNodeId: review.pullRequestNodeId, + reviewNodeId, + path: thread.filePath, + body: root.body, + line: thread.endLine, + side, + startLine, + startSide: startLine !== null ? side : null, + }); + } catch (err) { + // Don't leave an empty review behind if the root couldn't be posted. + if (created) await discardReview(run.repoRoot, reviewNodeId).catch(() => {}); + throw err; + } + // Delete each local comment only once its GitHub counterpart lands, so a failure + // part-way never loses an unposted comment (the leftover replies stay local) and + // never leaves the already-promoted root behind to be re-promoted as a duplicate. + db.delete(comment).where(eq(comment.id, root.id)).run(); + for (const reply of comments.slice(1)) { + await addReviewReply(run.repoRoot, threadNodeId, reply.body, reviewNodeId); + db.delete(comment).where(eq(comment.id, reply.id)).run(); + } + // Every comment promoted — drop the now-empty local thread. + db.delete(commentThread).where(eq(commentThread.id, localThreadId)).run(); +} + +export interface PendingCommentAnchor { + filePath: string; + side: DiffSide; + startLine: number; + endLine: number; + body: string; +} + +/** + * Create a comment directly on the PR as a pending (draft) review comment, opening + * the viewer's review if needed. This is the "Comment on the PR" path — unlike + * `addLocalThreadToReview`, nothing is stored locally; the comment lives only on + * GitHub. GitHub anchors it to the PR's current diff, rejecting out-of-diff lines. + */ +export async function addPendingComment( + run: ChapterRunRow, + anchor: PendingCommentAnchor, +): Promise { + const { review } = await loadTarget(run); + assertPushable(run, review); + const side = toGitHubSide(anchor.side); + const startLine = anchor.endLine !== anchor.startLine ? anchor.startLine : null; + await withPendingReview(run, review, (reviewNodeId) => + addReviewThread(run.repoRoot, { + pullRequestNodeId: review.pullRequestNodeId, + reviewNodeId, + path: anchor.filePath, + body: anchor.body, + line: anchor.endLine, + side, + startLine, + startSide: startLine !== null ? side : null, + }), + ); +} + +/** Reply to a GitHub thread, adding to the viewer's pending review (or as a single comment). */ +export async function replyToGitHubThread( + run: ChapterRunRow, + threadNodeId: string, + body: string, + pending: boolean, +): Promise { + const { review } = await loadTarget(run); + // Same guard as the comment paths: don't act on the PR from a run whose diff + // isn't the PR's current diff (the thread on screen may not be what's live). + assertPushable(run, review); + if (!pending) { + await addReviewReply(run.repoRoot, threadNodeId, body, null); + return; + } + await withPendingReview(run, review, (reviewNodeId) => + addReviewReply(run.repoRoot, threadNodeId, body, reviewNodeId), + ); +} + +/** Submit the viewer's pending review with the chosen event, opening one if needed (e.g. a bare approval). */ +export async function submitRunReview( + run: ChapterRunRow, + event: ReviewEvent, + body: string, +): Promise { + const { review } = await loadTarget(run); + // A review approves/comments on the PR's current diff; block submitting from a run + // whose diff isn't that, even via the API (the UI already hides the tray then). + assertPushable(run, review); + // Mirror the tray's rule at the API boundary: a Comment review needs a summary or + // at least one pending comment, else we'd open and submit an empty review. Uses the + // raw pending count (includes anchorless drafts getReview drops) to avoid a false reject. + if (event === REVIEW_EVENT.COMMENT && body.trim() === "" && review.pendingCommentCount === 0) { + throw new ReviewError("Add a summary or at least one pending comment to submit a review.", 400); + } + await withPendingReview(run, review, (reviewNodeId) => + submitReview(run.repoRoot, review.pullRequestNodeId, reviewNodeId, event, body), + ); +} + +/** Discard the viewer's pending review and all its draft comments. */ +export async function discardRunReview(run: ChapterRunRow): Promise { + const { review } = await loadTarget(run); + if (review.pendingReviewNodeId === null) { + throw new ReviewError("There's no pending review to discard.", 409); + } + await discardReview(run.repoRoot, review.pendingReviewNodeId); +} + +/** Edit a GitHub review comment by node id (used for pending comments). */ +export async function editGitHubComment( + run: ChapterRunRow, + nodeId: string, + body: string, +): Promise { + await updateReviewComment(run.repoRoot, nodeId, body); +} + +/** Delete a pending GitHub review comment by node id. */ +export async function deleteGitHubComment(run: ChapterRunRow, nodeId: string): Promise { + await deleteReviewComment(run.repoRoot, nodeId); +} + +/** Resolve or reopen a GitHub review thread. */ +export async function resolveGitHubThread( + run: ChapterRunRow, + threadNodeId: string, + resolved: boolean, +): Promise { + await setThreadResolved(run.repoRoot, threadNodeId, resolved); +} diff --git a/packages/cli/src/show.ts b/packages/cli/src/show.ts index 2d71487..8b48f98 100644 --- a/packages/cli/src/show.ts +++ b/packages/cli/src/show.ts @@ -10,6 +10,7 @@ import { commentRoutes } from "./routes/comments.js"; import { diffRoutes } from "./routes/diff.js"; import { pullRequestRoutes } from "./routes/pull-request.js"; import { pullRequestMutationRoutes } from "./routes/pull-request-mutations.js"; +import { reviewRoutes } from "./routes/review.js"; import { runRoutes } from "./routes/runs.js"; import { viewStateRoutes } from "./routes/view-state.js"; import { viewerRoutes } from "./routes/viewer.js"; @@ -36,6 +37,7 @@ export async function show(jsonPath: string, options: DiffScopeOptions): Promise ...runRoutes(db), ...viewStateRoutes(db), ...commentRoutes(db), + ...reviewRoutes(db), ...viewerRoutes(), ...diffRoutes(db), ...pullRequestRoutes(db), diff --git a/packages/types/package.json b/packages/types/package.json index f9635a9..1e14d60 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -12,6 +12,7 @@ "./parsed-diff": "./src/parsed-diff.ts", "./prologue": "./src/prologue.ts", "./pull-request": "./src/pull-request.ts", + "./review": "./src/review.ts", "./view-state": "./src/view-state.ts", "./viewer": "./src/viewer.ts" }, diff --git a/packages/types/src/comments.ts b/packages/types/src/comments.ts index 845a2d9..899b874 100644 --- a/packages/types/src/comments.ts +++ b/packages/types/src/comments.ts @@ -3,8 +3,7 @@ import { DIFF_SIDE } from "./chapters.ts"; // A single authored comment. Replies are sibling comments sharing a thread, so a // comment carries no positional data of its own — the thread owns the anchor. -// Non-strict (like the other wire response schemas) so the server can add fields -// the SPA doesn't yet read without the response failing to parse. +// These are CLI-local comments; GitHub review comments use the `review` wire model. export const CommentSchema = z.object({ id: z.string(), body: z.string(), diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 18eb175..ff6b3a3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -4,5 +4,6 @@ export * from "./diff.ts"; export * from "./parsed-diff.ts"; export * from "./prologue.ts"; export * from "./pull-request.ts"; +export * from "./review.ts"; export * from "./view-state.ts"; export * from "./viewer.ts"; diff --git a/packages/types/src/review.ts b/packages/types/src/review.ts new file mode 100644 index 0000000..354d4fd --- /dev/null +++ b/packages/types/src/review.ts @@ -0,0 +1,136 @@ +import { z } from "zod"; +import { DIFF_SIDE } from "./chapters.ts"; + +// A comment's lifecycle state. `local` lives only in the CLI; `pending` is a draft +// on the viewer's unsubmitted GitHub review (only they see it); `submitted` is +// published on the PR for everyone. +export const COMMENT_STATE = { + LOCAL: "local", + PENDING: "pending", + SUBMITTED: "submitted", +} as const; +export type CommentState = (typeof COMMENT_STATE)[keyof typeof COMMENT_STATE]; + +// Where a thread originates. `local` threads are CLI-only rows; `github` threads +// are live review threads on the PR (pending and/or submitted comments). +export const THREAD_SOURCE = { + LOCAL: "local", + GITHUB: "github", +} as const; +export type ThreadSource = (typeof THREAD_SOURCE)[keyof typeof THREAD_SOURCE]; + +// The events a review can be submitted with, mirroring GitHub's review model. +export const REVIEW_EVENT = { + COMMENT: "COMMENT", + APPROVE: "APPROVE", + REQUEST_CHANGES: "REQUEST_CHANGES", +} as const; +export type ReviewEvent = (typeof REVIEW_EVENT)[keyof typeof REVIEW_EVENT]; + +// Whether the GitHub review layer is usable for this run. `none` = not a GitHub PR; +// `offline` = `gh` is missing/unauthenticated/unreachable (GitHub actions disabled); +// `available` = the PR's review state loaded. +export const GITHUB_REVIEW_STATUS = { + NONE: "none", + OFFLINE: "offline", + AVAILABLE: "available", +} as const; +export type GitHubReviewStatus = (typeof GITHUB_REVIEW_STATUS)[keyof typeof GITHUB_REVIEW_STATUS]; + +export const ReviewCommentAuthorSchema = z.object({ + login: z.string(), + avatarUrl: z.string().nullable(), +}); +export type ReviewCommentAuthor = z.infer; + +// A single comment in a thread. `author` is null for local comments (the local +// reviewer, "You"). `nodeId` is the GitHub GraphQL id, present for github comments +// (needed to edit/delete pending ones). `commentId` is the local row id for local +// comments. +export const ReviewCommentSchema = z.object({ + id: z.string(), + state: z.enum(COMMENT_STATE), + body: z.string(), + // GitHub's server-rendered HTML (resolves @mentions/#refs/emoji); null for local comments. + bodyHtml: z.string().nullable(), + author: ReviewCommentAuthorSchema.nullable(), + nodeId: z.string().nullable(), + // Permalink to the comment on GitHub; null for local comments. + htmlUrl: z.string().nullable(), + createdAt: z.string(), +}); +export type ReviewComment = z.infer; + +// A line-anchored thread. Local and github threads share this shape so the diff +// viewer can place both uniformly; `side`/`startLine`/`endLine` are normalized to +// the local diff convention. `threadNodeId` is set for github threads (resolve/reply). +export const ReviewThreadSchema = z.object({ + id: z.string(), + source: z.enum(THREAD_SOURCE), + threadNodeId: z.string().nullable(), + filePath: z.string(), + side: z.enum(DIFF_SIDE), + startLine: z.number().int().positive(), + endLine: z.number().int().positive(), + isResolved: z.boolean(), + comments: z.array(ReviewCommentSchema), +}); +export type ReviewThread = z.infer; + +export const ReviewResponseSchema = z.object({ + github: z.enum(GITHUB_REVIEW_STATUS), + threads: z.array(ReviewThreadSchema), + // Count of the viewer's pending (draft) comments, for the review tray badge. + pendingCommentCount: z.number().int().nonnegative(), + hasPendingReview: z.boolean(), + // The viewer opened this PR — GitHub forbids approving/requesting changes on it. + isOwnPullRequest: z.boolean(), + // Whether comments can be added to the PR right now — true when the run's diff IS + // the PR's current diff (a committed run whose head equals the PR head). When false, + // only local comments are allowed (the GitHub review isn't shown for this diff). + canPushToReview: z.boolean(), +}); +export type ReviewResponse = z.infer; + +// ─── Action bodies ────────────────────────────────────────────────────────────── + +// Promote a local comment to a pending GitHub review comment (or reply). The local +// thread/comment is identified by id; the server reads its anchor + body. +export const AddToReviewBodySchema = z.object({ + localThreadId: z.string().min(1), +}); +export type AddToReviewBody = z.infer; + +export const SubmitReviewBodySchema = z.object({ + event: z.enum(REVIEW_EVENT), + body: z.string(), +}); +export type SubmitReviewBody = z.infer; + +// Reply to a github thread. `pending` (default) adds the reply to the viewer's +// pending review; false posts it immediately as a single comment. +export const GitHubReplyBodySchema = z.object({ + threadNodeId: z.string().min(1), + body: z.string().min(1), + pending: z.boolean().default(true), +}); +export type GitHubReplyBody = z.infer; + +// Edit/delete a pending github comment by node id. +export const GitHubCommentEditBodySchema = z.object({ + nodeId: z.string().min(1), + body: z.string().min(1), +}); +export type GitHubCommentEditBody = z.infer; + +export const GitHubCommentDeleteBodySchema = z.object({ + nodeId: z.string().min(1), +}); +export type GitHubCommentDeleteBody = z.infer; + +// Resolve/reopen a github thread by node id. +export const GitHubResolveBodySchema = z.object({ + threadNodeId: z.string().min(1), + resolved: z.boolean(), +}); +export type GitHubResolveBody = z.infer; diff --git a/packages/web/src/app/runs.$runId.tsx b/packages/web/src/app/runs.$runId.tsx index 0ed666b..4fc243e 100644 --- a/packages/web/src/app/runs.$runId.tsx +++ b/packages/web/src/app/runs.$runId.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { Topbar } from "@/components/layout/topbar"; -import { CommentThreadsProvider } from "@/lib/comment-threads-context"; +import { ReviewProvider } from "@/lib/review-context"; import { PullRequestLayout } from "@/routes/pull-request-layout"; export const Route = createFileRoute("/runs/$runId")({ @@ -10,9 +10,9 @@ export const Route = createFileRoute("/runs/$runId")({ function RunLayout() { const { runId } = Route.useParams(); return ( - + - + ); } diff --git a/packages/web/src/components/chapter/pierre-diff-viewer.tsx b/packages/web/src/components/chapter/pierre-diff-viewer.tsx index 091108a..d525734 100644 --- a/packages/web/src/components/chapter/pierre-diff-viewer.tsx +++ b/packages/web/src/components/chapter/pierre-diff-viewer.tsx @@ -19,7 +19,7 @@ import { useState, } from "react"; import { CommentForm } from "@/components/comments/comment-form"; -import { CommentThreadView } from "@/components/comments/comment-thread"; +import { ReviewThreadView } from "@/components/comments/review-thread"; import { buildCommentAnnotations, type CommentDraft, @@ -32,7 +32,6 @@ import { upsertDraft, writeDraftBody, } from "@/lib/comment-drafts"; -import { useCommentThreadsContext } from "@/lib/comment-threads-context"; import { type AnnotatedLineRef, COMMENT_SIDE, @@ -40,9 +39,10 @@ import { type LineRef, SIDE_TO_DIFF, } from "@/lib/diff-types"; +import { useReviewContext } from "@/lib/review-context"; import { resolveSyntaxTheme } from "@/lib/syntax-themes"; -import type { CommentThread } from "@/lib/use-comment-threads"; import { useDiffSettings } from "@/lib/use-diff-settings"; +import type { ReviewThread as CommentThread } from "@/lib/use-review"; import { toSingleSideSelection, useTextSelection } from "@/lib/use-text-selection"; import { LineHighlightOverlay } from "./hunk-highlight-overlay"; import { TextSelectionPopup } from "./text-selection-popup"; @@ -194,8 +194,9 @@ export function PierreDiffViewer({ }, [allLineRefsByFile, filePath]); // ---- Line-anchored comments ---- - const comments = useCommentThreadsContext(); - const { createThread } = comments; + const comments = useReviewContext(); + const { createLocalThread, createPendingComment } = comments; + const canPushToReview = comments.canPushToReview; const fileThreads = useMemo( () => (filePath ? (comments.threadsByFile.get(filePath) ?? []) : []), [comments.threadsByFile, filePath], @@ -231,28 +232,31 @@ export function PierreDiffViewer({ }, []); const handleCreateComment = useCallback( - async (draft: CommentDraft, body: string) => { + async (draft: CommentDraft, body: string, onPr: boolean) => { if (!filePath) return; const setError = (error: string | null) => setDrafts((prev) => prev.map((d) => (isSameAnchor(d, draft.side, draft.endLine) ? { ...d, error } : d)), ); setError(null); + const anchor = { + filePath, + side: draft.side, + startLine: draft.startLine, + endLine: draft.endLine, + body, + }; try { - await createThread({ - filePath, - side: draft.side, - startLine: draft.startLine, - endLine: draft.endLine, - body, - }); + // "Comment on the PR" creates a pending GitHub comment; otherwise it stays local. + if (onPr && canPushToReview) await createPendingComment(anchor); + else await createLocalThread(anchor); closeDraft(draft); } catch (err) { setError(err instanceof Error ? err.message : "Failed to add comment"); throw err; // keep the composer open with the body intact } }, - [filePath, createThread, closeDraft], + [filePath, createLocalThread, createPendingComment, canPushToReview, closeDraft], ); const handleThreadMouseEnter = useCallback((thread: CommentThread) => { @@ -287,7 +291,7 @@ export function PierreDiffViewer({ onMouseEnter={() => handleThreadMouseEnter(thread)} onMouseLeave={handleThreadMouseLeave} > - + ))} {draft && ( @@ -304,14 +308,22 @@ export function PierreDiffViewer({ onBodyChange={(body) => writeDraftBody(draftBodiesRef.current, draft.side, draft.endLine, body) } - onSubmit={(body) => handleCreateComment(draft, body)} + toggleLabel={canPushToReview ? "Comment on the PR" : undefined} + onSubmit={(body, onPr) => handleCreateComment(draft, body, onPr)} onCancel={() => closeDraft(draft)} /> )} ); }, - [drafts, handleCreateComment, closeDraft, handleThreadMouseEnter, handleThreadMouseLeave], + [ + drafts, + canPushToReview, + handleCreateComment, + closeDraft, + handleThreadMouseEnter, + handleThreadMouseLeave, + ], ); const renderGutterUtility = useCallback( diff --git a/packages/web/src/components/comments/comment-form.tsx b/packages/web/src/components/comments/comment-form.tsx index d431d0d..f1ad1b0 100644 --- a/packages/web/src/components/comments/comment-form.tsx +++ b/packages/web/src/components/comments/comment-form.tsx @@ -1,11 +1,13 @@ -import { type KeyboardEvent, useEffect, useRef, useState } from "react"; +import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { CommentMarkdownEditor } from "./comment-markdown-editor"; interface CommentFormProps { /** Label for the primary submit button (e.g. "Comment", "Reply", "Update"). */ label: string; - onSubmit: (body: string) => void | Promise; + /** `toggleOn` carries the optional checkbox state; it's `true` when no toggle is shown. */ + onSubmit: (body: string, toggleOn: boolean) => void | Promise; onCancel: () => void; placeholder?: string; error?: string | null; @@ -14,6 +16,9 @@ interface CommentFormProps { /** Reports each edit so a parent can persist an in-progress draft across remounts. */ onBodyChange?: (body: string) => void; autoFocus?: boolean; + /** When set, renders a checkbox (e.g. "Comment on the PR") whose state is passed to onSubmit. */ + toggleLabel?: string; + toggleDefault?: boolean; } export function CommentForm({ @@ -25,9 +30,13 @@ export function CommentForm({ initialBody, onBodyChange, autoFocus = true, + toggleLabel, + toggleDefault = true, }: CommentFormProps) { const [body, setBody] = useState(initialBody ?? ""); const [isSubmitting, setIsSubmitting] = useState(false); + const [toggleOn, setToggleOn] = useState(toggleDefault); + const toggleId = useId(); const textareaRef = useRef(null); const submittingRef = useRef(false); const hasContent = body.trim().length > 0; @@ -47,7 +56,7 @@ export function CommentForm({ submittingRef.current = true; setIsSubmitting(true); try { - await onSubmit(trimmed); + await onSubmit(trimmed, toggleLabel === undefined ? true : toggleOn); setBody(""); } catch { // The caller surfaces the error; preserve the body so the user can retry. @@ -87,18 +96,36 @@ export function CommentForm({ previewClassName="max-h-[12rem] overflow-y-auto" > {error &&

{error}

} -
- - +
+ {toggleLabel !== undefined ? ( + + ) : ( +
+ )} +
+ + +
diff --git a/packages/web/src/components/comments/comment-thread.tsx b/packages/web/src/components/comments/comment-thread.tsx deleted file mode 100644 index f1f2104..0000000 --- a/packages/web/src/components/comments/comment-thread.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import { ChevronRight, Circle, CircleCheck, MessageSquare, User } from "lucide-react"; -import { useState } from "react"; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { Markdown } from "@/components/ui/markdown"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useCommentThreadsContext } from "@/lib/comment-threads-context"; -import { formatTimeAgo } from "@/lib/format"; -import type { Comment, CommentThread } from "@/lib/use-comment-threads"; -import { useViewer } from "@/lib/use-viewer"; -import { cn } from "@/lib/utils"; -import { CommentActions } from "./comment-actions"; -import { CommentForm } from "./comment-form"; - -type DeleteTarget = - | { kind: "thread"; hasReplies: boolean } - | { kind: "comment"; commentId: string }; - -function errorMessage(err: unknown, fallback: string): string { - return err instanceof Error ? err.message : fallback; -} - -export function CommentThreadView({ thread }: { thread: CommentThread }) { - const { replyToThread, setThreadResolved, editComment, deleteThread, deleteComment } = - useCommentThreadsContext(); - const isResolved = thread.resolvedAt !== null; - - const [isOpen, setIsOpen] = useState(!isResolved); - const [isReplying, setIsReplying] = useState(false); - const [editingId, setEditingId] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - const [error, setError] = useState(null); - - const root = thread.comments[0]; - // A thread always has a root comment (deleting the last one removes the thread), - // but noUncheckedIndexedAccess types the lookup as possibly-undefined. - if (!root) return null; - const replies = thread.comments.slice(1); - - function handleResolveToggle() { - const next = !isResolved; - // Collapse on resolve / expand on reopen — but never collapse out from under an - // active reply/edit/delete form (it would unmount CommentForm and drop unsaved - // text), mirroring the handleOpenChange guard. - const hasActiveForm = isReplying || editingId !== null || deleteTarget !== null; - if (!next || !hasActiveForm) setIsOpen(!next); - void setThreadResolved({ threadId: thread.id, resolved: next }); - } - - function handleOpenChange(open: boolean) { - // Keep the thread expanded while the user is mid-action. - if (!open && (isReplying || editingId !== null || deleteTarget !== null)) return; - setIsOpen(open); - } - - async function submitReply(body: string) { - setError(null); - try { - await replyToThread({ threadId: thread.id, body }); - setIsReplying(false); - } catch (err) { - setError(errorMessage(err, "Failed to add reply")); - throw err; - } - } - - async function submitEdit(commentId: string, body: string) { - setError(null); - try { - await editComment({ commentId, body }); - setEditingId(null); - } catch (err) { - setError(errorMessage(err, "Failed to update comment")); - throw err; - } - } - - function confirmDelete() { - if (!deleteTarget) return; - if (deleteTarget.kind === "thread") void deleteThread(thread.id); - else void deleteComment(deleteTarget.commentId); - setDeleteTarget(null); - } - - const idle = !isReplying && editingId === null; - - return ( - -
-
- - - - - - - {isOpen ? "Collapse thread" : "Expand thread"} - - - - {idle && ( -
- - - - - Reply - - { - setIsOpen(true); - setError(null); - setEditingId(root.id); - }} - onDelete={() => setDeleteTarget({ kind: "thread", hasReplies: replies.length > 0 })} - deleteLabel={replies.length > 0 ? "Delete thread" : "Delete"} - /> -
- )} -
- - - {editingId === root.id ? ( - submitEdit(root.id, b)} - onCancel={() => { - setEditingId(null); - setError(null); - }} - /> - ) : ( - - )} - - {replies.length > 0 && ( -
- {replies.map((reply) => ( - { - setError(null); - setEditingId(reply.id); - }} - onCancelEdit={() => { - setEditingId(null); - setError(null); - }} - onSubmitEdit={(b) => submitEdit(reply.id, b)} - onDelete={() => setDeleteTarget({ kind: "comment", commentId: reply.id })} - /> - ))} -
- )} - - {isReplying && ( - { - setIsReplying(false); - setError(null); - }} - /> - )} -
-
- - setDeleteTarget(null)} - onConfirm={confirmDelete} - /> -
- ); -} - -function ResolveButton({ isResolved, onToggle }: { isResolved: boolean; onToggle: () => void }) { - return ( - - - - - {isResolved ? "Reopen conversation" : "Mark as resolved"} - - ); -} - -function CommentByline({ createdAt }: { createdAt: string }) { - const viewer = useViewer(); - return ( -

- - {viewer.avatarUrl && } - - - - - {viewer.name} - -

- ); -} - -function ReplyItem({ - reply, - idle, - isEditing, - error, - onEdit, - onCancelEdit, - onSubmitEdit, - onDelete, -}: { - reply: Comment; - idle: boolean; - isEditing: boolean; - error: string | null; - onEdit: () => void; - onCancelEdit: () => void; - onSubmitEdit: (body: string) => Promise; - onDelete: () => void; -}) { - return ( -
-
- - {/* Only when the whole thread is idle, so opening this reply's editor can't - discard another in-progress edit or reply (matches the root comment). */} - {idle && } -
- {isEditing ? ( - - ) : ( - - )} -
- ); -} - -function DeleteDialog({ - target, - onCancel, - onConfirm, -}: { - target: DeleteTarget | null; - onCancel: () => void; - onConfirm: () => void; -}) { - const isThreadDelete = target?.kind === "thread" && target.hasReplies; - return ( - { - if (!open) onCancel(); - }} - > - - - {isThreadDelete ? "Delete thread" : "Delete comment"} - - {isThreadDelete - ? "This deletes the whole conversation, including replies. This can't be undone." - : "This deletes the comment. This can't be undone."} - - - - Cancel - - - - - ); -} diff --git a/packages/web/src/components/comments/review-thread.tsx b/packages/web/src/components/comments/review-thread.tsx new file mode 100644 index 0000000..6fb0d0f --- /dev/null +++ b/packages/web/src/components/comments/review-thread.tsx @@ -0,0 +1,468 @@ +import { + COMMENT_STATE, + type ReviewComment, + type ReviewThread, + THREAD_SOURCE, +} from "@stagereview/types/review"; +import { + ChevronRight, + Circle, + CircleCheck, + GitPullRequestArrow, + MessageSquare, + User, +} from "lucide-react"; +import { useState } from "react"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Markdown } from "@/components/ui/markdown"; +import { toast } from "@/components/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { formatTimeAgo } from "@/lib/format"; +import { useReviewContext } from "@/lib/review-context"; +import { GITHUB_REVIEW_STATUS } from "@/lib/use-review"; +import { useViewer } from "@/lib/use-viewer"; +import { cn } from "@/lib/utils"; +import { CommentActions } from "./comment-actions"; +import { CommentForm } from "./comment-form"; + +function errorMessage(err: unknown, fallback: string): string { + return err instanceof Error ? err.message : fallback; +} + +const PENDING_BADGE_CN = + "border-yellow-500/50 bg-yellow-50 text-yellow-800 dark:bg-yellow-950/20 dark:text-yellow-200"; + +// A pending comment is editable (it's the viewer's own draft); a local comment is +// editable too. Submitted comments live on GitHub and are read-only here. +function canActOn(comment: ReviewComment): boolean { + return comment.state !== COMMENT_STATE.SUBMITTED; +} + +function StateBadge({ state }: { state: ReviewComment["state"] }) { + if (state === COMMENT_STATE.PENDING) { + return ( + + Pending + + ); + } + if (state === COMMENT_STATE.LOCAL) { + return ( + + Local + + ); + } + return null; +} + +export function ReviewThreadView({ thread }: { thread: ReviewThread }) { + const review = useReviewContext(); + const isGitHub = thread.source === THREAD_SOURCE.GITHUB; + const githubAvailable = review.github === GITHUB_REVIEW_STATUS.AVAILABLE; + const canPushToReview = review.canPushToReview; + + const [isOpen, setIsOpen] = useState(!thread.isResolved); + const [isReplying, setIsReplying] = useState(false); + const [editingId, setEditingId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [error, setError] = useState(null); + + const root = thread.comments[0]; + if (!root) return null; + const replies = thread.comments.slice(1); + const idle = !isReplying && editingId === null; + + function setOpenError(message: string | null) { + setError(message); + } + + async function handleResolveToggle() { + const next = !thread.isResolved; + const wasOpen = isOpen; + const hasActiveForm = isReplying || editingId !== null || deleteTarget !== null; + if (!next || !hasActiveForm) setIsOpen(!next); + try { + if (isGitHub && thread.threadNodeId) { + await review.resolveGitHub({ threadNodeId: thread.threadNodeId, resolved: next }); + } else { + await review.resolveLocalThread({ threadId: thread.id, resolved: next }); + } + } catch (err) { + setIsOpen(wasOpen); + toastError(err, "Failed to update resolved state"); + } + } + + function handleOpenChange(open: boolean) { + if (!open && (isReplying || editingId !== null || deleteTarget !== null)) return; + setIsOpen(open); + } + + async function submitReply(body: string, startReview: boolean) { + setOpenError(null); + try { + if (isGitHub && thread.threadNodeId) { + // "Start a review" → add the reply to the pending review; otherwise post it now. + await review.replyGitHub({ threadNodeId: thread.threadNodeId, body, pending: startReview }); + } else { + await review.replyLocal({ threadId: thread.id, body }); + } + setIsReplying(false); + } catch (err) { + setOpenError(errorMessage(err, "Failed to add reply")); + throw err; + } + } + + async function submitEdit(comment: ReviewComment, body: string) { + setOpenError(null); + try { + if (comment.state === COMMENT_STATE.LOCAL) { + await review.editLocalComment({ commentId: comment.id, body }); + } else if (comment.nodeId) { + await review.editGitHubComment({ nodeId: comment.nodeId, body }); + } + setEditingId(null); + } catch (err) { + setOpenError(errorMessage(err, "Failed to update comment")); + throw err; + } + } + + async function confirmDelete() { + const comment = deleteTarget; + setDeleteTarget(null); + if (!comment) return; + try { + if (comment.state === COMMENT_STATE.LOCAL) { + // Deleting a local root removes the whole thread; a reply removes just itself. + if (comment.id === root?.id) await review.deleteLocalThread(thread.id); + else await review.deleteLocalComment(comment.id); + } else if (comment.nodeId) { + await review.deleteGitHubComment(comment.nodeId); + } + } catch (err) { + toastError(err, "Failed to delete comment"); + } + } + + async function handleAddToReview() { + try { + await review.addToReview(thread.id); + } catch (err) { + toastError(err, "Failed to add to review"); + } + } + + const rootIsDeletableThread = root.state === COMMENT_STATE.LOCAL && replies.length > 0; + + return ( + +
+
+ + + + + + + {isOpen ? "Collapse thread" : "Expand thread"} + + + + + {idle && ( +
+ {root.state === COMMENT_STATE.LOCAL && canPushToReview && ( + + + + + Add to GitHub review (pending) + + )} + {(!isGitHub || githubAvailable) && ( + + + + + Reply + + )} + {canActOn(root) && ( + { + setIsOpen(true); + setOpenError(null); + setEditingId(root.id); + }} + onDelete={() => setDeleteTarget(root)} + deleteLabel={rootIsDeletableThread ? "Delete thread" : "Delete"} + /> + )} +
+ )} +
+ + + {editingId === root.id ? ( + submitEdit(root, b)} + onCancel={() => { + setEditingId(null); + setOpenError(null); + }} + /> + ) : ( + + )} + + {replies.length > 0 && ( +
+ {replies.map((reply) => ( + { + setOpenError(null); + setEditingId(reply.id); + }} + onCancelEdit={() => { + setEditingId(null); + setOpenError(null); + }} + onSubmitEdit={(b) => submitEdit(reply, b)} + onDelete={() => setDeleteTarget(reply)} + /> + ))} +
+ )} + + {isReplying && ( + { + setIsReplying(false); + setOpenError(null); + }} + /> + )} +
+
+ + setDeleteTarget(null)} + onConfirm={confirmDelete} + /> +
+ ); +} + +function ResolveButton({ isResolved, onToggle }: { isResolved: boolean; onToggle: () => void }) { + return ( + + + + + {isResolved ? "Reopen conversation" : "Mark as resolved"} + + ); +} + +// Local comments (author null) render as the local reviewer; GitHub comments show +// their author. +function Byline({ comment }: { comment: ReviewComment }) { + const viewer = useViewer(); + const name = comment.author?.login ?? viewer.name; + const avatarUrl = comment.author ? comment.author.avatarUrl : viewer.avatarUrl; + return ( +

+ + {avatarUrl && } + + + + + {name} + {comment.htmlUrl ? ( + + + + ) : ( + + )} +

+ ); +} + +// GitHub comments render GitHub's own server-rendered HTML (resolves @mentions, +// #refs, emoji); local comments render their raw markdown. +function CommentBody({ comment }: { comment: ReviewComment }) { + return comment.bodyHtml !== null ? ( + + ) : ( + + ); +} + +function ReplyItem({ + reply, + idle, + isEditing, + error, + onEdit, + onCancelEdit, + onSubmitEdit, + onDelete, +}: { + reply: ReviewComment; + idle: boolean; + isEditing: boolean; + error: string | null; + onEdit: () => void; + onCancelEdit: () => void; + onSubmitEdit: (body: string) => Promise; + onDelete: () => void; +}) { + return ( +
+
+ + + {idle && canActOn(reply) && } +
+ {isEditing ? ( + + ) : ( + + )} +
+ ); +} + +function DeleteDialog({ + target, + isThread, + onCancel, + onConfirm, +}: { + target: ReviewComment | null; + isThread: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( + { + if (!open) onCancel(); + }} + > + + + {isThread ? "Delete thread" : "Delete comment"} + + {isThread + ? "This deletes the whole conversation, including replies. This can't be undone." + : "This deletes the comment. This can't be undone."} + + + + Cancel + + + + + ); +} + +function toastError(err: unknown, fallback: string): void { + toast.error(errorMessage(err, fallback)); +} diff --git a/packages/web/src/components/pull-request/review-panel.tsx b/packages/web/src/components/pull-request/review-panel.tsx new file mode 100644 index 0000000..a7985cd --- /dev/null +++ b/packages/web/src/components/pull-request/review-panel.tsx @@ -0,0 +1,328 @@ +import { + COMMENT_STATE, + REVIEW_EVENT, + type ReviewEvent, + type ReviewThread, +} from "@stagereview/types/review"; +import { ChevronRight, CornerDownLeft, MessageSquarePlus, Trash2 } from "lucide-react"; +import { type KeyboardEvent, useMemo, useRef, useState } from "react"; +import { CommentMarkdownEditor } from "@/components/comments/comment-markdown-editor"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { toast } from "@/components/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useReviewContext } from "@/lib/review-context"; +import { GITHUB_REVIEW_STATUS } from "@/lib/use-review"; +import { cn } from "@/lib/utils"; + +const ACTION_OPTIONS: { event: ReviewEvent; label: string; description: string }[] = [ + { + event: REVIEW_EVENT.COMMENT, + label: "Comment", + description: "Submit general feedback without approval", + }, + { event: REVIEW_EVENT.APPROVE, label: "Approve", description: "Approve this pull request" }, + { + event: REVIEW_EVENT.REQUEST_CHANGES, + label: "Request changes", + description: "Submit feedback that must be addressed", + }, +]; + +interface PendingComment { + id: string; + filePath: string; + line: number; + body: string; +} + +// Flatten the viewer's pending comments, grouped by file, for the "what you're +// about to submit" list. +function collectPendingByFile(threads: ReviewThread[]): Map { + const byFile = new Map(); + for (const thread of threads) { + for (const c of thread.comments) { + if (c.state !== COMMENT_STATE.PENDING) continue; + const list = byFile.get(thread.filePath) ?? []; + if (!byFile.has(thread.filePath)) byFile.set(thread.filePath, list); + list.push({ id: c.id, filePath: thread.filePath, line: thread.endLine, body: c.body }); + } + } + return byFile; +} + +function ActionSelector({ + selected, + onSelect, + disabled, + isOwnPullRequest, +}: { + selected: ReviewEvent; + onSelect: (event: ReviewEvent) => void; + disabled: boolean; + isOwnPullRequest: boolean; +}) { + return ( +
+ {ACTION_OPTIONS.map(({ event, label, description }) => { + const isSelected = selected === event; + // GitHub forbids approving / requesting changes on your own PR. + const blockedByOwnership = isOwnPullRequest && event !== REVIEW_EVENT.COMMENT; + const isDisabled = disabled || blockedByOwnership; + return ( + + ); + })} +
+ ); +} + +function PendingCommentsList({ + byFile, + count, +}: { + byFile: Map; + count: number; +}) { + const [open, setOpen] = useState(false); + if (count === 0) return null; + return ( + + + + Pending comments + + {count} + + + +
+
+ {[...byFile.entries()].map(([path, comments]) => ( +
+

{path}

+
+ {comments.map((c) => ( +
+ + L{c.line} + + {c.body} +
+ ))} +
+
+ ))} +
+
+
+
+ ); +} + +const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); + +/** + * The review tray: submit the viewer's pending GitHub review (Comment / Approve / + * Request changes) or discard it. Only shown when the run targets a reachable PR; + * the badge counts the viewer's draft comments and the list shows what will publish. + */ +export function ReviewPanel() { + const review = useReviewContext(); + const [open, setOpen] = useState(false); + const [body, setBody] = useState(""); + const [selected, setSelected] = useState(REVIEW_EVENT.COMMENT); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showDiscard, setShowDiscard] = useState(false); + const textareaRef = useRef(null); + + const { pendingCommentCount, hasPendingReview, isOwnPullRequest } = review; + const pendingByFile = useMemo(() => collectPendingByFile(review.threads), [review.threads]); + + if (review.github !== GITHUB_REVIEW_STATUS.AVAILABLE) return null; + + const hasContent = body.trim().length > 0; + // On your own PR only "Comment" is allowed; coerce the effective event so a stale + // Approve/Request-changes selection can never be submitted (the radios are disabled, + // but the prior `selected` state would otherwise persist). + const effectiveEvent = + isOwnPullRequest && selected !== REVIEW_EVENT.COMMENT ? REVIEW_EVENT.COMMENT : selected; + // A bare "Comment" submit with neither body nor pending comments is a no-op. + const canSubmit = + !isSubmitting && + (effectiveEvent !== REVIEW_EVENT.COMMENT || hasContent || pendingCommentCount > 0); + + function selectAction(event: ReviewEvent) { + if (isOwnPullRequest && event !== REVIEW_EVENT.COMMENT) return; + setSelected(event); + } + + async function handleSubmit() { + if (!canSubmit) return; + setIsSubmitting(true); + try { + await review.submitReview({ event: effectiveEvent, body: body.trim() }); + setBody(""); + setOpen(false); + toast.success("Review submitted"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to submit review"); + } finally { + setIsSubmitting(false); + } + } + + async function handleDiscard() { + setIsSubmitting(true); + try { + await review.discardReview(); + setShowDiscard(false); + setOpen(false); + toast.success("Pending review discarded"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to discard review"); + } finally { + setIsSubmitting(false); + } + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + void handleSubmit(); + } + } + + return ( + <> + + + + + + + + Submit your review + + +

Finish your review

+

+ {pendingCommentCount > 0 + ? `${pendingCommentCount} pending comment${pendingCommentCount === 1 ? "" : "s"} will be published.` + : "No pending comments yet — add comments to your review from the diff."} +

+ + + +
+ + +
+
+
+ + { + if (!v && !isSubmitting) setShowDiscard(false); + }} + > + + + Discard review + + This deletes all your pending comments on the PR. This can't be undone. + + + + Cancel + + + + + + ); +} diff --git a/packages/web/src/lib/__tests__/comment-drafts.test.ts b/packages/web/src/lib/__tests__/comment-drafts.test.ts index f755893..e903cb7 100644 --- a/packages/web/src/lib/__tests__/comment-drafts.test.ts +++ b/packages/web/src/lib/__tests__/comment-drafts.test.ts @@ -11,18 +11,18 @@ import { upsertDraft, writeDraftBody, } from "../comment-drafts"; -import type { CommentThread } from "../use-comment-threads"; +import type { ReviewThread as CommentThread } from "../use-review"; function makeThread( over: Partial & Pick, ): CommentThread { return { id: `t-${over.side}-${over.endLine}`, + source: "local", + threadNodeId: null, filePath: "a.ts", startLine: over.endLine, - resolvedAt: null, - createdAt: "2026-06-08T00:00:00.000Z", - updatedAt: "2026-06-08T00:00:00.000Z", + isResolved: false, comments: [], ...over, }; diff --git a/packages/web/src/lib/__tests__/comment-threads-context.test.tsx b/packages/web/src/lib/__tests__/comment-threads-context.test.tsx deleted file mode 100644 index 8deb9b0..0000000 --- a/packages/web/src/lib/__tests__/comment-threads-context.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -// @vitest-environment happy-dom - -import { act, render, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { toast } from "@/components/ui/sonner"; -import { CommentThreadsProvider } from "../comment-threads-context"; -import { makeWrapper } from "./fixtures"; - -vi.mock("@/components/ui/sonner", () => ({ toast: { error: vi.fn(), dismiss: vi.fn() } })); - -afterEach(() => { - vi.unstubAllGlobals(); - vi.clearAllMocks(); -}); - -function stubFetch(status: number, body: string): void { - vi.stubGlobal( - "fetch", - vi.fn( - async () => new Response(body, { status, headers: { "Content-Type": "application/json" } }), - ), - ); -} - -describe("CommentThreadsProvider", () => { - it("surfaces a failed threads fetch as a toast so it isn't mistaken for no comments", async () => { - stubFetch(500, "boom"); - const { Wrapper } = makeWrapper(); - - render( - - diff - , - { wrapper: Wrapper }, - ); - - await waitFor(() => - expect(vi.mocked(toast.error)).toHaveBeenCalledWith( - "Couldn't load comments", - expect.objectContaining({ id: "comment-threads-error" }), - ), - ); - }); - - it("does not toast when the fetch succeeds with no comments", async () => { - stubFetch(200, "[]"); - const { Wrapper } = makeWrapper(); - - render( - - diff - , - { wrapper: Wrapper }, - ); - - await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)); - expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); - }); - - it("dismisses the error toast once a later fetch recovers", async () => { - let calls = 0; - vi.stubGlobal( - "fetch", - vi.fn(async () => { - calls += 1; - return calls === 1 - ? new Response("boom", { status: 500 }) - : new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } }); - }), - ); - const { client, Wrapper } = makeWrapper(); - - render( - - diff - , - { wrapper: Wrapper }, - ); - - await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalled()); - // Ignore the no-op dismiss that runs before any error appears. - vi.mocked(toast.dismiss).mockClear(); - - await act(async () => { - await client.refetchQueries(); - }); - - await waitFor(() => - expect(vi.mocked(toast.dismiss)).toHaveBeenCalledWith("comment-threads-error"), - ); - }); -}); diff --git a/packages/web/src/lib/comment-drafts.ts b/packages/web/src/lib/comment-drafts.ts index 210672a..2242c8c 100644 --- a/packages/web/src/lib/comment-drafts.ts +++ b/packages/web/src/lib/comment-drafts.ts @@ -1,6 +1,6 @@ import type { DiffLineAnnotation } from "@pierre/diffs"; import type { DiffSide } from "@/lib/diff-types"; -import type { CommentThread } from "@/lib/use-comment-threads"; +import type { ReviewThread as CommentThread } from "@/lib/use-review"; /** An in-progress comment the reviewer is composing, anchored to a line range. */ export interface CommentDraft { diff --git a/packages/web/src/lib/comment-threads-context.tsx b/packages/web/src/lib/comment-threads-context.tsx deleted file mode 100644 index 42d2862..0000000 --- a/packages/web/src/lib/comment-threads-context.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { createContext, type ReactNode, useContext, useEffect } from "react"; -import { toast } from "@/components/ui/sonner"; -import { type UseCommentThreadsResult, useCommentThreads } from "./use-comment-threads"; - -const CommentThreadsContext = createContext(null); - -const LOAD_ERROR_TOAST_ID = "comment-threads-error"; - -/** - * Provides the run's comment threads + mutations to the diff tree without - * prop-drilling through FileDiffList. Mounted once at the run layout. - */ -export function CommentThreadsProvider({ - runId, - children, -}: { - runId: string; - children: ReactNode; -}) { - const value = useCommentThreads(runId); - - // A failed threads fetch is otherwise indistinguishable from "no comments" — - // the diff still renders, but the overlay is silently empty. Surface it as a - // toast (React Query only sets `error` once its retries are exhausted), and - // dismiss it once a later fetch recovers so a stale message doesn't linger. - useEffect(() => { - if (!value.error) { - toast.dismiss(LOAD_ERROR_TOAST_ID); - return; - } - // Stable id so a re-fire (StrictMode double-mount, remount with a cached error, - // refetch failing with a new error reference) updates one toast instead of stacking. - toast.error("Couldn't load comments", { - id: LOAD_ERROR_TOAST_ID, - description: value.error instanceof Error ? value.error.message : undefined, - }); - }, [value.error]); - - return {children}; -} - -export function useCommentThreadsContext(): UseCommentThreadsResult { - const ctx = useContext(CommentThreadsContext); - if (!ctx) { - throw new Error("useCommentThreadsContext must be used within a CommentThreadsProvider"); - } - return ctx; -} diff --git a/packages/web/src/lib/review-context.tsx b/packages/web/src/lib/review-context.tsx new file mode 100644 index 0000000..8dbdcf8 --- /dev/null +++ b/packages/web/src/lib/review-context.tsx @@ -0,0 +1,37 @@ +import { createContext, type ReactNode, useContext, useEffect } from "react"; +import { toast } from "@/components/ui/sonner"; +import { type UseReviewResult, useReview } from "./use-review"; + +const ReviewContext = createContext(null); + +const LOAD_ERROR_TOAST_ID = "review-error"; + +/** + * Provides the run's merged review (local + GitHub threads) and its mutations to + * the diff tree without prop-drilling. Mounted once at the run layout. + */ +export function ReviewProvider({ runId, children }: { runId: string; children: ReactNode }) { + const value = useReview(runId); + + // A failed review fetch is otherwise indistinguishable from "no comments" — the + // diff still renders but the overlay is silently empty. Surface it as a toast, + // and dismiss it once a later fetch recovers so a stale message doesn't linger. + useEffect(() => { + if (!value.error) { + toast.dismiss(LOAD_ERROR_TOAST_ID); + return; + } + toast.error("Couldn't load review comments", { + id: LOAD_ERROR_TOAST_ID, + description: value.error instanceof Error ? value.error.message : undefined, + }); + }, [value.error]); + + return {children}; +} + +export function useReviewContext(): UseReviewResult { + const ctx = useContext(ReviewContext); + if (!ctx) throw new Error("useReviewContext must be used within a ReviewProvider"); + return ctx; +} diff --git a/packages/web/src/lib/use-comment-threads.ts b/packages/web/src/lib/use-comment-threads.ts deleted file mode 100644 index 972e3f9..0000000 --- a/packages/web/src/lib/use-comment-threads.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { - type Comment, - type CommentThread, - CommentThreadsResponseSchema, - type CreateCommentThreadBody, -} from "@stagereview/types/comments"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useMemo } from "react"; -import { jsonFetch } from "./use-view-state"; - -export type { Comment, CommentThread, CreateCommentThreadBody }; - -const COMMENT_THREADS_ROOT = "comment-threads"; - -export function commentThreadsQueryKey(runId: string): readonly unknown[] { - return [COMMENT_THREADS_ROOT, runId]; -} - -async function fetchCommentThreads(runId: string): Promise { - // Parse at the boundary so server-side schema drift surfaces as a query error - // here, not as a render crash deeper in the diff. - const raw = await jsonFetch(`/api/runs/${encodeURIComponent(runId)}/comment-threads`); - return CommentThreadsResponseSchema.parse(raw); -} - -const jsonRequest = (method: string, body?: unknown): RequestInit => ({ - method, - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), -}); - -export interface UseCommentThreadsResult { - threads: CommentThread[]; - /** Stable reference; rebuilt only when the underlying query data changes. */ - threadsByFile: ReadonlyMap; - isLoading: boolean; - error: unknown; - createThread: (input: CreateCommentThreadBody) => Promise; - replyToThread: (input: { threadId: string; body: string }) => Promise; - setThreadResolved: (input: { threadId: string; resolved: boolean }) => Promise; - editComment: (input: { commentId: string; body: string }) => Promise; - deleteThread: (threadId: string) => Promise; - deleteComment: (commentId: string) => Promise; -} - -/** - * Loads the comment threads anchored to a run's diff scope and exposes the - * thread/comment mutations. The local server commits synchronously, so each - * mutation simply invalidates the query rather than maintaining optimistic - * caches — the refetch round-trip is effectively instant. - */ -export function useCommentThreads(runId: string): UseCommentThreadsResult { - const queryClient = useQueryClient(); - const queryKey = useMemo(() => commentThreadsQueryKey(runId), [runId]); - - const { data, isLoading, error } = useQuery({ - queryKey, - queryFn: () => fetchCommentThreads(runId), - enabled: runId !== "", - }); - - const threads = useMemo(() => data ?? [], [data]); - const threadsByFile = useMemo(() => groupByFile(threads), [threads]); - - const invalidate = () => queryClient.invalidateQueries({ queryKey }); - - const createMutation = useMutation({ - mutationFn: (input: CreateCommentThreadBody) => - jsonFetch( - `/api/runs/${encodeURIComponent(runId)}/comment-threads`, - jsonRequest("POST", input), - ), - onSuccess: invalidate, - }); - - const replyMutation = useMutation({ - mutationFn: async ({ threadId, body }: { threadId: string; body: string }) => { - await jsonFetch( - `/api/comment-threads/${encodeURIComponent(threadId)}/replies`, - jsonRequest("POST", { body }), - ); - }, - onSuccess: invalidate, - }); - - const resolveMutation = useMutation({ - mutationFn: async ({ threadId, resolved }: { threadId: string; resolved: boolean }) => { - await jsonFetch( - `/api/comment-threads/${encodeURIComponent(threadId)}`, - jsonRequest("PATCH", { resolved }), - ); - }, - onSuccess: invalidate, - }); - - const editMutation = useMutation({ - mutationFn: async ({ commentId, body }: { commentId: string; body: string }) => { - await jsonFetch( - `/api/comments/${encodeURIComponent(commentId)}`, - jsonRequest("PATCH", { body }), - ); - }, - onSuccess: invalidate, - }); - - const deleteThreadMutation = useMutation({ - mutationFn: async (threadId: string) => { - await jsonFetch( - `/api/comment-threads/${encodeURIComponent(threadId)}`, - jsonRequest("DELETE"), - ); - }, - onSuccess: invalidate, - }); - - const deleteCommentMutation = useMutation({ - mutationFn: async (commentId: string) => { - await jsonFetch(`/api/comments/${encodeURIComponent(commentId)}`, jsonRequest("DELETE")); - }, - onSuccess: invalidate, - }); - - return useMemo( - () => ({ - threads, - threadsByFile, - isLoading, - error, - createThread: createMutation.mutateAsync, - replyToThread: replyMutation.mutateAsync, - setThreadResolved: resolveMutation.mutateAsync, - editComment: editMutation.mutateAsync, - deleteThread: deleteThreadMutation.mutateAsync, - deleteComment: deleteCommentMutation.mutateAsync, - }), - [ - threads, - threadsByFile, - isLoading, - error, - createMutation.mutateAsync, - replyMutation.mutateAsync, - resolveMutation.mutateAsync, - editMutation.mutateAsync, - deleteThreadMutation.mutateAsync, - deleteCommentMutation.mutateAsync, - ], - ); -} - -function groupByFile(threads: CommentThread[]): ReadonlyMap { - const map = new Map(); - for (const thread of threads) { - const list = map.get(thread.filePath); - if (list) list.push(thread); - else map.set(thread.filePath, [thread]); - } - return map; -} diff --git a/packages/web/src/lib/use-review.ts b/packages/web/src/lib/use-review.ts new file mode 100644 index 0000000..84920bb --- /dev/null +++ b/packages/web/src/lib/use-review.ts @@ -0,0 +1,206 @@ +import type { CreateCommentThreadBody } from "@stagereview/types/comments"; +import { + GITHUB_REVIEW_STATUS, + type GitHubReviewStatus, + type ReviewEvent, + type ReviewResponse, + ReviewResponseSchema, + type ReviewThread, +} from "@stagereview/types/review"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { jsonFetch } from "./use-view-state"; + +export type { CreateCommentThreadBody, GitHubReviewStatus, ReviewEvent, ReviewThread }; +export { GITHUB_REVIEW_STATUS }; + +const REVIEW_ROOT = "review"; + +export function reviewQueryKey(runId: string): readonly unknown[] { + return [REVIEW_ROOT, runId]; +} + +async function fetchReview(runId: string): Promise { + const raw = await jsonFetch(`/api/runs/${encodeURIComponent(runId)}/review`); + return ReviewResponseSchema.parse(raw); +} + +const jsonRequest = (method: string, body?: unknown): RequestInit => ({ + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +export interface UseReviewResult { + threads: ReviewThread[]; + threadsByFile: ReadonlyMap; + github: GitHubReviewStatus; + pendingCommentCount: number; + hasPendingReview: boolean; + isOwnPullRequest: boolean; + canPushToReview: boolean; + isLoading: boolean; + error: unknown; + // Local comments (CLI-only, work offline). + createLocalThread: (input: CreateCommentThreadBody) => Promise; + // Create a comment directly on the PR as a pending review comment. + createPendingComment: (input: CreateCommentThreadBody) => Promise; + replyLocal: (input: { threadId: string; body: string }) => Promise; + editLocalComment: (input: { commentId: string; body: string }) => Promise; + deleteLocalThread: (threadId: string) => Promise; + deleteLocalComment: (commentId: string) => Promise; + resolveLocalThread: (input: { threadId: string; resolved: boolean }) => Promise; + // GitHub review actions. + addToReview: (localThreadId: string) => Promise; + submitReview: (input: { event: ReviewEvent; body: string }) => Promise; + discardReview: () => Promise; + replyGitHub: (input: { threadNodeId: string; body: string; pending: boolean }) => Promise; + editGitHubComment: (input: { nodeId: string; body: string }) => Promise; + deleteGitHubComment: (nodeId: string) => Promise; + resolveGitHub: (input: { threadNodeId: string; resolved: boolean }) => Promise; +} + +/** + * The run's merged review — local threads plus the PR's live pending/submitted + * GitHub threads — and the mutations that act on each. Every mutation invalidates + * the review query so the merged view refetches (the local server commits + * synchronously and GitHub round-trips are quick), keeping local and GitHub in step. + */ +export function useReview(runId: string): UseReviewResult { + const queryClient = useQueryClient(); + const queryKey = useMemo(() => reviewQueryKey(runId), [runId]); + + const { data, isLoading, error } = useQuery({ + queryKey, + queryFn: () => fetchReview(runId), + enabled: runId !== "", + }); + + const threads = useMemo(() => data?.threads ?? [], [data]); + const threadsByFile = useMemo(() => groupByFile(threads), [threads]); + const invalidate = () => queryClient.invalidateQueries({ queryKey }); + // GitHub-affecting actions (submit/resolve/reply/promote) change PR-level state — + // reviewer decisions, the merge button — that lives behind separate, infinitely- + // stale query keys. Refresh those too so the PR header doesn't go stale until reload. + const invalidateGitHub = () => { + invalidate(); + queryClient.invalidateQueries({ queryKey: ["pull-request-reviews", runId] }); + queryClient.invalidateQueries({ queryKey: ["pull-request-merge-status", runId] }); + }; + + const runPath = (suffix: string) => `/api/runs/${encodeURIComponent(runId)}${suffix}`; + + const m = { + createLocalThread: useMutation({ + mutationFn: (input: CreateCommentThreadBody) => + jsonFetch(runPath("/comment-threads"), jsonRequest("POST", input)), + onSuccess: invalidate, + }), + createPendingComment: useMutation({ + mutationFn: (input: CreateCommentThreadBody) => + jsonFetch(runPath("/review/comment"), jsonRequest("POST", input)), + onSuccess: invalidateGitHub, + }), + replyLocal: useMutation({ + mutationFn: ({ threadId, body }: { threadId: string; body: string }) => + jsonFetch( + `/api/comment-threads/${encodeURIComponent(threadId)}/replies`, + jsonRequest("POST", { body }), + ), + onSuccess: invalidate, + }), + editLocalComment: useMutation({ + mutationFn: ({ commentId, body }: { commentId: string; body: string }) => + jsonFetch(`/api/comments/${encodeURIComponent(commentId)}`, jsonRequest("PATCH", { body })), + onSuccess: invalidate, + }), + deleteLocalThread: useMutation({ + mutationFn: (threadId: string) => + jsonFetch(`/api/comment-threads/${encodeURIComponent(threadId)}`, jsonRequest("DELETE")), + onSuccess: invalidate, + }), + deleteLocalComment: useMutation({ + mutationFn: (commentId: string) => + jsonFetch(`/api/comments/${encodeURIComponent(commentId)}`, jsonRequest("DELETE")), + onSuccess: invalidate, + }), + resolveLocalThread: useMutation({ + mutationFn: ({ threadId, resolved }: { threadId: string; resolved: boolean }) => + jsonFetch( + `/api/comment-threads/${encodeURIComponent(threadId)}`, + jsonRequest("PATCH", { resolved }), + ), + onSuccess: invalidate, + }), + addToReview: useMutation({ + mutationFn: (localThreadId: string) => + jsonFetch(runPath("/review/add"), jsonRequest("POST", { localThreadId })), + onSuccess: invalidateGitHub, + }), + submitReview: useMutation({ + mutationFn: (input: { event: ReviewEvent; body: string }) => + jsonFetch(runPath("/review/submit"), jsonRequest("POST", input)), + onSuccess: invalidateGitHub, + }), + discardReview: useMutation({ + mutationFn: () => jsonFetch(runPath("/review/discard"), jsonRequest("POST")), + onSuccess: invalidateGitHub, + }), + replyGitHub: useMutation({ + mutationFn: (input: { threadNodeId: string; body: string; pending: boolean }) => + jsonFetch(runPath("/review/reply"), jsonRequest("POST", input)), + onSuccess: invalidateGitHub, + }), + editGitHubComment: useMutation({ + mutationFn: (input: { nodeId: string; body: string }) => + jsonFetch(runPath("/review/comment/edit"), jsonRequest("POST", input)), + onSuccess: invalidateGitHub, + }), + deleteGitHubComment: useMutation({ + mutationFn: (nodeId: string) => + jsonFetch(runPath("/review/comment/delete"), jsonRequest("POST", { nodeId })), + onSuccess: invalidateGitHub, + }), + resolveGitHub: useMutation({ + mutationFn: (input: { threadNodeId: string; resolved: boolean }) => + jsonFetch(runPath("/review/resolve"), jsonRequest("POST", input)), + onSuccess: invalidateGitHub, + }), + }; + + return { + threads, + threadsByFile, + github: data?.github ?? GITHUB_REVIEW_STATUS.NONE, + pendingCommentCount: data?.pendingCommentCount ?? 0, + hasPendingReview: data?.hasPendingReview ?? false, + isOwnPullRequest: data?.isOwnPullRequest ?? false, + canPushToReview: data?.canPushToReview ?? false, + isLoading, + error, + createLocalThread: m.createLocalThread.mutateAsync, + createPendingComment: async (i) => void (await m.createPendingComment.mutateAsync(i)), + replyLocal: async (i) => void (await m.replyLocal.mutateAsync(i)), + editLocalComment: async (i) => void (await m.editLocalComment.mutateAsync(i)), + deleteLocalThread: async (id) => void (await m.deleteLocalThread.mutateAsync(id)), + deleteLocalComment: async (id) => void (await m.deleteLocalComment.mutateAsync(id)), + resolveLocalThread: async (i) => void (await m.resolveLocalThread.mutateAsync(i)), + addToReview: async (id) => void (await m.addToReview.mutateAsync(id)), + submitReview: async (i) => void (await m.submitReview.mutateAsync(i)), + discardReview: async () => void (await m.discardReview.mutateAsync()), + replyGitHub: async (i) => void (await m.replyGitHub.mutateAsync(i)), + editGitHubComment: async (i) => void (await m.editGitHubComment.mutateAsync(i)), + deleteGitHubComment: async (id) => void (await m.deleteGitHubComment.mutateAsync(id)), + resolveGitHub: async (i) => void (await m.resolveGitHub.mutateAsync(i)), + }; +} + +function groupByFile(threads: ReviewThread[]): ReadonlyMap { + const map = new Map(); + for (const thread of threads) { + const list = map.get(thread.filePath); + if (list) list.push(thread); + else map.set(thread.filePath, [thread]); + } + return map; +} diff --git a/packages/web/src/lib/use-view-state.ts b/packages/web/src/lib/use-view-state.ts index fa4e402..7cdab78 100644 --- a/packages/web/src/lib/use-view-state.ts +++ b/packages/web/src/lib/use-view-state.ts @@ -26,15 +26,33 @@ export function viewStateQueryKey(runId: string): readonly unknown[] { export async function jsonFetch(url: string, init?: RequestInit): Promise { const res = await fetch(url, init); + // POST/DELETE handlers (and error responses) can return an empty body — read as + // text first so JSON.parse doesn't throw SyntaxError on `""`. + const text = await res.text(); if (!res.ok) { - throw new Error(`${init?.method ?? "GET"} ${url} failed: ${res.status}`); + // Surface the server's `{ error }` message verbatim (the review/write paths + // carry actionable reasons), but tolerate a non-JSON error body (e.g. an HTML + // proxy error) — fall back to the status code rather than throwing SyntaxError. + throw new Error( + errorBodyMessage(text) ?? `${init?.method ?? "GET"} ${url} failed: ${res.status}`, + ); } - // POST/DELETE handlers can return an empty body — read as text first so - // JSON.parse doesn't throw SyntaxError on `""`. - const text = await res.text(); return (text ? JSON.parse(text) : {}) as T; } +function errorBodyMessage(text: string): string | null { + if (!text) return null; + try { + const parsed: unknown = JSON.parse(text); + if (typeof parsed === "object" && parsed !== null && "error" in parsed) { + return String((parsed as { error: unknown }).error); + } + } catch { + // Non-JSON error body — let the caller fall back to the status code. + } + return null; +} + async function fetchViewState(runId: string): Promise { // Parse at the boundary so a server-side schema drift surfaces as a query // error here, not as a render crash deeper in the component tree. diff --git a/packages/web/src/routes/pull-request-layout.tsx b/packages/web/src/routes/pull-request-layout.tsx index 5b3fad2..37b4697 100644 --- a/packages/web/src/routes/pull-request-layout.tsx +++ b/packages/web/src/routes/pull-request-layout.tsx @@ -4,6 +4,7 @@ import { type CSSProperties, useCallback, useMemo, useRef, useState } from "reac 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 { ReviewPanel } from "@/components/pull-request/review-panel"; import { SectionLabel } from "@/components/pull-request/section-label"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -279,6 +280,7 @@ export function PullRequestLayout({ runId }: { runId: string }) {
+