diff --git a/specs/sync.md b/specs/sync.md index dcf2515..8757d0a 100644 --- a/specs/sync.md +++ b/specs/sync.md @@ -47,17 +47,28 @@ When remote is newer, these fields are pulled: - `started_at` timestamp - `commit` metadata (if present) +### Subtask Sync + +Subtasks are embedded in the parent issue body. During sync and import: + +- **Push**: Subtask state is rendered as `
` blocks in the parent issue +- **Pull**: When remote is newer, subtask state is reconciled: + - Existing local subtasks are updated from remote + - New subtasks found in remote are created locally +- **Import**: `dex import` and `dex import --all` both create subtasks from issue body +- **Update**: `dex import --update` creates new subtasks and updates existing ones + ## Labels Dex manages labels on GitHub issues (using the configured prefix, default `dex`): -| Label | Meaning | -| ------------------ | --------------------------------- | -| `dex` | Base label identifying dex-managed issues | -| `dex:pending` | Task not yet started | -| `dex:in-progress` | Task started but not completed | -| `dex:completed` | Task completed and verified | -| `dex:priority-N` | Task priority level | +| Label | Meaning | +| ----------------- | ----------------------------------------- | +| `dex` | Base label identifying dex-managed issues | +| `dex:pending` | Task not yet started | +| `dex:in-progress` | Task started but not completed | +| `dex:completed` | Task completed and verified | +| `dex:priority-N` | Task priority level | Non-dex labels are preserved during sync updates. If you add labels like `bug`, `enhancement`, or custom team labels to a dex-managed issue, sync will not remove them. diff --git a/src/cli/import.test.ts b/src/cli/import.test.ts index 922762b..823f32c 100644 --- a/src/cli/import.test.ts +++ b/src/cli/import.test.ts @@ -391,6 +391,194 @@ describe("import command", () => { }); }); + describe("subtask import across flows", () => { + let shortcutMock: ReturnType; + let originalShortcutToken: string | undefined; + + beforeEach(() => { + originalShortcutToken = process.env.SHORTCUT_API_TOKEN; + process.env.SHORTCUT_API_TOKEN = "test-shortcut-token"; + shortcutMock = setupShortcutMock(); + shortcutMock.getCurrentMember(createMemberFixture()); + shortcutMock.searchStories([]); + }); + + afterEach(() => { + cleanupShortcutMock(); + if (originalShortcutToken !== undefined) { + process.env.SHORTCUT_API_TOKEN = originalShortcutToken; + } else { + delete process.env.SHORTCUT_API_TOKEN; + } + }); + + it("imports subtasks during --all", async () => { + githubMock.listIssues("test-owner", "test-repo", [ + createIssueFixture({ + number: 10, + title: "Parent with subtasks", + body: createFullDexIssueBody({ + context: "Parent context", + rootMetadata: { id: "parent10" }, + subtasks: [ + { id: "sub10a", name: "Subtask A" }, + { id: "sub10b", name: "Subtask B", completed: true }, + ], + }), + labels: [{ name: "dex" }], + }), + ]); + + await runCli(["import", "--all"], { storage }); + + const tasks = await storage.readAsync(); + expect(tasks.tasks).toHaveLength(3); // 1 parent + 2 subtasks + + const parent = tasks.tasks.find((t) => !t.parent_id); + const subA = tasks.tasks.find((t) => t.id === "sub10a"); + const subB = tasks.tasks.find((t) => t.id === "sub10b"); + expect(parent).toBeDefined(); + expect(subA).toBeDefined(); + expect(subA!.parent_id).toBe(parent!.id); + expect(subB).toBeDefined(); + expect(subB!.parent_id).toBe(parent!.id); + expect(subB!.completed).toBe(true); + + const out = output.stdout.join("\n"); + expect(out).toContain("2 subtask(s)"); + }); + + it("creates new subtasks during --update", async () => { + // First import: no subtasks + githubMock.getIssue( + "test-owner", + "test-repo", + 600, + createIssueFixture({ + number: 600, + title: "Original Task", + body: createFullDexIssueBody({ + context: "No subtasks yet", + rootMetadata: { id: "task600" }, + }), + }), + ); + await runCli(["import", "#600"], { storage }); + + let tasks = await storage.readAsync(); + expect(tasks.tasks).toHaveLength(1); + + // Second import with --update: now has subtasks + githubMock.getIssue( + "test-owner", + "test-repo", + 600, + createIssueFixture({ + number: 600, + title: "Original Task", + body: createFullDexIssueBody({ + context: "Now with subtasks", + rootMetadata: { id: "task600" }, + subtasks: [{ id: "newsub1", name: "New Subtask 1" }], + }), + }), + ); + await runCli(["import", "#600", "--update"], { storage }); + + tasks = await storage.readAsync(); + expect(tasks.tasks).toHaveLength(2); // parent + new subtask + const subtask = tasks.tasks.find((t) => t.id === "newsub1"); + expect(subtask).toBeDefined(); + expect(subtask!.parent_id).toBe("task600"); + }); + + it("updates existing subtasks during --update", async () => { + // First import: task with a subtask + githubMock.getIssue( + "test-owner", + "test-repo", + 700, + createIssueFixture({ + number: 700, + title: "Task With Sub", + body: createFullDexIssueBody({ + context: "Has subtask", + rootMetadata: { id: "task700" }, + subtasks: [{ id: "existsub1", name: "Original Name", priority: 1 }], + }), + }), + ); + await runCli(["import", "#700"], { storage }); + + let tasks = await storage.readAsync(); + expect(tasks.tasks).toHaveLength(2); + const originalSub = tasks.tasks.find((t) => t.id === "existsub1"); + expect(originalSub).toBeDefined(); + expect(originalSub!.name).toBe("Original Name"); + + // Second import with --update: subtask has changed + githubMock.getIssue( + "test-owner", + "test-repo", + 700, + createIssueFixture({ + number: 700, + title: "Task With Sub", + body: createFullDexIssueBody({ + context: "Has subtask", + rootMetadata: { id: "task700" }, + subtasks: [ + { + id: "existsub1", + name: "Updated Name", + priority: 3, + completed: true, + }, + ], + }), + }), + ); + await runCli(["import", "#700", "--update"], { storage }); + + tasks = await storage.readAsync(); + // Should still have 2 tasks, not 3 (no duplicate) + expect(tasks.tasks).toHaveLength(2); + const updatedSub = tasks.tasks.find((t) => t.id === "existsub1"); + expect(updatedSub).toBeDefined(); + expect(updatedSub!.name).toBe("Updated Name"); + expect(updatedSub!.priority).toBe(3); + expect(updatedSub!.completed).toBe(true); + expect(updatedSub!.parent_id).toBe("task700"); + }); + + it("shows subtask counts in --all --dry-run", async () => { + githubMock.listIssues("test-owner", "test-repo", [ + createIssueFixture({ + number: 20, + title: "Task with subs", + body: createFullDexIssueBody({ + context: "Context", + subtasks: [ + { id: "s1", name: "Sub 1" }, + { id: "s2", name: "Sub 2" }, + { id: "s3", name: "Sub 3" }, + ], + }), + labels: [{ name: "dex" }], + }), + ]); + + await runCli(["import", "--all", "--dry-run"], { storage }); + + const out = output.stdout.join("\n"); + expect(out).toContain("3 subtasks"); + + // Dry-run should not create any tasks + const tasks = await storage.readAsync(); + expect(tasks.tasks).toHaveLength(0); + }); + }); + describe("error handling", () => { it("errors without GitHub token", async () => { delete process.env.GITHUB_TOKEN; diff --git a/src/cli/import.ts b/src/cli/import.ts index 89cbc2c..a156001 100644 --- a/src/cli/import.ts +++ b/src/cli/import.ts @@ -223,10 +223,21 @@ async function importGitHubIssue( issue, parsed, ); + const subtaskResult = await importSubtasksFromIssueBody( + service, + issue.body || "", + updatedTask.id, + existingTasks, + ); console.log( `${colors.green}Updated${colors.reset} task ${colors.bold}${updatedTask.id}${colors.reset} ` + `from GitHub issue #${parsed.number}`, ); + if (subtaskResult.created > 0 || subtaskResult.updated > 0) { + console.log( + ` Subtasks: ${subtaskResult.created} created, ${subtaskResult.updated} updated`, + ); + } return; } @@ -258,31 +269,14 @@ async function importGitHubIssue( `${colors.bold}${task.id}${colors.reset}: "${task.name}"`, ); - if (subtasks.length > 0) { - const idMapping = new Map(); - - for (const subtask of subtasks) { - const localParentId = subtask.parentId - ? idMapping.get(subtask.parentId) || task.id - : task.id; - - const createdSubtask = await service.create({ - id: subtask.id, - name: subtask.name, - description: subtask.description || "Imported from GitHub issue", - parent_id: localParentId, - priority: subtask.priority, - completed: subtask.completed, - result: subtask.result, - created_at: subtask.created_at, - updated_at: subtask.updated_at, - completed_at: subtask.completed_at, - metadata: subtask.metadata, - }); - - idMapping.set(subtask.id, createdSubtask.id); - } - console.log(` Created ${idMapping.size} subtask(s)`); + const subtaskResult = await importSubtasksFromIssueBody( + service, + body, + task.id, + existingTasks, + ); + if (subtaskResult.created > 0) { + console.log(` Created ${subtaskResult.created} subtask(s)`); } } @@ -352,7 +346,11 @@ async function importAllFromGitHub( `Would import ${toImport.length} GitHub issue(s) from ${colors.cyan}${repo.owner}/${repo.repo}${colors.reset}:`, ); for (const issue of toImport) { + const { subtasks } = parseHierarchicalIssueBody(issue.body || ""); console.log(` #${issue.number}: ${issue.title}`); + if (subtasks.length > 0) { + console.log(` (${subtasks.length} subtasks)`); + } } } if (toUpdate.length > 0) { @@ -375,18 +373,38 @@ async function importAllFromGitHub( for (const issue of toImport) { const task = await importGitHubIssueAsTask(service, issue, repo); + const subtaskResult = await importSubtasksFromIssueBody( + service, + issue.body || "", + task.id, + existingTasks, + ); console.log( `${colors.green}Imported${colors.reset} GitHub #${issue.number} as ${colors.bold}${task.id}${colors.reset}`, ); + if (subtaskResult.created > 0) { + console.log(` Created ${subtaskResult.created} subtask(s)`); + } imported++; } for (const issue of toUpdate) { const existingTask = importedByNumber.get(issue.number)!; await updateTaskFromGitHubIssue(service, existingTask, issue, repo); + const subtaskResult = await importSubtasksFromIssueBody( + service, + issue.body || "", + existingTask.id, + existingTasks, + ); console.log( `${colors.green}Updated${colors.reset} GitHub #${issue.number} → ${colors.bold}${existingTask.id}${colors.reset}`, ); + if (subtaskResult.created > 0 || subtaskResult.updated > 0) { + console.log( + ` Subtasks: ${subtaskResult.created} created, ${subtaskResult.updated} updated`, + ); + } updated++; } @@ -443,6 +461,72 @@ function parseGitHubIssueData( }; } +/** + * Parse and create/update subtasks from a GitHub issue body. + * Reusable across single import, bulk import, and update flows. + */ +async function importSubtasksFromIssueBody( + service: ReturnType, + issueBody: string, + parentTaskId: string, + preloadedTasks?: Task[], +): Promise<{ created: number; updated: number }> { + const { subtasks } = parseHierarchicalIssueBody(issueBody); + if (subtasks.length === 0) { + return { created: 0, updated: 0 }; + } + + const existingTasks = preloadedTasks ?? (await service.list({ all: true })); + const existingById = new Map(existingTasks.map((t) => [t.id, t])); + const idMapping = new Map(); + let created = 0; + let updated = 0; + + for (const subtask of subtasks) { + const localParentId = subtask.parentId + ? idMapping.get(subtask.parentId) || parentTaskId + : parentTaskId; + + const existing = existingById.get(subtask.id); + if (existing) { + await service.update({ + id: existing.id, + name: subtask.name, + description: subtask.description || existing.description, + parent_id: localParentId, + priority: subtask.priority, + completed: subtask.completed, + started_at: subtask.started_at, + result: subtask.result, + metadata: subtask.metadata + ? { ...existing.metadata, ...subtask.metadata } + : existing.metadata, + }); + idMapping.set(subtask.id, existing.id); + updated++; + } else { + const createdSubtask = await service.create({ + id: subtask.id, + name: subtask.name, + description: subtask.description || "Imported from GitHub issue", + parent_id: localParentId, + priority: subtask.priority, + completed: subtask.completed, + result: subtask.result, + created_at: subtask.created_at, + updated_at: subtask.updated_at, + started_at: subtask.started_at, + completed_at: subtask.completed_at, + metadata: subtask.metadata, + }); + idMapping.set(subtask.id, createdSubtask.id); + created++; + } + } + + return { created, updated }; +} + async function importGitHubIssueAsTask( service: ReturnType, issue: GitHubIssue, @@ -471,6 +555,7 @@ async function importGitHubIssueAsTask( metadata, created_at: rootMetadata?.created_at, updated_at: rootMetadata?.updated_at, + started_at: rootMetadata?.started_at, completed_at: rootMetadata?.completed_at, }); } diff --git a/src/cli/sync.test.ts b/src/cli/sync.test.ts index b42a4da..76e3a60 100644 --- a/src/cli/sync.test.ts +++ b/src/cli/sync.test.ts @@ -11,6 +11,7 @@ import { setupGitHubMock, cleanupGitHubMock, createIssueFixture, + createFullDexIssueBody, setupShortcutMock, cleanupShortcutMock, createStoryFixture, @@ -298,6 +299,74 @@ describe("sync command", () => { expect(out).toContain("1 task(s)"); expect(out).toContain("1 updated"); }); + + it("creates subtasks locally when pulling from remote", async () => { + const taskId = await createTask("Parent task", { + description: "context", + }); + + // First sync to create the issue + githubMock.listIssues("test-owner", "test-repo", []); + githubMock.createIssue( + "test-owner", + "test-repo", + createIssueFixture({ number: 500, title: "Parent task" }), + ); + await run(["sync", taskId]); + fixture.output.stdout.length = 0; + + // Read the task to get its updated_at + const store = await fixture.storage.readAsync(); + const parentTask = store.tasks.find((t) => t.id === taskId)!; + + // Remote is newer and has a subtask that doesn't exist locally + const futureTime = new Date( + new Date(parentTask.updated_at).getTime() + 3600000, + ).toISOString(); + + const remoteBody = createFullDexIssueBody({ + context: "context", + rootMetadata: { + id: taskId, + updated_at: futureTime, + }, + subtasks: [ + { + id: "remotesub1", + name: "Remote Subtask", + priority: 2, + created_at: futureTime, + updated_at: futureTime, + }, + ], + }); + + // Page 1: issue with newer updated_at and subtask + githubMock.listIssues("test-owner", "test-repo", [ + createIssueFixture({ + number: 500, + title: "Parent task", + body: remoteBody, + labels: [{ name: "dex" }, { name: "dex:pending" }], + }), + ]); + // Page 2: empty (end of pagination) + githubMock.listIssues("test-owner", "test-repo", []); + + await run(["sync"]); + + // Verify the subtask was created locally + const updatedStore = await fixture.storage.readAsync(); + const subtask = updatedStore.tasks.find((t) => t.id === "remotesub1"); + expect(subtask).toBeDefined(); + expect(subtask!.name).toBe("Remote Subtask"); + expect(subtask!.parent_id).toBe(taskId); + expect(subtask!.priority).toBe(2); + + // Verify summary includes pulled from remote + const out = fixture.output.stdout.join("\n"); + expect(out).toContain("pulled from remote"); + }); }); describe("error handling", () => { diff --git a/src/cli/sync.ts b/src/cli/sync.ts index 361cc48..3873377 100644 --- a/src/cli/sync.ts +++ b/src/cli/sync.ts @@ -342,8 +342,10 @@ async function syncAllWithProgress( } // Save metadata for all synced tasks + // Include pulledFromRemote results which are marked as skipped (not pushed) + // but still need local updates applied and subtasks created for (const result of results) { - if (!result.skipped) { + if (!result.skipped || result.pulledFromRemote) { await saveMetadata(service, syncService.id, result); } } @@ -449,10 +451,15 @@ async function saveMetadata( }); } - // Handle subtask results for integrations that support them (like Shortcut) + // Handle subtask results for integrations that support them if (result.subtaskResults) { for (const subtaskResult of result.subtaskResults) { - await saveMetadata(service, integrationId, subtaskResult); + if (subtaskResult.needsCreation && subtaskResult.createData) { + // Create subtask that exists in remote but not locally + await service.create(subtaskResult.createData); + } else { + await saveMetadata(service, integrationId, subtaskResult); + } } } } diff --git a/src/core/github-sync.test.ts b/src/core/github-sync.test.ts index 61ec276..303777f 100644 --- a/src/core/github-sync.test.ts +++ b/src/core/github-sync.test.ts @@ -2080,6 +2080,198 @@ Completed the subtask }); }); + it("returns needsCreation for subtasks not found locally", async () => { + const now = new Date(); + const localTime = new Date(now.getTime() - 3600000).toISOString(); + const remoteTime = now.toISOString(); + + const parent = createTask({ + id: "parent3", + name: "Parent Task", + updated_at: localTime, + metadata: { + github: { + issueNumber: 3, + issueUrl: "https://github.com/test-owner/test-repo/issues/3", + repo: "test-owner/test-repo", + }, + }, + }); + + // No subtask locally - only parent exists + const store = createStore([parent]); + + const remoteBody = ` + + + + + + + + +Test description + +## Tasks + +
+└─ New Remote Subtask + + + + + + + + + + + + +### Description +Subtask from remote + +
`; + + githubMock.listIssues("test-owner", "test-repo", [ + createIssueFixture({ + number: 3, + title: "Parent Task", + body: remoteBody, + state: "open", + labels: [ + { name: "dex" }, + { name: "dex:priority-1" }, + { name: "dex:pending" }, + ], + }), + ]); + githubMock.listIssues("test-owner", "test-repo", []); + + const results = await service.syncAll(store); + + expect(results).toHaveLength(1); + expect(results[0].pulledFromRemote).toBe(true); + expect(results[0].subtaskResults).toBeDefined(); + expect(results[0].subtaskResults).toHaveLength(1); + + const subtaskResult = results[0].subtaskResults![0]; + expect(subtaskResult.taskId).toBe("newsub3"); + expect(subtaskResult.needsCreation).toBe(true); + expect(subtaskResult.createData).toBeDefined(); + expect(subtaskResult.createData!.parent_id).toBe("parent3"); + expect(subtaskResult.createData!.name).toBe("New Remote Subtask"); + expect(subtaskResult.createData!.priority).toBe(2); + }); + + it("resolves nested subtask parents within the same batch", async () => { + const now = new Date(); + const localTime = new Date(now.getTime() - 3600000).toISOString(); + const remoteTime = now.toISOString(); + + const parent = createTask({ + id: "parent4", + name: "Parent Task", + updated_at: localTime, + metadata: { + github: { + issueNumber: 10, + issueUrl: "https://github.com/test-owner/test-repo/issues/10", + repo: "test-owner/test-repo", + }, + }, + }); + + // Neither subtask nor grandchild exist locally + const store = createStore([parent]); + + // Remote body has a subtask and a nested grandchild subtask + const remoteBody = ` + + + + + + + + +Test description + +## Tasks + +
+└─ Child Subtask + + + + + + + + + + + + +### Description +Child subtask + +
+ +
+ └─ Grandchild Subtask + + + + + + + + + + + + +### Description +Grandchild subtask + +
`; + + githubMock.listIssues("test-owner", "test-repo", [ + createIssueFixture({ + number: 10, + title: "Parent Task", + body: remoteBody, + state: "open", + labels: [ + { name: "dex" }, + { name: "dex:priority-1" }, + { name: "dex:pending" }, + ], + }), + ]); + githubMock.listIssues("test-owner", "test-repo", []); + + const results = await service.syncAll(store); + + expect(results).toHaveLength(1); + expect(results[0].subtaskResults).toBeDefined(); + expect(results[0].subtaskResults).toHaveLength(2); + + const childResult = results[0].subtaskResults!.find( + (r) => r.taskId === "childsub4", + ); + const grandchildResult = results[0].subtaskResults!.find( + (r) => r.taskId === "grandchild4", + ); + + // Child should be parented to root task + expect(childResult!.createData!.parent_id).toBe("parent4"); + + // Grandchild should be parented to child (not flattened to root) + expect(grandchildResult!.createData!.parent_id).toBe("childsub4"); + }); + it("does not pull subtask state when local is newer than remote", async () => { const now = new Date(); const remoteTime = new Date(now.getTime() - 3600000).toISOString(); // 1 hour ago diff --git a/src/core/github/issue-rendering.ts b/src/core/github/issue-rendering.ts index d9f5db5..db99796 100644 --- a/src/core/github/issue-rendering.ts +++ b/src/core/github/issue-rendering.ts @@ -132,11 +132,7 @@ function renderTaskDetailsBlock( options?: { depth?: number; parentId?: string | null }, ): string { const isInProgress = !task.completed && task.started_at !== null; - const statusIndicator = task.completed - ? "✅ " - : isInProgress - ? "🔄 " - : ""; + const statusIndicator = task.completed ? "✅ " : isInProgress ? "🔄 " : ""; const depth = options?.depth ?? 0; const treePrefix = depth > 0 ? "└─ " : ""; diff --git a/src/core/github/sync.ts b/src/core/github/sync.ts index 3ec065e..64a7d0e 100644 --- a/src/core/github/sync.ts +++ b/src/core/github/sync.ts @@ -359,6 +359,7 @@ export class GitHubSyncService { const subtaskResults = this.reconcileSubtasksFromRemote( cached.body, store, + parent.id, ); onProgress?.({ @@ -837,15 +838,50 @@ export class GitHubSyncService { private reconcileSubtasksFromRemote( issueBody: string, store: TaskStore, + parentTaskId: string, ): SyncResult[] { const results: SyncResult[] = []; const { subtasks: remoteSubtasks } = parseHierarchicalIssueBody(issueBody); + // Track subtask IDs processed in this batch so nested subtasks + // can resolve parents that are also being created in the same sync + const processedIds = new Set(); for (const remoteSubtask of remoteSubtasks) { const localSubtask = store.tasks.find((t) => t.id === remoteSubtask.id); if (!localSubtask) { - // Subtask exists in remote but not locally - skip - // (import flow handles this case) + // Subtask exists in remote but not locally - include for creation + // Use the subtask's declared parent if it exists locally or was already processed in this batch + const parentExists = + remoteSubtask.parentId !== undefined && + (store.tasks.some((t) => t.id === remoteSubtask.parentId) || + processedIds.has(remoteSubtask.parentId)); + const resolvedParentId = parentExists + ? remoteSubtask.parentId! + : parentTaskId; + + results.push({ + taskId: remoteSubtask.id, + metadata: {}, + created: false, + needsCreation: true, + pulledFromRemote: true, + createData: { + id: remoteSubtask.id, + name: remoteSubtask.name, + description: + remoteSubtask.description || "Imported from GitHub issue", + parent_id: resolvedParentId, + priority: remoteSubtask.priority, + completed: remoteSubtask.completed, + result: remoteSubtask.result, + created_at: remoteSubtask.created_at, + updated_at: remoteSubtask.updated_at, + started_at: remoteSubtask.started_at, + completed_at: remoteSubtask.completed_at, + metadata: remoteSubtask.metadata, + }, + }); + processedIds.add(remoteSubtask.id); continue; } diff --git a/src/core/sync/ARCHITECTURE.md b/src/core/sync/ARCHITECTURE.md index 8ad6fa2..6f34986 100644 --- a/src/core/sync/ARCHITECTURE.md +++ b/src/core/sync/ARCHITECTURE.md @@ -1,11 +1,10 @@ # Sync Integration Architecture -This document describes how to implement a sync integration for dex. Sync integrations enable one-way push of tasks to external issue trackers (GitHub Issues, Shortcut Stories, etc.). Local file storage remains the source of truth. +This document describes how to implement a sync integration for dex. Sync integrations enable local-first sync of tasks with external issue trackers (GitHub Issues, Shortcut Stories, etc.), pushing local state and pulling remote state when newer. ## Core Concepts -- **One-way sync**: Tasks push from dex → remote. Remote changes don't flow back. -- **Source of truth**: The local `.dex/tasks.jsonl` file is always authoritative. +- **Local-first sync**: The local `.dex/tasks.jsonl` file is the source of truth, but remote state is pulled when newer (timestamp-based reconciliation). - **Idempotent operations**: Running sync twice should not duplicate remote items. - **Progress reporting**: Bulk operations report progress via callbacks. @@ -43,7 +42,11 @@ Sync a single task to the remote system. - `metadata` - Integration-specific data to save on the task - `created` - True if a new remote item was created - `skipped` - True if no changes were needed (optional) -- `subtaskResults` - Results for subtasks synced separately (optional, used by Shortcut) +- `subtaskResults` - Results for subtasks synced separately (optional) +- `localUpdates` - Updates to apply locally when remote is newer (optional) +- `pulledFromRemote` - True if local was updated from remote (optional) +- `needsCreation` - True if a subtask exists remotely but not locally (optional) +- `createData` - Full task data for creation when `needsCreation` is true (optional) ### `syncAll(store, options)` @@ -122,10 +125,10 @@ export const TaskMetadataSchema = z.object({ Integrations can handle subtasks differently: -| Integration | Subtask Strategy | -| ----------- | ---------------------------------------------------- | -| GitHub | Embedded in parent issue body as markdown checkboxes | -| Shortcut | Created as separate linked stories (Sub-tasks) | +| Integration | Push Strategy | Import/Pull Strategy | +| ----------- | ---------------------------------------------- | ------------------------------------------- | +| GitHub | Embedded in parent issue body as markdown | Parsed from body, created as local subtasks | +| Shortcut | Created as separate linked stories (Sub-tasks) | Imported as linked subtasks | Choose the strategy that best fits the remote system's capabilities. @@ -244,4 +247,6 @@ Key test scenarios: - Skipping unchanged items - Handling API errors gracefully - Progress callback invocation -- Subtask handling +- Subtask handling (push: embedded in parent body) +- Subtask creation during bulk import (`--all`) +- Subtask creation during sync pull (remote newer than local) diff --git a/src/core/sync/registry.ts b/src/core/sync/registry.ts index 8339eb6..35bf4c8 100644 --- a/src/core/sync/registry.ts +++ b/src/core/sync/registry.ts @@ -1,4 +1,4 @@ -import type { Task, TaskStore } from "../../types.js"; +import type { Task, TaskStore, CreateTaskInput } from "../../types.js"; import type { IntegrationId, SyncAllOptions } from "./interface.js"; /** @@ -9,7 +9,7 @@ export interface SyncResult { metadata: unknown; created: boolean; skipped?: boolean; - /** Results for subtasks (used by integrations like Shortcut that sync subtasks separately) */ + /** Results for subtasks synced separately (e.g., Shortcut stories, GitHub pull reconciliation) */ subtaskResults?: SyncResult[]; /** Updates to apply to the local task (when remote is newer) */ localUpdates?: Record; @@ -17,6 +17,10 @@ export interface SyncResult { pulledFromRemote?: boolean; /** Reason why a completed task's issue/story won't close (e.g., commit not pushed) */ issueNotClosingReason?: string; + /** True if the task doesn't exist locally and needs to be created */ + needsCreation?: boolean; + /** Full task data for creation (used when needsCreation is true) */ + createData?: CreateTaskInput; } /** diff --git a/src/types.ts b/src/types.ts index 165b5bb..05e73bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -176,8 +176,9 @@ export const UpdateTaskInputSchema = z.object({ result: z .string() .max(MAX_CONTENT_LENGTH, "Result exceeds maximum length") + .nullable() .optional(), - metadata: TaskMetadataSchema.optional(), + metadata: TaskMetadataSchema.nullable().optional(), started_at: z.string().datetime().nullable().optional(), delete: z.boolean().optional(), add_blocked_by: z.array(z.string().min(1)).optional(),