From 195620b7cf6c14ec592ca5b853dd7f26403f628f Mon Sep 17 00:00:00 2001 From: Greg Hughes Date: Sun, 1 Mar 2026 19:51:54 -0800 Subject: [PATCH 1/3] feat: add comment support (sd comment add/list/delete) Adds issue comment management with add, list, and delete subcommands. Comments are stored inline on the issue object in JSONL, keeping the git-native single-file approach. Includes 15 integration tests covering CRUD operations, error paths, and SEEDS_AUTHOR env var fallback. - IssueComment type added to types.ts - Comment rendering in sd show output - Full Commander registration with --json and --author flags - CLI reference updated in CLAUDE.md --- CLAUDE.md | 4 + src/commands/comment.test.ts | 225 +++++++++++++++++++++++++++++++++++ src/commands/comment.ts | 204 +++++++++++++++++++++++++++++++ src/index.ts | 1 + src/output.ts | 10 ++ src/types.ts | 9 ++ 6 files changed, 453 insertions(+) create mode 100644 src/commands/comment.test.ts create mode 100644 src/commands/comment.ts 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/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; From f823650d6c303d3d106ae7a0e24f1395d2af7397 Mon Sep 17 00:00:00 2001 From: Greg Hughes Date: Sun, 1 Mar 2026 19:52:08 -0800 Subject: [PATCH 2/3] feat: enhance migration with Dolt support and comment mapping The migrate-from-beads command now supports two data sources: 1. Legacy JSONL: reads .beads/issues.jsonl directly (existing behavior) 2. Dolt (bd CLI): when .beads/ exists but no JSONL file, shells out to bd list/show --json to extract issues including comments Beads v0.55+ moved from JSONL to Dolt storage, making the JSONL path unreachable for modern installations. The Dolt path auto-detects when the bd CLI is available and extracts all open and closed issues with full comment history. Also maps beads comments (text field) to seeds comments (body field) for both migration paths. --- src/commands/migrate.ts | 136 +++++++++++++++++++++++++++++++++++----- 1 file changed, 121 insertions(+), 15 deletions(-) diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 788cd3b..bac98b8 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,117 @@ 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'"); + + const openIssues = JSON.parse(openOut) as Array<{ id: string }>; + const closedIssues = JSON.parse(closedOut) as Array<{ id: string }>; + const allIds = [...openIssues.map((i) => i.id), ...closedIssues.map((i) => i.id)]; + + 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 +195,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.`); } From 06f1580fea7a52ccec742656ce4a9a6d96b14501 Mon Sep 17 00:00:00 2001 From: Greg Hughes Date: Wed, 4 Mar 2026 11:59:47 -0800 Subject: [PATCH 3/3] fix: harden bd CLI output handling in migrate command - Wrap JSON.parse calls on bd list --json output in try/catch with a descriptive error message prompting users to check their bd CLI version - Validate issue IDs from bd CLI output against an alphanumeric+hyphen pattern before passing them as subprocess arguments to Bun.spawn; warn and skip any IDs that do not match --- src/commands/migrate.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index bac98b8..f4ab104 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -137,9 +137,26 @@ async function loadFromDolt(projectRoot: string): Promise { await closedProc.exited; if (closedProc.exitCode !== 0) throw new Error("Failed to run 'bd list --status closed --json'"); - const openIssues = JSON.parse(openOut) as Array<{ id: string }>; - const closedIssues = JSON.parse(closedOut) as Array<{ id: string }>; - const allIds = [...openIssues.map((i) => i.id), ...closedIssues.map((i) => i.id)]; + 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) {