diff --git a/CLAUDE.md b/CLAUDE.md index f2b6931..ce31afb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,6 +133,10 @@ sd close [ ...] Close one or more issues sd dep add Add dependency sd dep remove Remove dependency sd dep list Show deps for an issue +sd comment add Add a comment to an issue + --author (or set SEEDS_AUTHOR env var) +sd comment list List comments on an issue +sd comment delete Delete a comment sd blocked Show all blocked issues sd stats Project statistics sd sync Stage and commit .seeds/ changes diff --git a/src/commands/comment.test.ts b/src/commands/comment.test.ts new file mode 100644 index 0000000..1c43e4a --- /dev/null +++ b/src/commands/comment.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let tmpDir: string; +let issueId: string; + +const CLI = join(import.meta.dir, "../../src/index.ts"); + +async function run( + args: string[], + cwd: string, + env?: Record, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn(["bun", "run", CLI, ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, ...env }, + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + return { stdout, stderr, exitCode }; +} + +async function runJson( + args: string[], + cwd: string, + env?: Record, +): Promise { + const { stdout } = await run([...args, "--json"], cwd, env); + return JSON.parse(stdout) as T; +} + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "seeds-comment-test-")); + await run(["init"], tmpDir); + + const result = await runJson<{ success: boolean; id: string }>( + ["create", "--title", "Test issue for comments"], + tmpDir, + ); + issueId = result.id; +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe("sd comment add", () => { + test("adds a comment to an issue", async () => { + const result = await runJson<{ + success: boolean; + command: string; + issueId: string; + commentId: string; + }>(["comment", "add", issueId, "Hello world", "--author", "tester"], tmpDir); + expect(result.success).toBe(true); + expect(result.command).toBe("comment add"); + expect(result.issueId).toBe(issueId); + expect(result.commentId).toMatch(/^c-/); + }); + + test("uses SEEDS_AUTHOR env var when --author not provided", async () => { + const result = await runJson<{ success: boolean; commentId: string }>( + ["comment", "add", issueId, "Env author test"], + tmpDir, + { SEEDS_AUTHOR: "env-user" }, + ); + expect(result.success).toBe(true); + expect(result.commentId).toMatch(/^c-/); + }); + + test("fails without author", async () => { + const { exitCode } = await run(["comment", "add", issueId, "No author"], tmpDir, { + SEEDS_AUTHOR: "", + }); + expect(exitCode).not.toBe(0); + }); + + test("fails with empty body", async () => { + const { exitCode } = await run(["comment", "add", issueId, "", "--author", "tester"], tmpDir); + expect(exitCode).not.toBe(0); + }); + + test("fails for nonexistent issue", async () => { + const { exitCode } = await run( + ["comment", "add", "proj-ffff", "body", "--author", "tester"], + tmpDir, + ); + expect(exitCode).not.toBe(0); + }); + + test("comment appears in issue show output", async () => { + await run(["comment", "add", issueId, "Visible comment", "--author", "tester"], tmpDir); + const show = await runJson<{ + success: boolean; + issue: { comments?: Array<{ id: string; author: string; body: string }> }; + }>(["show", issueId], tmpDir); + expect(show.issue.comments).toHaveLength(1); + expect(show.issue.comments![0]!.author).toBe("tester"); + expect(show.issue.comments![0]!.body).toBe("Visible comment"); + }); +}); + +describe("sd comment list", () => { + test("lists comments on an issue", async () => { + await run(["comment", "add", issueId, "First", "--author", "alice"], tmpDir); + await run(["comment", "add", issueId, "Second", "--author", "bob"], tmpDir); + + const result = await runJson<{ + success: boolean; + command: string; + issueId: string; + comments: Array<{ id: string; author: string; body: string }>; + count: number; + }>(["comment", "list", issueId], tmpDir); + expect(result.success).toBe(true); + expect(result.command).toBe("comment list"); + expect(result.count).toBe(2); + expect(result.comments[0]!.author).toBe("alice"); + expect(result.comments[0]!.body).toBe("First"); + expect(result.comments[1]!.author).toBe("bob"); + expect(result.comments[1]!.body).toBe("Second"); + }); + + test("returns empty list for issue with no comments", async () => { + const result = await runJson<{ success: boolean; count: number; comments: unknown[] }>( + ["comment", "list", issueId], + tmpDir, + ); + expect(result.success).toBe(true); + expect(result.count).toBe(0); + expect(result.comments).toHaveLength(0); + }); + + test("fails for nonexistent issue", async () => { + const { exitCode } = await run(["comment", "list", "proj-ffff"], tmpDir); + expect(exitCode).not.toBe(0); + }); +}); + +describe("sd comment delete", () => { + let commentId: string; + + beforeEach(async () => { + const result = await runJson<{ success: boolean; commentId: string }>( + ["comment", "add", issueId, "To be deleted", "--author", "tester"], + tmpDir, + ); + commentId = result.commentId; + }); + + test("deletes a comment", async () => { + const result = await runJson<{ + success: boolean; + command: string; + issueId: string; + commentId: string; + }>(["comment", "delete", issueId, commentId], tmpDir); + expect(result.success).toBe(true); + expect(result.command).toBe("comment delete"); + expect(result.commentId).toBe(commentId); + }); + + test("comment no longer appears after deletion", async () => { + await run(["comment", "delete", issueId, commentId], tmpDir); + const list = await runJson<{ success: boolean; count: number }>( + ["comment", "list", issueId], + tmpDir, + ); + expect(list.count).toBe(0); + }); + + test("fails for nonexistent comment", async () => { + const { exitCode } = await run(["comment", "delete", issueId, "c-ffff"], tmpDir); + expect(exitCode).not.toBe(0); + }); + + test("fails for nonexistent issue", async () => { + const { exitCode } = await run(["comment", "delete", "proj-ffff", commentId], tmpDir); + expect(exitCode).not.toBe(0); + }); + + test("updates issue updatedAt timestamp", async () => { + const before = await runJson<{ success: boolean; issue: { updatedAt: string } }>( + ["show", issueId], + tmpDir, + ); + // Small delay to ensure timestamp differs + await new Promise((resolve) => setTimeout(resolve, 10)); + await run(["comment", "delete", issueId, commentId], tmpDir); + const after = await runJson<{ success: boolean; issue: { updatedAt: string } }>( + ["show", issueId], + tmpDir, + ); + expect(after.issue.updatedAt).not.toBe(before.issue.updatedAt); + }); +}); + +describe("sd comment (multiple operations)", () => { + test("add multiple then delete one leaves the other", async () => { + const c1 = await runJson<{ success: boolean; commentId: string }>( + ["comment", "add", issueId, "Keep me", "--author", "alice"], + tmpDir, + ); + const c2 = await runJson<{ success: boolean; commentId: string }>( + ["comment", "add", issueId, "Delete me", "--author", "bob"], + tmpDir, + ); + + await run(["comment", "delete", issueId, c2.commentId], tmpDir); + + const list = await runJson<{ + success: boolean; + comments: Array<{ id: string; body: string }>; + count: number; + }>(["comment", "list", issueId], tmpDir); + expect(list.count).toBe(1); + expect(list.comments[0]!.id).toBe(c1.commentId); + expect(list.comments[0]!.body).toBe("Keep me"); + }); +}); diff --git a/src/commands/comment.ts b/src/commands/comment.ts new file mode 100644 index 0000000..0d05406 --- /dev/null +++ b/src/commands/comment.ts @@ -0,0 +1,204 @@ +import { Command } from "commander"; +import { findSeedsDir } from "../config.ts"; +import { generateId } from "../id.ts"; +import { accent, muted, outputJson, printSuccess } from "../output.ts"; +import { issuesPath, readIssues, withLock, writeIssues } from "../store.ts"; +import type { IssueComment } from "../types.ts"; + +function parseArgs(args: string[]) { + const flags: Record = {}; + const positional: string[] = []; + let i = 0; + while (i < args.length) { + const arg = args[i]; + if (!arg) { + i++; + continue; + } + if (arg.startsWith("--")) { + const key = arg.slice(2); + const eqIdx = key.indexOf("="); + if (eqIdx !== -1) { + flags[key.slice(0, eqIdx)] = key.slice(eqIdx + 1); + i++; + } else { + const next = args[i + 1]; + if (next !== undefined && !next.startsWith("--")) { + flags[key] = next; + i += 2; + } else { + flags[key] = true; + i++; + } + } + } else { + positional.push(arg); + i++; + } + } + return { flags, positional }; +} + +export async function run(args: string[], seedsDir?: string): Promise { + const jsonMode = args.includes("--json"); + const { flags, positional } = parseArgs(args); + const subcmd = positional[0]; + + if (!subcmd) throw new Error("Usage: sd comment [...]"); + + const dir = seedsDir ?? (await findSeedsDir()); + + // sd comment add [--author ] + if (subcmd === "add") { + const issueId = positional[1]; + const body = positional[2]; + if (!issueId) throw new Error("Usage: sd comment add --author "); + if (!body || !body.trim()) throw new Error("Comment body is required"); + + const author = + typeof flags.author === "string" ? flags.author : (process.env.SEEDS_AUTHOR ?? ""); + if (!author.trim()) { + throw new Error("--author is required (or set SEEDS_AUTHOR env var)"); + } + + let commentId = ""; + await withLock(issuesPath(dir), async () => { + const issues = await readIssues(dir); + const idx = issues.findIndex((i) => i.id === issueId); + if (idx === -1) throw new Error(`Issue not found: ${issueId}`); + + const issue = issues[idx]!; + const existingCommentIds = new Set((issue.comments ?? []).map((c) => c.id)); + const id = generateId("c", existingCommentIds); + const now = new Date().toISOString(); + const comment: IssueComment = { id, author, body: body.trim(), createdAt: now }; + + issues[idx] = { + ...issue, + comments: [...(issue.comments ?? []), comment], + updatedAt: now, + }; + commentId = id; + await writeIssues(dir, issues); + }); + + if (jsonMode) { + outputJson({ success: true, command: "comment add", issueId, commentId }); + } else { + printSuccess(`Added comment ${commentId} to ${issueId}`); + } + return; + } + + // sd comment list + if (subcmd === "list") { + const issueId = positional[1]; + if (!issueId) throw new Error("Usage: sd comment list "); + + const issues = await readIssues(dir); + const issue = issues.find((i) => i.id === issueId); + if (!issue) throw new Error(`Issue not found: ${issueId}`); + + const comments = issue.comments ?? []; + + if (jsonMode) { + outputJson({ + success: true, + command: "comment list", + issueId, + comments, + count: comments.length, + }); + } else { + if (comments.length === 0) { + console.log("No comments."); + return; + } + console.log( + `${accent.bold(issueId)} ${muted(`${comments.length} comment${comments.length === 1 ? "" : "s"}`)}`, + ); + for (const comment of comments) { + console.log( + `\n${accent.bold(comment.id)} ${muted(comment.author)} ${muted(comment.createdAt)}`, + ); + console.log(comment.body); + } + } + return; + } + + // sd comment delete + if (subcmd === "delete") { + const issueId = positional[1]; + const commentId = positional[2]; + if (!issueId || !commentId) { + throw new Error("Usage: sd comment delete "); + } + + await withLock(issuesPath(dir), async () => { + const issues = await readIssues(dir); + const idx = issues.findIndex((i) => i.id === issueId); + if (idx === -1) throw new Error(`Issue not found: ${issueId}`); + + const issue = issues[idx]!; + const comments = issue.comments ?? []; + const commentIdx = comments.findIndex((c) => c.id === commentId); + if (commentIdx === -1) throw new Error(`Comment not found: ${commentId}`); + + const updated = comments.filter((c) => c.id !== commentId); + issues[idx] = { + ...issue, + comments: updated.length > 0 ? updated : undefined, + updatedAt: new Date().toISOString(), + }; + await writeIssues(dir, issues); + }); + + if (jsonMode) { + outputJson({ success: true, command: "comment delete", issueId, commentId }); + } else { + printSuccess(`Deleted comment ${commentId} from ${issueId}`); + } + return; + } + + throw new Error(`Unknown comment subcommand: ${subcmd}. Use add, list, or delete.`); +} + +export function register(program: Command): void { + const comment = new Command("comment").description("Manage issue comments"); + + comment + .command("add ") + .description("Add a comment to an issue") + .option("--author ", "Comment author (or set SEEDS_AUTHOR env var)") + .option("--json", "Output as JSON") + .action(async (issueId: string, body: string, opts: { author?: string; json?: boolean }) => { + const args: string[] = ["add", issueId, body]; + if (opts.author) args.push("--author", opts.author); + if (opts.json) args.push("--json"); + await run(args); + }); + + comment + .command("list ") + .description("List comments on an issue") + .option("--json", "Output as JSON") + .action(async (issueId: string, opts: { json?: boolean }) => { + const args: string[] = ["list", issueId]; + if (opts.json) args.push("--json"); + await run(args); + }); + + comment + .command("delete ") + .description("Delete a comment") + .option("--json", "Output as JSON") + .action(async (issueId: string, commentId: string, opts: { json?: boolean }) => { + const args: string[] = ["delete", issueId, commentId]; + if (opts.json) args.push("--json"); + await run(args); + }); + + program.addCommand(comment); +} diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 788cd3b..f4ab104 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -4,7 +4,16 @@ import type { Command } from "commander"; import { findSeedsDir, projectRootFromSeedsDir } from "../config.ts"; import { outputJson } from "../output.ts"; import { issuesPath, readIssues, withLock, writeIssues } from "../store.ts"; -import type { Issue } from "../types.ts"; +import type { Issue, IssueComment } from "../types.ts"; + +interface BeadsComment { + id?: number | string; + author?: string; + text?: string; + body?: string; + created_at?: string; + createdAt?: string; +} interface BeadsIssue { id?: string; @@ -27,6 +36,7 @@ interface BeadsIssue { updatedAt?: string; closed_at?: string; closedAt?: string; + comments?: BeadsComment[]; } function mapStatus(s: string | undefined): Issue["status"] { @@ -64,31 +74,134 @@ function mapBeadsIssue(b: BeadsIssue): Issue | null { if (b.blocks?.length) issue.blocks = b.blocks; const closedAt = b.closed_at ?? b.closedAt; if (closedAt) issue.closedAt = closedAt; + if (b.comments?.length) { + const mapped: IssueComment[] = []; + for (const c of b.comments) { + const body = c.text ?? c.body; + if (!body) continue; + mapped.push({ + id: `c-${String(c.id ?? mapped.length)}`, + author: c.author ?? "unknown", + body, + createdAt: c.created_at ?? c.createdAt ?? now, + }); + } + if (mapped.length > 0) issue.comments = mapped; + } return issue; } -export async function run(args: string[], seedsDir?: string): Promise { - const jsonMode = args.includes("--json"); - const dir = seedsDir ?? (await findSeedsDir()); - const projectRoot = projectRootFromSeedsDir(dir); - - const beadsPath = join(projectRoot, ".beads", "issues.jsonl"); - if (!existsSync(beadsPath)) { - throw new Error(`Beads issues not found at: ${beadsPath}`); - } - +async function loadFromJsonl(beadsPath: string): Promise { const file = Bun.file(beadsPath); const content = await file.text(); const lines = content.split("\n").filter((l) => l.trim()); - const beadsIssues: BeadsIssue[] = []; + const issues: BeadsIssue[] = []; for (const line of lines) { try { - beadsIssues.push(JSON.parse(line) as BeadsIssue); + issues.push(JSON.parse(line) as BeadsIssue); } catch { // skip malformed lines } } + return issues; +} + +async function bdAvailable(): Promise { + try { + const proc = Bun.spawn(["bd", "--version"], { stdout: "pipe", stderr: "pipe" }); + await proc.exited; + return proc.exitCode === 0; + } catch { + return false; + } +} + +async function loadFromDolt(projectRoot: string): Promise { + // Get all issue IDs (open + closed) + const openProc = Bun.spawn(["bd", "list", "--json"], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + }); + const openOut = await new Response(openProc.stdout).text(); + await openProc.exited; + if (openProc.exitCode !== 0) throw new Error("Failed to run 'bd list --json'"); + + const closedProc = Bun.spawn(["bd", "list", "--status", "closed", "--json"], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + }); + const closedOut = await new Response(closedProc.stdout).text(); + await closedProc.exited; + if (closedProc.exitCode !== 0) throw new Error("Failed to run 'bd list --status closed --json'"); + + let openIssues: Array<{ id: string }>; + let closedIssues: Array<{ id: string }>; + try { + openIssues = JSON.parse(openOut) as Array<{ id: string }>; + closedIssues = JSON.parse(closedOut) as Array<{ id: string }>; + } catch { + throw new Error( + "Failed to parse bd list output. Is your bd CLI up to date?", + ); + } + + const ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/; + const allIds = [...openIssues.map((i) => i.id), ...closedIssues.map((i) => i.id)] + .filter((id) => { + if (!id || !ID_PATTERN.test(id)) { + console.warn(`Skipping issue with invalid ID: ${JSON.stringify(id)}`); + return false; + } + return true; + }); + + const issues: BeadsIssue[] = []; + for (const id of allIds) { + const proc = Bun.spawn(["bd", "show", id, "--json"], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + if (proc.exitCode !== 0) continue; + + try { + const data = JSON.parse(out) as BeadsIssue | BeadsIssue[]; + const issue = Array.isArray(data) ? data[0] : data; + if (issue) issues.push(issue); + } catch { + // skip unparseable + } + } + return issues; +} + +export async function run(args: string[], seedsDir?: string): Promise { + const jsonMode = args.includes("--json"); + const dir = seedsDir ?? (await findSeedsDir()); + const projectRoot = projectRootFromSeedsDir(dir); + + let beadsIssues: BeadsIssue[]; + let source: string; + + const beadsPath = join(projectRoot, ".beads", "issues.jsonl"); + if (existsSync(beadsPath)) { + // Legacy beads: JSONL file exists + beadsIssues = await loadFromJsonl(beadsPath); + source = "jsonl"; + } else if (existsSync(join(projectRoot, ".beads")) && (await bdAvailable())) { + // Modern beads (Dolt): .beads/ directory exists but no JSONL, use bd CLI + beadsIssues = await loadFromDolt(projectRoot); + source = "dolt"; + } else { + throw new Error( + "No beads data found. Expected .beads/issues.jsonl (legacy) or .beads/ directory with 'bd' CLI available (Dolt).", + ); + } const mapped: Issue[] = []; const skipped: string[] = []; @@ -99,18 +212,28 @@ export async function run(args: string[], seedsDir?: string): Promise { } let written = 0; + let commentCount = 0; await withLock(issuesPath(dir), async () => { const existing = await readIssues(dir); const existingIds = new Set(existing.map((i) => i.id)); const newIssues = mapped.filter((i) => !existingIds.has(i.id)); await writeIssues(dir, [...existing, ...newIssues]); written = newIssues.length; + commentCount = newIssues.reduce((sum, i) => sum + (i.comments?.length ?? 0), 0); }); if (jsonMode) { - outputJson({ success: true, command: "migrate-from-beads", written, skipped: skipped.length }); + outputJson({ + success: true, + command: "migrate-from-beads", + written, + comments: commentCount, + skipped: skipped.length, + source, + }); } else { - console.log(`Migrated ${written} issues from beads.`); + const sourceLabel = source === "dolt" ? " (via bd CLI)" : " (from JSONL)"; + console.log(`Migrated ${written} issues (${commentCount} comments) from beads${sourceLabel}.`); if (skipped.length > 0) { console.log(`Skipped ${skipped.length} malformed issues.`); } diff --git a/src/index.ts b/src/index.ts index 7423c6a..d829046 100755 --- a/src/index.ts +++ b/src/index.ts @@ -106,6 +106,7 @@ async function registerAll(): Promise { import("./commands/onboard.ts"), import("./commands/upgrade.ts"), import("./commands/completions.ts"), + import("./commands/comment.ts"), ]); for (const mod of mods) { diff --git a/src/output.ts b/src/output.ts index 0a0fdf7..4e64bdb 100644 --- a/src/output.ts +++ b/src/output.ts @@ -69,4 +69,14 @@ export function printIssueFull(issue: Issue): void { console.log(`Created: ${muted(issue.createdAt)}`); console.log(`Updated: ${muted(issue.updatedAt)}`); if (issue.closedAt) console.log(`Closed: ${muted(issue.closedAt)}`); + + if (issue.comments && issue.comments.length > 0) { + console.log(`\n${muted(`Comments (${issue.comments.length}):`)}`); + for (const comment of issue.comments) { + console.log( + `\n${accent.bold(comment.id)} ${muted(comment.author)} ${muted(comment.createdAt)}`, + ); + console.log(comment.body); + } + } } diff --git a/src/types.ts b/src/types.ts index 27f3589..dec2ede 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,11 @@ +export interface IssueComment { + id: string; + author: string; + body: string; + createdAt: string; + updatedAt?: string; +} + export interface Issue { id: string; title: string; @@ -10,6 +18,7 @@ export interface Issue { blocks?: string[]; blockedBy?: string[]; convoy?: string; + comments?: IssueComment[]; createdAt: string; updatedAt: string; closedAt?: string;