Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6f8cdc6
feat(PRO-547): add CLI-side GitHub comment sync (pull/push)
dastratakos Jun 22, 2026
ffbd8f3
feat(PRO-547): add GitHub comment sync UI and author rendering
dastratakos Jun 22, 2026
559ee09
feat(PRO-547): mirror PR resolved state on pull
dastratakos Jun 22, 2026
524cac4
feat(PRO-547): sync comment resolution back to GitHub
dastratakos Jun 22, 2026
5745864
feat(PRO-547): add live GitHub review server layer (pending-review li…
dastratakos Jun 22, 2026
d320501
feat(PRO-547): rebuild comment UI on live-fetch review model; remove …
dastratakos Jun 22, 2026
38b4911
chore(PRO-547): drop this PR's net-zero migrations (collapse into 0006)
dastratakos Jun 22, 2026
e7a9157
feat(PRO-547): add 'Comment on the PR' and 'Start a review' composer …
dastratakos Jun 22, 2026
aaf624e
feat(PRO-547): close review-tray fidelity gaps (pending list, markdow…
dastratakos Jun 22, 2026
83958d8
fix(PRO-547): address PR review — push guardrail, scope checks, atomi…
dastratakos Jun 22, 2026
105f8ac
fix(PRO-547): guard unresolvable PR + discard empty review on failed …
dastratakos Jun 22, 2026
d265444
fix(PRO-547): preserve unposted replies on promote failure; guard rea…
dastratakos Jun 22, 2026
545fcf6
fix(PRO-547): gate GitHub review on run diff == PR head; refresh PR q…
dastratakos Jun 22, 2026
9c4afec
fix(PRO-547): guard submit/reply with assertPushable; scope PR-query …
dastratakos Jun 22, 2026
b828070
fix(PRO-547): keep string GraphQL vars as -f; serialize concurrent pr…
dastratakos Jun 22, 2026
902637f
fix(PRO-547): reject empty Comment review at the API boundary
dastratakos Jun 22, 2026
6b25131
fix(PRO-547): count pending drafts on anchorless threads to avoid fal…
dastratakos Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cli/src/__tests__/comments.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,12 @@ describe("comment threads API", () => {
const { port } = await startWithRoutes();
const thread = await createThread(port, runId);

const resolved = await send(port, "PATCH", `/api/comment-threads/${thread.id}`, {
const resolved = await send(port, "PATCH", `/api/runs/${runId}/comment-threads/${thread.id}`, {
resolved: true,
});
expect((resolved.body as CommentThread).resolvedAt).not.toBeNull();

const reopened = await send(port, "PATCH", `/api/comment-threads/${thread.id}`, {
const reopened = await send(port, "PATCH", `/api/runs/${runId}/comment-threads/${thread.id}`, {
resolved: false,
});
expect((reopened.body as CommentThread).resolvedAt).toBeNull();
Expand Down
340 changes: 340 additions & 0 deletions packages/cli/src/__tests__/review.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
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 } 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,
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: "<p>Submitted comment</p>",
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: "<p>Draft comment</p>",
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): Promise<void> {
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");
emit({ data: { addPullRequestReviewThread: { thread: { id: "THREAD_new" } } } });
} else if (query.includes("mutation AddReviewReply")) {
fs.appendFileSync(log, "reply\\n");
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);
}

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"), "<html></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): string {
const db = getDb({ dbPath });
const [row] = db
.insert(chapterRun)
.values({
repoRoot,
originUrl,
prNumber: 5,
scopeKind: SCOPE_KIND.COMMITTED,
workingTreeRef: null,
baseSha: BASE,
headSha: HEAD,
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;
}

async function start(): Promise<number> {
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);

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);
});
});

describe("review API — actions", () => {
it("promotes a local thread to a pending review comment and removes the local copy", async () => {
await writeGhShim({
data: {
repository: {
pullRequest: {
id: "PR_node",
viewerDidAuthor: false,
reviews: { nodes: [] },
reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
},
},
},
});
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({
data: {
repository: {
pullRequest: {
id: "PR_node",
viewerDidAuthor: false,
reviews: { nodes: [] },
reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
},
},
},
});
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");
});
});
2 changes: 2 additions & 0 deletions packages/cli/src/db/schema/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,23 @@ export function hasUncommittedChanges(): boolean {
return out.length > 0;
}

/** Current `HEAD` commit SHA of a specific worktree (used by the PR push guardrails). */
export function readHeadSha(repoRoot: string): string {
return execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
}

/** True when a specific worktree has no staged, unstaged, or untracked changes. */
export function isWorkingTreeClean(repoRoot: string): boolean {
const out = execFileSync("git", ["-C", repoRoot, "status", "--porcelain"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return out.length === 0;
}

export interface ResolvedScope {
scope: Scope;
mergeBaseSha: string;
Expand Down
Loading
Loading