diff --git a/packages/cli/src/__tests__/pull-request-mutations.routes.test.ts b/packages/cli/src/__tests__/pull-request-mutations.routes.test.ts new file mode 100644 index 0000000..2d11692 --- /dev/null +++ b/packages/cli/src/__tests__/pull-request-mutations.routes.test.ts @@ -0,0 +1,289 @@ +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeDb, getDb } from "../db/client.js"; +import { chapterRun } from "../db/schema/index.js"; +import { pullRequestMutationRoutes } from "../routes/pull-request-mutations.js"; +import { SCOPE_KIND } from "../schema.js"; +import { LOOPBACK_HOST, type ServerHandle, startServer } from "../server.js"; + +let tmpDir: string; +let dbPath: string; +let webDist: string; +let repoRoot: string; +let binDir: string; +let logFile: string; +let originalPath: string | undefined; +const handles: ServerHandle[] = []; + +const SHA = "a".repeat(40); +const GITHUB_ORIGIN = "git@github.com:owner/repo.git"; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "stage-cli-pr-mut-")); + dbPath = path.join(tmpDir, "db.sqlite"); + webDist = path.join(tmpDir, "web-dist"); + repoRoot = path.join(tmpDir, "repo"); + binDir = path.join(tmpDir, "bin"); + logFile = path.join(tmpDir, "gh-args.log"); + await fs.mkdir(webDist); + await fs.writeFile(path.join(webDist, "index.html"), ""); + await fs.mkdir(repoRoot); + await fs.mkdir(binDir); + // Fake gh that records its argv (one line per invocation) and succeeds. + await fs.writeFile(path.join(binDir, "gh"), `#!/bin/sh\necho "$@" >> "${logFile}"\n`); + await fs.chmod(path.join(binDir, "gh"), 0o755); + 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(): string { + const db = getDb({ dbPath }); + const [row] = db + .insert(chapterRun) + .values({ + repoRoot, + originUrl: GITHUB_ORIGIN, + scopeKind: SCOPE_KIND.COMMITTED, + workingTreeRef: null, + baseSha: SHA, + headSha: SHA, + mergeBaseSha: SHA, + generatedAt: new Date(), + }) + .returning({ id: chapterRun.id }) + .all(); + if (!row) throw new Error("seed: chapter_run insert returned no row"); + return row.id; +} + +async function start(): Promise { + const db = getDb({ dbPath }); + const handle = await startServer({ webDistPath: webDist, routes: pullRequestMutationRoutes(db) }); + handles.push(handle); + return handle.port; +} + +function send( + port: number, + method: string, + p: string, + body: unknown, + extraHeaders: Record = {}, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body); + const req = http.request( + { + hostname: LOOPBACK_HOST, + port, + method, + path: p, + agent: false, + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + ...extraHeaders, + }, + }, + (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); + }); +} + +async function ghArgs(): Promise { + try { + return (await fs.readFile(logFile, "utf8")).trim().split("\n").filter(Boolean); + } catch { + return []; + } +} + +describe("pull-request mutation API", () => { + it("invokes `gh pr edit --title` for a title change", async () => { + const runId = insertRun(); + const res = await send(await start(), "PATCH", `/api/runs/${runId}/pull-request/title`, { + number: 7, + title: "New title", + }); + expect(res.status).toBe(200); + expect(await ghArgs()).toEqual(["pr edit 7 --title New title"]); + }); + + it("invokes `gh pr merge --squash --match-head-commit` for a squash merge", async () => { + const runId = insertRun(); + const res = await send(await start(), "POST", `/api/runs/${runId}/pull-request/merge`, { + number: 7, + mergeMethod: "SQUASH", + expectedHeadOid: SHA, + }); + expect(res.status).toBe(200); + expect(await ghArgs()).toEqual([`pr merge 7 --squash --match-head-commit ${SHA}`]); + }); + + it("maps auto-merge enable/disable onto gh's --auto/--disable-auto", async () => { + const runId = insertRun(); + const port = await start(); + await send(port, "POST", `/api/runs/${runId}/pull-request/auto-merge`, { + number: 7, + enabled: true, + mergeMethod: "MERGE", + }); + await send(port, "POST", `/api/runs/${runId}/pull-request/auto-merge`, { + number: 7, + enabled: false, + }); + expect(await ghArgs()).toEqual(["pr merge 7 --auto --merge", "pr merge 7 --disable-auto"]); + }); + + it("forwards expectedHeadOid to gh as --match-head-commit when enabling auto-merge", async () => { + const runId = insertRun(); + const res = await send(await start(), "POST", `/api/runs/${runId}/pull-request/auto-merge`, { + number: 7, + enabled: true, + mergeMethod: "SQUASH", + expectedHeadOid: SHA, + }); + expect(res.status).toBe(200); + expect(await ghArgs()).toEqual([`pr merge 7 --auto --squash --match-head-commit ${SHA}`]); + }); + + it("adds and removes reviewers via gh pr edit", async () => { + const runId = insertRun(); + const port = await start(); + await send(port, "POST", `/api/runs/${runId}/pull-request/reviewers`, { + number: 7, + reviewers: ["alice", "bob"], + }); + await send(port, "DELETE", `/api/runs/${runId}/pull-request/reviewers`, { + number: 7, + reviewers: ["bob"], + }); + expect(await ghArgs()).toEqual([ + "pr edit 7 --add-reviewer alice,bob", + "pr edit 7 --remove-reviewer bob", + ]); + }); + + it("rejects a cross-origin mutation (CSRF guard) without invoking gh", async () => { + const runId = insertRun(); + const port = await start(); + const res = await send( + port, + "POST", + `/api/runs/${runId}/pull-request/close`, + { number: 7 }, + { + Origin: "https://evil.example", + }, + ); + expect(res.status).toBe(403); + expect(await ghArgs()).toEqual([]); + }); + + it("rejects another local origin on a different port (CSRF guard)", async () => { + const runId = insertRun(); + const port = await start(); + const res = await send( + port, + "POST", + `/api/runs/${runId}/pull-request/close`, + { number: 7 }, + { + Origin: "http://localhost:3000", + }, + ); + expect(res.status).toBe(403); + expect(await ghArgs()).toEqual([]); + }); + + it("rejects a DNS-rebinding mutation (matching Origin + non-loopback Host)", async () => { + const runId = insertRun(); + const port = await start(); + // DNS rebinding: the attacker's page is served from attacker.example:PORT, + // rebound to 127.0.0.1, so the browser sends that hostname in BOTH Origin and + // Host — a bare Origin===Host check would pass. The loopback-Host guard rejects. + const res = await send( + port, + "POST", + `/api/runs/${runId}/pull-request/close`, + { number: 7 }, + { + Origin: `http://attacker.example:${port}`, + Host: `attacker.example:${port}`, + }, + ); + expect(res.status).toBe(403); + expect(await ghArgs()).toEqual([]); + }); + + it("allows a same-origin mutation (Origin host matches the server)", async () => { + const runId = insertRun(); + const port = await start(); + const res = await send( + port, + "POST", + `/api/runs/${runId}/pull-request/close`, + { number: 7 }, + { + Origin: `http://${LOOPBACK_HOST}:${port}`, + }, + ); + expect(res.status).toBe(200); + expect(await ghArgs()).toEqual(["pr close 7"]); + }); + + it("rejects an invalid body", async () => { + const runId = insertRun(); + const res = await send(await start(), "POST", `/api/runs/${runId}/pull-request/draft`, { + number: -1, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 (not 500) for a malformed JSON body", async () => { + const runId = insertRun(); + const port = await start(); + const res = await new Promise<{ status: number }>((resolve, reject) => { + const req = http.request( + { + hostname: LOOPBACK_HOST, + port, + method: "POST", + path: `/api/runs/${runId}/pull-request/close`, + agent: false, + headers: { "Content-Type": "application/json" }, + }, + (r) => { + r.on("data", () => {}); + r.on("end", () => resolve({ status: r.statusCode ?? 0 })); + }, + ); + req.on("error", reject); + req.end("{ not valid json"); + }); + expect(res.status).toBe(400); + expect(await ghArgs()).toEqual([]); + }); +}); diff --git a/packages/cli/src/__tests__/pull-request.routes.test.ts b/packages/cli/src/__tests__/pull-request.routes.test.ts index d8a5900..92afe84 100644 --- a/packages/cli/src/__tests__/pull-request.routes.test.ts +++ b/packages/cli/src/__tests__/pull-request.routes.test.ts @@ -110,6 +110,37 @@ const MERGE_JSON = JSON.stringify({ }, }, }); +// Deployments GraphQL response (newest-first). Exercises dedupe-by-environment, +// skipping non-success and non-https/null URLs. +const DEPLOYMENTS_JSON = JSON.stringify({ + data: { + repository: { + object: { + deployments: { + nodes: [ + { + environment: "Preview", + latestStatus: { state: "SUCCESS", environmentUrl: "https://preview-2.example.app" }, + }, + { + environment: "Preview", + latestStatus: { state: "SUCCESS", environmentUrl: "https://preview-1.example.app" }, + }, + { + environment: "Production", + latestStatus: { state: "SUCCESS", environmentUrl: "https://prod.example.app" }, + }, + { + environment: "Staging", + latestStatus: { state: "FAILURE", environmentUrl: "https://staging.example.app" }, + }, + { environment: "NoUrl", latestStatus: { state: "SUCCESS", environmentUrl: null } }, + ], + }, + }, + }, + }, +}); beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "stage-cli-pr-routes-")); @@ -143,6 +174,7 @@ async function writeFakeGh(fixtures: { reviews?: string; checks?: string; merge?: string; + deployments?: string; }): Promise { const dir = path.join(binDir, "fixtures"); await fs.mkdir(dir, { recursive: true }); @@ -155,13 +187,15 @@ async function writeFakeGh(fixtures: { write("reviews.json", fixtures.reviews), write("checks.json", fixtures.checks), write("merge.json", fixtures.merge), + write("deployments.json", fixtures.deployments), ]); const script = `#!/bin/sh dir="${dir}" emit() { [ -f "$dir/$1" ] && cat "$dir/$1" || exit 1; } all="$*" if [ "$1" = "pr" ] && [ "$2" = "view" ]; then emit pr.json -elif [ "$1" = "api" ] && [ "$2" = "graphql" ]; then emit merge.json +elif [ "$1" = "api" ] && [ "$2" = "graphql" ]; then + case "$all" in *deployments*) emit deployments.json ;; *) emit merge.json ;; esac elif [ "$1" = "api" ]; then case "$all" in *check-runs*) emit checks.json ;; @@ -288,6 +322,23 @@ describe("pull-request API", () => { }); }); + it("returns one deployment link per environment (latest success, https only)", async () => { + await writeFakeGh({ checks: CHECKS_JSON, deployments: DEPLOYMENTS_JSON }); + const runId = insertRun(GITHUB_ORIGIN); + const res = await request( + await start(), + `/api/runs/${runId}/pull-request/checks?headSha=${SHA}`, + ); + expect(res.status).toBe(200); + const body = JSON.parse(res.body) as ChecksResponse; + // Preview deduped to the newest success; Production kept; Staging (failure) + // and NoUrl (null url) dropped. + expect(body.deploymentLinks).toEqual([ + { environment: "Preview", url: "https://preview-2.example.app" }, + { environment: "Production", url: "https://prod.example.app" }, + ]); + }); + it("rejects a checks request without a valid headSha", async () => { await writeFakeGh({ checks: CHECKS_JSON }); const runId = insertRun(GITHUB_ORIGIN); diff --git a/packages/cli/src/github/index.ts b/packages/cli/src/github/index.ts index 638250f..c065068 100644 --- a/packages/cli/src/github/index.ts +++ b/packages/cli/src/github/index.ts @@ -1,2 +1,13 @@ +export { + addReviewers, + closePullRequest, + editTitle, + listCollaborators, + mergePullRequest, + removeReviewers, + reopenPullRequest, + setAutoMerge, + setDraft, +} from "./mutations.js"; export { getChecks, getMergeStatus, getPullRequest, getReviews } from "./pull-request.js"; export { type GitHubRepo, isGitHubRemote, parseGitHubRepo } from "./repo.js"; diff --git a/packages/cli/src/github/mutations.ts b/packages/cli/src/github/mutations.ts new file mode 100644 index 0000000..31fb6dd --- /dev/null +++ b/packages/cli/src/github/mutations.ts @@ -0,0 +1,139 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + PULL_REQUEST_MERGE_METHOD, + type PullRequestMergeMethod, +} from "@stagereview/types/pull-request"; +import type { GitHubRepo } from "./repo.js"; + +const execFileAsync = promisify(execFile); + +/** + * Run a `gh` write command in `repoRoot`. Unlike the read adapters in + * pull-request.ts (which swallow errors to null), writes surface failures so + * the UI can toast them — the user explicitly asked to mutate their PR. + */ +async function ghWrite(args: string[], repoRoot: string): Promise { + try { + await execFileAsync("gh", args, { cwd: repoRoot, encoding: "utf8" }); + } catch (err: unknown) { + const stderr = + typeof err === "object" && err !== null && "stderr" in err && typeof err.stderr === "string" + ? err.stderr.trim() + : ""; + throw new Error(stderr || (err instanceof Error ? err.message : "gh command failed")); + } +} + +const MERGE_METHOD_FLAG: Record = { + [PULL_REQUEST_MERGE_METHOD.MERGE]: "--merge", + [PULL_REQUEST_MERGE_METHOD.SQUASH]: "--squash", + [PULL_REQUEST_MERGE_METHOD.REBASE]: "--rebase", +}; + +export function editTitle(repoRoot: string, number: number, title: string): Promise { + return ghWrite(["pr", "edit", String(number), "--title", title], repoRoot); +} + +export function closePullRequest(repoRoot: string, number: number): Promise { + return ghWrite(["pr", "close", String(number)], repoRoot); +} + +export function reopenPullRequest(repoRoot: string, number: number): Promise { + return ghWrite(["pr", "reopen", String(number)], repoRoot); +} + +export function setDraft(repoRoot: string, number: number, draft: boolean): Promise { + // `gh pr ready` marks ready; `--undo` converts back to draft. + const args = ["pr", "ready", String(number)]; + if (draft) args.push("--undo"); + return ghWrite(args, repoRoot); +} + +export function mergePullRequest( + repoRoot: string, + number: number, + mergeMethod: PullRequestMergeMethod, + expectedHeadOid?: string, +): Promise { + const args = ["pr", "merge", String(number), MERGE_METHOD_FLAG[mergeMethod]]; + if (expectedHeadOid) args.push("--match-head-commit", expectedHeadOid); + return ghWrite(args, repoRoot); +} + +/** + * Enable/disable auto-merge. On merge-queue repos `gh pr merge --auto` enqueues + * when ready, so the UI's enqueue/dequeue toggles map onto this too. + */ +export function setAutoMerge( + repoRoot: string, + number: number, + enabled: boolean, + mergeMethod?: PullRequestMergeMethod, + expectedHeadOid?: string, +): Promise { + if (!enabled) return ghWrite(["pr", "merge", String(number), "--disable-auto"], repoRoot); + const args = ["pr", "merge", String(number), "--auto"]; + if (mergeMethod) args.push(MERGE_METHOD_FLAG[mergeMethod]); + // Guard against enabling auto-merge for a stale head the user hasn't seen. + if (expectedHeadOid) args.push("--match-head-commit", expectedHeadOid); + return ghWrite(args, repoRoot); +} + +export function addReviewers(repoRoot: string, number: number, logins: string[]): Promise { + return ghWrite(["pr", "edit", String(number), "--add-reviewer", logins.join(",")], repoRoot); +} + +export function removeReviewers(repoRoot: string, number: number, logins: string[]): Promise { + return ghWrite(["pr", "edit", String(number), "--remove-reviewer", logins.join(",")], repoRoot); +} + +const COLLABORATOR_FIELDS = "login,type,avatar_url"; + +interface Collaborator { + login: string; + avatar_url: string; + type: string; +} + +/** Repo collaborators eligible as reviewers, for the reviewer picker. */ +export async function listCollaborators( + repoRoot: string, + repo: GitHubRepo, +): Promise { + try { + const { stdout } = await execFileAsync( + "gh", + [ + "api", + `repos/${repo.owner}/${repo.repo}/collaborators`, + "--paginate", + "--jq", + `[.[] | {${COLLABORATOR_FIELDS}}]`, + ], + { cwd: repoRoot, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + ); + // --paginate with --jq emits one JSON array per page; concat them. + const collaborators: Collaborator[] = []; + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const page: unknown = JSON.parse(trimmed); + if (Array.isArray(page)) { + for (const c of page) { + if ( + c && + typeof c.login === "string" && + typeof c.avatar_url === "string" && + typeof c.type === "string" + ) { + collaborators.push({ login: c.login, avatar_url: c.avatar_url, type: c.type }); + } + } + } + } + return collaborators; + } catch { + return []; + } +} diff --git a/packages/cli/src/github/pull-request.ts b/packages/cli/src/github/pull-request.ts index b5914b6..bb60dea 100644 --- a/packages/cli/src/github/pull-request.ts +++ b/packages/cli/src/github/pull-request.ts @@ -3,6 +3,7 @@ import { CHECK_ITEM_SOURCE, type CheckItem, type ChecksResponse, + type DeploymentLink, type GitHubPullRequest, type GitHubUser, type MergeStatusInfo, @@ -206,20 +207,11 @@ function deriveCiState(items: CheckItem[]): ChecksResponse["state"] { return anyPending ? PULL_REQUEST_CI_STATUS.PENDING : PULL_REQUEST_CI_STATUS.SUCCESS; } -/** - * CI check runs for `headSha`. Deployment links require a GitHub App - * integration the CLI doesn't have, so `deploymentLinks` is always empty here. - */ -export async function getChecks( +async function getCheckRunItems( repoRoot: string, repo: GitHubRepo, headSha: string, -): Promise { - const empty: ChecksResponse = { - state: PULL_REQUEST_CI_STATUS.NONE, - items: [], - deploymentLinks: [], - }; +): Promise { try { // `--slurp` wraps every page into one JSON array (`[{page}, {page}, …]`); // without it, `--paginate` concatenates raw page objects, which isn't valid @@ -234,14 +226,116 @@ export async function getChecks( repoRoot, ); const parsed = z.array(GhCheckRunsSchema).safeParse(JSON.parse(stdout)); - if (!parsed.success) return empty; - const items = parsed.data.flatMap((page) => page.check_runs).map(toCheckItem); - return { state: deriveCiState(items), items, deploymentLinks: [] }; + if (!parsed.success) return []; + return parsed.data.flatMap((page) => page.check_runs).map(toCheckItem); } catch { - return empty; + return []; } } +// ─── Deployments ────────────────────────────────────────────────────────────── + +// Fetch the commit's deployments + each one's latest status in a single query, +// newest-first, so dedupe-by-environment below keeps the most recent per env. +const DEPLOYMENTS_QUERY = `query GetDeployments($owner: String!, $repo: String!, $sha: GitObjectID!) { + repository(owner: $owner, name: $repo) { + object(oid: $sha) { + ... on Commit { + deployments(first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { environment latestStatus { state environmentUrl } } + } + } + } + } +}`; + +const GhDeploymentsSchema = z.object({ + data: z.object({ + repository: z + .object({ + object: z + .object({ + deployments: z.object({ + nodes: z.array( + z.object({ + environment: z.string(), + latestStatus: z + .object({ state: z.string(), environmentUrl: z.string().nullable() }) + .nullable(), + }), + ), + }), + }) + .nullable(), + }) + .nullable(), + }), +}); + +/** + * Preview/deployment links for `headSha`: one per environment, keeping the + * latest successful deployment with an https URL (mirrors hosted Stage's + * resolveDeploymentLinks). Returns [] on any failure so checks still render. + */ +async function getDeploymentLinks( + repoRoot: string, + repo: GitHubRepo, + headSha: string, +): Promise { + try { + const stdout = await gh( + [ + "api", + "graphql", + "-f", + `query=${DEPLOYMENTS_QUERY}`, + "-F", + `owner=${repo.owner}`, + "-F", + `repo=${repo.repo}`, + "-F", + `sha=${headSha}`, + ], + repoRoot, + ); + const parsed = GhDeploymentsSchema.safeParse(JSON.parse(stdout)); + if (!parsed.success) return []; + const nodes = parsed.data.data.repository?.object?.deployments.nodes ?? []; + const byEnvironment = new Map(); + for (const node of nodes) { + const url = node.latestStatus?.environmentUrl; + if ( + node.latestStatus?.state === "SUCCESS" && + url && + url.startsWith("https://") && + !byEnvironment.has(node.environment) + ) { + byEnvironment.set(node.environment, { environment: node.environment, url }); + } + } + return [...byEnvironment.values()]; + } catch { + return []; + } +} + +/** + * CI check runs plus preview-deployment links for `headSha`. Runs the two gh + * reads in parallel; each degrades to empty independently so a failure in one + * never blanks the other. + */ +export async function getChecks( + repoRoot: string, + repo: GitHubRepo, + headSha: string, +): Promise { + const [items, deploymentLinks] = await Promise.all([ + getCheckRunItems(repoRoot, repo, headSha), + getDeploymentLinks(repoRoot, repo, headSha), + ]); + return { state: deriveCiState(items), items, deploymentLinks }; +} + // ─── Reviews ──────────────────────────────────────────────────────────────── // REST reviews are returned oldest-first, so iterating and overwriting per login diff --git a/packages/cli/src/routes/pull-request-mutations.ts b/packages/cli/src/routes/pull-request-mutations.ts new file mode 100644 index 0000000..ef56608 --- /dev/null +++ b/packages/cli/src/routes/pull-request-mutations.ts @@ -0,0 +1,194 @@ +import { + type CollaboratorsResponse, + PULL_REQUEST_MERGE_METHOD, +} from "@stagereview/types/pull-request"; +import { z } from "zod"; +import type { StageDb } from "../db/client.js"; +import { + addReviewers, + closePullRequest, + editTitle, + listCollaborators, + mergePullRequest, + removeReviewers, + reopenPullRequest, + setAutoMerge, + setDraft, +} from "../github/index.js"; +import type { Route, RouteHandler } from "../server.js"; +import { readJsonBody, writeJson } from "./json.js"; +import { enforceSameOrigin, requireRepo, resolveRun } from "./pull-request-shared.js"; + +type Req = Parameters[0]; +type Res = Parameters[1]; + +const numberField = z.number().int().positive(); +const mergeMethod = z.enum(PULL_REQUEST_MERGE_METHOD); + +const titleInput = z.object({ number: numberField, title: z.string().min(1) }); +const numberInput = z.object({ number: numberField }); +const draftInput = z.object({ number: numberField, draft: z.boolean() }); +const mergeInput = z.object({ + number: numberField, + mergeMethod, + expectedHeadOid: z.string().optional(), +}); +const autoMergeInput = z.object({ + number: numberField, + enabled: z.boolean(), + mergeMethod: mergeMethod.optional(), + expectedHeadOid: z.string().optional(), +}); +const reviewersInput = z.object({ number: numberField, reviewers: z.array(z.string()).min(1) }); + +async function parseBody(req: Req, res: Res, schema: z.ZodType): Promise { + let raw: unknown; + try { + raw = await readJsonBody(req); + } catch { + // Malformed JSON throws inside readJsonBody — return 400 rather than letting + // it escape to the server's plain-text 500 catch-all. + writeJson(res, 400, { error: "Invalid request body" }); + return null; + } + const parsed = schema.safeParse(raw); + if (!parsed.success) { + writeJson(res, 400, { error: "Invalid request body" }); + return null; + } + return parsed.data; +} + +/** Run a gh write, surfacing failures as a 500 so the UI can toast the message. */ +async function runMutation(res: Res, fn: () => Promise): Promise { + try { + await fn(); + writeJson(res, 200, { ok: true }); + } catch (err) { + writeJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); + } +} + +export function pullRequestMutationRoutes(db: StageDb): Route[] { + return [ + { + method: "PATCH", + pattern: "/api/runs/:runId/pull-request/title", + handler: async (req, res, params) => { + if (!enforceSameOrigin(req, res)) return; + const run = resolveRun(db, params, res); + if (!run) return; + const input = await parseBody(req, res, titleInput); + if (!input) return; + await runMutation(res, () => editTitle(run.repoRoot, input.number, input.title)); + }, + }, + { + method: "POST", + pattern: "/api/runs/:runId/pull-request/close", + handler: async (req, res, params) => { + if (!enforceSameOrigin(req, res)) return; + const run = resolveRun(db, params, res); + if (!run) return; + const input = await parseBody(req, res, numberInput); + if (!input) return; + await runMutation(res, () => closePullRequest(run.repoRoot, input.number)); + }, + }, + { + method: "POST", + pattern: "/api/runs/:runId/pull-request/reopen", + handler: async (req, res, params) => { + if (!enforceSameOrigin(req, res)) return; + const run = resolveRun(db, params, res); + if (!run) return; + const input = await parseBody(req, res, numberInput); + if (!input) return; + await runMutation(res, () => reopenPullRequest(run.repoRoot, input.number)); + }, + }, + { + method: "POST", + pattern: "/api/runs/:runId/pull-request/draft", + handler: async (req, res, params) => { + if (!enforceSameOrigin(req, res)) return; + const run = resolveRun(db, params, res); + if (!run) return; + const input = await parseBody(req, res, draftInput); + if (!input) return; + await runMutation(res, () => setDraft(run.repoRoot, input.number, input.draft)); + }, + }, + { + method: "POST", + pattern: "/api/runs/:runId/pull-request/merge", + handler: async (req, res, params) => { + if (!enforceSameOrigin(req, res)) return; + const run = resolveRun(db, params, res); + if (!run) return; + const input = await parseBody(req, res, mergeInput); + if (!input) return; + await runMutation(res, () => + mergePullRequest(run.repoRoot, input.number, input.mergeMethod, input.expectedHeadOid), + ); + }, + }, + { + method: "POST", + pattern: "/api/runs/:runId/pull-request/auto-merge", + handler: async (req, res, params) => { + if (!enforceSameOrigin(req, res)) return; + const run = resolveRun(db, params, res); + if (!run) return; + const input = await parseBody(req, res, autoMergeInput); + if (!input) return; + await runMutation(res, () => + setAutoMerge( + run.repoRoot, + input.number, + input.enabled, + input.mergeMethod, + input.expectedHeadOid, + ), + ); + }, + }, + { + method: "POST", + pattern: "/api/runs/:runId/pull-request/reviewers", + handler: async (req, res, params) => { + if (!enforceSameOrigin(req, res)) return; + const run = resolveRun(db, params, res); + if (!run) return; + const input = await parseBody(req, res, reviewersInput); + if (!input) return; + await runMutation(res, () => addReviewers(run.repoRoot, input.number, input.reviewers)); + }, + }, + { + method: "DELETE", + pattern: "/api/runs/:runId/pull-request/reviewers", + handler: async (req, res, params) => { + if (!enforceSameOrigin(req, res)) return; + const run = resolveRun(db, params, res); + if (!run) return; + const input = await parseBody(req, res, reviewersInput); + if (!input) return; + await runMutation(res, () => removeReviewers(run.repoRoot, input.number, input.reviewers)); + }, + }, + { + method: "GET", + pattern: "/api/runs/:runId/pull-request/collaborators", + handler: async (_req, res, params) => { + const run = resolveRun(db, params, res); + if (!run) return; + const repo = requireRepo(run, res); + if (!repo) return; + const collaborators = await listCollaborators(run.repoRoot, repo); + const body: CollaboratorsResponse = { collaborators }; + writeJson(res, 200, body); + }, + }, + ]; +} diff --git a/packages/cli/src/routes/pull-request-shared.ts b/packages/cli/src/routes/pull-request-shared.ts new file mode 100644 index 0000000..6f4dcad --- /dev/null +++ b/packages/cli/src/routes/pull-request-shared.ts @@ -0,0 +1,113 @@ +import path from "node:path"; +import { eq } from "drizzle-orm"; +import type { StageDb } from "../db/client.js"; +import { chapterRun } from "../db/schema/index.js"; +import { type GitHubRepo, parseGitHubRepo } from "../github/index.js"; +import type { RouteHandler, RouteParams } from "../server.js"; +import { writeJson } from "./json.js"; + +type Res = Parameters[1]; +type Req = Parameters[0]; + +export interface RunRepo { + repoRoot: string; + originUrl: string | null; +} + +/** Resolve a run's repo context, writing the matching error response on failure. */ +export function resolveRun(db: StageDb, params: RouteParams, res: Res): RunRepo | null { + const runId = params.runId; + if (!runId) { + writeJson(res, 400, { error: "Missing runId" }); + return null; + } + const [run] = db.select().from(chapterRun).where(eq(chapterRun.id, runId)).limit(1).all(); + if (!run) { + writeJson(res, 404, { error: `Run ${runId} not found` }); + return null; + } + const repoRoot = run.repoRoot; + if (!path.isAbsolute(repoRoot) || repoRoot.split(path.sep).includes("..")) { + writeJson(res, 500, { + error: "Run repoRoot is not an absolute path or contains traversal segments", + }); + return null; + } + return { repoRoot, originUrl: run.originUrl }; +} + +export function requireRepo(run: RunRepo, res: Res): GitHubRepo | null { + const repo = parseGitHubRepo(run.originUrl); + if (!repo) { + writeJson(res, 404, { error: "Run is not associated with a GitHub remote" }); + return null; + } + return repo; +} + +export function query(req: Req, key: string): string | null { + const url = req.url ?? ""; + const qIdx = url.indexOf("?"); + if (qIdx < 0) return null; + return new URLSearchParams(url.slice(qIdx + 1)).get(key); +} + +export function parseNumber(value: string | null): number | null { + if (value === null) return null; + const n = Number(value); + return Number.isInteger(n) && n > 0 ? n : null; +} + +/** The server only ever binds to loopback, so a legitimate `Host` is one of these. */ +const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost"]); + +/** Hostname from a `Host` header (`host[:port]`), or null if it can't be parsed. */ +function hostHeaderHostname(host: string | undefined): string | null { + if (!host) return null; + try { + return new URL(`http://${host}`).hostname; + } catch { + return null; + } +} + +/** + * Reject state-changing requests that could be CSRF or DNS-rebinding vectors + * against the gh-backed mutations. The server binds to loopback, but a browser + * on any site can POST to the predictable port and trigger a write. + * + * Two checks, both required: + * + * 1. **Loopback Host.** The server only binds to `127.0.0.1`, so a legitimate + * request always carries a loopback `Host`. This is the anti-DNS-rebinding + * guard: an attacker who rebinds their hostname to `127.0.0.1` reaches us + * with that hostname in *both* `Origin` and `Host` — defeating a bare + * `Origin === Host` check — but the hostname isn't a loopback literal, so we + * reject it here. `Host` is mandatory under HTTP/1.1, so this also rejects + * requests that omit it. + * 2. **Same-origin.** Browsers always attach an accurate `Origin` on + * cross-origin requests and JS can't forge it, so when an `Origin` is present + * its host:port must match the `Host` connected to. This rejects remote sites + * and other local origins alike (e.g. a page on `http://localhost:3000`). + * + * Non-browser clients (curl, scripts) send no `Origin` and are allowed once the + * Host check passes — they aren't a CSRF vector. Returns false (and writes 403) + * when the request must be rejected. + */ +export function enforceSameOrigin(req: Req, res: Res): boolean { + const host = req.headers.host; + const hostname = hostHeaderHostname(host); + if (hostname === null || !LOOPBACK_HOSTNAMES.has(hostname)) { + writeJson(res, 403, { error: "Cross-origin request rejected" }); + return false; + } + const origin = req.headers.origin; + if (origin === undefined) return true; + try { + if (new URL(origin).host === host) return true; + } catch { + // malformed Origin — fall through to reject + } + writeJson(res, 403, { error: "Cross-origin request rejected" }); + return false; +} diff --git a/packages/cli/src/routes/pull-request.ts b/packages/cli/src/routes/pull-request.ts index d6183d6..6bcded8 100644 --- a/packages/cli/src/routes/pull-request.ts +++ b/packages/cli/src/routes/pull-request.ts @@ -1,76 +1,14 @@ -import path from "node:path"; import type { ChecksResponse, MergeStatusResponse, PullRequestResponse, ReviewsResponse, } from "@stagereview/types/pull-request"; -import { eq } from "drizzle-orm"; import type { StageDb } from "../db/client.js"; -import { chapterRun } from "../db/schema/index.js"; -import { - type GitHubRepo, - getChecks, - getMergeStatus, - getPullRequest, - getReviews, - parseGitHubRepo, -} from "../github/index.js"; -import type { Route, RouteHandler, RouteParams } from "../server.js"; +import { getChecks, getMergeStatus, getPullRequest, getReviews } from "../github/index.js"; +import type { Route } from "../server.js"; import { writeJson } from "./json.js"; - -interface RunRepo { - repoRoot: string; - originUrl: string | null; -} - -/** Resolve a run's repo context, writing the matching error response on failure. */ -function resolveRun( - db: StageDb, - params: RouteParams, - res: Parameters[1], -): RunRepo | null { - const runId = params.runId; - if (!runId) { - writeJson(res, 400, { error: "Missing runId" }); - return null; - } - const [run] = db.select().from(chapterRun).where(eq(chapterRun.id, runId)).limit(1).all(); - if (!run) { - writeJson(res, 404, { error: `Run ${runId} not found` }); - return null; - } - const repoRoot = run.repoRoot; - if (!path.isAbsolute(repoRoot) || repoRoot.split(path.sep).includes("..")) { - writeJson(res, 500, { - error: "Run repoRoot is not an absolute path or contains traversal segments", - }); - return null; - } - return { repoRoot, originUrl: run.originUrl }; -} - -function requireRepo(run: RunRepo, res: Parameters[1]): GitHubRepo | null { - const repo = parseGitHubRepo(run.originUrl); - if (!repo) { - writeJson(res, 404, { error: "Run is not associated with a GitHub remote" }); - return null; - } - return repo; -} - -function query(req: Parameters[0], key: string): string | null { - const url = req.url ?? ""; - const qIdx = url.indexOf("?"); - if (qIdx < 0) return null; - return new URLSearchParams(url.slice(qIdx + 1)).get(key); -} - -function parseNumber(value: string | null): number | null { - if (value === null) return null; - const n = Number(value); - return Number.isInteger(n) && n > 0 ? n : null; -} +import { parseNumber, query, requireRepo, resolveRun } from "./pull-request-shared.js"; const SHA_RE = /^[0-9a-f]{40}$/i; diff --git a/packages/cli/src/show.ts b/packages/cli/src/show.ts index 0479849..ff3847c 100644 --- a/packages/cli/src/show.ts +++ b/packages/cli/src/show.ts @@ -8,6 +8,7 @@ import { filterFilesForLlm, loadStageIgnore } from "./filter-files.js"; import { type ResolveScopeOptions, readRepoContext, readRepoRoot, resolveScope } from "./git.js"; import { diffRoutes } from "./routes/diff.js"; import { pullRequestRoutes } from "./routes/pull-request.js"; +import { pullRequestMutationRoutes } from "./routes/pull-request-mutations.js"; import { runRoutes } from "./routes/runs.js"; import { viewStateRoutes } from "./routes/view-state.js"; import { insertChaptersFile } from "./runs/import-chapters.js"; @@ -34,7 +35,13 @@ export async function show( const { runId } = insertChaptersFile(db, chaptersFile, readRepoContext()); const handle = await startServer({ - routes: [...runRoutes(db), ...viewStateRoutes(db), ...diffRoutes(db), ...pullRequestRoutes(db)], + routes: [ + ...runRoutes(db), + ...viewStateRoutes(db), + ...diffRoutes(db), + ...pullRequestRoutes(db), + ...pullRequestMutationRoutes(db), + ], }); const { port } = handle; const url = `http://${LOOPBACK_HOST}:${port}/runs/${encodeURIComponent(runId)}`; diff --git a/packages/types/src/pull-request.ts b/packages/types/src/pull-request.ts index a0b6f9d..5851c0a 100644 --- a/packages/types/src/pull-request.ts +++ b/packages/types/src/pull-request.ts @@ -200,6 +200,11 @@ export const ReviewsResponseSchema = z.object({ }); export type ReviewsResponse = z.infer; +export const CollaboratorsResponseSchema = z.object({ + collaborators: z.array(ReviewUserSchema), +}); +export type CollaboratorsResponse = z.infer; + // ─── Merge status ───────────────────────────────────────────────────────────── export const MergeQueueEntrySchema = z.object({ diff --git a/packages/web/package.json b/packages/web/package.json index ee2325d..d76a705 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@pierre/diffs": "^1.0.11", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", diff --git a/packages/web/src/components/pull-request/merge-status.tsx b/packages/web/src/components/pull-request/merge-status.tsx new file mode 100644 index 0000000..9312013 --- /dev/null +++ b/packages/web/src/components/pull-request/merge-status.tsx @@ -0,0 +1,279 @@ +import { + type MergeStatusInfo, + PULL_REQUEST_MERGE_METHOD, + type PullRequestMergeMethod, +} from "@stagereview/types/pull-request"; +import { useMutation } from "@tanstack/react-query"; +import { Check, ChevronDown, GitMerge, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { toast } from "@/components/ui/sonner"; +import { Switch } from "@/components/ui/switch"; +import { usePullRequestContext } from "@/lib/pull-request-context"; +import { + dequeueMutationOptions, + enqueueMutationOptions, + mergeMutationOptions, + setAutoMergeMutationOptions, + useInvalidatePullRequest, +} from "@/lib/pull-request-mutations"; +import { cn } from "@/lib/utils"; +import { + getMergeStatusSummary, + MERGE_STATUS, + type MergeStatusSummary, +} from "./merge-status-summary"; + +const MERGE_METHOD_LABELS: Record = { + [PULL_REQUEST_MERGE_METHOD.SQUASH]: "Squash and merge", + [PULL_REQUEST_MERGE_METHOD.MERGE]: "Merge pull request", + [PULL_REQUEST_MERGE_METHOD.REBASE]: "Rebase and merge", +}; + +function MergeActions({ + mergeInfo, + summary, + owner, + repo, + number, + headSha, +}: { + mergeInfo: MergeStatusInfo; + summary: MergeStatusSummary; + owner: string; + repo: string; + number: number; + headSha: string; +}) { + const { runId } = usePullRequestContext(); + const invalidateAfterMutation = useInvalidatePullRequest(runId); + const [mergeMethod, setMergeMethod] = useState( + () => mergeInfo.allowedMergeMethods[0] ?? PULL_REQUEST_MERGE_METHOD.MERGE, + ); + + const mergeMutation = useMutation({ + ...mergeMutationOptions(runId), + onSuccess: () => { + toast.success("Pull request merged"); + invalidateAfterMutation(); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to merge pull request"); + }, + }); + + const enqueueMutation = useMutation({ + ...enqueueMutationOptions(runId), + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to add to merge queue"); + }, + onSettled: invalidateAfterMutation, + }); + + const autoMergeMutation = useMutation({ + ...setAutoMergeMutationOptions(runId), + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to update auto-merge"); + }, + onSettled: invalidateAfterMutation, + }); + + const dequeueMutation = useMutation({ + ...dequeueMutationOptions(runId), + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to remove from merge queue"); + }, + onSettled: invalidateAfterMutation, + }); + + const anyPending = + mergeMutation.isPending || + enqueueMutation.isPending || + autoMergeMutation.isPending || + dequeueMutation.isPending; + + const optimisticAutoMergeEnabled = + autoMergeMutation.isPending && autoMergeMutation.variables + ? autoMergeMutation.variables.enabled + : mergeInfo.autoMergeEnabled; + const optimisticInMergeQueue = enqueueMutation.isPending + ? true + : dequeueMutation.isPending + ? false + : mergeInfo.isInMergeQueue; + + const isReady = summary.status === MERGE_STATUS.READY; + + if (isReady && !mergeInfo.isMergeQueueEnabled && mergeInfo.allowedMergeMethods.length > 0) { + return ( +
+
+ + {mergeInfo.allowedMergeMethods.length > 1 && ( + + + + + + {mergeInfo.allowedMergeMethods.map((method) => ( + setMergeMethod(method)}> + + {MERGE_METHOD_LABELS[method]} + + ))} + + + )} +
+
+ ); + } + + if (mergeInfo.isMergeQueueEnabled) { + const isMergeWhenReady = optimisticInMergeQueue || optimisticAutoMergeEnabled; + return ( +
+ + {anyPending ? "Updating…" : "Merge when ready"} + + { + if (checked) { + if (isReady) { + enqueueMutation.mutate({ owner, repo, number, expectedHeadOid: headSha }); + } else { + autoMergeMutation.mutate({ + owner, + repo, + number, + enabled: true, + mergeMethod, + expectedHeadOid: headSha, + }); + } + } else if (mergeInfo.isInMergeQueue && mergeInfo.entry) { + dequeueMutation.mutate({ + owner, + repo, + number, + mergeQueueEntryId: mergeInfo.entry.id, + }); + } else if (mergeInfo.autoMergeEnabled) { + // `else if`: both dequeue and disabling auto-merge map to the same + // `gh pr merge --disable-auto` here, so fire only one to avoid a + // duplicate request (and a spurious error toast on the second). + autoMergeMutation.mutate({ owner, repo, number, enabled: false }); + } + }} + /> +
+ ); + } + + if (mergeInfo.autoMergeAllowed || mergeInfo.viewerCanDisableAutoMerge) { + return ( +
+ + {anyPending ? "Updating…" : "Merge when ready"} + + { + autoMergeMutation.mutate({ + owner, + repo, + number, + enabled: checked, + ...(checked && { mergeMethod, expectedHeadOid: headSha }), + }); + }} + /> +
+ ); + } + + return ( +

+ {summary.isTransient + ? "Waiting for status checks to complete." + : "This pull request cannot be merged yet."} +

+ ); +} + +export interface MergeStatusProps { + mergeInfo: MergeStatusInfo; + owner: string; + repo: string; + number: number; + headSha: string; +} + +export function MergeStatus({ mergeInfo, owner, repo, number, headSha }: MergeStatusProps) { + const summary = getMergeStatusSummary(mergeInfo); + const MergeIcon = summary.icon; + + return ( + + + + + + + + + ); +} diff --git a/packages/web/src/components/pull-request/pull-request-header.tsx b/packages/web/src/components/pull-request/pull-request-header.tsx index cf26289..ad9b7c1 100644 --- a/packages/web/src/components/pull-request/pull-request-header.tsx +++ b/packages/web/src/components/pull-request/pull-request-header.tsx @@ -1,30 +1,43 @@ -import type { DeploymentLink } from "@stagereview/types/pull-request"; import { + type DeploymentLink, type GitHubPullRequest, MERGE_STATE_STATUS, MERGEABLE_STATE, type MergeStatusInfo, PULL_REQUEST_STATUS, } from "@stagereview/types/pull-request"; -import { GitBranch, Github, ScanSearch } from "lucide-react"; -import { useCallback } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Check, GitBranch, Github, Pencil, ScanSearch, X } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { CIChecks } from "@/components/pull-request/ci-checks"; -import { getMergeStatusSummary } from "@/components/pull-request/merge-status-summary"; +import { MergeStatus } from "@/components/pull-request/merge-status"; +import { PullRequestStatus } from "@/components/pull-request/pull-request-status"; import { Reviewers } from "@/components/pull-request/reviewers"; import { DeploymentLinkList } from "@/components/shared/deployment-link-list"; import { ShortcutTooltip } from "@/components/shared/shortcut-tooltip"; import { getUserDisplay, UserName } from "@/components/shared/user-name"; +import { + 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 { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { toast } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { formatTimeAgo } from "@/lib/format"; import { KEYBOARD_SHORTCUTS } from "@/lib/keyboard-shortcuts"; import { usePullRequestContext } from "@/lib/pull-request-context"; +import { titleMutationOptions, useInvalidatePullRequest } from "@/lib/pull-request-mutations"; import { usePullRequestChecks } from "@/lib/use-pull-request"; -import { cn } from "@/lib/utils"; +import { usePullRequestStatusActions } from "@/lib/use-pull-request-status-actions"; import { getPullRequestStatusInfo } from "@/lib/utils/pull-request-status"; function HeaderDeploymentPopover({ deploymentLinks }: { deploymentLinks: DeploymentLink[] }) { @@ -52,31 +65,16 @@ function HeaderDeploymentPopover({ deploymentLinks }: { deploymentLinks: Deploym ); } -function MergeStatusPill({ mergeInfo }: { mergeInfo: MergeStatusInfo }) { - const summary = getMergeStatusSummary(mergeInfo); - const Icon = summary.icon; - return ( - - - {summary.label} - - ); -} - export interface PullRequestHeaderProps { pullRequest: GitHubPullRequest; mergeInfo?: MergeStatusInfo; } export function PullRequestHeader({ pullRequest, mergeInfo }: PullRequestHeaderProps) { - const { runId } = usePullRequestContext(); - const status = getPullRequestStatusInfo(pullRequest); - const StatusIcon = status.icon; + const { runId, owner, repo } = usePullRequestContext(); + const inMergeQueue = mergeInfo?.isInMergeQueue; + const mergeQueuePosition = mergeInfo?.entry?.position; + const status = getPullRequestStatusInfo(pullRequest, { inMergeQueue, mergeQueuePosition }); const authorProfileUrl = pullRequest.user ? getUserDisplay(pullRequest.user).profileUrl : null; const isOpen = pullRequest.state === PULL_REQUEST_STATUS.OPEN && !pullRequest.merged_at && !pullRequest.draft; @@ -93,6 +91,47 @@ export function PullRequestHeader({ pullRequest, mergeInfo }: PullRequestHeaderP const deploymentLinks = checksData?.deploymentLinks ?? []; const hasChecks = checksData && checksData.items.length > 0; + // --- Title editing --- + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(pullRequest.title); + const inputRef = useRef(null); + const invalidate = useInvalidatePullRequest(runId); + + const updateMutation = useMutation({ + ...titleMutationOptions(runId), + onSuccess: async () => { + await invalidate(); + setIsEditing(false); + toast.success("Title updated"); + }, + onError: (error) => { + setEditValue(pullRequest.title); + setIsEditing(false); + toast.error(error instanceof Error ? error.message : "Failed to update title"); + }, + }); + + function startEditing() { + setEditValue(pullRequest.title); + setIsEditing(true); + requestAnimationFrame(() => inputRef.current?.focus()); + } + + function cancelEditing() { + setIsEditing(false); + setEditValue(pullRequest.title); + } + + function submitEdit() { + const trimmed = editValue.trim(); + if (!trimmed) return; + if (trimmed === pullRequest.title) { + setIsEditing(false); + return; + } + updateMutation.mutate({ number: pullRequest.number, title: trimmed }); + } + const copyToClipboard = useCallback((text: string, label: string) => { navigator.clipboard.writeText(text).then( () => toast.success(`Copied ${label} to clipboard`), @@ -106,16 +145,21 @@ export function PullRequestHeader({ pullRequest, mergeInfo }: PullRequestHeaderP useHotkeys(KEYBOARD_SHORTCUTS.COPY_BRANCH_NAME.hotkey, copyBranchName); - const statusPill = ( -
- - {status.label} -
+ const statusActions = usePullRequestStatusActions({ runId, pullRequest }); + + const statusDropdown = ( + ); const externalLinks = ( @@ -157,82 +201,162 @@ export function PullRequestHeader({ pullRequest, mergeInfo }: PullRequestHeaderP ); return ( -
- {/* Row 1: Status + Title + External links */} -
-
- {statusPill} - {externalLinks} + <> +
+ {/* Row 1: Status + Title + External links */} +
+
+ {statusDropdown} + {externalLinks} +
+
+
{statusDropdown}
+ {isEditing ? ( + <> + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitEdit(); + if (e.key === "Escape") cancelEditing(); + }} + disabled={updateMutation.isPending} + className="min-w-0 flex-1 font-semibold text-xl @xl:text-2xl" + /> + + + + ) : ( + <> +

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

+ + + )} +
{externalLinks}
+
-
-
{statusPill}
-

- {pullRequest.title} - - #{pullRequest.number} - -

-
{externalLinks}
-
-
- {/* Row 2: Metadata */} -
- {pullRequest.user && authorProfileUrl && ( - <> - - - - - {pullRequest.user.login[0]?.toUpperCase()} - - - - - - {" opened "} - {formatTimeAgo(pullRequest.created_at)} - - - - )} -
+ + + + + + + Copy base branch name + + {isOpenOrDraft && ( + <> + + {hasMergeData && mergeInfo && ( + + )} + {hasChecks && } + + )} + + + + + { + if (!statusActions.isClosePending) statusActions.setShowCloseDialog(open); + }} + > + + + Close pull request + + Are you sure you want to close this pull request? You can reopen it later. + + + + Cancel + + + + + ); } diff --git a/packages/web/src/components/pull-request/pull-request-status.tsx b/packages/web/src/components/pull-request/pull-request-status.tsx new file mode 100644 index 0000000..2214146 --- /dev/null +++ b/packages/web/src/components/pull-request/pull-request-status.tsx @@ -0,0 +1,152 @@ +import { type GitHubPullRequest, PULL_REQUEST_STATUS } from "@stagereview/types/pull-request"; +import { + ChevronDown, + GitPullRequest, + GitPullRequestClosed, + GitPullRequestDraft, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import type { PullRequestStatusInfo } from "@/lib/utils/pull-request-status"; + +interface PullRequestStatusDropdownProps { + pullRequest: GitHubPullRequest; + statusInfo: PullRequestStatusInfo; + onClose: () => void; + onReopen: () => void; + onConvertToDraft: () => void; + onMarkReady: () => void; + isDraftTogglePending: boolean; + isClosePending: boolean; + isReopenPending: boolean; + inMergeQueue?: boolean; +} + +export function PullRequestStatus({ + pullRequest, + statusInfo, + onClose, + onReopen, + onConvertToDraft, + onMarkReady, + isDraftTogglePending, + isClosePending, + isReopenPending, + inMergeQueue, +}: PullRequestStatusDropdownProps) { + const [open, setOpen] = useState(false); + const hasPendingAction = useRef(false); + + const anyPending = isDraftTogglePending || isClosePending || isReopenPending; + + useEffect(() => { + if (anyPending) { + hasPendingAction.current = true; + } else if (hasPendingAction.current) { + hasPendingAction.current = false; + setOpen(false); + } + }, [anyPending]); + + const StatusIcon = statusInfo.icon; + const isMerged = !!pullRequest.merged_at; + const isOpen = pullRequest.state === PULL_REQUEST_STATUS.OPEN; + const isClosed = pullRequest.state === PULL_REQUEST_STATUS.CLOSED && !isMerged; + + const canToggleDraft = isOpen && !inMergeQueue; + + // Merged is a terminal state — static pill, no dropdown + if (isMerged) { + return ( +
+ + {statusInfo.label} +
+ ); + } + + return ( + + + + + + {isOpen && ( + <> + {canToggleDraft && + (pullRequest.draft ? ( + { + e.preventDefault(); + onMarkReady(); + }} + disabled={isDraftTogglePending} + > + + {isDraftTogglePending ? "Marking ready…" : "Mark as ready"} + + ) : ( + { + e.preventDefault(); + onConvertToDraft(); + }} + disabled={isDraftTogglePending} + > + + {isDraftTogglePending ? "Converting to draft…" : "Convert to draft"} + + ))} + { + e.preventDefault(); + onClose(); + }} + disabled={isClosePending} + > + + Close pull request + + + )} + {isClosed && ( + { + e.preventDefault(); + onReopen(); + }} + disabled={isReopenPending} + > + {isReopenPending ? "Reopening…" : "Reopen pull request"} + + )} + + + ); +} diff --git a/packages/web/src/components/pull-request/reviewers.tsx b/packages/web/src/components/pull-request/reviewers.tsx index 324fe15..5742fd2 100644 --- a/packages/web/src/components/pull-request/reviewers.tsx +++ b/packages/web/src/components/pull-request/reviewers.tsx @@ -2,7 +2,9 @@ import { REVIEWER_STATUS, type Reviewer, type ReviewerStatus, + type ReviewUser, } from "@stagereview/types/pull-request"; +import { useMutation } from "@tanstack/react-query"; import { Check, ChevronDown, @@ -10,6 +12,7 @@ import { Loader2, type LucideIcon, MessageSquare, + RefreshCw, Users, X, } from "lucide-react"; @@ -18,9 +21,17 @@ import { BotBadge } from "@/components/shared/bot-badge"; import { ReviewerAvatars } from "@/components/shared/reviewer-avatars"; import { getUserDisplay } from "@/components/shared/user-utils"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { toast } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { usePullRequestContext } from "@/lib/pull-request-context"; +import { + addReviewerMutationOptions, + removeReviewerMutationOptions, +} from "@/lib/pull-request-mutations"; +import { useReviewerManager } from "@/lib/use-reviewer-manager"; import { cn } from "@/lib/utils"; const STATUS_DESCRIPTIONS: Record = { @@ -46,10 +57,62 @@ function StatusIcon({ status }: { status: ReviewerStatus }) { return ; } -function ReviewerRow({ reviewer }: { reviewer: Reviewer }) { +interface ReviewerRowProps { + reviewer: Reviewer; + owner: string; + repo: string; + pullNumber: number; + onRemoveMutate: (login: string) => void; + onRemoveError: (login: string) => void; + invalidatePullRequestQueries: () => void; +} + +function ReviewerRow({ + reviewer, + owner, + repo, + pullNumber, + onRemoveMutate, + onRemoveError, + invalidatePullRequestQueries, +}: ReviewerRowProps) { + const { runId } = usePullRequestContext(); + const removeMutation = useMutation({ + ...removeReviewerMutationOptions(runId), + onMutate: () => onRemoveMutate(reviewer.user.login), + onSuccess: () => { + invalidatePullRequestQueries(); + toast.success("Reviewer removed"); + }, + onError: (error) => { + onRemoveError(reviewer.user.login); + toast.error(error instanceof Error ? error.message : "Failed to remove reviewer"); + }, + }); + + const rerequestMutation = useMutation({ + ...addReviewerMutationOptions(runId), + onSuccess: () => { + invalidatePullRequestQueries(); + toast.success("Review re-requested"); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to re-request review"); + }, + }); + const { isBot, displayName, profileUrl } = getUserDisplay(reviewer.user); + const isRequested = + reviewer.status === REVIEWER_STATUS.REQUESTED || reviewer.status === REVIEWER_STATUS.PENDING; + const hasReviewed = + reviewer.status === REVIEWER_STATUS.APPROVED || + reviewer.status === REVIEWER_STATUS.CHANGES_REQUESTED || + reviewer.status === REVIEWER_STATUS.COMMENTED || + reviewer.status === REVIEWER_STATUS.DISMISSED; + const isPending = removeMutation.isPending || rerequestMutation.isPending; + return ( -
+ ); } +interface CollaboratorRowProps { + user: ReviewUser; + owner: string; + repo: string; + pullNumber: number; + onSuccess: () => void; + onAddMutate: (user: ReviewUser) => void; + onAddError: (login: string) => void; + invalidatePullRequestQueries: () => void; +} + +function CollaboratorRow({ + user, + owner, + repo, + pullNumber, + onSuccess, + onAddMutate, + onAddError, + invalidatePullRequestQueries, +}: CollaboratorRowProps) { + const { runId } = usePullRequestContext(); + const addMutation = useMutation({ + ...addReviewerMutationOptions(runId), + onMutate: () => onAddMutate(user), + onSuccess: () => { + onSuccess(); + invalidatePullRequestQueries(); + toast.success("Reviewer requested"); + }, + onError: (error) => { + onAddError(user.login); + toast.error(error instanceof Error ? error.message : "Failed to add reviewer"); + }, + }); + + return ( + + ); +} + export function Reviewers() { const [open, setOpen] = useState(false); - const { reviews } = usePullRequestContext(); - const reviewers = reviews?.reviewers ?? []; + const [search, setSearch] = useState(""); + + const { + owner, + repo, + pullNumber, + reviews, + reviewers, + collaborators, + filteredCollaborators, + onAddMutate, + onAddError, + onRemoveMutate, + onRemoveError, + invalidatePullRequestQueries, + } = useReviewerManager({ open, search }); return ( - + { + setOpen(newOpen); + if (!newOpen) setSearch(""); + }} + >
- {reviewers.length > 0 ? ( + + {reviewers.length > 0 && (
{reviewers.map((reviewer) => ( - + ))}
- ) : ( -

No reviewers yet

)} + +
+ setSearch(e.target.value)} + className="h-8 text-sm" + /> +
+ {filteredCollaborators.length > 0 ? ( + filteredCollaborators.map((user) => ( + setSearch("")} + onAddMutate={onAddMutate} + onAddError={onAddError} + invalidatePullRequestQueries={invalidatePullRequestQueries} + /> + )) + ) : !collaborators ? ( +
+ +
+ ) : search ? ( +

+ No matching collaborators +

+ ) : null} +
+
diff --git a/packages/web/src/components/ui/alert-dialog.tsx b/packages/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..192ea7c --- /dev/null +++ b/packages/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,132 @@ +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import type * as React from "react"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/packages/web/src/lib/pull-request-mutations.ts b/packages/web/src/lib/pull-request-mutations.ts new file mode 100644 index 0000000..099a574 --- /dev/null +++ b/packages/web/src/lib/pull-request-mutations.ts @@ -0,0 +1,156 @@ +import type { PullRequestMergeMethod } from "@stagereview/types/pull-request"; +import { useQueryClient } from "@tanstack/react-query"; + +function prPath(runId: string, suffix: string): string { + return `/api/runs/${encodeURIComponent(runId)}/pull-request${suffix}`; +} + +async function write( + runId: string, + suffix: string, + method: "POST" | "PATCH" | "DELETE", + body: Record, +): Promise { + const path = prPath(runId, suffix); + const res = await fetch(path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) { + // The server returns `{ error: }` on failure — surface it so the + // toast shows the actionable gh message, not a generic "POST … failed: 500". + let message = `${method} ${path} failed: ${res.status}`; + try { + const parsed: unknown = text ? JSON.parse(text) : null; + if (parsed && typeof parsed === "object" && "error" in parsed) { + const { error } = parsed as { error: unknown }; + if (typeof error === "string" && error) message = error; + } + } catch { + // non-JSON body — keep the generic message + } + throw new Error(message); + } + return text ? JSON.parse(text) : {}; +} + +/** Invalidate every PR-derived query for a run after a mutation. */ +export function useInvalidatePullRequest(runId: string): () => Promise { + const queryClient = useQueryClient(); + return () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ["pull-request", runId] }), + queryClient.invalidateQueries({ queryKey: ["pull-request-reviews", runId] }), + queryClient.invalidateQueries({ queryKey: ["pull-request-merge-status", runId] }), + queryClient.invalidateQueries({ queryKey: ["pull-request-checks", runId] }), + ]); +} + +// Mutation-option factories — mirror hosted's `orpc.pullRequests.X.mutationOptions()` +// so the vendored components keep their `useMutation({ ...factory, onSuccess })` shape. +// They accept the components' `{ owner, repo, number, ... }` call shape (owner/repo +// are ignored — the server resolves the repo from the run). + +/** Vendored components call `.mutate({ owner, repo, ... })`; accept and ignore those. */ +type RepoVars = { owner?: string; repo?: string }; + +export function titleMutationOptions(runId: string) { + return { + mutationFn: (v: { number: number; title: string }) => + write(runId, "/title", "PATCH", { number: v.number, title: v.title }), + }; +} + +export function closeMutationOptions(runId: string) { + return { + mutationFn: (v: { number: number }) => write(runId, "/close", "POST", { number: v.number }), + }; +} + +export function reopenMutationOptions(runId: string) { + return { + mutationFn: (v: { number: number }) => write(runId, "/reopen", "POST", { number: v.number }), + }; +} + +export function draftMutationOptions(runId: string) { + return { + mutationFn: (v: { number: number; draft: boolean }) => + write(runId, "/draft", "POST", { number: v.number, draft: v.draft }), + }; +} + +export function mergeMutationOptions(runId: string) { + return { + mutationFn: ( + v: RepoVars & { + number: number; + mergeMethod: PullRequestMergeMethod; + expectedHeadOid?: string; + }, + ) => + write(runId, "/merge", "POST", { + number: v.number, + mergeMethod: v.mergeMethod, + expectedHeadOid: v.expectedHeadOid, + }), + }; +} + +// Merge-queue enqueue maps to "enable auto-merge" — gh enqueues when ready. +// Forward the head SHA so the server can guard against a stale head (--match-head-commit). +export function enqueueMutationOptions(runId: string) { + return { + mutationFn: (v: RepoVars & { number: number; expectedHeadOid?: string }) => + write(runId, "/auto-merge", "POST", { + number: v.number, + enabled: true, + expectedHeadOid: v.expectedHeadOid, + }), + }; +} + +export function setAutoMergeMutationOptions(runId: string) { + return { + mutationFn: ( + v: RepoVars & { + number: number; + enabled: boolean; + mergeMethod?: PullRequestMergeMethod; + // Forward the head SHA so enabling auto-merge guards against a stale head + // (--match-head-commit). The server ignores it when disabling. + expectedHeadOid?: string; + }, + ) => + write(runId, "/auto-merge", "POST", { + number: v.number, + enabled: v.enabled, + mergeMethod: v.mergeMethod, + expectedHeadOid: v.expectedHeadOid, + }), + }; +} + +// Dequeue maps to "disable auto-merge". +export function dequeueMutationOptions(runId: string) { + return { + mutationFn: (v: RepoVars & { number: number; mergeQueueEntryId: string }) => + write(runId, "/auto-merge", "POST", { number: v.number, enabled: false }), + }; +} + +export function addReviewerMutationOptions(runId: string) { + return { + mutationFn: (v: RepoVars & { number: number; reviewers: string[] }) => + write(runId, "/reviewers", "POST", { number: v.number, reviewers: v.reviewers }), + }; +} + +export function removeReviewerMutationOptions(runId: string) { + return { + mutationFn: (v: RepoVars & { number: number; reviewer: string }) => + write(runId, "/reviewers", "DELETE", { number: v.number, reviewers: [v.reviewer] }), + }; +} diff --git a/packages/web/src/lib/use-pull-request-status-actions.ts b/packages/web/src/lib/use-pull-request-status-actions.ts new file mode 100644 index 0000000..9f28199 --- /dev/null +++ b/packages/web/src/lib/use-pull-request-status-actions.ts @@ -0,0 +1,70 @@ +import type { GitHubPullRequest } from "@stagereview/types/pull-request"; +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "@/components/ui/sonner"; +import { + closeMutationOptions, + draftMutationOptions, + reopenMutationOptions, + useInvalidatePullRequest, +} from "@/lib/pull-request-mutations"; + +interface Options { + runId: string; + pullRequest: GitHubPullRequest; +} + +export function usePullRequestStatusActions({ runId, pullRequest }: Options) { + const invalidate = useInvalidatePullRequest(runId); + const [showCloseDialog, setShowCloseDialog] = useState(false); + + const draftMutation = useMutation({ + ...draftMutationOptions(runId), + onSuccess: async () => { + await invalidate(); + toast.success(pullRequest.draft ? "Marked as ready for review" : "Converted to draft"); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : "Failed to update draft status"), + }); + + const closeMutation = useMutation({ + ...closeMutationOptions(runId), + onSuccess: async () => { + await invalidate(); + setShowCloseDialog(false); + toast.success("Pull request closed"); + }, + onError: (error) => { + setShowCloseDialog(false); + toast.error(error instanceof Error ? error.message : "Failed to close pull request"); + }, + }); + + const reopenMutation = useMutation({ + ...reopenMutationOptions(runId), + onSuccess: async () => { + await invalidate(); + toast.success("Pull request reopened"); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : "Failed to reopen pull request"), + }); + + const toggleDraft = () => { + draftMutation.mutate({ number: pullRequest.number, draft: !pullRequest.draft }); + }; + + return { + onClose: () => setShowCloseDialog(true), + onReopen: () => reopenMutation.mutate({ number: pullRequest.number }), + onConvertToDraft: toggleDraft, + onMarkReady: toggleDraft, + isDraftTogglePending: draftMutation.isPending, + isClosePending: closeMutation.isPending, + isReopenPending: reopenMutation.isPending, + showCloseDialog, + setShowCloseDialog, + confirmClose: () => closeMutation.mutate({ number: pullRequest.number }), + }; +} diff --git a/packages/web/src/lib/use-pull-request.ts b/packages/web/src/lib/use-pull-request.ts index 4d4b299..5669713 100644 --- a/packages/web/src/lib/use-pull-request.ts +++ b/packages/web/src/lib/use-pull-request.ts @@ -1,6 +1,8 @@ import { type ChecksResponse, ChecksResponseSchema, + type CollaboratorsResponse, + CollaboratorsResponseSchema, type MergeStatusResponse, MergeStatusResponseSchema, type PullRequestResponse, @@ -75,3 +77,14 @@ export function usePullRequestMergeStatus(runId: string, number: number | null, ...LIVE, }); } + +export function usePullRequestCollaborators(runId: string, enabled: boolean) { + return useQuery({ + queryKey: ["pull-request-collaborators", runId], + queryFn: !enabled + ? skipToken + : async () => + CollaboratorsResponseSchema.parse(await jsonFetch(prPath(runId, "/collaborators"))), + staleTime: 5 * 60 * 1000, + }); +} diff --git a/packages/web/src/lib/use-reviewer-manager.ts b/packages/web/src/lib/use-reviewer-manager.ts new file mode 100644 index 0000000..71a65ff --- /dev/null +++ b/packages/web/src/lib/use-reviewer-manager.ts @@ -0,0 +1,126 @@ +import { REVIEWER_STATUS, type Reviewer, type ReviewUser } from "@stagereview/types/pull-request"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { filterAndSortReviewers } from "@/components/shared/reviewer-avatars"; +import { usePullRequestContext } from "@/lib/pull-request-context"; +import { useInvalidatePullRequest } from "@/lib/pull-request-mutations"; +import { usePullRequestCollaborators } from "@/lib/use-pull-request"; + +interface UseReviewerManagerOptions { + open: boolean; + search: string; +} + +export function useReviewerManager({ open, search }: UseReviewerManagerOptions) { + const { runId, owner, repo, number, pullRequest, reviews } = usePullRequestContext(); + const invalidatePullRequestQueries = useInvalidatePullRequest(runId); + const [optimisticAdditions, setOptimisticAdditions] = useState>( + () => new Map(), + ); + const [optimisticRemovals, setOptimisticRemovals] = useState>(() => new Set()); + + const { data: collaboratorsData } = usePullRequestCollaborators(runId, open); + const collaborators = collaboratorsData?.collaborators ?? null; + + const serverReviewers = useMemo(() => { + if (!reviews?.reviewers) return []; + return filterAndSortReviewers(reviews.reviewers, pullRequest.user?.login); + }, [reviews?.reviewers, pullRequest.user?.login]); + + // Clear optimistic state once server data reflects the changes. + useEffect(() => { + const serverLogins = new Set(serverReviewers.map((r) => r.user.login)); + setOptimisticAdditions((prev) => { + const next = new Map([...prev].filter(([login]) => !serverLogins.has(login))); + return next.size === prev.size ? prev : next; + }); + setOptimisticRemovals((prev) => { + const next = new Set( + [...prev].filter((login) => serverLogins.has(login) || optimisticAdditions.has(login)), + ); + return next.size === prev.size ? prev : next; + }); + }, [serverReviewers, optimisticAdditions]); + + const reviewers = useMemo(() => { + const filtered = serverReviewers.filter((r) => !optimisticRemovals.has(r.user.login)); + const additions = [...optimisticAdditions.values()].filter( + (r) => !optimisticRemovals.has(r.user.login), + ); + return filterAndSortReviewers([...filtered, ...additions], pullRequest.user?.login); + }, [serverReviewers, optimisticRemovals, optimisticAdditions, pullRequest.user?.login]); + + const currentReviewerLogins = useMemo( + () => new Set(reviewers.map((r) => r.user.login)), + [reviewers], + ); + + const availableCollaborators = useMemo(() => { + if (!collaborators) return []; + const authorLogin = pullRequest.user?.login; + return collaborators.filter( + (c) => c.type !== "Bot" && c.login !== authorLogin && !currentReviewerLogins.has(c.login), + ); + }, [collaborators, pullRequest.user?.login, currentReviewerLogins]); + + const filteredCollaborators = useMemo(() => { + if (!search) return availableCollaborators; + const queryText = search.toLowerCase(); + return availableCollaborators.filter((c) => c.login.toLowerCase().includes(queryText)); + }, [availableCollaborators, search]); + + const onAddMutate = useCallback((user: ReviewUser) => { + setOptimisticRemovals((prev) => { + if (!prev.has(user.login)) return prev; + const next = new Set(prev); + next.delete(user.login); + return next; + }); + setOptimisticAdditions((prev) => { + const next = new Map(prev); + next.set(user.login, { user, status: REVIEWER_STATUS.REQUESTED }); + return next; + }); + }, []); + + const onAddError = useCallback((login: string) => { + setOptimisticAdditions((prev) => { + const next = new Map(prev); + next.delete(login); + return next.size === prev.size ? prev : next; + }); + }, []); + + const onRemoveMutate = useCallback((login: string) => { + setOptimisticAdditions((prev) => { + if (!prev.has(login)) return prev; + const next = new Map(prev); + next.delete(login); + return next; + }); + setOptimisticRemovals((prev) => new Set(prev).add(login)); + }, []); + + const onRemoveError = useCallback((login: string) => { + setOptimisticRemovals((prev) => { + if (!prev.has(login)) return prev; + const next = new Set(prev); + next.delete(login); + return next; + }); + }, []); + + return { + owner, + repo, + pullNumber: number, + reviews, + reviewers, + collaborators, + filteredCollaborators, + onAddMutate, + onAddError, + onRemoveMutate, + onRemoveError, + invalidatePullRequestQueries, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 684aa88..4e00d87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ importers: '@pierre/diffs': specifier: ^1.0.11 version: 1.1.20(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-avatar': specifier: ^1.1.10 version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)