From 98782b597280f841a1c996deb14054876fd909bb Mon Sep 17 00:00:00 2001 From: gkze Date: Sun, 1 Mar 2026 13:03:45 -0800 Subject: [PATCH 1/2] feat(import): add Beads JSONL import with selective issue trees Add --beads and optional issue IDs to import selected issue trees. Map Beads metadata and dependency links into Dex task fields. Preserve completed_at when closed issues are re-imported without closed_at. Add fixtures, parser coverage, and CLI/docs updates for this import flow. --- docs/src/pages/cli.astro | 8 +- specs/beads-import.md | 69 +++++ src/cli/help.ts | 11 +- src/cli/import.test.ts | 369 +++++++++++++++++++++++ src/cli/import.ts | 360 ++++++++++++++++++++-- src/core/beads/fixtures.test.ts | 19 ++ src/core/beads/fixtures/README.md | 18 ++ src/core/beads/fixtures/basic.jsonl | 10 + src/core/beads/fixtures/edge-cases.jsonl | 8 + src/core/beads/fixtures/graph.jsonl | 20 ++ src/core/beads/import.test.ts | 132 ++++++++ src/core/beads/import.ts | 259 ++++++++++++++++ src/core/beads/index.ts | 5 + src/core/task-service.test.ts | 40 +++ src/core/task-service.ts | 8 +- src/types.test.ts | 54 ++++ src/types.ts | 16 + 17 files changed, 1377 insertions(+), 29 deletions(-) create mode 100644 specs/beads-import.md create mode 100644 src/core/beads/fixtures.test.ts create mode 100644 src/core/beads/fixtures/README.md create mode 100644 src/core/beads/fixtures/basic.jsonl create mode 100644 src/core/beads/fixtures/edge-cases.jsonl create mode 100644 src/core/beads/fixtures/graph.jsonl create mode 100644 src/core/beads/import.test.ts create mode 100644 src/core/beads/import.ts create mode 100644 src/core/beads/index.ts diff --git a/docs/src/pages/cli.astro b/docs/src/pages/cli.astro index 93683bd..9667cd7 100644 --- a/docs/src/pages/cli.astro +++ b/docs/src/pages/cli.astro @@ -243,15 +243,17 @@ dex sync --dry-run # Preview sync`}

dex import

-
dex import <ref> [options]
-

Import a GitHub Issue or Shortcut Story as a dex task.

+
dex import <ref> [options] | dex import --beads <path> [issue-id...] [options]
+

Import a GitHub Issue, Shortcut Story, or Beads JSONL export as dex tasks.

  • --all — Import all items with the dex label
  • +
  • --beads <path> — Import from Beads JSONL export file
  • --github — Filter --all to only GitHub
  • --shortcut — Filter --all to only Shortcut
  • --update — Update existing task if already imported
  • --dry-run — Preview what would be imported
+

Beads selection: pass one or more issue IDs after --beads <path> to import only those issues and their descendants.

Reference formats:

  • GitHub: #123, owner/repo#123, or full URL
  • @@ -261,6 +263,8 @@ dex sync --dry-run # Preview sync`} [issue-id...]` to ingest Beads JSONL exports into Dex tasks. + +## Goals + +- Import Beads issue graphs without adding a full sync integration. +- Preserve Beads provenance in task metadata. +- Keep imports idempotent and safe to re-run. +- Keep existing GitHub/Shortcut import flows unchanged. + +## CLI Contract + +- New flag: `--beads ` +- Supported with: + - `--dry-run` + - `--update` +- Optional positional arguments in Beads mode: + - `[issue-id...]` to import one or more root Beads issues and all descendants +- Invalid combinations: + - `--beads` with `--all`, `--github`, or `--shortcut` + +## Data Mapping + +- `id` -> task `id` +- `title` -> task `name` +- `description` -> task `description` +- `priority` -> task `priority` +- `status=closed` (or `closed_at` present) -> `completed=true` +- `created_at`, `updated_at`, `closed_at` -> task timestamps +- `status in {in_progress, hooked}` -> `started_at` (best-effort from `updated_at`) +- Dependency type `parent-child` -> task `parent_id` +- Dependency type `blocks` -> task `blockedBy` +- Non-blocking dependency types are preserved in `metadata.beads` and not mapped to Dex relationships. + +## Implementation Shape + +- Add Beads parser/normalizer under `src/core/beads/`. +- Extend task metadata schema with `metadata.beads` in `src/types.ts`. +- Extend `src/cli/import.ts` to handle `--beads` branch. +- Apply import in two passes: + 1. Upsert task fields (create/update) + 2. Apply relationships (parent + blockers) + +Relationship failures (depth/cycle/missing target) should produce warnings and continue. + +## Test Strategy + +- Parser tests in `src/core/beads/import.test.ts`: + - valid JSONL parsing + - dependency normalization + - malformed line handling (line number in error) +- CLI tests in `src/cli/import.test.ts`: + - happy path import + - dry-run no writes + - update semantics + - invalid flag combinations + - relationship warnings do not abort import +- Schema test in `src/types.test.ts` for `metadata.beads` compatibility. + +## Anonymized Fixtures + +- Add anonymized Beads-derived fixtures under `src/core/beads/fixtures/`. +- Produce fixture data from local Beads exports via an external/local workflow that: + - pseudonymizes IDs/actors/labels/external refs + - redacts free-text fields + - preserves graph shape, status mix, priorities, and dependency semantics + +No raw Beads state or secret-bearing exports are committed. diff --git a/src/cli/help.ts b/src/cli/help.ts index 28b446d..188e192 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -50,6 +50,7 @@ ${colors.bold}COMMANDS:${colors.reset} import #N Import GitHub issue import sc#N Import Shortcut story import --all Import all dex-labeled items + import --beads Import tasks from Beads JSONL export export ... Export tasks to GitHub (no sync back) completion Generate shell completion script @@ -102,9 +103,11 @@ ${colors.bold}EXAMPLES:${colors.reset} dex sync --dry-run # Preview what would be synced ${colors.dim}# Import from external services:${colors.reset} - dex import #42 # Import GitHub issue #42 - dex import sc#123 # Import Shortcut story #123 - dex import --all # Import all dex-labeled items - dex import --all --shortcut # Import only from Shortcut + dex import #42 # Import GitHub issue #42 + dex import sc#123 # Import Shortcut story #123 + dex import --all # Import all dex-labeled items + dex import --all --shortcut # Import only from Shortcut + dex import --beads ./beads.jsonl # Import all from Beads export + dex import --beads ./beads.jsonl id1 id2 # Import selected Beads trees `); } diff --git a/src/cli/import.test.ts b/src/cli/import.test.ts index 823f32c..3bf29ca 100644 --- a/src/cli/import.test.ts +++ b/src/cli/import.test.ts @@ -1,3 +1,5 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { FileStorage } from "../core/storage/index.js"; import { runCli } from "./index.js"; @@ -1267,4 +1269,371 @@ Old subtask context. expect(task.priority).toBe(5); }); }); + + describe("Beads import", () => { + function writeBeadsFixture(contents: string): string { + const fixturePath = path.join( + storage.getIdentifier(), + "beads-fixture.jsonl", + ); + fs.writeFileSync(fixturePath, contents, "utf-8"); + return fixturePath; + } + + it("imports tasks from --beads", async () => { + const filePath = writeBeadsFixture( + [ + JSON.stringify({ + id: "beads-parent", + title: "Parent task", + description: "Parent description", + status: "open", + priority: 1, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:10:00Z", + }), + JSON.stringify({ + id: "beads-child", + title: "Child task", + description: "Child description", + status: "hooked", + priority: 2, + created_at: "2026-01-01T00:20:00Z", + updated_at: "2026-01-01T00:30:00Z", + dependencies: [ + { + issue_id: "beads-child", + depends_on_id: "beads-parent", + type: "parent-child", + }, + { + issue_id: "beads-child", + depends_on_id: "beads-parent", + type: "blocks", + }, + ], + }), + ].join("\n") + "\n", + ); + + await runCli(["import", "--beads", filePath], { storage }); + + const out = output.stdout.join("\n"); + expect(out).toContain("Beads: Imported 2, updated 0 task(s)"); + + const tasks = await storage.readAsync(); + expect(tasks.tasks).toHaveLength(2); + + const parent = tasks.tasks.find((task) => task.id === "beads-parent"); + const child = tasks.tasks.find((task) => task.id === "beads-child"); + + expect(parent).toBeDefined(); + expect(parent?.metadata?.beads?.issueId).toBe("beads-parent"); + expect(child).toBeDefined(); + expect(child?.parent_id).toBe("beads-parent"); + expect(child?.blockedBy).toEqual(["beads-parent"]); + expect(child?.started_at).toBe("2026-01-01T00:30:00Z"); + }); + + it("supports --dry-run for --beads", async () => { + const filePath = writeBeadsFixture( + `${JSON.stringify({ id: "dry-run-1", title: "Dry run", status: "open", priority: 1 })}\n`, + ); + + await runCli(["import", "--beads", filePath, "--dry-run"], { + storage, + }); + + const out = output.stdout.join("\n"); + expect(out).toContain("Would import 1 and update 0 task(s)"); + + const tasks = await storage.readAsync(); + expect(tasks.tasks).toHaveLength(0); + }); + + it("updates existing tasks from Beads with --update", async () => { + const filePath = writeBeadsFixture( + [ + JSON.stringify({ + id: "update-parent", + title: "Parent v1", + status: "open", + priority: 1, + }), + JSON.stringify({ + id: "update-child", + title: "Child v1", + status: "open", + priority: 2, + dependencies: [ + { + issue_id: "update-child", + depends_on_id: "update-parent", + type: "blocks", + }, + ], + }), + ].join("\n") + "\n", + ); + + await runCli(["import", "--beads", filePath], { storage }); + + writeBeadsFixture( + [ + JSON.stringify({ + id: "update-parent", + title: "Parent v2", + status: "closed", + priority: 1, + closed_at: "2026-01-02T00:00:00Z", + close_reason: "Done", + }), + JSON.stringify({ + id: "update-child", + title: "Child v2", + status: "open", + priority: 3, + dependencies: [], + }), + ].join("\n") + "\n", + ); + + await runCli(["import", "--beads", filePath, "--update"], { + storage, + }); + + const tasks = await storage.readAsync(); + const parent = tasks.tasks.find((task) => task.id === "update-parent"); + const child = tasks.tasks.find((task) => task.id === "update-child"); + + expect(parent?.name).toBe("Parent v2"); + expect(parent?.completed).toBe(true); + expect(new Date(parent?.completed_at ?? "").toISOString()).toBe( + "2026-01-02T00:00:00.000Z", + ); + expect(child?.name).toBe("Child v2"); + expect(child?.priority).toBe(3); + expect(child?.blockedBy).toEqual([]); + }); + + it("preserves completed_at on update when Beads closed issue has no closed_at", async () => { + const filePath = writeBeadsFixture( + `${JSON.stringify({ + id: "completed-preserve", + title: "Completed v1", + status: "closed", + priority: 1, + closed_at: "2026-01-05T00:00:00Z", + })}\n`, + ); + + await runCli(["import", "--beads", filePath], { storage }); + + writeBeadsFixture( + `${JSON.stringify({ + id: "completed-preserve", + title: "Completed v2", + status: "closed", + priority: 1, + })}\n`, + ); + + await runCli(["import", "--beads", filePath, "--update"], { + storage, + }); + + const tasks = await storage.readAsync(); + const task = tasks.tasks.find((item) => item.id === "completed-preserve"); + + expect(task?.completed).toBe(true); + expect(task?.name).toBe("Completed v2"); + expect(new Date(task?.completed_at ?? "").toISOString()).toBe( + "2026-01-05T00:00:00.000Z", + ); + }); + + it("reports warnings for missing relationship targets without failing import", async () => { + const filePath = writeBeadsFixture( + `${JSON.stringify({ + id: "warn-1", + title: "Warn issue", + status: "open", + priority: 1, + dependencies: [ + { + issue_id: "warn-1", + depends_on_id: "missing-issue", + type: "blocks", + }, + ], + })}\n`, + ); + + await runCli(["import", "--beads", filePath], { storage }); + + const out = output.stdout.join("\n"); + expect(out).toContain("Warnings:"); + expect(out).toContain("missing-issue"); + + const tasks = await storage.readAsync(); + expect(tasks.tasks).toHaveLength(1); + expect(tasks.tasks[0].id).toBe("warn-1"); + }); + + it("imports selected Beads issue and all descendants", async () => { + const filePath = writeBeadsFixture( + [ + JSON.stringify({ + id: "tree-root", + title: "Root", + status: "open", + priority: 1, + }), + JSON.stringify({ + id: "tree-child", + title: "Child", + status: "open", + priority: 1, + dependencies: [ + { + issue_id: "tree-child", + depends_on_id: "tree-root", + type: "parent-child", + }, + ], + }), + JSON.stringify({ + id: "tree-grandchild", + title: "Grandchild", + status: "open", + priority: 1, + dependencies: [ + { + issue_id: "tree-grandchild", + depends_on_id: "tree-child", + type: "parent-child", + }, + ], + }), + JSON.stringify({ + id: "other-root", + title: "Other root", + status: "open", + priority: 1, + }), + ].join("\n") + "\n", + ); + + await runCli(["import", "--beads", filePath, "tree-root"], { storage }); + + const tasks = await storage.readAsync(); + const importedIds = tasks.tasks.map((task) => task.id).sort(); + expect(importedIds).toEqual([ + "tree-child", + "tree-grandchild", + "tree-root", + ]); + + const child = tasks.tasks.find((task) => task.id === "tree-child"); + const grandchild = tasks.tasks.find( + (task) => task.id === "tree-grandchild", + ); + expect(child?.parent_id).toBe("tree-root"); + expect(grandchild?.parent_id).toBe("tree-child"); + }); + + it("imports multiple selected Beads issue trees", async () => { + const filePath = writeBeadsFixture( + [ + JSON.stringify({ + id: "alpha", + title: "Alpha", + status: "open", + priority: 1, + }), + JSON.stringify({ + id: "alpha-child", + title: "Alpha child", + status: "open", + priority: 1, + dependencies: [ + { + issue_id: "alpha-child", + depends_on_id: "alpha", + type: "parent-child", + }, + ], + }), + JSON.stringify({ + id: "beta", + title: "Beta", + status: "open", + priority: 1, + }), + JSON.stringify({ + id: "beta-child", + title: "Beta child", + status: "open", + priority: 1, + dependencies: [ + { + issue_id: "beta-child", + depends_on_id: "beta", + type: "parent-child", + }, + ], + }), + JSON.stringify({ + id: "gamma", + title: "Gamma", + status: "open", + priority: 1, + }), + ].join("\n") + "\n", + ); + + await runCli(["import", "--beads", filePath, "alpha", "beta"], { + storage, + }); + + const tasks = await storage.readAsync(); + const importedIds = tasks.tasks.map((task) => task.id).sort(); + expect(importedIds).toEqual([ + "alpha", + "alpha-child", + "beta", + "beta-child", + ]); + }); + + it("fails when selected Beads issue ids are missing", async () => { + const filePath = writeBeadsFixture( + `${JSON.stringify({ id: "known-1", title: "Known", status: "open", priority: 1 })}\n`, + ); + + await expect( + runCli(["import", "--beads", filePath, "missing-1", "missing-2"], { + storage, + }), + ).rejects.toThrow("process.exit"); + + const err = output.stderr.join("\n"); + expect(err).toContain( + "Beads issue id(s) not found in export: missing-1, missing-2", + ); + }); + + it("rejects invalid flag combinations with --beads", async () => { + const filePath = writeBeadsFixture( + `${JSON.stringify({ id: "invalid-1", title: "Invalid", status: "open" })}\n`, + ); + + await expect( + runCli(["import", "--beads", filePath, "--all"], { storage }), + ).rejects.toThrow("process.exit"); + + const err = output.stderr.join("\n"); + expect(err).toContain("cannot be combined"); + }); + }); }); diff --git a/src/cli/import.ts b/src/cli/import.ts index a156001..fbb13f7 100644 --- a/src/cli/import.ts +++ b/src/cli/import.ts @@ -1,7 +1,9 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; import type { CliOptions } from "./utils.js"; import { createService, formatCliError } from "./utils.js"; import { colors } from "./colors.js"; -import { getBooleanFlag, parseArgs } from "./args.js"; +import { getBooleanFlag, getStringFlag, parseArgs } from "./args.js"; import type { GitHubRepo } from "../core/github/index.js"; import { getGitHubIssueNumber, @@ -18,6 +20,10 @@ import { parseTaskMetadata as parseShortcutTaskMetadata, parseStoryDescription, } from "../core/shortcut/index.js"; +import { + parseBeadsExportJsonl, + type ParsedBeadsIssue, +} from "../core/beads/index.js"; import { loadConfig } from "../core/config.js"; import type { Task, ShortcutMetadata } from "../types.js"; import { Octokit } from "@octokit/rest"; @@ -32,6 +38,7 @@ export async function importCommand( all: { hasValue: false }, "dry-run": { hasValue: false }, update: { hasValue: false }, + beads: { hasValue: true }, github: { hasValue: false }, shortcut: { hasValue: false }, help: { short: "h", hasValue: false }, @@ -40,30 +47,35 @@ export async function importCommand( ); if (getBooleanFlag(flags, "help")) { - console.log(`${colors.bold}dex import${colors.reset} - Import GitHub Issues or Shortcut Stories as tasks + console.log(`${colors.bold}dex import${colors.reset} - Import GitHub, Shortcut, or Beads items as tasks ${colors.bold}USAGE:${colors.reset} - dex import #123 # Import GitHub issue #123 - dex import sc#123 # Import Shortcut story #123 - dex import # Import by full URL - dex import --all # Import all dex-labeled items - dex import --all --github # Import only from GitHub - dex import --all --shortcut # Import only from Shortcut - dex import --dry-run # Preview without importing - dex import #123 --update # Update existing task + dex import #123 # Import GitHub issue #123 + dex import sc#123 # Import Shortcut story #123 + dex import --beads data.jsonl # Import all issues from Beads export + dex import --beads data.jsonl id1 id2 # Import selected Beads issues + descendants + dex import # Import by full URL + dex import --all # Import all dex-labeled items + dex import --all --github # Import only from GitHub + dex import --all --shortcut # Import only from Shortcut + dex import --dry-run # Preview without importing + dex import #123 --update # Update existing task ${colors.bold}ARGUMENTS:${colors.reset} - Reference format: - GitHub: #N, URL, or owner/repo#N - Shortcut: sc#N, SC#N, or full URL + Reference format (ref mode only): + GitHub: #N, URL, or owner/repo#N + Shortcut: sc#N, SC#N, or full URL + [issue-id...] Optional Beads issue IDs (beads mode only) + Imports each selected issue and all descendants ${colors.bold}OPTIONS:${colors.reset} - --all Import all items with dex label - --github Filter --all to only GitHub - --shortcut Filter --all to only Shortcut - --update Update existing task if already imported - --dry-run Show what would be imported without making changes - -h, --help Show this help message + --all Import all items with dex label + --beads Import from Beads JSONL export file + --github Filter --all to only GitHub + --shortcut Filter --all to only Shortcut + --update Update existing task if already imported + --dry-run Show what would be imported without making changes + -h, --help Show this help message ${colors.bold}REQUIREMENTS:${colors.reset} GitHub: @@ -73,9 +85,14 @@ ${colors.bold}REQUIREMENTS:${colors.reset} Shortcut: - SHORTCUT_API_TOKEN environment variable + Beads: + - Local JSONL export file (for example from 'bd export') + ${colors.bold}EXAMPLE:${colors.reset} dex import #42 # Import GitHub issue dex import sc#123 # Import Shortcut story + dex import --beads ~/tmp/beads.jsonl # Import all from Beads + dex import --beads ~/tmp/beads.jsonl i1 i2 # Import selected Beads issues + descendants dex import https://github.com/user/repo/issues/42 dex import https://app.shortcut.com/myorg/story/123 dex import --all # Import all dex items @@ -87,17 +104,31 @@ ${colors.bold}EXAMPLE:${colors.reset} const ref = positional[0]; const importAll = getBooleanFlag(flags, "all"); + const beadsFile = getStringFlag(flags, "beads"); + const beadsIssueIds = beadsFile ? positional : []; const dryRun = getBooleanFlag(flags, "dry-run"); const update = getBooleanFlag(flags, "update"); const githubOnly = getBooleanFlag(flags, "github"); const shortcutOnly = getBooleanFlag(flags, "shortcut"); - if (!ref && !importAll) { + if (beadsFile) { + if (importAll || githubOnly || shortcutOnly) { + console.error( + `${colors.red}Error:${colors.reset} --beads cannot be combined with --all, --github, or --shortcut`, + ); + console.error( + `Usage: dex import --beads [issue-id...] [--update] [--dry-run]`, + ); + process.exit(1); + } + } + + if (!ref && !importAll && !beadsFile) { console.error( `${colors.red}Error:${colors.reset} Reference or --all required`, ); console.error( - `Usage: dex import #123, dex import sc#123, or dex import --all`, + `Usage: dex import #123, dex import sc#123, dex import --all, or dex import --beads [issue-id...]`, ); process.exit(1); } @@ -106,7 +137,15 @@ ${colors.bold}EXAMPLE:${colors.reset} const service = createService(options); try { - if (importAll) { + if (beadsFile) { + await importFromBeadsFile( + service, + beadsFile, + dryRun, + update, + beadsIssueIds, + ); + } else if (importAll) { // Import all from GitHub and/or Shortcut const importFromGitHub = !shortcutOnly; const importFromShortcut = !githubOnly; @@ -159,6 +198,283 @@ function parseShortcutRef( return null; } +// ============================================================ +// Beads Import Functions +// ============================================================ + +async function importFromBeadsFile( + service: ReturnType, + filePath: string, + dryRun: boolean, + update: boolean, + requestedIssueIds: string[] = [], +): Promise { + const resolvedPath = path.resolve(filePath); + + let input: string; + try { + input = fs.readFileSync(resolvedPath, "utf-8"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to read Beads file ${resolvedPath}: ${message}`); + } + + const parsed = parseBeadsExportJsonl(input); + const parseWarnings = [...parsed.warnings]; + + if (parsed.issues.length === 0) { + console.log(`No Beads issues found in ${resolvedPath}.`); + if (parseWarnings.length > 0) { + printBeadsWarnings(parseWarnings); + } + return; + } + + const issuesToImport = selectBeadsIssues(parsed.issues, requestedIssueIds); + + const existingTasks = await service.list({ all: true }); + const existingById = new Map(existingTasks.map((task) => [task.id, task])); + + const toCreate = issuesToImport.filter( + (issue) => !existingById.has(issue.id), + ); + const toExisting = issuesToImport.filter((issue) => + existingById.has(issue.id), + ); + + if (dryRun) { + const wouldUpdate = update ? toExisting.length : 0; + const wouldSkip = update ? 0 : toExisting.length; + + console.log( + `Would import ${toCreate.length} and update ${wouldUpdate} task(s) from Beads file ${colors.cyan}${resolvedPath}${colors.reset}`, + ); + if (wouldSkip > 0) { + console.log( + `Would skip ${wouldSkip} existing task(s) (use --update to refresh)`, + ); + } + + if (parseWarnings.length > 0) { + printBeadsWarnings(parseWarnings); + } + return; + } + + let created = 0; + let updated = 0; + let skipped = 0; + const createdIds = new Set(); + + for (const issue of issuesToImport) { + const existing = existingById.get(issue.id); + + if (existing) { + if (!update) { + skipped++; + continue; + } + + await service.update({ + id: issue.id, + name: issue.name, + description: issue.description, + priority: issue.priority, + completed: issue.completed, + ...(!issue.completed + ? { completed_at: null } + : issue.completed_at + ? { completed_at: issue.completed_at } + : {}), + result: issue.completed ? issue.result : null, + started_at: issue.started_at ?? null, + metadata: { + ...(existing.metadata ?? {}), + beads: issue.beadsMetadata, + }, + }); + updated++; + continue; + } + + await service.create({ + id: issue.id, + name: issue.name, + description: issue.description, + priority: issue.priority, + completed: issue.completed, + result: issue.result, + created_at: issue.created_at, + updated_at: issue.updated_at, + started_at: issue.started_at, + completed_at: issue.completed_at, + metadata: { + beads: issue.beadsMetadata, + }, + }); + createdIds.add(issue.id); + created++; + } + + const relationshipWarnings = [...parseWarnings]; + const currentTasks = await service.list({ all: true }); + const currentById = new Map(currentTasks.map((task) => [task.id, task])); + + for (const issue of issuesToImport) { + const shouldApplyRelationships = + createdIds.has(issue.id) || (update && existingById.has(issue.id)); + if (!shouldApplyRelationships) continue; + + const current = currentById.get(issue.id); + if (!current) { + relationshipWarnings.push( + `Issue ${issue.id}: task was not found after import; skipping relationship sync`, + ); + continue; + } + + const desiredParent = issue.parentId ?? null; + if (desiredParent !== current.parent_id) { + if (desiredParent && !currentById.has(desiredParent)) { + relationshipWarnings.push( + `Issue ${issue.id}: parent ${desiredParent} is missing, skipping parent link`, + ); + } else { + try { + await service.update({ id: issue.id, parent_id: desiredParent }); + current.parent_id = desiredParent; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + relationshipWarnings.push( + `Issue ${issue.id}: could not set parent to ${desiredParent ?? "(none)"}: ${message}`, + ); + } + } + } + + const desiredBlockers: string[] = []; + for (const blockerId of issue.blockerIds) { + if (blockerId === issue.id) { + relationshipWarnings.push( + `Issue ${issue.id}: self-blocking dependency ignored`, + ); + continue; + } + if (!currentById.has(blockerId)) { + relationshipWarnings.push( + `Issue ${issue.id}: blocker ${blockerId} missing, skipping blocker link`, + ); + continue; + } + desiredBlockers.push(blockerId); + } + + const currentBlockers = new Set(current.blockedBy); + const desiredSet = new Set(desiredBlockers); + + const addBlockedBy = [...desiredSet].filter( + (id) => !currentBlockers.has(id), + ); + const removeBlockedBy = update + ? [...currentBlockers].filter((id) => !desiredSet.has(id)) + : []; + + if (addBlockedBy.length > 0 || removeBlockedBy.length > 0) { + try { + await service.update({ + id: issue.id, + ...(addBlockedBy.length > 0 && { add_blocked_by: addBlockedBy }), + ...(removeBlockedBy.length > 0 && { + remove_blocked_by: removeBlockedBy, + }), + }); + + const nextBlockedBy = [ + ...current.blockedBy.filter((id) => !removeBlockedBy.includes(id)), + ...addBlockedBy, + ]; + current.blockedBy = Array.from(new Set(nextBlockedBy)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + relationshipWarnings.push( + `Issue ${issue.id}: could not update blockers: ${message}`, + ); + } + } + } + + console.log( + `Beads: Imported ${created}, updated ${updated} task(s) from ${colors.cyan}${resolvedPath}${colors.reset}`, + ); + if (skipped > 0) { + console.log( + `Skipped ${skipped} existing task(s) (use --update to refresh)`, + ); + } + + if (relationshipWarnings.length > 0) { + printBeadsWarnings(relationshipWarnings); + } +} + +function selectBeadsIssues( + issues: ParsedBeadsIssue[], + requestedIssueIds: string[], +): ParsedBeadsIssue[] { + const normalizedRequested = Array.from( + new Set(requestedIssueIds.map((id) => id.trim()).filter(Boolean)), + ); + + if (normalizedRequested.length === 0) { + return issues; + } + + const issueById = new Map(issues.map((issue) => [issue.id, issue])); + const missingIssueIds = normalizedRequested.filter( + (id) => !issueById.has(id), + ); + if (missingIssueIds.length > 0) { + throw new Error( + `Beads issue id(s) not found in export: ${missingIssueIds.join(", ")}`, + ); + } + + const childrenByParent = new Map(); + for (const issue of issues) { + if (!issue.parentId) continue; + const children = childrenByParent.get(issue.parentId) ?? []; + children.push(issue.id); + childrenByParent.set(issue.parentId, children); + } + + const selectedIds = new Set(); + const queue = [...normalizedRequested]; + while (queue.length > 0) { + const issueId = queue.shift(); + if (!issueId || selectedIds.has(issueId)) continue; + + selectedIds.add(issueId); + const childIds = childrenByParent.get(issueId) ?? []; + queue.push(...childIds); + } + + return issues.filter((issue) => selectedIds.has(issue.id)); +} + +function printBeadsWarnings(warnings: string[]): void { + const maxWarnings = 20; + console.log( + `${colors.yellow}Warnings:${colors.reset} ${warnings.length} encountered during Beads import`, + ); + const shown = warnings.slice(0, maxWarnings); + for (const warning of shown) { + console.log(` - ${warning}`); + } + if (warnings.length > maxWarnings) { + console.log(` - ...and ${warnings.length - maxWarnings} more`); + } +} + // ============================================================ // GitHub Import Functions // ============================================================ diff --git a/src/core/beads/fixtures.test.ts b/src/core/beads/fixtures.test.ts new file mode 100644 index 0000000..2e07473 --- /dev/null +++ b/src/core/beads/fixtures.test.ts @@ -0,0 +1,19 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, it, expect } from "vitest"; + +const FIXTURE_FILES = ["basic.jsonl", "graph.jsonl", "edge-cases.jsonl"]; + +describe("beads fixtures hygiene", () => { + it("does not contain obvious sensitive patterns", () => { + const fixturesDir = path.resolve(import.meta.dirname, "fixtures"); + const content = FIXTURE_FILES.map((file) => + fs.readFileSync(path.join(fixturesDir, file), "utf-8"), + ).join("\n"); + + expect(content).not.toMatch(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); + expect(content).not.toContain("/Users/"); + expect(content).not.toContain("github.com/"); + expect(content).not.toContain("ghp_"); + }); +}); diff --git a/src/core/beads/fixtures/README.md b/src/core/beads/fixtures/README.md new file mode 100644 index 0000000..10e801b --- /dev/null +++ b/src/core/beads/fixtures/README.md @@ -0,0 +1,18 @@ +# Beads Fixtures + +These fixtures are anonymized and derived from real local Beads state under `~/Development`. + +## Files + +- `basic.jsonl` - small representative sample for smoke tests +- `graph.jsonl` - includes parent-child and blocks relationships +- `edge-cases.jsonl` - includes non-ideal relationships (e.g. missing targets) + +## Regeneration + +Fixtures are intentionally committed as static anonymized snapshots. + +When refreshing them, use a local one-off script/workflow outside this repository, +then copy in only anonymized output. + +Raw Beads data is never written into this repository. diff --git a/src/core/beads/fixtures/basic.jsonl b/src/core/beads/fixtures/basic.jsonl new file mode 100644 index 0000000..64e0f7b --- /dev/null +++ b/src/core/beads/fixtures/basic.jsonl @@ -0,0 +1,10 @@ +{"id":"beads-001","title":"Issue beads-001","description":"Redacted description for beads-001.","status":"closed","priority":0,"issue_type":"epic","created_at":"2024-02-19T09:07:44.000Z","updated_at":"2024-05-02T01:03:42.000Z","closed_at":"2024-05-02T01:03:42.000Z","close_reason":"Redacted close reason"} +{"id":"beads-002","title":"Issue beads-002","description":"Redacted description for beads-002.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T02:01:32.000Z","closed_at":"2024-05-03T02:01:32.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-002","depends_on_id":"beads-003","type":"blocks","created_at":"2024-05-02T17:49:54.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"} +{"id":"beads-003","title":"Issue beads-003","description":"Redacted description for beads-003.","status":"closed","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:49:55.000Z","updated_at":"2024-05-03T02:01:31.000Z","closed_at":"2024-05-03T02:01:31.000Z","close_reason":"Redacted close reason"} +{"id":"beads-004","title":"Issue beads-004","description":"Redacted description for beads-004.","status":"hooked","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T01:49:39.000Z","dependencies":[{"issue_id":"beads-004","depends_on_id":"beads-005","type":"blocks","created_at":"2024-05-02T17:49:38.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"} +{"id":"beads-005","title":"Issue beads-005","description":"Redacted description for beads-005.","status":"open","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:49:38.000Z","updated_at":"2024-05-03T01:49:38.000Z"} +{"id":"beads-006","title":"Issue beads-006","description":"Redacted description for beads-006.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-03T00:55:29.000Z","updated_at":"2024-05-03T01:28:08.000Z","closed_at":"2024-05-03T01:27:00.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-006","depends_on_id":"beads-007","type":"blocks","created_at":"2024-05-02T17:26:17.000Z","created_by":"user-005"}],"created_by":"user-004","owner":"user-002","assignee":"user-003"} +{"id":"beads-007","title":"Issue beads-007","description":"Redacted description for beads-007.","status":"closed","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:26:17.000Z","updated_at":"2024-05-03T01:28:08.000Z","closed_at":"2024-05-03T01:28:08.000Z","close_reason":"Redacted close reason"} +{"id":"beads-008","title":"Issue beads-008","description":"Redacted description for beads-008.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:59:02.000Z","updated_at":"2024-05-03T00:25:48.000Z","closed_at":"2024-05-03T00:24:21.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-008","depends_on_id":"external-001","type":"blocks","created_at":"2024-05-02T16:17:31.000Z","created_by":"user-001"}],"created_by":"user-006","owner":"user-002","assignee":"user-004"} +{"id":"beads-009","title":"Issue beads-009","description":"Redacted description for beads-009.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:37:06.000Z","updated_at":"2024-05-02T23:20:50.000Z","closed_at":"2024-05-02T23:16:02.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-009","depends_on_id":"external-002","type":"blocks","created_at":"2024-05-02T15:11:16.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"} +{"id":"beads-010","title":"Issue beads-010","description":"Redacted description for beads-010.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:28:15.000Z","updated_at":"2024-05-02T22:48:07.000Z","closed_at":"2024-05-02T22:40:06.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-010","depends_on_id":"external-003","type":"blocks","created_at":"2024-05-02T14:32:20.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-007"} diff --git a/src/core/beads/fixtures/edge-cases.jsonl b/src/core/beads/fixtures/edge-cases.jsonl new file mode 100644 index 0000000..fe30f95 --- /dev/null +++ b/src/core/beads/fixtures/edge-cases.jsonl @@ -0,0 +1,8 @@ +{"id":"beads-002","title":"Issue beads-002","description":"Redacted description for beads-002.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T02:01:32.000Z","closed_at":"2024-05-03T02:01:32.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-002","depends_on_id":"beads-003","type":"blocks","created_at":"2024-05-02T17:49:54.000Z","created_by":"user-001"},{"issue_id":"beads-002","depends_on_id":"external-999","type":"blocks","created_at":"2024-05-03T02:01:32.000Z","created_by":"user-999"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"} +{"id":"beads-004","title":"Issue beads-004","description":"Redacted description for beads-004.","status":"hooked","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T01:49:39.000Z","dependencies":[{"issue_id":"beads-004","depends_on_id":"beads-005","type":"blocks","created_at":"2024-05-02T17:49:38.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"} +{"id":"beads-006","title":"Issue beads-006","description":"Redacted description for beads-006.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-03T00:55:29.000Z","updated_at":"2024-05-03T01:28:08.000Z","closed_at":"2024-05-03T01:27:00.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-006","depends_on_id":"beads-007","type":"blocks","created_at":"2024-05-02T17:26:17.000Z","created_by":"user-005"}],"created_by":"user-004","owner":"user-002","assignee":"user-003"} +{"id":"beads-008","title":"Issue beads-008","description":"Redacted description for beads-008.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:59:02.000Z","updated_at":"2024-05-03T00:25:48.000Z","closed_at":"2024-05-03T00:24:21.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-008","depends_on_id":"external-001","type":"blocks","created_at":"2024-05-02T16:17:31.000Z","created_by":"user-001"}],"created_by":"user-006","owner":"user-002","assignee":"user-004"} +{"id":"beads-009","title":"Issue beads-009","description":"Redacted description for beads-009.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:37:06.000Z","updated_at":"2024-05-02T23:20:50.000Z","closed_at":"2024-05-02T23:16:02.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-009","depends_on_id":"external-002","type":"blocks","created_at":"2024-05-02T15:11:16.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"} +{"id":"beads-010","title":"Issue beads-010","description":"Redacted description for beads-010.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:28:15.000Z","updated_at":"2024-05-02T22:48:07.000Z","closed_at":"2024-05-02T22:40:06.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-010","depends_on_id":"external-003","type":"blocks","created_at":"2024-05-02T14:32:20.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-007"} +{"id":"beads-018","title":"Issue beads-018","description":"Redacted description for beads-018.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T20:56:25.000Z","updated_at":"2024-05-02T21:08:11.000Z","closed_at":"2024-05-02T21:08:11.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-018","depends_on_id":"external-004","type":"blocks","created_at":"2024-05-02T12:56:49.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"} +{"id":"beads-019","title":"Issue beads-019","description":"Redacted description for beads-019.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T18:43:54.000Z","updated_at":"2024-05-02T19:18:30.000Z","closed_at":"2024-05-02T19:18:30.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-019","depends_on_id":"external-005","type":"blocks","created_at":"2024-05-02T10:44:02.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"} diff --git a/src/core/beads/fixtures/graph.jsonl b/src/core/beads/fixtures/graph.jsonl new file mode 100644 index 0000000..61c7866 --- /dev/null +++ b/src/core/beads/fixtures/graph.jsonl @@ -0,0 +1,20 @@ +{"id":"beads-002","title":"Issue beads-002","description":"Redacted description for beads-002.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T02:01:32.000Z","closed_at":"2024-05-03T02:01:32.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-002","depends_on_id":"beads-003","type":"blocks","created_at":"2024-05-02T17:49:54.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"} +{"id":"beads-003","title":"Issue beads-003","description":"Redacted description for beads-003.","status":"closed","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:49:55.000Z","updated_at":"2024-05-03T02:01:31.000Z","closed_at":"2024-05-03T02:01:31.000Z","close_reason":"Redacted close reason"} +{"id":"beads-004","title":"Issue beads-004","description":"Redacted description for beads-004.","status":"hooked","priority":1,"issue_type":"task","created_at":"2024-05-03T01:49:00.000Z","updated_at":"2024-05-03T01:49:39.000Z","dependencies":[{"issue_id":"beads-004","depends_on_id":"beads-005","type":"blocks","created_at":"2024-05-02T17:49:38.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"} +{"id":"beads-005","title":"Issue beads-005","description":"Redacted description for beads-005.","status":"open","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:49:38.000Z","updated_at":"2024-05-03T01:49:38.000Z"} +{"id":"beads-006","title":"Issue beads-006","description":"Redacted description for beads-006.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-03T00:55:29.000Z","updated_at":"2024-05-03T01:28:08.000Z","closed_at":"2024-05-03T01:27:00.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-006","depends_on_id":"beads-007","type":"blocks","created_at":"2024-05-02T17:26:17.000Z","created_by":"user-005"}],"created_by":"user-004","owner":"user-002","assignee":"user-003"} +{"id":"beads-007","title":"Issue beads-007","description":"Redacted description for beads-007.","status":"closed","priority":2,"issue_type":"epic","created_at":"2024-05-03T01:26:17.000Z","updated_at":"2024-05-03T01:28:08.000Z","closed_at":"2024-05-03T01:28:08.000Z","close_reason":"Redacted close reason"} +{"id":"beads-008","title":"Issue beads-008","description":"Redacted description for beads-008.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:59:02.000Z","updated_at":"2024-05-03T00:25:48.000Z","closed_at":"2024-05-03T00:24:21.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-008","depends_on_id":"external-001","type":"blocks","created_at":"2024-05-02T16:17:31.000Z","created_by":"user-001"}],"created_by":"user-006","owner":"user-002","assignee":"user-004"} +{"id":"beads-009","title":"Issue beads-009","description":"Redacted description for beads-009.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:37:06.000Z","updated_at":"2024-05-02T23:20:50.000Z","closed_at":"2024-05-02T23:16:02.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-009","depends_on_id":"external-002","type":"blocks","created_at":"2024-05-02T15:11:16.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"} +{"id":"beads-010","title":"Issue beads-010","description":"Redacted description for beads-010.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T22:28:15.000Z","updated_at":"2024-05-02T22:48:07.000Z","closed_at":"2024-05-02T22:40:06.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-010","depends_on_id":"external-003","type":"blocks","created_at":"2024-05-02T14:32:20.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-007"} +{"id":"beads-018","title":"Issue beads-018","description":"Redacted description for beads-018.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T20:56:25.000Z","updated_at":"2024-05-02T21:08:11.000Z","closed_at":"2024-05-02T21:08:11.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-018","depends_on_id":"external-004","type":"blocks","created_at":"2024-05-02T12:56:49.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"} +{"id":"beads-019","title":"Issue beads-019","description":"Redacted description for beads-019.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-05-02T18:43:54.000Z","updated_at":"2024-05-02T19:18:30.000Z","closed_at":"2024-05-02T19:18:30.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-019","depends_on_id":"external-005","type":"blocks","created_at":"2024-05-02T10:44:02.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"} +{"id":"beads-020","title":"Issue beads-020","description":"Redacted description for beads-020.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-05-02T03:55:25.000Z","updated_at":"2024-05-02T03:58:59.000Z","closed_at":"2024-05-02T03:58:07.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-020","depends_on_id":"external-006","type":"blocks","created_at":"2024-05-01T19:55:53.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-003"} +{"id":"beads-021","title":"Issue beads-021","description":"Redacted description for beads-021.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-05-02T03:55:24.000Z","updated_at":"2024-05-02T03:59:14.000Z","closed_at":"2024-05-02T03:58:39.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-021","depends_on_id":"external-007","type":"blocks","created_at":"2024-05-01T19:55:44.000Z","created_by":"user-001"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"} +{"id":"beads-024","title":"Issue beads-024","description":"Redacted description for beads-024.","status":"closed","priority":1,"issue_type":"feature","created_at":"2024-04-30T23:54:55.000Z","updated_at":"2024-05-02T21:37:50.000Z","closed_at":"2024-05-02T21:37:50.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-024","depends_on_id":"external-008","type":"blocks","created_at":"2024-05-02T13:24:12.000Z","created_by":"user-005"}],"created_by":"user-001","owner":"user-002","assignee":"user-004"} +{"id":"beads-025","title":"Issue beads-025","description":"Redacted description for beads-025.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-04-30T23:11:38.000Z","updated_at":"2024-05-01T21:24:03.000Z","closed_at":"2024-05-01T21:22:14.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-025","depends_on_id":"external-009","type":"blocks","created_at":"2024-05-01T13:20:21.000Z","created_by":"user-001"}],"created_by":"user-006","owner":"user-002","assignee":"user-004"} +{"id":"beads-031","title":"Issue beads-031","description":"Redacted description for beads-031.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-02-20T00:24:12.000Z","updated_at":"2024-05-02T19:58:44.000Z","closed_at":"2024-05-02T19:36:07.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-031","depends_on_id":"beads-032","type":"blocks","created_at":"2024-02-20T00:25:56.000Z","created_by":"user-001"},{"issue_id":"beads-031","depends_on_id":"external-010","type":"blocks","created_at":"2024-05-02T11:34:08.000Z","created_by":"user-001"}],"assignee":"user-004"} +{"id":"beads-032","title":"Issue beads-032","description":"Redacted description for beads-032.","status":"closed","priority":2,"issue_type":"epic","created_at":"2024-02-20T00:25:48.000Z","updated_at":"2024-05-02T01:03:42.000Z","closed_at":"2024-05-02T01:03:42.000Z","close_reason":"Redacted close reason"} +{"id":"beads-033","title":"Issue beads-033","description":"Redacted description for beads-033.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-02-20T00:24:11.000Z","updated_at":"2024-05-02T19:58:35.000Z","closed_at":"2024-05-02T19:35:31.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-033","depends_on_id":"beads-032","type":"blocks","created_at":"2024-02-20T00:25:56.000Z","created_by":"user-001"},{"issue_id":"beads-033","depends_on_id":"external-011","type":"blocks","created_at":"2024-05-02T11:34:30.000Z","created_by":"user-001"}],"assignee":"user-007"} +{"id":"beads-034","title":"Issue beads-034","description":"Redacted description for beads-034.","status":"closed","priority":1,"issue_type":"task","created_at":"2024-02-20T00:24:07.000Z","updated_at":"2024-05-02T22:17:39.000Z","closed_at":"2024-05-02T22:17:39.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-034","depends_on_id":"beads-032","type":"blocks","created_at":"2024-02-20T00:25:56.000Z","created_by":"user-001"},{"issue_id":"beads-034","depends_on_id":"external-012","type":"blocks","created_at":"2024-05-02T14:00:50.000Z","created_by":"user-001"}],"assignee":"user-004"} +{"id":"beads-035","title":"Issue beads-035","description":"Redacted description for beads-035.","status":"closed","priority":1,"issue_type":"bug","created_at":"2024-02-17T22:58:15.000Z","updated_at":"2024-05-02T22:30:28.000Z","closed_at":"2024-05-02T22:29:53.000Z","close_reason":"Redacted close reason","dependencies":[{"issue_id":"beads-035","depends_on_id":"external-013","type":"blocks","created_at":"2024-05-02T14:22:23.000Z","created_by":"user-001"}],"assignee":"user-007"} diff --git a/src/core/beads/import.test.ts b/src/core/beads/import.test.ts new file mode 100644 index 0000000..ce460b3 --- /dev/null +++ b/src/core/beads/import.test.ts @@ -0,0 +1,132 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, it, expect } from "vitest"; +import { parseBeadsExportJsonl } from "./import.js"; + +function fixturePath(name: string): string { + return path.resolve(import.meta.dirname, "fixtures", name); +} + +describe("parseBeadsExportJsonl", () => { + it("parses Beads JSONL and maps relationships", () => { + const input = [ + JSON.stringify({ + id: "bd-1", + title: "Parent", + description: "Parent issue", + status: "open", + priority: 1, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T01:00:00Z", + }), + JSON.stringify({ + id: "bd-2", + title: "Child", + description: "Child issue", + status: "in_progress", + priority: 2, + created_at: "2026-01-01T02:00:00Z", + updated_at: "2026-01-01T03:00:00Z", + dependencies: [ + { issue_id: "bd-2", depends_on_id: "bd-1", type: "parent-child" }, + { issue_id: "bd-2", depends_on_id: "bd-1", type: "blocks" }, + ], + }), + ].join("\n"); + + const parsed = parseBeadsExportJsonl(input); + expect(parsed.issues).toHaveLength(2); + expect(parsed.warnings).toEqual([]); + + const child = parsed.issues.find((issue) => issue.id === "bd-2"); + expect(child).toBeDefined(); + expect(child?.parentId).toBe("bd-1"); + expect(child?.blockerIds).toEqual(["bd-1"]); + expect(child?.started_at).toBe("2026-01-01T03:00:00Z"); + expect(child?.beadsMetadata.status).toBe("in_progress"); + }); + + it("parses records containing embedded Issue objects", () => { + const input = JSON.stringify({ + Issue: { + id: "bd-3", + title: "Embedded", + description: "Embedded format", + status: "closed", + priority: 0, + created_at: "2026-02-01T00:00:00Z", + updated_at: "2026-02-01T01:00:00Z", + closed_at: "2026-02-01T01:00:00Z", + }, + dependency_count: 0, + dependent_count: 0, + }); + + const parsed = parseBeadsExportJsonl(input); + expect(parsed.issues).toHaveLength(1); + expect(parsed.issues[0].id).toBe("bd-3"); + expect(parsed.issues[0].completed).toBe(true); + expect(parsed.issues[0].result).toBe("Imported as completed from Beads"); + }); + + it("prefers depends_on.id over dependency row id", () => { + const input = [ + JSON.stringify({ + id: "bd-parent", + title: "Parent", + status: "open", + priority: 1, + }), + JSON.stringify({ + id: "bd-child", + title: "Child", + status: "open", + priority: 1, + dependencies: [ + { + id: "dep-row-123", + issue_id: "bd-child", + type: "blocks", + depends_on: { id: "bd-parent" }, + }, + ], + }), + ].join("\n"); + + const parsed = parseBeadsExportJsonl(input); + const child = parsed.issues.find((issue) => issue.id === "bd-child"); + expect(child?.blockerIds).toEqual(["bd-parent"]); + }); + + it("throws on malformed JSON with line number", () => { + expect(() => + parseBeadsExportJsonl('{"id":"bd-1","title":"ok"}\n{"bad"'), + ).toThrow(/Invalid JSON on line 2/); + }); + + it("throws on duplicate issue ids", () => { + const duplicate = [ + JSON.stringify({ id: "dup-1", title: "A" }), + JSON.stringify({ id: "dup-1", title: "B" }), + ].join("\n"); + + expect(() => parseBeadsExportJsonl(duplicate)).toThrow( + /Duplicate issue id in input: dup-1/, + ); + }); + + it("loads anonymized fixtures generated from local Beads state", () => { + const graphFixture = fs.readFileSync(fixturePath("graph.jsonl"), "utf-8"); + const parsed = parseBeadsExportJsonl(graphFixture); + + expect(parsed.issues.length).toBeGreaterThan(0); + + const hasBlocks = parsed.issues.some( + (issue) => issue.blockerIds.length > 0, + ); + expect(hasBlocks).toBe(true); + + const hasClosed = parsed.issues.some((issue) => issue.completed); + expect(hasClosed).toBe(true); + }); +}); diff --git a/src/core/beads/import.ts b/src/core/beads/import.ts new file mode 100644 index 0000000..0188109 --- /dev/null +++ b/src/core/beads/import.ts @@ -0,0 +1,259 @@ +import type { BeadsMetadata } from "../../types.js"; + +export interface ParsedBeadsIssue { + id: string; + name: string; + description: string; + priority: number; + completed: boolean; + result: string | null; + created_at?: string; + updated_at?: string; + started_at?: string | null; + completed_at?: string | null; + parentId?: string; + blockerIds: string[]; + beadsMetadata: BeadsMetadata; +} + +export interface ParsedBeadsImport { + issues: ParsedBeadsIssue[]; + warnings: string[]; +} + +interface NormalizedDependency { + issueId: string; + dependsOnId: string; + type: string; +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null) return null; + return value as Record; +} + +function getString( + record: Record, + key: string, +): string | undefined { + const value = record[key]; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function getNumber( + record: Record, + key: string, +): number | undefined { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function getStringArray( + record: Record, + key: string, +): string[] | undefined { + const value = record[key]; + if (!Array.isArray(value)) return undefined; + const items = value + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + return items.length > 0 ? items : undefined; +} + +function dedupe(values: string[]): string[] { + return Array.from(new Set(values)); +} + +function normalizeStatus(status: string | undefined): string | undefined { + if (!status) return undefined; + return status.trim().toLowerCase(); +} + +function extractEmbeddedIssue( + record: Record, +): Record { + const embedded = record.Issue; + const embeddedRecord = asRecord(embedded); + if (!embeddedRecord) return record; + + // Prefer embedded issue fields but allow top-level fallback. + return { + ...record, + ...embeddedRecord, + }; +} + +function parseDependency( + raw: unknown, + fallbackIssueId: string, +): NormalizedDependency | null { + const record = asRecord(raw); + if (!record) return null; + + const type = + getString(record, "type") ?? getString(record, "dependency_type"); + if (!type) return null; + + const issueId = getString(record, "issue_id") ?? fallbackIssueId; + + const dependsOnRecord = asRecord(record.depends_on); + const dependsOnId = + getString(record, "depends_on_id") ?? + (dependsOnRecord ? getString(dependsOnRecord, "id") : undefined) ?? + getString(record, "id"); + + if (!issueId || !dependsOnId) return null; + + return { + issueId, + dependsOnId, + type, + }; +} + +function parseIssueRecord( + lineNo: number, + line: string, +): { issue: ParsedBeadsIssue | null; warnings: string[] } { + const warnings: string[] = []; + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid JSON on line ${lineNo}: ${message}`); + } + + const root = asRecord(parsed); + if (!root) { + warnings.push(`Line ${lineNo}: expected JSON object, skipping`); + return { issue: null, warnings }; + } + + const record = extractEmbeddedIssue(root); + + const id = getString(record, "id"); + const title = getString(record, "title"); + if (!id || !title) { + warnings.push(`Line ${lineNo}: missing required id/title, skipping`); + return { issue: null, warnings }; + } + + const description = getString(record, "description") ?? ""; + const status = normalizeStatus(getString(record, "status")); + const priority = Math.max( + 0, + Math.min(100, Math.trunc(getNumber(record, "priority") ?? 1)), + ); + + const createdAt = getString(record, "created_at"); + const updatedAt = getString(record, "updated_at"); + const closedAt = getString(record, "closed_at"); + + const completed = status === "closed" || Boolean(closedAt); + const closeReason = getString(record, "close_reason"); + const result = completed + ? (closeReason ?? "Imported as completed from Beads") + : null; + + const startedAt = + status === "in_progress" || status === "hooked" + ? (updatedAt ?? createdAt ?? null) + : null; + + const depsRaw = Array.isArray(record.dependencies) ? record.dependencies : []; + const normalizedDeps = depsRaw + .map((dep) => parseDependency(dep, id)) + .filter((dep): dep is NormalizedDependency => dep !== null); + + const parentCandidates = dedupe( + normalizedDeps + .filter((dep) => dep.type === "parent-child") + .map((dep) => dep.dependsOnId), + ); + if (parentCandidates.length > 1) { + warnings.push( + `Issue ${id}: multiple parent-child dependencies found (${parentCandidates.join(", ")}), using ${parentCandidates[0]}`, + ); + } + + const blockerIds = dedupe( + normalizedDeps + .filter((dep) => dep.type === "blocks") + .map((dep) => dep.dependsOnId), + ); + + const labels = getStringArray(record, "labels"); + const dependencyTypes = dedupe(normalizedDeps.map((dep) => dep.type)); + + const beadsMetadata: BeadsMetadata = { + issueId: id, + ...(status && { status }), + ...(getString(record, "issue_type") && { + issueType: getString(record, "issue_type"), + }), + ...(getString(record, "source_system") && { + sourceSystem: getString(record, "source_system"), + }), + ...(getString(record, "external_ref") && { + externalRef: getString(record, "external_ref"), + }), + ...(labels && { labels }), + ...(parentCandidates[0] && { parentId: parentCandidates[0] }), + ...(blockerIds.length > 0 && { blockerIds }), + ...(dependencyTypes.length > 0 && { dependencyTypes }), + }; + + return { + issue: { + id, + name: title, + description, + priority, + completed, + result, + created_at: createdAt, + updated_at: updatedAt, + started_at: startedAt, + completed_at: closedAt ?? null, + parentId: parentCandidates[0], + blockerIds, + beadsMetadata, + }, + warnings, + }; +} + +export function parseBeadsExportJsonl(input: string): ParsedBeadsImport { + const warnings: string[] = []; + const issues: ParsedBeadsIssue[] = []; + const seen = new Set(); + + const lines = input.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const lineNo = i + 1; + const line = lines[i].trim(); + if (!line) continue; + + const { issue, warnings: lineWarnings } = parseIssueRecord(lineNo, line); + warnings.push(...lineWarnings); + if (!issue) continue; + + if (seen.has(issue.id)) { + throw new Error(`Duplicate issue id in input: ${issue.id}`); + } + seen.add(issue.id); + issues.push(issue); + } + + return { issues, warnings }; +} diff --git a/src/core/beads/index.ts b/src/core/beads/index.ts new file mode 100644 index 0000000..3f9be6a --- /dev/null +++ b/src/core/beads/index.ts @@ -0,0 +1,5 @@ +export { + parseBeadsExportJsonl, + type ParsedBeadsIssue, + type ParsedBeadsImport, +} from "./import.js"; diff --git a/src/core/task-service.test.ts b/src/core/task-service.test.ts index 0ecf6b9..5f51dda 100644 --- a/src/core/task-service.test.ts +++ b/src/core/task-service.test.ts @@ -125,6 +125,46 @@ describe("TaskService", () => { expect(updated.priority).toBe(10); }); + it("preserves explicit completed_at when marking task completed", async () => { + const task = await service.create({ + name: "Test", + description: "Description", + }); + + const updated = await service.update({ + id: task.id, + completed: true, + completed_at: "2026-01-02T03:04:05Z", + }); + + expect(updated.completed).toBe(true); + expect(new Date(updated.completed_at ?? "").toISOString()).toBe( + "2026-01-02T03:04:05.000Z", + ); + }); + + it("clears completed_at when reopening, even if completed_at is provided", async () => { + const task = await service.create({ + name: "Test", + description: "Description", + }); + + await service.update({ + id: task.id, + completed: true, + completed_at: "2026-01-02T03:04:05Z", + }); + + const reopened = await service.update({ + id: task.id, + completed: false, + completed_at: "2027-02-03T04:05:06Z", + }); + + expect(reopened.completed).toBe(false); + expect(reopened.completed_at).toBeNull(); + }); + it("throws when task does not exist", async () => { await expect( service.update({ diff --git a/src/core/task-service.ts b/src/core/task-service.ts index 1317997..ae84449 100644 --- a/src/core/task-service.ts +++ b/src/core/task-service.ts @@ -407,9 +407,15 @@ export class TaskService { if (input.completed !== undefined) { // Handle completed_at timestamp based on completion transition if (input.completed && !task.completed) { - task.completed_at = now; + task.completed_at = input.completed_at ?? now; } else if (!input.completed && task.completed) { task.completed_at = null; + } else if ( + input.completed && + task.completed && + input.completed_at !== undefined + ) { + task.completed_at = input.completed_at; } task.completed = input.completed; } diff --git a/src/types.test.ts b/src/types.test.ts index 672252f..70d4380 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -314,6 +314,60 @@ describe("TaskSchema migrations", () => { expect(task.metadata?.commit?.sha).toBe("abc123"); }); }); + + describe("beads metadata compatibility", () => { + it("accepts beads metadata on tasks", () => { + const taskWithBeadsMetadata = { + id: "beads-task-1", + name: "Imported from Beads", + description: "Imported description", + priority: 2, + completed: false, + result: null, + metadata: { + beads: { + issueId: "beads-task-1", + status: "open", + issueType: "task", + blockerIds: ["beads-task-2"], + }, + }, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + completed_at: null, + }; + + const task = TaskSchema.parse(taskWithBeadsMetadata); + expect(task.metadata?.beads?.issueId).toBe("beads-task-1"); + expect(task.metadata?.beads?.status).toBe("open"); + expect(task.metadata?.beads?.blockerIds).toEqual(["beads-task-2"]); + }); + + it("preserves backward compatibility for tasks without beads metadata", () => { + const taskWithoutBeads = { + id: "legacy-no-beads", + name: "Legacy task", + description: "Still valid", + priority: 1, + completed: false, + result: null, + metadata: { + github: { + issueNumber: 42, + issueUrl: "https://github.com/example/repo/issues/42", + repo: "example/repo", + }, + }, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + completed_at: null, + }; + + const task = TaskSchema.parse(taskWithoutBeads); + expect(task.metadata?.github?.issueNumber).toBe(42); + expect(task.metadata?.beads).toBeUndefined(); + }); + }); }); describe("ArchivedTaskSchema migrations", () => { diff --git a/src/types.ts b/src/types.ts index 58f5f3d..923dad1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,11 +37,26 @@ export const ShortcutMetadataSchema = z.object({ export type ShortcutMetadata = z.infer; +export const BeadsMetadataSchema = z.object({ + issueId: z.string().min(1), + status: z.string().min(1).optional(), + issueType: z.string().min(1).optional(), + sourceSystem: z.string().min(1).optional(), + externalRef: z.string().min(1).optional(), + labels: z.array(z.string().min(1)).optional(), + parentId: z.string().min(1).optional(), + blockerIds: z.array(z.string().min(1)).optional(), + dependencyTypes: z.array(z.string().min(1)).optional(), +}); + +export type BeadsMetadata = z.infer; + export const TaskMetadataSchema = z .object({ commit: CommitMetadataSchema.optional(), github: GithubMetadataSchema.optional(), shortcut: ShortcutMetadataSchema.optional(), + beads: BeadsMetadataSchema.optional(), }) .nullable(); @@ -186,6 +201,7 @@ export const UpdateTaskInputSchema = z.object({ .optional(), metadata: TaskMetadataSchema.nullable().optional(), started_at: flexibleDatetime().nullable().optional(), + completed_at: flexibleDatetime().nullable().optional(), delete: z.boolean().optional(), add_blocked_by: z.array(z.string().min(1)).optional(), remove_blocked_by: z.array(z.string().min(1)).optional(), From ab25fbfadbc0f6e12ef1a036afee4f994c231d08 Mon Sep 17 00:00:00 2001 From: gkze Date: Sun, 1 Mar 2026 14:20:28 -0800 Subject: [PATCH 2/2] clean up beads fixtures README --- src/core/beads/fixtures/README.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/core/beads/fixtures/README.md b/src/core/beads/fixtures/README.md index 10e801b..dcfdcef 100644 --- a/src/core/beads/fixtures/README.md +++ b/src/core/beads/fixtures/README.md @@ -1,6 +1,6 @@ # Beads Fixtures -These fixtures are anonymized and derived from real local Beads state under `~/Development`. +These fixtures are anonymized and derived from real-world Beads state. ## Files @@ -8,11 +8,7 @@ These fixtures are anonymized and derived from real local Beads state under `~/D - `graph.jsonl` - includes parent-child and blocks relationships - `edge-cases.jsonl` - includes non-ideal relationships (e.g. missing targets) -## Regeneration +## Generation -Fixtures are intentionally committed as static anonymized snapshots. - -When refreshing them, use a local one-off script/workflow outside this repository, -then copy in only anonymized output. - -Raw Beads data is never written into this repository. +Initial generation was done via a one-off agent prompt to pull all local Beads +state and anonymize it