Skip to content

Commit 4e15a8a

Browse files
adnankurt16claudedcramer
authored
fix(sync): Create subtasks during bulk import and sync pull (#118)
* fix(sync): Create subtasks during bulk import and sync pull Subtasks embedded in GitHub issue bodies (as <details> blocks) were only created during single-issue import. Three flows were broken: - `dex import --all` only created parent tasks - `dex import #N --update` only updated the parent task - `dex sync` pull path skipped subtasks not found locally Extract importSubtasksFromIssueBody helper from the working single-import path and reuse it across all import flows. For sync pull, extend SyncResult with needsCreation/createData fields so reconcileSubtasksFromRemote can signal that new local subtasks should be created. Fixes #117 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: David Cramer <dcramer@gmail.com>
1 parent 6ae7d06 commit 4e15a8a

File tree

11 files changed

+648
-54
lines changed

11 files changed

+648
-54
lines changed

specs/sync.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,28 @@ When remote is newer, these fields are pulled:
4747
- `started_at` timestamp
4848
- `commit` metadata (if present)
4949

50+
### Subtask Sync
51+
52+
Subtasks are embedded in the parent issue body. During sync and import:
53+
54+
- **Push**: Subtask state is rendered as `<details>` blocks in the parent issue
55+
- **Pull**: When remote is newer, subtask state is reconciled:
56+
- Existing local subtasks are updated from remote
57+
- New subtasks found in remote are created locally
58+
- **Import**: `dex import` and `dex import --all` both create subtasks from issue body
59+
- **Update**: `dex import --update` creates new subtasks and updates existing ones
60+
5061
## Labels
5162

5263
Dex manages labels on GitHub issues (using the configured prefix, default `dex`):
5364

54-
| Label | Meaning |
55-
| ------------------ | --------------------------------- |
56-
| `dex` | Base label identifying dex-managed issues |
57-
| `dex:pending` | Task not yet started |
58-
| `dex:in-progress` | Task started but not completed |
59-
| `dex:completed` | Task completed and verified |
60-
| `dex:priority-N` | Task priority level |
65+
| Label | Meaning |
66+
| ----------------- | ----------------------------------------- |
67+
| `dex` | Base label identifying dex-managed issues |
68+
| `dex:pending` | Task not yet started |
69+
| `dex:in-progress` | Task started but not completed |
70+
| `dex:completed` | Task completed and verified |
71+
| `dex:priority-N` | Task priority level |
6172

6273
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.
6374

src/cli/import.test.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,194 @@ describe("import command", () => {
391391
});
392392
});
393393

394+
describe("subtask import across flows", () => {
395+
let shortcutMock: ReturnType<typeof setupShortcutMock>;
396+
let originalShortcutToken: string | undefined;
397+
398+
beforeEach(() => {
399+
originalShortcutToken = process.env.SHORTCUT_API_TOKEN;
400+
process.env.SHORTCUT_API_TOKEN = "test-shortcut-token";
401+
shortcutMock = setupShortcutMock();
402+
shortcutMock.getCurrentMember(createMemberFixture());
403+
shortcutMock.searchStories([]);
404+
});
405+
406+
afterEach(() => {
407+
cleanupShortcutMock();
408+
if (originalShortcutToken !== undefined) {
409+
process.env.SHORTCUT_API_TOKEN = originalShortcutToken;
410+
} else {
411+
delete process.env.SHORTCUT_API_TOKEN;
412+
}
413+
});
414+
415+
it("imports subtasks during --all", async () => {
416+
githubMock.listIssues("test-owner", "test-repo", [
417+
createIssueFixture({
418+
number: 10,
419+
title: "Parent with subtasks",
420+
body: createFullDexIssueBody({
421+
context: "Parent context",
422+
rootMetadata: { id: "parent10" },
423+
subtasks: [
424+
{ id: "sub10a", name: "Subtask A" },
425+
{ id: "sub10b", name: "Subtask B", completed: true },
426+
],
427+
}),
428+
labels: [{ name: "dex" }],
429+
}),
430+
]);
431+
432+
await runCli(["import", "--all"], { storage });
433+
434+
const tasks = await storage.readAsync();
435+
expect(tasks.tasks).toHaveLength(3); // 1 parent + 2 subtasks
436+
437+
const parent = tasks.tasks.find((t) => !t.parent_id);
438+
const subA = tasks.tasks.find((t) => t.id === "sub10a");
439+
const subB = tasks.tasks.find((t) => t.id === "sub10b");
440+
expect(parent).toBeDefined();
441+
expect(subA).toBeDefined();
442+
expect(subA!.parent_id).toBe(parent!.id);
443+
expect(subB).toBeDefined();
444+
expect(subB!.parent_id).toBe(parent!.id);
445+
expect(subB!.completed).toBe(true);
446+
447+
const out = output.stdout.join("\n");
448+
expect(out).toContain("2 subtask(s)");
449+
});
450+
451+
it("creates new subtasks during --update", async () => {
452+
// First import: no subtasks
453+
githubMock.getIssue(
454+
"test-owner",
455+
"test-repo",
456+
600,
457+
createIssueFixture({
458+
number: 600,
459+
title: "Original Task",
460+
body: createFullDexIssueBody({
461+
context: "No subtasks yet",
462+
rootMetadata: { id: "task600" },
463+
}),
464+
}),
465+
);
466+
await runCli(["import", "#600"], { storage });
467+
468+
let tasks = await storage.readAsync();
469+
expect(tasks.tasks).toHaveLength(1);
470+
471+
// Second import with --update: now has subtasks
472+
githubMock.getIssue(
473+
"test-owner",
474+
"test-repo",
475+
600,
476+
createIssueFixture({
477+
number: 600,
478+
title: "Original Task",
479+
body: createFullDexIssueBody({
480+
context: "Now with subtasks",
481+
rootMetadata: { id: "task600" },
482+
subtasks: [{ id: "newsub1", name: "New Subtask 1" }],
483+
}),
484+
}),
485+
);
486+
await runCli(["import", "#600", "--update"], { storage });
487+
488+
tasks = await storage.readAsync();
489+
expect(tasks.tasks).toHaveLength(2); // parent + new subtask
490+
const subtask = tasks.tasks.find((t) => t.id === "newsub1");
491+
expect(subtask).toBeDefined();
492+
expect(subtask!.parent_id).toBe("task600");
493+
});
494+
495+
it("updates existing subtasks during --update", async () => {
496+
// First import: task with a subtask
497+
githubMock.getIssue(
498+
"test-owner",
499+
"test-repo",
500+
700,
501+
createIssueFixture({
502+
number: 700,
503+
title: "Task With Sub",
504+
body: createFullDexIssueBody({
505+
context: "Has subtask",
506+
rootMetadata: { id: "task700" },
507+
subtasks: [{ id: "existsub1", name: "Original Name", priority: 1 }],
508+
}),
509+
}),
510+
);
511+
await runCli(["import", "#700"], { storage });
512+
513+
let tasks = await storage.readAsync();
514+
expect(tasks.tasks).toHaveLength(2);
515+
const originalSub = tasks.tasks.find((t) => t.id === "existsub1");
516+
expect(originalSub).toBeDefined();
517+
expect(originalSub!.name).toBe("Original Name");
518+
519+
// Second import with --update: subtask has changed
520+
githubMock.getIssue(
521+
"test-owner",
522+
"test-repo",
523+
700,
524+
createIssueFixture({
525+
number: 700,
526+
title: "Task With Sub",
527+
body: createFullDexIssueBody({
528+
context: "Has subtask",
529+
rootMetadata: { id: "task700" },
530+
subtasks: [
531+
{
532+
id: "existsub1",
533+
name: "Updated Name",
534+
priority: 3,
535+
completed: true,
536+
},
537+
],
538+
}),
539+
}),
540+
);
541+
await runCli(["import", "#700", "--update"], { storage });
542+
543+
tasks = await storage.readAsync();
544+
// Should still have 2 tasks, not 3 (no duplicate)
545+
expect(tasks.tasks).toHaveLength(2);
546+
const updatedSub = tasks.tasks.find((t) => t.id === "existsub1");
547+
expect(updatedSub).toBeDefined();
548+
expect(updatedSub!.name).toBe("Updated Name");
549+
expect(updatedSub!.priority).toBe(3);
550+
expect(updatedSub!.completed).toBe(true);
551+
expect(updatedSub!.parent_id).toBe("task700");
552+
});
553+
554+
it("shows subtask counts in --all --dry-run", async () => {
555+
githubMock.listIssues("test-owner", "test-repo", [
556+
createIssueFixture({
557+
number: 20,
558+
title: "Task with subs",
559+
body: createFullDexIssueBody({
560+
context: "Context",
561+
subtasks: [
562+
{ id: "s1", name: "Sub 1" },
563+
{ id: "s2", name: "Sub 2" },
564+
{ id: "s3", name: "Sub 3" },
565+
],
566+
}),
567+
labels: [{ name: "dex" }],
568+
}),
569+
]);
570+
571+
await runCli(["import", "--all", "--dry-run"], { storage });
572+
573+
const out = output.stdout.join("\n");
574+
expect(out).toContain("3 subtasks");
575+
576+
// Dry-run should not create any tasks
577+
const tasks = await storage.readAsync();
578+
expect(tasks.tasks).toHaveLength(0);
579+
});
580+
});
581+
394582
describe("error handling", () => {
395583
it("errors without GitHub token", async () => {
396584
delete process.env.GITHUB_TOKEN;

0 commit comments

Comments
 (0)