From 4b322b56c4678b9ddf99956471c9745f4fd7a970 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:11:02 +0000 Subject: [PATCH 1/6] refactor: share diff-scope CLI options and a local comment thread store Extract the [refs...]/--base/--compare/--pr/--ref surface into addDiffScopeOptions so every diff-addressing command registers the same selectors, and move local thread persistence into LocalCommentThreadStore so the HTTP routes and the upcoming comments CLI share one implementation. --- packages/cli/src/diff-scope-options.ts | 53 +++++ packages/cli/src/index.ts | 77 ++----- packages/cli/src/routes/comments.ts | 122 ++--------- packages/cli/src/runs/import-chapters.ts | 10 +- .../cli/src/runs/local-comment-threads.ts | 204 +++++++++++++++--- packages/cli/src/runs/review.ts | 10 +- packages/cli/src/runs/scope-key.ts | 13 +- 7 files changed, 278 insertions(+), 211 deletions(-) create mode 100644 packages/cli/src/diff-scope-options.ts diff --git a/packages/cli/src/diff-scope-options.ts b/packages/cli/src/diff-scope-options.ts new file mode 100644 index 00000000..215f63b9 --- /dev/null +++ b/packages/cli/src/diff-scope-options.ts @@ -0,0 +1,53 @@ +import { type Command, Option } from "commander"; +import { z } from "zod"; +import { WORKING_TREE_REF } from "./schema.js"; +import type { DiffScopeOptions } from "./scope.js"; + +/** The raw diff-scope flags Commander collects for any command that addresses a diff. */ +export interface DiffCommandOptions { + base?: string; + compare?: string; + ref?: string; + pr?: string; +} + +/** + * Register the shared diff-scope surface — a trailing `[refs...]` argument plus + * `--base`, `--compare`, `--pr`, and `--ref` — on a command. Every command that + * targets a diff (`prep`, `show`, `comments`) uses this so they all accept the + * same selectors and resolve the same scope. + */ +export function addDiffScopeOptions(command: Command): Command { + return command + .argument("[refs...]", "Git refs to diff, for example: main, main feature, or main..feature") + .option("--base ", "Base ref to diff against (default: auto-detect main/master)") + .option("--compare ", "Compare ref to diff against --base") + .option("--pr ", "Review a GitHub pull request by number or URL") + .addOption( + new Option( + "--ref ", + "Diff scope: work (staged + unstaged + untracked), staged, or unstaged (default: auto-detect)", + ).choices(Object.values(WORKING_TREE_REF)), + ); +} + +/** + * Build the diff scope from CLI input. `--pr` resolves the base/head from a + * GitHub PR and so can't be combined with the local-ref selectors. + */ +export function toDiffScopeOptions(refs: string[], opts: DiffCommandOptions): DiffScopeOptions { + if (opts.pr !== undefined) { + if ( + refs.length > 0 || + opts.base !== undefined || + opts.compare !== undefined || + opts.ref !== undefined + ) { + throw new Error("--pr cannot be combined with git refs, --base, --compare, or --ref."); + } + return { pr: opts.pr }; + } + const workingTreeRef = + opts.ref !== undefined ? z.enum(WORKING_TREE_REF).parse(opts.ref) : undefined; + return { base: opts.base, compare: opts.compare, refs, workingTreeRef }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0a9ced48..93f9e571 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,10 +1,12 @@ #!/usr/bin/env node import { createRequire } from "node:module"; -import { Command, Option } from "commander"; -import { z } from "zod"; +import { Command } from "commander"; +import { + addDiffScopeOptions, + type DiffCommandOptions, + toDiffScopeOptions, +} from "./diff-scope-options.js"; import { runPrep } from "./prep.js"; -import { WORKING_TREE_REF } from "./schema.js"; -import type { DiffScopeOptions } from "./scope.js"; import { show } from "./show.js"; const require = createRequire(import.meta.url); @@ -17,53 +19,20 @@ program .description("Chapter-style code review against your local git branch.") .version(version); -const refOption = new Option( - "--ref ", - "Diff scope: work (staged + unstaged + untracked), staged, or unstaged (default: auto-detect)", -).choices(Object.values(WORKING_TREE_REF)); - -interface DiffCommandOptions { - base?: string; - compare?: string; - ref?: string; - pr?: string; +interface PrepCommandOptions extends DiffCommandOptions { instructions?: string; } -/** - * Build the diff scope from CLI input. `--pr` resolves the base/head from a - * GitHub PR and so can't be combined with the local-ref selectors. - */ -function toDiffScopeOptions(refs: string[], opts: DiffCommandOptions): DiffScopeOptions { - if (opts.pr !== undefined) { - if ( - refs.length > 0 || - opts.base !== undefined || - opts.compare !== undefined || - opts.ref !== undefined - ) { - throw new Error("--pr cannot be combined with git refs, --base, --compare, or --ref."); - } - return { pr: opts.pr }; - } - const workingTreeRef = - opts.ref !== undefined ? z.enum(WORKING_TREE_REF).parse(opts.ref) : undefined; - return { base: opts.base, compare: opts.compare, refs, workingTreeRef }; -} - -program - .command("prep") - .description("Parse the current branch diff and prepare input for chapter generation") - .argument("[refs...]", "Git refs to diff, for example: main, main feature, or main..feature") - .option("--base ", "Base ref to diff against (default: auto-detect main/master)") - .option("--compare ", "Compare ref to diff against --base") - .option("--pr ", "Review a GitHub pull request by number or URL") +addDiffScopeOptions( + program + .command("prep") + .description("Parse the current branch diff and prepare input for chapter generation"), +) .option( "--instructions ", "One-off instructions appended to the generation prompt (max 1000 characters)", ) - .addOption(refOption) - .action(async (refs: string[], opts: DiffCommandOptions) => { + .action(async (refs: string[], opts: PrepCommandOptions) => { const filePath = await runPrep({ ...toDiffScopeOptions(refs, opts), instructions: opts.instructions, @@ -71,18 +40,14 @@ program process.stdout.write(filePath); }); -program - .command("show") - .description("Load a chapters.json file and open it in a local browser") - .argument("", "Path to a chapters.json file") - .argument("[refs...]", "Git refs to diff, for example: main, main feature, or main..feature") - .option("--base ", "Base ref to diff against (default: auto-detect main/master)") - .option("--compare ", "Compare ref to diff against --base") - .option("--pr ", "Review a GitHub pull request by number or URL") - .addOption(refOption) - .action(async (jsonPath: string, refs: string[], opts: DiffCommandOptions) => { - await show(jsonPath, toDiffScopeOptions(refs, opts)); - }); +addDiffScopeOptions( + program + .command("show") + .description("Load a chapters.json file and open it in a local browser") + .argument("", "Path to a chapters.json file"), +).action(async (jsonPath: string, refs: string[], opts: DiffCommandOptions) => { + await show(jsonPath, toDiffScopeOptions(refs, opts)); +}); program.parseAsync(process.argv).catch((err) => { process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); diff --git a/packages/cli/src/routes/comments.ts b/packages/cli/src/routes/comments.ts index 45b9ade9..228a75da 100644 --- a/packages/cli/src/routes/comments.ts +++ b/packages/cli/src/routes/comments.ts @@ -1,22 +1,16 @@ import { CommentBodySchema, - type Comment as CommentDto, - type CommentThread as CommentThreadDto, CreateCommentThreadBodySchema, ResolveThreadBodySchema, } from "@stagereview/types/comments"; -import { asc, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { StageDb } from "../db/client.js"; -import { LOCAL_USER_ID } from "../db/local-user.js"; +import { chapterRun, comment, commentThread } from "../db/schema/index.js"; import { - type CommentRow, - type CommentThreadRow, - chapterRun, - comment, - commentInsertionOrder, - commentThread, -} from "../db/schema/index.js"; -import { loadLocalThreadRecords } from "../runs/local-comment-threads.js"; + LocalCommentThreadStore, + toCommentDto, + toThreadDto, +} from "../runs/local-comment-threads.js"; import { isLocalThreadPromotionInFlight } from "../runs/review.js"; import { REVIEW_ACTION_SCOPE, reviewActions } from "../runs/review-action-queue.js"; import { deriveScopeKey } from "../runs/scope-key.js"; @@ -27,6 +21,7 @@ import { enforceSameOrigin } from "./pull-request-shared.js"; const THREAD_PROMOTION_IN_PROGRESS = "This comment thread is being added to the review."; export function commentRoutes(db: StageDb): Route[] { + const store = new LocalCommentThreadStore(db); return [ // Threads are anchored to a diff scope rather than a single run, so // comments survive re-imports of the same diff. @@ -40,7 +35,7 @@ export function commentRoutes(db: StageDb): Route[] { writeJson(res, 404, { error: `Run ${params.runId} not found` }); return; } - writeJson(res, 200, listThreads(db, scopeKey)); + writeJson(res, 200, store.listByScope(scopeKey).map(toThreadDto)); }, }, { @@ -55,29 +50,7 @@ export function commentRoutes(db: StageDb): Route[] { } const body = await parseJsonBody(req, res, CreateCommentThreadBodySchema); if (!body) return; - - const created = db.transaction((tx) => { - const [threadRow] = tx - .insert(commentThread) - .values({ - scopeKey, - filePath: body.filePath, - side: body.side, - startLine: body.startLine, - endLine: body.endLine, - }) - .returning() - .all(); - if (!threadRow) throw new Error("comment_thread insert returned no row"); - const [commentRow] = tx - .insert(comment) - .values({ threadId: threadRow.id, authorId: LOCAL_USER_ID, body: body.body }) - .returning() - .all(); - if (!commentRow) throw new Error("comment insert returned no row"); - return toThreadDto(threadRow, [commentRow]); - }); - writeJson(res, 201, created); + writeJson(res, 201, toThreadDto(store.create(scopeKey, body))); }, }, { @@ -98,25 +71,11 @@ export function commentRoutes(db: StageDb): Route[] { } await reviewActions.run({ kind: REVIEW_ACTION_SCOPE.LOCAL_THREAD, threadId }, async () => { - if (!threadExists(db, threadId)) { + if (!store.exists(threadId)) { writeJson(res, 404, { error: `Thread ${threadId} not found` }); return; } - const created = db.transaction((tx) => { - const [commentRow] = tx - .insert(comment) - .values({ threadId, authorId: LOCAL_USER_ID, body: body.body }) - .returning() - .all(); - if (!commentRow) throw new Error("comment insert returned no row"); - // Bump the thread so its updatedAt reflects the latest activity. - tx.update(commentThread) - .set({ updatedAt: new Date() }) - .where(eq(commentThread.id, threadId)) - .run(); - return toCommentDto(commentRow); - }); - writeJson(res, 201, created); + writeJson(res, 201, toCommentDto(store.reply(threadId, body.body))); }); }, }, @@ -140,17 +99,12 @@ export function commentRoutes(db: StageDb): Route[] { } await reviewActions.run({ kind: REVIEW_ACTION_SCOPE.LOCAL_THREAD, threadId }, async () => { - const [updated] = db - .update(commentThread) - .set({ resolvedAt: body.resolved ? new Date() : null }) - .where(eq(commentThread.id, threadId)) - .returning() - .all(); + const updated = store.setResolved(threadId, body.resolved); if (!updated) { writeJson(res, 404, { error: `Thread ${threadId} not found` }); return; } - writeJson(res, 200, toThreadDto(updated, threadComments(db, threadId))); + writeJson(res, 200, toThreadDto({ thread: updated, comments: store.comments(threadId) })); }); }, }, @@ -287,53 +241,3 @@ function resolveRunScopeKey(db: StageDb, runId: string | undefined): string | nu if (!run) return null; return deriveScopeKey(run); } - -function listThreads(db: StageDb, scopeKey: string): CommentThreadDto[] { - return loadLocalThreadRecords(db, scopeKey).map(({ thread, comments }) => - toThreadDto(thread, comments), - ); -} - -function threadComments(db: StageDb, threadId: string): CommentRow[] { - return db - .select() - .from(comment) - .where(eq(comment.threadId, threadId)) - .orderBy(asc(commentInsertionOrder)) - .all(); -} - -function threadExists(db: StageDb, threadId: string): boolean { - return ( - db - .select({ id: commentThread.id }) - .from(commentThread) - .where(eq(commentThread.id, threadId)) - .limit(1) - .all().length > 0 - ); -} - -function toThreadDto(thread: CommentThreadRow, comments: CommentRow[]): CommentThreadDto { - return { - id: thread.id, - filePath: thread.filePath, - side: thread.side, - startLine: thread.startLine, - endLine: thread.endLine, - resolvedAt: thread.resolvedAt?.toISOString() ?? null, - createdAt: thread.createdAt.toISOString(), - updatedAt: thread.updatedAt.toISOString(), - comments: comments.map(toCommentDto), - }; -} - -function toCommentDto(row: CommentRow): CommentDto { - return { - id: row.id, - body: row.body, - authorId: row.authorId, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} diff --git a/packages/cli/src/runs/import-chapters.ts b/packages/cli/src/runs/import-chapters.ts index cd4c9300..c3e956c4 100644 --- a/packages/cli/src/runs/import-chapters.ts +++ b/packages/cli/src/runs/import-chapters.ts @@ -4,8 +4,8 @@ import path from "node:path"; import { getDb, type StageDb } from "../db/client.js"; import { chapter, chapterRun, keyChange } from "../db/schema/index.js"; import { type RepoContext, readRepoContext } from "../git.js"; -import { type ChaptersFile, ChaptersFileSchema, SCOPE_KIND } from "../schema.js"; -import { deriveScopeKey } from "./scope-key.js"; +import { type ChaptersFile, ChaptersFileSchema } from "../schema.js"; +import { deriveScopeKey, scopeKeyParts } from "./scope-key.js"; export interface ImportChaptersResult { runId: string; @@ -38,11 +38,7 @@ export function insertChaptersFile( originUrl: repo.originUrl, prNumber, headRef: repo.headRef, - scopeKind: file.scope.kind, - workingTreeRef: file.scope.kind === SCOPE_KIND.WORKING_TREE ? file.scope.ref : null, - baseSha: file.scope.baseSha, - headSha: file.scope.headSha, - mergeBaseSha: file.scope.mergeBaseSha, + ...scopeKeyParts(file.scope), generatedAt: new Date(file.generatedAt), prologue: file.prologue ?? null, }; diff --git a/packages/cli/src/runs/local-comment-threads.ts b/packages/cli/src/runs/local-comment-threads.ts index e94470cf..57ff2920 100644 --- a/packages/cli/src/runs/local-comment-threads.ts +++ b/packages/cli/src/runs/local-comment-threads.ts @@ -1,5 +1,11 @@ -import { asc, eq, inArray } from "drizzle-orm"; +import type { + Comment as CommentDto, + CommentThread as CommentThreadDto, + CreateCommentThreadBody, +} from "@stagereview/types/comments"; +import { asc, eq, inArray, sql } from "drizzle-orm"; import type { StageDb } from "../db/client.js"; +import { LOCAL_USER_ID } from "../db/local-user.js"; import { type CommentRow, type CommentThreadRow, @@ -13,36 +19,168 @@ export interface LocalThreadRecord { comments: CommentRow[]; } -/** Load a diff scope's local threads and all of their comments in two queries. */ -export function loadLocalThreadRecords(db: StageDb, scopeKey: string): LocalThreadRecord[] { - const threads = db - .select() - .from(commentThread) - .where(eq(commentThread.scopeKey, scopeKey)) - .orderBy(asc(commentThread.createdAt)) - .all(); - if (threads.length === 0) return []; - - const comments = db - .select() - .from(comment) - .where( - inArray( - comment.threadId, - threads.map((thread) => thread.id), - ), - ) - .orderBy(asc(commentInsertionOrder)) - .all(); - const commentsByThread = new Map(); - for (const row of comments) { - const threadComments = commentsByThread.get(row.threadId); - if (threadComments) threadComments.push(row); - else commentsByThread.set(row.threadId, [row]); - } - - return threads.map((thread) => ({ - thread, - comments: commentsByThread.get(thread.id) ?? [], - })); +/** + * Persistence for CLI-local review comment threads. Shared by the HTTP routes + * (browser UI) and the `stagereview comments` command (coding agents) so both + * surfaces read and write threads identically. HTTP-only concerns — origin + * checks, promotion locks, status codes — stay in the routes. + */ +export class LocalCommentThreadStore { + constructor(private readonly db: StageDb) {} + + /** A diff scope's threads (oldest first) with all of their comments, in two queries. */ + listByScope(scopeKey: string): LocalThreadRecord[] { + const threads = this.db + .select() + .from(commentThread) + .where(eq(commentThread.scopeKey, scopeKey)) + .orderBy(asc(commentThread.createdAt)) + .all(); + if (threads.length === 0) return []; + + const comments = this.db + .select() + .from(comment) + .where( + inArray( + comment.threadId, + threads.map((thread) => thread.id), + ), + ) + .orderBy(asc(commentInsertionOrder)) + .all(); + const commentsByThread = new Map(); + for (const row of comments) { + const threadComments = commentsByThread.get(row.threadId); + if (threadComments) threadComments.push(row); + else commentsByThread.set(row.threadId, [row]); + } + + return threads.map((thread) => ({ + thread, + comments: commentsByThread.get(thread.id) ?? [], + })); + } + + find(threadId: string): LocalThreadRecord | null { + const [thread] = this.db + .select() + .from(commentThread) + .where(eq(commentThread.id, threadId)) + .limit(1) + .all(); + if (!thread) return null; + return { thread, comments: this.comments(threadId) }; + } + + /** + * Threads whose ID starts with `prefix`, across every scope. Thread IDs are + * UUIDs, so a short prefix is enough for a human or agent to name one; the + * caller decides how to treat zero or several matches. + */ + findByIdPrefix(prefix: string): CommentThreadRow[] { + return this.db + .select() + .from(commentThread) + .where(sql`substr(${commentThread.id}, 1, ${prefix.length}) = ${prefix}`) + .orderBy(asc(commentThread.createdAt)) + .all(); + } + + exists(threadId: string): boolean { + return ( + this.db + .select({ id: commentThread.id }) + .from(commentThread) + .where(eq(commentThread.id, threadId)) + .limit(1) + .all().length > 0 + ); + } + + comments(threadId: string): CommentRow[] { + return this.db + .select() + .from(comment) + .where(eq(comment.threadId, threadId)) + .orderBy(asc(commentInsertionOrder)) + .all(); + } + + /** Create a thread and its root comment atomically. */ + create(scopeKey: string, input: CreateCommentThreadBody): LocalThreadRecord { + return this.db.transaction((tx) => { + const [threadRow] = tx + .insert(commentThread) + .values({ + scopeKey, + filePath: input.filePath, + side: input.side, + startLine: input.startLine, + endLine: input.endLine, + }) + .returning() + .all(); + if (!threadRow) throw new Error("comment_thread insert returned no row"); + const [commentRow] = tx + .insert(comment) + .values({ threadId: threadRow.id, authorId: LOCAL_USER_ID, body: input.body }) + .returning() + .all(); + if (!commentRow) throw new Error("comment insert returned no row"); + return { thread: threadRow, comments: [commentRow] }; + }); + } + + /** Append a reply to an existing thread and bump the thread's activity timestamp. */ + reply(threadId: string, body: string): CommentRow { + return this.db.transaction((tx) => { + const [commentRow] = tx + .insert(comment) + .values({ threadId, authorId: LOCAL_USER_ID, body }) + .returning() + .all(); + if (!commentRow) throw new Error("comment insert returned no row"); + tx.update(commentThread) + .set({ updatedAt: new Date() }) + .where(eq(commentThread.id, threadId)) + .run(); + return commentRow; + }); + } + + /** Resolve or reopen a thread; null when no such thread exists. */ + setResolved(threadId: string, resolved: boolean): CommentThreadRow | null { + const [updated] = this.db + .update(commentThread) + .set({ resolvedAt: resolved ? new Date() : null }) + .where(eq(commentThread.id, threadId)) + .returning() + .all(); + return updated ?? null; + } +} + +export function toThreadDto({ thread, comments }: LocalThreadRecord): CommentThreadDto { + return { + id: thread.id, + filePath: thread.filePath, + side: thread.side, + startLine: thread.startLine, + endLine: thread.endLine, + resolvedAt: thread.resolvedAt?.toISOString() ?? null, + createdAt: thread.createdAt.toISOString(), + updatedAt: thread.updatedAt.toISOString(), + comments: comments.map(toCommentDto), + }; +} + +export function toCommentDto(row: CommentRow): CommentDto { + return { + id: row.id, + body: row.body, + authorId: row.authorId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; } diff --git a/packages/cli/src/runs/review.ts b/packages/cli/src/runs/review.ts index 7f928aff..d2ef3c60 100644 --- a/packages/cli/src/runs/review.ts +++ b/packages/cli/src/runs/review.ts @@ -48,7 +48,7 @@ import { updateReviewComment, } from "../github/review.js"; import { DIFF_SIDE, type DiffSide, SCOPE_KIND } from "../schema.js"; -import { loadLocalThreadRecords } from "./local-comment-threads.js"; +import { LocalCommentThreadStore } from "./local-comment-threads.js"; import { REVIEW_ACTION_SCOPE, reviewActions } from "./review-action-queue.js"; import { deriveScopeKey } from "./scope-key.js"; @@ -128,8 +128,9 @@ function requirePendingComment(review: GitHubReview, nodeId: string): GitHubApiR // ─── Read: merged local + GitHub review ───────────────────────────────────────── function loadLocalThreads(db: StageDb, run: ChapterRunRow): ReviewThreadDto[] { - return loadLocalThreadRecords(db, deriveScopeKey(run)).map( - ({ thread, comments }): LocalReviewThreadDto => { + return new LocalCommentThreadStore(db) + .listByScope(deriveScopeKey(run)) + .map(({ thread, comments }): LocalReviewThreadDto => { return { id: thread.id, source: THREAD_SOURCE.LOCAL, @@ -152,8 +153,7 @@ function loadLocalThreads(db: StageDb, run: ChapterRunRow): ReviewThreadDto[] { }), ), }; - }, - ); + }); } function toGitHubCommentDto(c: GitHubApiReviewComment): GitHubReviewCommentDto { diff --git a/packages/cli/src/runs/scope-key.ts b/packages/cli/src/runs/scope-key.ts index 494ad891..6cf59d51 100644 --- a/packages/cli/src/runs/scope-key.ts +++ b/packages/cli/src/runs/scope-key.ts @@ -1,4 +1,4 @@ -import { SCOPE_KIND, type ScopeKind, type WorkingTreeRef } from "../schema.js"; +import { SCOPE_KIND, type Scope, type ScopeKind, type WorkingTreeRef } from "../schema.js"; export interface ScopeKeyParts { scopeKind: ScopeKind; @@ -22,3 +22,14 @@ export function deriveScopeKey(parts: ScopeKeyParts): string { } return `workingTree:${workingTreeRef}:${baseSha}:${headSha}:${mergeBaseSha}`; } + +/** Flatten a chapters-file scope into the column shape `chapter_run` stores. */ +export function scopeKeyParts(scope: Scope): ScopeKeyParts { + return { + scopeKind: scope.kind, + workingTreeRef: scope.kind === SCOPE_KIND.WORKING_TREE ? scope.ref : null, + baseSha: scope.baseSha, + headSha: scope.headSha, + mergeBaseSha: scope.mergeBaseSha, + }; +} From 556b3b328dd3a67c4bec263580744e5999b3bd16 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:14:28 +0000 Subject: [PATCH 2/6] feat: record who authored a local comment (user vs agent) Add comment.authorType with a Drizzle migration defaulting existing rows to user, expose it on the comment and review wire types, and render an Agent badge next to Local on agent-authored comments in the review UI. --- .../drizzle/0008_wakeful_roxanne_simpson.sql | 1 + packages/cli/drizzle/meta/0008_snapshot.json | 804 ++++++++++++++++++ packages/cli/drizzle/meta/_journal.json | 9 +- .../cli/src/__tests__/comments.routes.test.ts | 9 +- .../migration-comment-author-type.test.ts | 79 ++ packages/cli/src/db/schema/comment.ts | 6 + packages/cli/src/routes/comments.ts | 9 +- .../cli/src/runs/local-comment-threads.ts | 14 +- packages/cli/src/runs/review.ts | 1 + packages/types/src/comments.ts | 9 + packages/types/src/review.ts | 3 +- .../src/components/comments/review-thread.tsx | 29 +- .../__tests__/use-review-promotion.test.tsx | 1 + .../web/src/lib/__tests__/use-review.test.tsx | 2 + 14 files changed, 958 insertions(+), 18 deletions(-) create mode 100644 packages/cli/drizzle/0008_wakeful_roxanne_simpson.sql create mode 100644 packages/cli/drizzle/meta/0008_snapshot.json create mode 100644 packages/cli/src/__tests__/migration-comment-author-type.test.ts diff --git a/packages/cli/drizzle/0008_wakeful_roxanne_simpson.sql b/packages/cli/drizzle/0008_wakeful_roxanne_simpson.sql new file mode 100644 index 00000000..63577409 --- /dev/null +++ b/packages/cli/drizzle/0008_wakeful_roxanne_simpson.sql @@ -0,0 +1 @@ +ALTER TABLE `comment` ADD `authorType` text DEFAULT 'user' NOT NULL; \ No newline at end of file diff --git a/packages/cli/drizzle/meta/0008_snapshot.json b/packages/cli/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..f0a06972 --- /dev/null +++ b/packages/cli/drizzle/meta/0008_snapshot.json @@ -0,0 +1,804 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "006d0abc-adf7-42e8-8e8d-43dff8b38f47", + "prevId": "6871dd89-e933-47db-a3e6-def42ead92df", + "tables": { + "chapter": { + "name": "chapter", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runId": { + "name": "runId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapterIndex": { + "name": "chapterIndex", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hunkRefs": { + "name": "hunkRefs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyChanges": { + "name": "keyChanges", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "riskLevel": { + "name": "riskLevel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "riskReasons": { + "name": "riskReasons", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chapter_run_idx_unique": { + "name": "chapter_run_idx_unique", + "columns": [ + "runId", + "chapterIndex" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_runId_chapter_run_id_fk": { + "name": "chapter_runId_chapter_run_id_fk", + "tableFrom": "chapter", + "tableTo": "chapter_run", + "columnsFrom": [ + "runId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_file_view": { + "name": "chapter_file_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chapter_file_view_chapter_id_idx": { + "name": "chapter_file_view_chapter_id_idx", + "columns": [ + "chapterId" + ], + "isUnique": false + }, + "chapter_file_view_user_chapter_path_unique": { + "name": "chapter_file_view_user_chapter_path_unique", + "columns": [ + "userId", + "chapterId", + "filePath" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_file_view_chapterId_chapter_id_fk": { + "name": "chapter_file_view_chapterId_chapter_id_fk", + "tableFrom": "chapter_file_view", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_run": { + "name": "chapter_run", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repoRoot": { + "name": "repoRoot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originUrl": { + "name": "originUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prNumber": { + "name": "prNumber", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "headRef": { + "name": "headRef", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopeKind": { + "name": "scopeKind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workingTreeRef": { + "name": "workingTreeRef", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "baseSha": { + "name": "baseSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headSha": { + "name": "headSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mergeBaseSha": { + "name": "mergeBaseSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generatedAt": { + "name": "generatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prologue": { + "name": "prologue", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chapter_run_created_at_idx": { + "name": "chapter_run_created_at_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_view": { + "name": "chapter_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chapter_view_user_chapter_unique": { + "name": "chapter_view_user_chapter_unique", + "columns": [ + "userId", + "chapterId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_view_chapterId_chapter_id_fk": { + "name": "chapter_view_chapterId_chapter_id_fk", + "tableFrom": "chapter_view", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment": { + "name": "comment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "threadId": { + "name": "threadId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "authorType": { + "name": "authorType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "comment_thread_id_idx": { + "name": "comment_thread_id_idx", + "columns": [ + "threadId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comment_threadId_comment_thread_id_fk": { + "name": "comment_threadId_comment_thread_id_fk", + "tableFrom": "comment", + "tableTo": "comment_thread", + "columnsFrom": [ + "threadId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_thread": { + "name": "comment_thread", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopeKey": { + "name": "scopeKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startLine": { + "name": "startLine", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endLine": { + "name": "endLine", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolvedAt": { + "name": "resolvedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "comment_thread_scope_key_idx": { + "name": "comment_thread_scope_key_idx", + "columns": [ + "scopeKey" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_view": { + "name": "file_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "runId": { + "name": "runId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_view_user_run_path_unique": { + "name": "file_view_user_run_path_unique", + "columns": [ + "userId", + "runId", + "filePath" + ], + "isUnique": true + } + }, + "foreignKeys": { + "file_view_runId_chapter_run_id_fk": { + "name": "file_view_runId_chapter_run_id_fk", + "tableFrom": "file_view", + "tableTo": "chapter_run", + "columnsFrom": [ + "runId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "key_change": { + "name": "key_change", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lineRefs": { + "name": "lineRefs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": { + "key_change_chapter_id_idx": { + "name": "key_change_chapter_id_idx", + "columns": [ + "chapterId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "key_change_chapterId_chapter_id_fk": { + "name": "key_change_chapterId_chapter_id_fk", + "tableFrom": "key_change", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "key_change_view": { + "name": "key_change_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "keyChangeId": { + "name": "keyChangeId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "key_change_view_key_change_id_idx": { + "name": "key_change_view_key_change_id_idx", + "columns": [ + "keyChangeId" + ], + "isUnique": false + }, + "key_change_view_user_key_change_unique": { + "name": "key_change_view_user_key_change_unique", + "columns": [ + "userId", + "keyChangeId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "key_change_view_keyChangeId_key_change_id_fk": { + "name": "key_change_view_keyChangeId_key_change_id_fk", + "tableFrom": "key_change_view", + "tableTo": "key_change", + "columnsFrom": [ + "keyChangeId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/cli/drizzle/meta/_journal.json b/packages/cli/drizzle/meta/_journal.json index 07e225a5..886ab95b 100644 --- a/packages/cli/drizzle/meta/_journal.json +++ b/packages/cli/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1786332453144, "tag": "0007_mysterious_clea", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1788721948788, + "tag": "0008_wakeful_roxanne_simpson", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/cli/src/__tests__/comments.routes.test.ts b/packages/cli/src/__tests__/comments.routes.test.ts index 4cadf8d2..4eac0c73 100644 --- a/packages/cli/src/__tests__/comments.routes.test.ts +++ b/packages/cli/src/__tests__/comments.routes.test.ts @@ -57,7 +57,11 @@ describe("comment threads API — reads and updates", () => { resolvedAt: null, }); expect(thread.comments).toHaveLength(1); - expect(thread.comments[0]).toMatchObject({ body: "First!", authorId: "local" }); + expect(thread.comments[0]).toMatchObject({ + body: "First!", + authorId: "local", + authorType: "user", + }); expect(harness.db.select().from(commentThread).all()).toHaveLength(1); expect(harness.db.select().from(comment).all()).toHaveLength(1); }); @@ -70,9 +74,10 @@ describe("comment threads API — reads and updates", () => { ); const thread = await harness.createThread(port, runId); - await harness.request(port, "POST", `/api/comment-threads/${thread.id}/replies`, { + const reply = await harness.request(port, "POST", `/api/comment-threads/${thread.id}/replies`, { body: "A reply", }); + expect(reply.body).toMatchObject({ authorType: "user" }); const response = await harness.request(port, "GET", `/api/runs/${runId}/comment-threads`); const threads = response.body as CommentThread[]; diff --git a/packages/cli/src/__tests__/migration-comment-author-type.test.ts b/packages/cli/src/__tests__/migration-comment-author-type.test.ts new file mode 100644 index 00000000..c246e114 --- /dev/null +++ b/packages/cli/src/__tests__/migration-comment-author-type.test.ts @@ -0,0 +1,79 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const AUTHOR_TYPE_TAG = "0008_wakeful_roxanne_simpson"; +const MIGRATIONS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../drizzle"); + +interface JournalEntry { + tag: string; +} +interface Journal { + entries: JournalEntry[]; +} + +let tmpDir: string; +let sqlite: Database.Database; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "stage-cli-migration-")); + sqlite = new Database(path.join(tmpDir, "db.sqlite")); + sqlite.pragma("foreign_keys = ON"); +}); + +afterEach(async () => { + sqlite.close(); + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +/** A copy of the migrations folder with every entry from `tag` onward removed. */ +async function migrationsFolderBefore(tag: string): Promise { + const folder = path.join(tmpDir, "migrations"); + await fs.cp(MIGRATIONS_DIR, folder, { recursive: true }); + const journalPath = path.join(folder, "meta", "_journal.json"); + const journal = JSON.parse(await fs.readFile(journalPath, "utf8")) as Journal; + const cutoff = journal.entries.findIndex((entry) => entry.tag === tag); + if (cutoff < 0) throw new Error(`Migration ${tag} not found in journal`); + journal.entries = journal.entries.slice(0, cutoff); + await fs.writeFile(journalPath, JSON.stringify(journal)); + return folder; +} + +function seedLegacyComment(threadId: string, commentId: string): void { + const now = Date.now(); + sqlite + .prepare( + `INSERT INTO comment_thread (id, createdAt, updatedAt, scopeKey, filePath, side, startLine, endLine) + VALUES (?, ?, ?, 'committed:a:b:c', 'src/foo.ts', 'additions', 1, 1)`, + ) + .run(threadId, now, now); + sqlite + .prepare( + `INSERT INTO comment (id, createdAt, updatedAt, threadId, authorId, body) + VALUES (?, ?, ?, ?, 'local', 'Legacy comment')`, + ) + .run(commentId, now, now, threadId); +} + +describe("migration 0008 — comment.authorType", () => { + it("backfills existing comments as user-authored and keeps them readable", async () => { + const db = drizzle(sqlite); + migrate(db, { migrationsFolder: await migrationsFolderBefore(AUTHOR_TYPE_TAG) }); + const threadId = randomUUID(); + const commentId = randomUUID(); + seedLegacyComment(threadId, commentId); + + migrate(db, { migrationsFolder: MIGRATIONS_DIR }); + + const row = sqlite + .prepare("SELECT authorType, body FROM comment WHERE id = ?") + .get(commentId) as { authorType: string; body: string }; + expect(row).toEqual({ authorType: "user", body: "Legacy comment" }); + }); +}); diff --git a/packages/cli/src/db/schema/comment.ts b/packages/cli/src/db/schema/comment.ts index 86fa9c18..d9c39746 100644 --- a/packages/cli/src/db/schema/comment.ts +++ b/packages/cli/src/db/schema/comment.ts @@ -1,3 +1,4 @@ +import { COMMENT_AUTHOR_TYPE } from "@stagereview/types/comments"; import { sql } from "drizzle-orm"; import { index, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { LOCAL_USER_ID } from "../local-user.js"; @@ -14,6 +15,11 @@ export const comment = sqliteTable( .notNull() .references(() => commentThread.id, { onDelete: "cascade" }), authorId: text().notNull().default(LOCAL_USER_ID), + // Distinguishes the human reviewing in the browser from a coding agent + // writing through the `stagereview comments` CLI. + authorType: text({ enum: [COMMENT_AUTHOR_TYPE.USER, COMMENT_AUTHOR_TYPE.AGENT] }) + .notNull() + .default(COMMENT_AUTHOR_TYPE.USER), body: text().notNull(), }, (table) => [index("comment_thread_id_idx").on(table.threadId)], diff --git a/packages/cli/src/routes/comments.ts b/packages/cli/src/routes/comments.ts index 228a75da..0ac8db83 100644 --- a/packages/cli/src/routes/comments.ts +++ b/packages/cli/src/routes/comments.ts @@ -1,4 +1,5 @@ import { + COMMENT_AUTHOR_TYPE, CommentBodySchema, CreateCommentThreadBodySchema, ResolveThreadBodySchema, @@ -50,7 +51,7 @@ export function commentRoutes(db: StageDb): Route[] { } const body = await parseJsonBody(req, res, CreateCommentThreadBodySchema); if (!body) return; - writeJson(res, 201, toThreadDto(store.create(scopeKey, body))); + writeJson(res, 201, toThreadDto(store.create(scopeKey, body, COMMENT_AUTHOR_TYPE.USER))); }, }, { @@ -75,7 +76,11 @@ export function commentRoutes(db: StageDb): Route[] { writeJson(res, 404, { error: `Thread ${threadId} not found` }); return; } - writeJson(res, 201, toCommentDto(store.reply(threadId, body.body))); + writeJson( + res, + 201, + toCommentDto(store.reply(threadId, body.body, COMMENT_AUTHOR_TYPE.USER)), + ); }); }, }, diff --git a/packages/cli/src/runs/local-comment-threads.ts b/packages/cli/src/runs/local-comment-threads.ts index 57ff2920..fec596cc 100644 --- a/packages/cli/src/runs/local-comment-threads.ts +++ b/packages/cli/src/runs/local-comment-threads.ts @@ -1,4 +1,5 @@ import type { + CommentAuthorType, Comment as CommentDto, CommentThread as CommentThreadDto, CreateCommentThreadBody, @@ -108,7 +109,11 @@ export class LocalCommentThreadStore { } /** Create a thread and its root comment atomically. */ - create(scopeKey: string, input: CreateCommentThreadBody): LocalThreadRecord { + create( + scopeKey: string, + input: CreateCommentThreadBody, + authorType: CommentAuthorType, + ): LocalThreadRecord { return this.db.transaction((tx) => { const [threadRow] = tx .insert(commentThread) @@ -124,7 +129,7 @@ export class LocalCommentThreadStore { if (!threadRow) throw new Error("comment_thread insert returned no row"); const [commentRow] = tx .insert(comment) - .values({ threadId: threadRow.id, authorId: LOCAL_USER_ID, body: input.body }) + .values({ threadId: threadRow.id, authorId: LOCAL_USER_ID, authorType, body: input.body }) .returning() .all(); if (!commentRow) throw new Error("comment insert returned no row"); @@ -133,11 +138,11 @@ export class LocalCommentThreadStore { } /** Append a reply to an existing thread and bump the thread's activity timestamp. */ - reply(threadId: string, body: string): CommentRow { + reply(threadId: string, body: string, authorType: CommentAuthorType): CommentRow { return this.db.transaction((tx) => { const [commentRow] = tx .insert(comment) - .values({ threadId, authorId: LOCAL_USER_ID, body }) + .values({ threadId, authorId: LOCAL_USER_ID, authorType, body }) .returning() .all(); if (!commentRow) throw new Error("comment insert returned no row"); @@ -180,6 +185,7 @@ export function toCommentDto(row: CommentRow): CommentDto { id: row.id, body: row.body, authorId: row.authorId, + authorType: row.authorType, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; diff --git a/packages/cli/src/runs/review.ts b/packages/cli/src/runs/review.ts index d2ef3c60..eeab41ea 100644 --- a/packages/cli/src/runs/review.ts +++ b/packages/cli/src/runs/review.ts @@ -144,6 +144,7 @@ function loadLocalThreads(db: StageDb, run: ChapterRunRow): ReviewThreadDto[] { (c): LocalReviewCommentDto => ({ id: c.id, state: COMMENT_STATE.LOCAL, + authorType: c.authorType, body: c.body, bodyHtml: null, author: null, diff --git a/packages/types/src/comments.ts b/packages/types/src/comments.ts index 899b874e..4251d717 100644 --- a/packages/types/src/comments.ts +++ b/packages/types/src/comments.ts @@ -1,6 +1,14 @@ import { z } from "zod"; import { DIFF_SIDE } from "./chapters.ts"; +// Who wrote a local comment: the human reviewing in the browser (`user`) or a +// coding agent acting through the `stagereview comments` CLI (`agent`). +export const COMMENT_AUTHOR_TYPE = { + USER: "user", + AGENT: "agent", +} as const; +export type CommentAuthorType = (typeof COMMENT_AUTHOR_TYPE)[keyof typeof COMMENT_AUTHOR_TYPE]; + // 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. // These are CLI-local comments; GitHub review comments use the `review` wire model. @@ -8,6 +16,7 @@ export const CommentSchema = z.object({ id: z.string(), body: z.string(), authorId: z.string(), + authorType: z.enum(COMMENT_AUTHOR_TYPE), createdAt: z.string(), updatedAt: z.string(), }); diff --git a/packages/types/src/review.ts b/packages/types/src/review.ts index 539f98d6..0b4b5efd 100644 --- a/packages/types/src/review.ts +++ b/packages/types/src/review.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { DIFF_SIDE } from "./chapters.ts"; -import { CreateCommentThreadBodySchema } from "./comments.ts"; +import { COMMENT_AUTHOR_TYPE, CreateCommentThreadBodySchema } from "./comments.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 @@ -52,6 +52,7 @@ const ReviewCommentBaseSchema = z.object({ export const LocalReviewCommentSchema = ReviewCommentBaseSchema.extend({ state: z.literal(COMMENT_STATE.LOCAL), + authorType: z.enum(COMMENT_AUTHOR_TYPE), bodyHtml: z.null(), author: z.null(), nodeId: z.null(), diff --git a/packages/web/src/components/comments/review-thread.tsx b/packages/web/src/components/comments/review-thread.tsx index de47f400..d58ceaed 100644 --- a/packages/web/src/components/comments/review-thread.tsx +++ b/packages/web/src/components/comments/review-thread.tsx @@ -1,3 +1,4 @@ +import { COMMENT_AUTHOR_TYPE } from "@stagereview/types/comments"; import { COMMENT_STATE, type ReviewComment, @@ -5,6 +6,7 @@ import { THREAD_SOURCE, } from "@stagereview/types/review"; import { + Bot, ChevronRight, Circle, CircleCheck, @@ -62,8 +64,11 @@ export function activeEditingCommentId( return comment && canEditReviewComment(comment, canWriteToGitHub) ? editingId : null; } -function StateBadge({ state }: { state: ReviewComment["state"] }) { - if (state === COMMENT_STATE.PENDING) { +// Local comments carry a Local badge; ones a coding agent wrote through the +// `stagereview comments` CLI also carry an Agent badge so the human can tell +// their own notes from the agent's replies at a glance. +function StateBadge({ comment }: { comment: ReviewComment }) { + if (comment.state === COMMENT_STATE.PENDING) { return ( ); } - if (state === COMMENT_STATE.LOCAL) { + if (comment.state === COMMENT_STATE.LOCAL) { return ( - - Local - + <> + + Local + + {comment.authorType === COMMENT_AUTHOR_TYPE.AGENT && ( + + + )} + ); } return null; @@ -269,7 +282,7 @@ export function ReviewThreadView({ model }: { model: ReviewThreadViewModel }) { {isOpen ? "Collapse thread" : "Expand thread"} - +
- + {idle && canEditReviewComment(reply, canWriteToGitHub) && ( )} diff --git a/packages/web/src/lib/__tests__/use-review-promotion.test.tsx b/packages/web/src/lib/__tests__/use-review-promotion.test.tsx index 2aa774cb..c8297e3d 100644 --- a/packages/web/src/lib/__tests__/use-review-promotion.test.tsx +++ b/packages/web/src/lib/__tests__/use-review-promotion.test.tsx @@ -19,6 +19,7 @@ const LOCAL_THREAD: ReviewThread = { { id: "COMMENT_local", state: "local", + authorType: "user", body: "Promote me", bodyHtml: null, author: null, diff --git a/packages/web/src/lib/__tests__/use-review.test.tsx b/packages/web/src/lib/__tests__/use-review.test.tsx index 7a6389f8..f74d04f5 100644 --- a/packages/web/src/lib/__tests__/use-review.test.tsx +++ b/packages/web/src/lib/__tests__/use-review.test.tsx @@ -58,6 +58,7 @@ const LOCAL_REVIEW_THREAD: ReviewThread = { { id: "COMMENT_local", state: "local", + authorType: "user", body: "Local comment", bodyHtml: null, author: null, @@ -87,6 +88,7 @@ const LOCAL_THREAD: CommentThread = { id: "COMMENT_local", body: "Local comment", authorId: "local", + authorType: "user", createdAt: "2026-01-02T00:00:00Z", updatedAt: "2026-01-02T00:00:00Z", }, From fc7cfdbe80cd899110caeb58501f27227f800a6b Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:19:10 +0000 Subject: [PATCH 3/6] feat: add stagereview comments for agents to read and act on local review threads list/show/reply/resolve/reopen/create operate directly on the SQLite database using the same diff-scope selectors as prep and show, so no server has to be running. Thread IDs accept an unambiguous prefix of at least six characters, and everything the CLI writes is attributed to the agent. --- .../src/__tests__/comments-cli-scope.test.ts | 103 +++++++++ .../cli/src/__tests__/comments-cli.test.ts | 186 ++++++++++++++++ packages/cli/src/comments/command.ts | 210 ++++++++++++++++++ packages/cli/src/comments/comments-cli.ts | 133 +++++++++++ packages/cli/src/index.ts | 3 + 5 files changed, 635 insertions(+) create mode 100644 packages/cli/src/__tests__/comments-cli-scope.test.ts create mode 100644 packages/cli/src/__tests__/comments-cli.test.ts create mode 100644 packages/cli/src/comments/command.ts create mode 100644 packages/cli/src/comments/comments-cli.ts diff --git a/packages/cli/src/__tests__/comments-cli-scope.test.ts b/packages/cli/src/__tests__/comments-cli-scope.test.ts new file mode 100644 index 00000000..a5e482e6 --- /dev/null +++ b/packages/cli/src/__tests__/comments-cli-scope.test.ts @@ -0,0 +1,103 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveCommentScopeKey } from "../comments/comments-cli.js"; +import { closeDb, getDb } from "../db/client.js"; +import { chapterRun } from "../db/schema/index.js"; +import { insertChaptersFile } from "../runs/import-chapters.js"; +import { deriveScopeKey } from "../runs/scope-key.js"; +import { type DiffScopeOptions, resolveDiffScope } from "../scope.js"; +import { makeFixture, makeRepoContext } from "./fixtures.js"; + +let tmpDir: string; +let originalCwd: string; + +beforeEach(async () => { + originalCwd = process.cwd(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "stage-cli-comments-scope-")); + closeDb(); +}); + +afterEach(async () => { + process.chdir(originalCwd); + closeDb(); + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +function git(...args: string[]): string { + return execFileSync("git", args, { + cwd: tmpDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }, + }); +} + +async function writeFile(filePath: string, contents: string): Promise { + await fs.writeFile(path.join(tmpDir, filePath), contents); +} + +/** main with one commit, plus a feature branch (checked out) with one more commit. */ +async function initFeatureBranchRepo(): Promise { + git("init", "--initial-branch=main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + git("config", "commit.gpgsign", "false"); + await writeFile("file.txt", "base\n"); + git("add", "file.txt"); + git("commit", "-m", "base"); + git("checkout", "-b", "feature"); + await writeFile("file.txt", "base\nfeature\n"); + git("commit", "-am", "feature change"); + process.chdir(tmpDir); +} + +/** + * The scope key `show` would store for these options: resolve the scope the same + * way `show` does, import a chapters file carrying it, and read the run back. + */ +async function scopeKeyShowWouldStore(options: DiffScopeOptions): Promise { + const { scope } = await resolveDiffScope(options); + const db = getDb({ dbPath: path.join(tmpDir, "db.sqlite") }); + const { runId } = insertChaptersFile(db, makeFixture({ scope }), makeRepoContext()); + const run = await db.query.chapterRun.findFirst({ where: eq(chapterRun.id, runId) }); + if (!run) throw new Error("run not inserted"); + return deriveScopeKey(run); +} + +describe("comments CLI — scope resolution parity with show", () => { + it("targets the committed scope show uses for a clean feature branch", async () => { + await initFeatureBranchRepo(); + + const scopeKey = await resolveCommentScopeKey({}); + + expect(scopeKey).toMatch(/^committed:/); + expect(scopeKey).toBe(await scopeKeyShowWouldStore({})); + }); + + it("targets the working-tree scope show uses when changes are uncommitted", async () => { + await initFeatureBranchRepo(); + await writeFile("file.txt", "base\nfeature\nwip\n"); + + const scopeKey = await resolveCommentScopeKey({}); + + expect(scopeKey).toMatch(/^workingTree:work:/); + expect(scopeKey).toBe(await scopeKeyShowWouldStore({})); + }); + + it("honours explicit --ref and --base/--compare selectors like show", async () => { + await initFeatureBranchRepo(); + await writeFile("file.txt", "base\nfeature\nstaged\n"); + git("add", "file.txt"); + + const staged = { workingTreeRef: "staged" } as const; + expect(await resolveCommentScopeKey(staged)).toMatch(/^workingTree:staged:/); + expect(await resolveCommentScopeKey(staged)).toBe(await scopeKeyShowWouldStore(staged)); + + const comparison = { base: "main", compare: "feature" }; + expect(await resolveCommentScopeKey(comparison)).toBe(await scopeKeyShowWouldStore(comparison)); + }); +}); diff --git a/packages/cli/src/__tests__/comments-cli.test.ts b/packages/cli/src/__tests__/comments-cli.test.ts new file mode 100644 index 00000000..1c026c3e --- /dev/null +++ b/packages/cli/src/__tests__/comments-cli.test.ts @@ -0,0 +1,186 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { CreateCommentThreadBody } from "@stagereview/types/comments"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { formatThreadLine } from "../comments/command.js"; +import { CommentsCli } from "../comments/comments-cli.js"; +import { closeDb, getDb, type StageDb } from "../db/client.js"; +import { commentThread } from "../db/schema/index.js"; +import { LocalCommentThreadStore } from "../runs/local-comment-threads.js"; + +const SCOPE_KEY = "committed:aaa:bbb:ccc"; +const OTHER_SCOPE_KEY = "committed:ddd:eee:fff"; + +let tmpDir: string; +let db: StageDb; +let cli: CommentsCli; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "stage-cli-comments-cli-")); + closeDb(); + db = getDb({ dbPath: path.join(tmpDir, "db.sqlite") }); + cli = new CommentsCli(db); +}); + +afterEach(async () => { + closeDb(); + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +function makeInput(overrides: Partial = {}): CreateCommentThreadBody { + return { + filePath: "src/foo.ts", + side: "additions", + startLine: 5, + endLine: 10, + body: "Rename this helper", + ...overrides, + }; +} + +/** A thread the human left through the browser (user-authored root comment). */ +function seedUserThread(scopeKey = SCOPE_KEY, overrides: Partial = {}) { + return new LocalCommentThreadStore(db).create(scopeKey, makeInput(overrides), "user").thread; +} + +describe("comments CLI — reading threads", () => { + it("lists open threads for the scope by default and hides other scopes", () => { + const open = seedUserThread(); + seedUserThread(OTHER_SCOPE_KEY); + const resolved = seedUserThread(SCOPE_KEY, { body: "Already handled" }); + cli.resolve(resolved.id); + + expect(cli.list(SCOPE_KEY, "open").map((t) => t.id)).toEqual([open.id]); + expect(cli.list(SCOPE_KEY, "resolved").map((t) => t.id)).toEqual([resolved.id]); + expect(cli.list(SCOPE_KEY, "all").map((t) => t.id)).toEqual([open.id, resolved.id]); + }); + + it("shows a thread by full ID with its status and comments", () => { + const thread = seedUserThread(); + + const shown = cli.show(thread.id); + + expect(shown).toMatchObject({ + id: thread.id, + status: "open", + filePath: "src/foo.ts", + side: "additions", + startLine: 5, + endLine: 10, + resolvedAt: null, + }); + expect(shown.comments).toHaveLength(1); + expect(shown.comments[0]).toMatchObject({ body: "Rename this helper", authorType: "user" }); + }); +}); + +describe("comments CLI — thread ID prefixes", () => { + it("resolves an unambiguous prefix", () => { + const thread = seedUserThread(); + + expect(cli.show(thread.id.slice(0, 6)).id).toBe(thread.id); + }); + + it("rejects prefixes shorter than six characters", () => { + const thread = seedUserThread(); + + expect(() => cli.show(thread.id.slice(0, 5))).toThrow(/too short/); + }); + + it("errors clearly when nothing matches", () => { + expect(() => cli.show("zzzzzz-nope")).toThrow('No comment thread matches "zzzzzz-nope"'); + }); + + it("errors and lists candidates when a prefix is ambiguous", () => { + for (const id of ["abcdef-first", "abcdef-second"]) { + db.insert(commentThread) + .values({ + id, + scopeKey: SCOPE_KEY, + filePath: "src/foo.ts", + side: "additions", + startLine: 1, + endLine: 1, + }) + .run(); + } + + expect(() => cli.show("abcdef")).toThrow( + 'Thread ID "abcdef" is ambiguous — it matches 2 threads: abcdef-first, abcdef-second. Use a longer prefix.', + ); + }); +}); + +describe("comments CLI — acting on threads", () => { + it("replies as the agent and returns the updated thread", () => { + const thread = seedUserThread(); + + const { thread: updated, comment } = cli.reply(thread.id, "Which name did you have in mind?"); + + expect(comment).toMatchObject({ + body: "Which name did you have in mind?", + authorType: "agent", + }); + expect(updated.comments.map((c) => c.authorType)).toEqual(["user", "agent"]); + expect(updated.status).toBe("open"); + }); + + it("resolves with a closing reply, then reopens", () => { + const thread = seedUserThread(); + + const resolved = cli.resolve(thread.id, "Fixed: renamed the helper and added a test"); + expect(resolved.status).toBe("resolved"); + expect(resolved.resolvedAt).not.toBeNull(); + expect(resolved.comments.at(-1)).toMatchObject({ + body: "Fixed: renamed the helper and added a test", + authorType: "agent", + }); + + const reopened = cli.reopen(thread.id); + expect(reopened.status).toBe("open"); + expect(reopened.resolvedAt).toBeNull(); + }); + + it("resolves without adding a reply when no body is given", () => { + const thread = seedUserThread(); + + const resolved = cli.resolve(thread.id); + + expect(resolved.comments).toHaveLength(1); + expect(resolved.status).toBe("resolved"); + }); + + it("creates an agent-authored thread in the scope", () => { + const created = cli.create( + SCOPE_KEY, + makeInput({ body: "Consider extracting this", endLine: 5 }), + ); + + expect(created).toMatchObject({ status: "open", startLine: 5, endLine: 5 }); + expect(created.comments[0]).toMatchObject({ + body: "Consider extracting this", + authorType: "agent", + }); + expect(cli.list(SCOPE_KEY, "open").map((t) => t.id)).toEqual([created.id]); + }); +}); + +describe("comments CLI — human-readable listing", () => { + it("formats a short ID, status, anchor, and a one-line preview of the root comment", () => { + const thread = cli.create(SCOPE_KEY, makeInput({ body: "First line\n second line" })); + + expect(formatThreadLine(thread)).toBe( + `${thread.id.slice(0, 8)} open src/foo.ts:5-10 First line second line`, + ); + }); + + it("truncates long previews with an ellipsis", () => { + const thread = cli.create(SCOPE_KEY, makeInput({ body: "x".repeat(200) })); + + const preview = formatThreadLine(thread).split(" ").at(-1); + + expect(preview).toHaveLength(72); + expect(preview?.endsWith("…")).toBe(true); + }); +}); diff --git a/packages/cli/src/comments/command.ts b/packages/cli/src/comments/command.ts new file mode 100644 index 00000000..7551401f --- /dev/null +++ b/packages/cli/src/comments/command.ts @@ -0,0 +1,210 @@ +import { CreateCommentThreadBodySchema } from "@stagereview/types/comments"; +import { Command, Option } from "commander"; +import { z } from "zod"; +import { closeDb, getDb } from "../db/client.js"; +import { + addDiffScopeOptions, + type DiffCommandOptions, + toDiffScopeOptions, +} from "../diff-scope-options.js"; +import { DIFF_SIDE } from "../schema.js"; +import { + CommentsCli, + type CommentThreadWithStatus, + resolveCommentScopeKey, + THREAD_STATUS_FILTER, + type ThreadStatusFilter, +} from "./comments-cli.js"; + +interface ListCommandOptions extends DiffCommandOptions { + status: ThreadStatusFilter; + json?: boolean; +} + +interface CreateCommandOptions extends DiffCommandOptions { + file: string; + line: string; + endLine?: string; + side: string; + body: string; +} + +const positiveInt = z.coerce.number().int().positive(); + +/** Commander hands us strings; coerce and validate them at the CLI boundary. */ +const CreateCommandOptionsSchema = z.object({ + file: z.string().min(1), + line: positiveInt, + endLine: positiveInt.optional(), + side: z.enum(DIFF_SIDE), + body: z.string().min(1), +}); + +const SHORT_ID_LENGTH = 8; +const PREVIEW_LENGTH = 72; + +/** + * `stagereview comments`: lets a coding agent read the comments a reviewer left + * in the Stage UI and act on them (reply, resolve, reopen, or leave its own), + * straight from the SQLite database — the review server need not be running. + */ +export function commentsCommand(): Command { + const comments = new Command("comments").description( + "Read and act on local review comments for a diff (no server required)", + ); + + addDiffScopeOptions( + comments.command("list").description("List comment threads in the current diff scope"), + ) + .addOption( + new Option("--status ", "Which threads to list") + .choices(Object.values(THREAD_STATUS_FILTER)) + .default(THREAD_STATUS_FILTER.OPEN), + ) + .option("--json", "Print full thread objects as JSON") + .action(async (refs: string[], opts: ListCommandOptions) => { + const scopeKey = await resolveCommentScopeKey(toDiffScopeOptions(refs, opts)); + await withCli((cli) => { + const threads = cli.list(scopeKey, opts.status); + if (opts.json) { + printJson(threads); + return; + } + if (threads.length === 0) { + const qualifier = opts.status === THREAD_STATUS_FILTER.ALL ? "" : `${opts.status} `; + process.stdout.write(`No ${qualifier}comment threads in this diff scope.\n`); + return; + } + process.stdout.write(`${threads.map(formatThreadLine).join("\n")}\n`); + }); + }); + + comments + .command("show") + .description("Print one thread, with every comment, as JSON") + .argument("", "Thread ID or an unambiguous prefix (6+ characters)") + .action(async (threadId: string) => { + await withCli((cli) => printJson(cli.show(threadId))); + }); + + comments + .command("reply") + .description("Add a reply to a thread") + .argument("", "Thread ID or an unambiguous prefix (6+ characters)") + .requiredOption("--body ", "Reply text") + .action(async (threadId: string, opts: { body: string }) => { + await withCli((cli) => { + const { thread } = cli.reply(threadId, requireBody(opts.body)); + process.stdout.write(`Replied to thread ${describeThread(thread)}.\n`); + }); + }); + + comments + .command("resolve") + .description("Resolve a thread, optionally posting a final reply first") + .argument("", "Thread ID or an unambiguous prefix (6+ characters)") + .option("--body ", 'Closing reply, for example "Fixed: renamed the helper"') + .action(async (threadId: string, opts: { body?: string }) => { + await withCli((cli) => { + const body = opts.body === undefined ? undefined : requireBody(opts.body); + const thread = cli.resolve(threadId, body); + process.stdout.write(`Resolved thread ${describeThread(thread)}.\n`); + }); + }); + + comments + .command("reopen") + .description("Reopen a resolved thread") + .argument("", "Thread ID or an unambiguous prefix (6+ characters)") + .action(async (threadId: string) => { + await withCli((cli) => { + const thread = cli.reopen(threadId); + process.stdout.write(`Reopened thread ${describeThread(thread)}.\n`); + }); + }); + + addDiffScopeOptions( + comments + .command("create") + .description("Leave a new comment on a line range of the current diff"), + ) + .requiredOption("--file ", "File path as it appears in the diff") + .requiredOption("--line ", "First line of the range") + .option("--end-line ", "Last line of the range (default: --line)") + .addOption( + new Option("--side ", "Which side of the diff the lines are on") + .choices(Object.values(DIFF_SIDE)) + .default(DIFF_SIDE.ADDITIONS), + ) + .requiredOption("--body ", "Comment text") + .action(async (refs: string[], opts: CreateCommandOptions) => { + const input = parseCreateInput(opts); + const scopeKey = await resolveCommentScopeKey(toDiffScopeOptions(refs, opts)); + await withCli((cli) => { + const thread = cli.create(scopeKey, input); + process.stdout.write(`Created thread ${thread.id} (${formatAnchor(thread)}).\n`); + }); + }); + + return comments; +} + +async function withCli(run: (cli: CommentsCli) => void | Promise): Promise { + const db = getDb(); + try { + await run(new CommentsCli(db)); + } finally { + closeDb(); + } +} + +function parseCreateInput(opts: CreateCommandOptions) { + const parsed = CreateCommandOptionsSchema.safeParse(opts); + if (!parsed.success) throw new Error(z.prettifyError(parsed.error)); + const { file, line, endLine, side, body } = parsed.data; + const input = CreateCommentThreadBodySchema.safeParse({ + filePath: file, + side, + startLine: line, + endLine: endLine ?? line, + body, + }); + if (!input.success) throw new Error(z.prettifyError(input.error)); + return input.data; +} + +function requireBody(body: string): string { + if (body.trim().length === 0) throw new Error("--body must not be empty."); + return body; +} + +function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function describeThread(thread: CommentThreadWithStatus): string { + return `${shortId(thread.id)} (${formatAnchor(thread)})`; +} + +function shortId(id: string): string { + return id.slice(0, SHORT_ID_LENGTH); +} + +export function formatAnchor(thread: CommentThreadWithStatus): string { + const range = + thread.startLine === thread.endLine + ? `${thread.startLine}` + : `${thread.startLine}-${thread.endLine}`; + return `${thread.filePath}:${range}`; +} + +/** One human-readable line per thread: short ID, status, anchor, and a preview of the root comment. */ +export function formatThreadLine(thread: CommentThreadWithStatus): string { + const root = thread.comments[0]; + const preview = root ? truncate(root.body.replace(/\s+/g, " ").trim(), PREVIEW_LENGTH) : ""; + return `${shortId(thread.id)} ${thread.status.padEnd(8)} ${formatAnchor(thread)} ${preview}`; +} + +function truncate(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max - 1)}…`; +} diff --git a/packages/cli/src/comments/comments-cli.ts b/packages/cli/src/comments/comments-cli.ts new file mode 100644 index 00000000..67b73e69 --- /dev/null +++ b/packages/cli/src/comments/comments-cli.ts @@ -0,0 +1,133 @@ +import { + COMMENT_AUTHOR_TYPE, + type Comment, + type CommentThread, + type CreateCommentThreadBody, +} from "@stagereview/types/comments"; +import type { StageDb } from "../db/client.js"; +import { + LocalCommentThreadStore, + type LocalThreadRecord, + toThreadDto, +} from "../runs/local-comment-threads.js"; +import { deriveScopeKey, scopeKeyParts } from "../runs/scope-key.js"; +import { type DiffScopeOptions, resolveDiffScope } from "../scope.js"; + +export const THREAD_STATUS = { + OPEN: "open", + RESOLVED: "resolved", +} as const; +export type ThreadStatus = (typeof THREAD_STATUS)[keyof typeof THREAD_STATUS]; + +export const THREAD_STATUS_FILTER = { + ...THREAD_STATUS, + ALL: "all", +} as const; +export type ThreadStatusFilter = (typeof THREAD_STATUS_FILTER)[keyof typeof THREAD_STATUS_FILTER]; + +/** The HTTP wire shape plus an explicit status, so agents don't have to infer it from `resolvedAt`. */ +export interface CommentThreadWithStatus extends CommentThread { + status: ThreadStatus; +} + +export interface ReplyResult { + thread: CommentThreadWithStatus; + comment: Comment; +} + +/** Shortest thread-ID prefix the CLI accepts in place of a full UUID. */ +export const MIN_THREAD_REF_LENGTH = 6; + +/** + * The scope key `stagereview comments` operates on, resolved with the exact + * code path `show` uses so the CLI addresses the same threads the browser shows. + */ +export async function resolveCommentScopeKey(options: DiffScopeOptions): Promise { + const { scope } = await resolveDiffScope(options); + return deriveScopeKey(scopeKeyParts(scope)); +} + +/** + * Agent-facing operations on local comment threads, backed directly by the + * SQLite database — no HTTP server involved. Every comment written here is + * attributed to the agent so the UI can tell it apart from the human's notes. + */ +export class CommentsCli { + private readonly store: LocalCommentThreadStore; + + constructor(db: StageDb) { + this.store = new LocalCommentThreadStore(db); + } + + list(scopeKey: string, status: ThreadStatusFilter): CommentThreadWithStatus[] { + return this.store + .listByScope(scopeKey) + .map(withStatus) + .filter((thread) => status === THREAD_STATUS_FILTER.ALL || thread.status === status); + } + + show(threadRef: string): CommentThreadWithStatus { + return this.load(this.resolveThreadId(threadRef)); + } + + reply(threadRef: string, body: string): ReplyResult { + const threadId = this.resolveThreadId(threadRef); + const reply = this.store.reply(threadId, body, COMMENT_AUTHOR_TYPE.AGENT); + const thread = this.load(threadId); + const comment = thread.comments.find((candidate) => candidate.id === reply.id); + if (!comment) throw new Error(`Reply ${reply.id} missing from thread ${threadId}`); + return { thread, comment }; + } + + /** Resolve a thread, optionally posting a closing reply first. */ + resolve(threadRef: string, body?: string): CommentThreadWithStatus { + const threadId = this.resolveThreadId(threadRef); + if (body !== undefined) this.store.reply(threadId, body, COMMENT_AUTHOR_TYPE.AGENT); + this.store.setResolved(threadId, true); + return this.load(threadId); + } + + reopen(threadRef: string): CommentThreadWithStatus { + const threadId = this.resolveThreadId(threadRef); + this.store.setResolved(threadId, false); + return this.load(threadId); + } + + create(scopeKey: string, input: CreateCommentThreadBody): CommentThreadWithStatus { + return withStatus(this.store.create(scopeKey, input, COMMENT_AUTHOR_TYPE.AGENT)); + } + + private load(threadId: string): CommentThreadWithStatus { + const record = this.store.find(threadId); + if (!record) throw new Error(`Thread ${threadId} not found`); + return withStatus(record); + } + + /** Accept a full thread ID or an unambiguous prefix of at least {@link MIN_THREAD_REF_LENGTH} characters. */ + private resolveThreadId(threadRef: string): string { + const prefix = threadRef.trim(); + if (prefix.length < MIN_THREAD_REF_LENGTH) { + throw new Error( + `Thread ID "${prefix}" is too short. Use at least ${MIN_THREAD_REF_LENGTH} characters of the ID.`, + ); + } + const matches = this.store.findByIdPrefix(prefix); + const [first] = matches; + if (!first) throw new Error(`No comment thread matches "${prefix}".`); + if (matches.length > 1) { + const ids = matches.map((thread) => thread.id).join(", "); + throw new Error( + `Thread ID "${prefix}" is ambiguous — it matches ${matches.length} threads: ${ids}. Use a longer prefix.`, + ); + } + return first.id; + } +} + +function withStatus(record: LocalThreadRecord): CommentThreadWithStatus { + const dto = toThreadDto(record); + return { + ...dto, + status: dto.resolvedAt === null ? THREAD_STATUS.OPEN : THREAD_STATUS.RESOLVED, + }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 93f9e571..af65cd3d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { Command } from "commander"; +import { commentsCommand } from "./comments/command.js"; import { addDiffScopeOptions, type DiffCommandOptions, @@ -49,6 +50,8 @@ addDiffScopeOptions( await show(jsonPath, toDiffScopeOptions(refs, opts)); }); +program.addCommand(commentsCommand()); + program.parseAsync(process.argv).catch((err) => { process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); process.exit(1); From 9dffe03ef2037e7e0e5167d93493a4009b2087f2 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:19:11 +0000 Subject: [PATCH 4/6] feat(web): poll the review so agent-side comment changes appear without a refresh --- packages/web/src/lib/use-review.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/web/src/lib/use-review.ts b/packages/web/src/lib/use-review.ts index 2e95d34d..30627be8 100644 --- a/packages/web/src/lib/use-review.ts +++ b/packages/web/src/lib/use-review.ts @@ -20,6 +20,13 @@ export { GITHUB_REVIEW_STATUS }; const REVIEW_ROOT = "review"; +// Coding agents resolve and reply to local threads through the `stagereview +// comments` CLI, which writes straight to SQLite, so the browser polls to pick +// those changes up. Local-only runs are a cheap DB read; GitHub-backed runs hit +// the GitHub API on every fetch, so they poll less often. +const LOCAL_REVIEW_REFETCH_MS = 3_000; +const GITHUB_REVIEW_REFETCH_MS = 15_000; + interface ReviewMutationOrigin { runId: string; queryKey: readonly unknown[]; @@ -83,6 +90,10 @@ export function useReview(runId: string): UseReviewResult { queryKey, queryFn: () => fetchReview(runId), enabled: runId !== "", + refetchInterval: (query) => + query.state.data?.github === GITHUB_REVIEW_STATUS.NONE + ? LOCAL_REVIEW_REFETCH_MS + : GITHUB_REVIEW_REFETCH_MS, }); const threads = useMemo(() => data?.threads ?? [], [data]); From 81ef54ec6a47ca0d2d45d81e0347071bc8a06d58 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:19:12 +0000 Subject: [PATCH 5/6] docs: add the stage-resolve skill and document the comment-to-agent loop --- README.md | 24 +++++++++ skills/stage-chapters/SKILL.md | 10 ++++ skills/stage-resolve/SKILL.md | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 skills/stage-resolve/SKILL.md diff --git a/README.md b/README.md index c099aa13..c5b0f197 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,30 @@ Examples: /stage-chapters --pr https://github.com/owner/repo/pull/123 ``` +### Let your agent act on your comments + +Leave comments on the diff in the Stage UI, then hand them to your coding agent: + +``` +/stage-resolve +``` + +The agent reads every open thread, makes the requested change (or answers the question), and +resolves the thread with a short summary. Its replies show up in the browser automatically with an +"Agent" badge. Pass the same refs or `--pr` you used for `/stage-chapters` so the agent looks at the +same diff. + +Under the hood this uses `stagereview comments`, which works without the review server running: + +```bash +stagereview comments list --status open --json # threads for the current diff scope +stagereview comments show # one thread in full (ID or 6+ char prefix) +stagereview comments reply --body "..." # add a reply +stagereview comments resolve --body "Fixed: ..." +stagereview comments reopen +stagereview comments create --file src/foo.ts --line 12 --end-line 14 --body "..." +``` + ### Review a GitHub pull request With `gh` authenticated and a GitHub `origin` remote configured, use `--pr` to review a pull diff --git a/skills/stage-chapters/SKILL.md b/skills/stage-chapters/SKILL.md index 67522190..dba998b5 100644 --- a/skills/stage-chapters/SKILL.md +++ b/skills/stage-chapters/SKILL.md @@ -537,3 +537,13 @@ stagereview show "$AGENT_OUTPUT" `stagereview show` auto-detects the agent output format, independently computes the scope and "Other changes" chapter for filtered files, validates the JSON, inserts the run into the local SQLite database, boots a loopback HTTP server, and opens the browser. **The command blocks until the user presses Ctrl+C.** If your harness requires non-blocking execution, run it in the background (e.g., `run_in_background` in Claude Code). Invoke it as the final command in the workflow. + +## After the review — acting on comments + +The user can leave line-anchored comments on the diff in the Stage UI. Those comments are stored locally and are readable from the command line without the server running: + +```bash +stagereview comments list --status open --json # pass the same refs/--pr/--base/--ref you used above +``` + +To work through them — make the requested changes, answer questions, and resolve each thread — run the `/stage-resolve` skill (or follow its steps). Replies and resolutions made through `stagereview comments` appear in the browser automatically and are badged as agent-authored. diff --git a/skills/stage-resolve/SKILL.md b/skills/stage-resolve/SKILL.md new file mode 100644 index 00000000..03a7ce33 --- /dev/null +++ b/skills/stage-resolve/SKILL.md @@ -0,0 +1,97 @@ +--- +name: stage-resolve +description: Pick up the review comments the user left in the Stage UI for the current diff, make the requested changes, and resolve each thread. +user-invocable: true +--- + +# stage-resolve + +Reads the local review comments the user left in the Stage browser UI (created by `/stage-chapters`), acts on each open thread, and resolves it. Uses `stagereview comments`, which reads and writes the same local database the UI uses — the `stagereview show` server does not need to be running, and the UI picks up your replies and resolutions automatically. + +Every comment you write through the CLI is attributed to the agent and shows an "Agent" badge in the UI, so the user can tell your replies from their own notes. + +## Prerequisites + +Run this check before any other work. If it fails, stop with the error message — do not continue. + +1. **`stagereview` is installed.** Run `which stagereview`. If it exits non-zero, instruct the user: + + ``` + stagereview is not installed. Run: + + npm install -g stagereview + ``` + +## Arguments + +- **Diff selectors.** Pass through any git refs or flags the user gave (`--pr 123`, `--base main`, `--ref staged`, `main..feature`, …) to every `list` and `create` call exactly as given. They select the same diff scope `/stage-chapters` used, so you see the threads the UI shows. With no arguments the CLI uses the same default scope `stagereview show` uses for the working tree. +- **A single thread ID** (full UUID or a prefix of at least 6 characters). When given, handle only that thread and skip the listing step. + +## Step 1 — List open threads + +Run this **before editing any files**, and keep the output — you will address threads by the IDs it returns: + +```bash +stagereview comments list --status open --json [refs...] [--pr ] [--base ] [--compare ] [--ref ] +``` + +Each thread has `id`, `filePath`, `side` (`additions` = new file lines, `deletions` = old file lines), `startLine`, `endLine`, `status`, and `comments` ordered oldest first. Each comment carries `body`, `authorType` (`user` or `agent`), and timestamps. + +If the list is empty, tell the user there are no open comment threads for this diff and stop. + +> The diff scope is keyed on git state. If the working tree was clean when the review was opened and you then edit files, a later `list` with no arguments resolves to a different (working-tree) scope. Address threads by ID (`show`, `reply`, `resolve`, `reopen` are not scoped), and do not commit while working through threads unless the user asks. + +## Step 2 — Handle each thread + +For each open thread, in order: + +1. **Read the whole conversation.** The first comment is the request; later comments may refine it or answer earlier questions. +2. **Skip threads waiting on the user.** If the last comment has `authorType: "agent"` and asks the user a question they have not answered, leave the thread alone and mention it in your summary. +3. **Decide what the comment asks for.** + - A request or instruction ("rename this", "add a null check", "extract a helper") → make the change. + - A question that implies an action ("should we add a test for this?", "could this be a constant?") → treat it as a request and make the change. + - A pure question ("why does this fall back to the primary org?") → answer it and resolve the thread: + ```bash + stagereview comments resolve --body "" + ``` + - Genuinely unclear → do not guess and do not silently skip. Ask for clarification and leave the thread open: + ```bash + stagereview comments reply --body "" + ``` +4. **Before editing, read the surrounding source**, not just the anchored lines — the comment is anchored to a diff line range, but the right fix may live nearby. Follow the repository's coding guidelines (for example `AGENTS.md` or `CLAUDE.md`) and existing conventions. +5. **Make the change**, then run the project's relevant checks (typecheck, lint, tests) for the files you touched. +6. **Resolve with a short summary of what changed:** + ```bash + stagereview comments resolve --body "Fixed: " + ``` + Keep the body to one or two sentences. Do not paste file contents. + +To inspect a single thread in full at any point: + +```bash +stagereview comments show +``` + +## Step 3 — Report back + +Run the listing again with the same selectors you used in Step 1: + +```bash +stagereview comments list --status open [same selectors as Step 1] +``` + +Then tell the user: + +- which threads you resolved and, in a line each, what you changed; +- which threads you replied to with a question and are waiting on them; +- that the Stage UI in their browser has already picked up your replies and resolutions. + +## Leaving your own comments + +If you notice something worth flagging while working — a risk, a follow-up, a question about intent — leave a comment on the diff rather than burying it in chat: + +```bash +stagereview comments create --file --line [--end-line ] [--side additions|deletions] --body "" [same selectors as Step 1] +``` + +Line numbers refer to the new file for `additions` (the default) and the old file for `deletions`. From 49b5e24e1b6a28e38ba7ce4ecd515fd192e07e39 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:33:08 +0000 Subject: [PATCH 6/6] fix(comments): reject whitespace-only --body for create like reply and resolve --- packages/cli/src/__tests__/comments-cli.test.ts | 14 +++++++++++++- packages/cli/src/comments/command.ts | 12 +++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/__tests__/comments-cli.test.ts b/packages/cli/src/__tests__/comments-cli.test.ts index 1c026c3e..b441ca33 100644 --- a/packages/cli/src/__tests__/comments-cli.test.ts +++ b/packages/cli/src/__tests__/comments-cli.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import type { CreateCommentThreadBody } from "@stagereview/types/comments"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { formatThreadLine } from "../comments/command.js"; +import { commentsCommand, formatThreadLine } from "../comments/command.js"; import { CommentsCli } from "../comments/comments-cli.js"; import { closeDb, getDb, type StageDb } from "../db/client.js"; import { commentThread } from "../db/schema/index.js"; @@ -184,3 +184,15 @@ describe("comments CLI — human-readable listing", () => { expect(preview?.endsWith("…")).toBe(true); }); }); + +describe("comments CLI — argument validation", () => { + it.each([ + ["create", "--file", "src/foo.ts", "--line", "3", "--body", " "], + ["reply", "abcdef-thread", "--body", "\n\t"], + ["resolve", "abcdef-thread", "--body", " "], + ])("rejects a whitespace-only --body for %s before touching git or the database", async (...argv) => { + await expect(commentsCommand().parseAsync(argv, { from: "user" })).rejects.toThrow( + "--body must not be empty.", + ); + }); +}); diff --git a/packages/cli/src/comments/command.ts b/packages/cli/src/comments/command.ts index 7551401f..e22cc33c 100644 --- a/packages/cli/src/comments/command.ts +++ b/packages/cli/src/comments/command.ts @@ -31,13 +31,18 @@ interface CreateCommandOptions extends DiffCommandOptions { const positiveInt = z.coerce.number().int().positive(); +/** Text for `--body`; whitespace-only input is as empty as no input. */ +const commentBody = z + .string() + .refine((value) => value.trim().length > 0, "--body must not be empty."); + /** Commander hands us strings; coerce and validate them at the CLI boundary. */ const CreateCommandOptionsSchema = z.object({ file: z.string().min(1), line: positiveInt, endLine: positiveInt.optional(), side: z.enum(DIFF_SIDE), - body: z.string().min(1), + body: commentBody, }); const SHORT_ID_LENGTH = 8; @@ -174,8 +179,9 @@ function parseCreateInput(opts: CreateCommandOptions) { } function requireBody(body: string): string { - if (body.trim().length === 0) throw new Error("--body must not be empty."); - return body; + const parsed = commentBody.safeParse(body); + if (!parsed.success) throw new Error(z.prettifyError(parsed.error)); + return parsed.data; } function printJson(value: unknown): void {