diff --git a/README.md b/README.md index c099aa1..c5b0f19 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/packages/cli/drizzle/0008_wakeful_roxanne_simpson.sql b/packages/cli/drizzle/0008_wakeful_roxanne_simpson.sql new file mode 100644 index 0000000..6357740 --- /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 0000000..f0a0697 --- /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 07e225a..886ab95 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-cli-scope.test.ts b/packages/cli/src/__tests__/comments-cli-scope.test.ts new file mode 100644 index 0000000..a5e482e --- /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 0000000..b441ca3 --- /dev/null +++ b/packages/cli/src/__tests__/comments-cli.test.ts @@ -0,0 +1,198 @@ +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 { 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"; +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); + }); +}); + +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/__tests__/comments.routes.test.ts b/packages/cli/src/__tests__/comments.routes.test.ts index 4cadf8d..4eac0c7 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 0000000..c246e11 --- /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/comments/command.ts b/packages/cli/src/comments/command.ts new file mode 100644 index 0000000..e22cc33 --- /dev/null +++ b/packages/cli/src/comments/command.ts @@ -0,0 +1,216 @@ +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(); + +/** 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: commentBody, +}); + +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 { + const parsed = commentBody.safeParse(body); + if (!parsed.success) throw new Error(z.prettifyError(parsed.error)); + return parsed.data; +} + +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 0000000..67b73e6 --- /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/db/schema/comment.ts b/packages/cli/src/db/schema/comment.ts index 86fa9c1..d9c3974 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/diff-scope-options.ts b/packages/cli/src/diff-scope-options.ts new file mode 100644 index 0000000..215f63b --- /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 0a9ced4..af65cd3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,10 +1,13 @@ #!/usr/bin/env node import { createRequire } from "node:module"; -import { Command, Option } from "commander"; -import { z } from "zod"; +import { Command } from "commander"; +import { commentsCommand } from "./comments/command.js"; +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 +20,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 +41,16 @@ 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.addCommand(commentsCommand()); 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 45b9ade..0ac8db8 100644 --- a/packages/cli/src/routes/comments.ts +++ b/packages/cli/src/routes/comments.ts @@ -1,22 +1,17 @@ import { + COMMENT_AUTHOR_TYPE, 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 +22,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 +36,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 +51,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, COMMENT_AUTHOR_TYPE.USER))); }, }, { @@ -98,25 +72,15 @@ 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, COMMENT_AUTHOR_TYPE.USER)), + ); }); }, }, @@ -140,17 +104,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 +246,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 cd4c930..c3e956c 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 e94470c..fec596c 100644 --- a/packages/cli/src/runs/local-comment-threads.ts +++ b/packages/cli/src/runs/local-comment-threads.ts @@ -1,5 +1,12 @@ -import { asc, eq, inArray } from "drizzle-orm"; +import type { + CommentAuthorType, + 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 +20,173 @@ 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, + authorType: CommentAuthorType, + ): 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, authorType, 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, authorType: CommentAuthorType): CommentRow { + return this.db.transaction((tx) => { + const [commentRow] = tx + .insert(comment) + .values({ threadId, authorId: LOCAL_USER_ID, authorType, 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, + 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 7f928af..eeab41e 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, @@ -143,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, @@ -152,8 +154,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 494ad89..6cf59d5 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, + }; +} diff --git a/packages/types/src/comments.ts b/packages/types/src/comments.ts index 899b874..4251d71 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 539f98d..0b4b5ef 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 de47f40..d58ceae 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 2aa774c..c8297e3 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 7a6389f..f74d04f 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", }, diff --git a/packages/web/src/lib/use-review.ts b/packages/web/src/lib/use-review.ts index 2e95d34..30627be 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]); diff --git a/skills/stage-chapters/SKILL.md b/skills/stage-chapters/SKILL.md index 6752219..dba998b 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 0000000..03a7ce3 --- /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`.