Skip to content

Commit aeb7c81

Browse files
dcramerclaude
andcommitted
feat(sync): Encode started_at, blockedBy, blocks in GitHub issues
Add missing task attributes to GitHub issue sync encoding: - started_at: when task was started (in-progress tracking) - blockedBy: array of task IDs that block this task - blocks: array of task IDs this task blocks Also refactored to reduce duplication: - Extract safeParseJsonArray helper for JSON parsing - Unify metadata rendering with renderTaskMetadataComments(task, prefix) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 487a15b commit aeb7c81

File tree

5 files changed

+98
-70
lines changed

5 files changed

+98
-70
lines changed

src/core/github-sync.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,7 +1020,7 @@ describe("syncAll with issue cache", () => {
10201020
createIssueFixture({
10211021
number: 1,
10221022
title: "Task 1",
1023-
body: `<!-- dex:task:id:task1 -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task1.created_at} -->\n<!-- dex:task:updated_at:${task1.updated_at} -->\n<!-- dex:task:completed_at:null -->\nSame context`,
1023+
body: `<!-- dex:task:id:task1 -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task1.created_at} -->\n<!-- dex:task:updated_at:${task1.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nSame context`,
10241024
state: "open",
10251025
labels: [
10261026
{ name: "dex" },
@@ -1031,7 +1031,7 @@ describe("syncAll with issue cache", () => {
10311031
createIssueFixture({
10321032
number: 2,
10331033
title: "Task 2",
1034-
body: `<!-- dex:task:id:task2 -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task2.created_at} -->\n<!-- dex:task:updated_at:${task2.updated_at} -->\n<!-- dex:task:completed_at:null -->\nSame context`,
1034+
body: `<!-- dex:task:id:task2 -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task2.created_at} -->\n<!-- dex:task:updated_at:${task2.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nSame context`,
10351035
state: "open",
10361036
labels: [
10371037
{ name: "dex" },
@@ -1069,7 +1069,7 @@ describe("syncAll with issue cache", () => {
10691069
createIssueFixture({
10701070
number: 1,
10711071
title: "Task 1 Old",
1072-
body: `<!-- dex:task:id:task1 -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task1.created_at} -->\n<!-- dex:task:updated_at:${task1.updated_at} -->\n<!-- dex:task:completed_at:null -->\nOld context`,
1072+
body: `<!-- dex:task:id:task1 -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task1.created_at} -->\n<!-- dex:task:updated_at:${task1.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nOld context`,
10731073
state: "open",
10741074
labels: [
10751075
{ name: "dex" },
@@ -1080,7 +1080,7 @@ describe("syncAll with issue cache", () => {
10801080
createIssueFixture({
10811081
number: 2,
10821082
title: "Task 2",
1083-
body: `<!-- dex:task:id:task2 -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task2.created_at} -->\n<!-- dex:task:updated_at:${task2.updated_at} -->\n<!-- dex:task:completed_at:null -->\nSame context`,
1083+
body: `<!-- dex:task:id:task2 -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task2.created_at} -->\n<!-- dex:task:updated_at:${task2.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nSame context`,
10841084
state: "open",
10851085
labels: [
10861086
{ name: "dex" },
@@ -1123,7 +1123,7 @@ describe("syncAll with issue cache", () => {
11231123
createIssueFixture({
11241124
number: 1,
11251125
title: "Existing",
1126-
body: `<!-- dex:task:id:existingtask -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task1.created_at} -->\n<!-- dex:task:updated_at:${task1.updated_at} -->\n<!-- dex:task:completed_at:null -->\nTest description`,
1126+
body: `<!-- dex:task:id:existingtask -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task1.created_at} -->\n<!-- dex:task:updated_at:${task1.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nTest description`,
11271127
state: "open",
11281128
labels: [
11291129
{ name: "dex" },
@@ -1184,7 +1184,7 @@ describe("hasIssueChangedFromCache change detection", () => {
11841184
createIssueFixture({
11851185
number: 1,
11861186
title: "Test Task",
1187-
body: `<!-- dex:task:id:taskid -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:completed_at:null -->\nTest context`,
1187+
body: `<!-- dex:task:id:taskid -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nTest context`,
11881188
state: "open",
11891189
labels: [
11901190
{ name: "dex" },
@@ -1212,7 +1212,7 @@ describe("hasIssueChangedFromCache change detection", () => {
12121212
createIssueFixture({
12131213
number: 1,
12141214
title: "Old Title",
1215-
body: `<!-- dex:task:id:taskid -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:completed_at:null -->\nTest context`,
1215+
body: `<!-- dex:task:id:taskid -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nTest context`,
12161216
state: "open",
12171217
labels: [
12181218
{ name: "dex" },
@@ -1246,7 +1246,7 @@ describe("hasIssueChangedFromCache change detection", () => {
12461246
createIssueFixture({
12471247
number: 1,
12481248
title: "Test Task",
1249-
body: `<!-- dex:task:id:taskid -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:completed_at:null -->\nOld context`,
1249+
body: `<!-- dex:task:id:taskid -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nOld context`,
12501250
state: "open",
12511251
labels: [
12521252
{ name: "dex" },
@@ -1326,7 +1326,7 @@ describe("hasIssueChangedFromCache change detection", () => {
13261326
createIssueFixture({
13271327
number: 1,
13281328
title: "Test Task",
1329-
body: `<!-- dex:task:id:taskid -->\n<!-- dex:task:priority:2 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:completed_at:null -->\nTest context`,
1329+
body: `<!-- dex:task:id:taskid -->\n<!-- dex:task:priority:2 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nTest context`,
13301330
state: "open",
13311331
labels: [
13321332
{ name: "dex" },
@@ -1361,7 +1361,7 @@ describe("hasIssueChangedFromCache change detection", () => {
13611361
createIssueFixture({
13621362
number: 1,
13631363
title: "Test Task",
1364-
body: `<!-- dex:task:id:taskid -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:completed_at:null -->\nTest context \n`,
1364+
body: `<!-- dex:task:id:taskid -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nTest context \n`,
13651365
state: "open",
13661366
labels: [
13671367
{ name: "dex" },
@@ -1529,7 +1529,7 @@ describe("syncTask without cache (single-task sync)", () => {
15291529
createIssueFixture({
15301530
number: 77,
15311531
title: "Check Change Task",
1532-
body: `<!-- dex:task:id:checkchange -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:completed_at:null -->\nTest description`,
1532+
body: `<!-- dex:task:id:checkchange -->\n<!-- dex:task:priority:1 -->\n<!-- dex:task:completed:false -->\n<!-- dex:task:created_at:${task.created_at} -->\n<!-- dex:task:updated_at:${task.updated_at} -->\n<!-- dex:task:started_at:null -->\n<!-- dex:task:completed_at:null -->\n<!-- dex:task:blockedBy:[] -->\n<!-- dex:task:blocks:[] -->\nTest description`,
15331533
state: "open",
15341534
labels: [
15351535
{ name: "dex" },

src/core/github/issue-markdown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,6 @@ export {
3030
createSubtaskId,
3131
renderIssueBody,
3232
renderHierarchicalIssueBody,
33+
renderTaskMetadataComments,
34+
type TaskLike,
3335
} from "./issue-rendering.js";

src/core/github/issue-parsing.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ export function decodeMetadataValue(value: string): string {
2727
return value;
2828
}
2929

30+
/**
31+
* Safely parse a JSON array, returning empty array on parse failure.
32+
*/
33+
function safeParseJsonArray(value: string): string[] {
34+
try {
35+
return JSON.parse(value);
36+
} catch {
37+
return [];
38+
}
39+
}
40+
3041
/**
3142
* Parse a commit metadata field from a key-value pair.
3243
* Returns true if the key was a commit field and was processed.
@@ -68,6 +79,8 @@ export interface ParsedRootTaskMetadata {
6879
updated_at?: string;
6980
started_at?: string | null;
7081
completed_at?: string | null;
82+
blockedBy?: string[];
83+
blocks?: string[];
7184
result?: string | null;
7285
commit?: CommitMetadata;
7386
github?: import("../../types.js").GithubMetadata;
@@ -122,6 +135,12 @@ export function parseRootTaskMetadata(
122135
case "completed_at":
123136
metadata.completed_at = value === "null" ? null : value;
124137
break;
138+
case "blockedBy":
139+
metadata.blockedBy = safeParseJsonArray(value);
140+
break;
141+
case "blocks":
142+
metadata.blocks = safeParseJsonArray(value);
143+
break;
125144
case "result":
126145
metadata.result = value;
127146
break;
@@ -437,8 +456,8 @@ function parseDetailsBlockWithContext(
437456
updated_at: metadata.updated_at ?? new Date().toISOString(),
438457
started_at: metadata.started_at ?? null,
439458
completed_at: metadata.completed_at ?? null,
440-
blockedBy: [],
441-
blocks: [],
459+
blockedBy: metadata.blockedBy ?? [],
460+
blocks: metadata.blocks ?? [],
442461
children: [],
443462
};
444463
}
@@ -455,6 +474,8 @@ function parseMetadataComments(content: string): {
455474
updated_at?: string;
456475
started_at?: string | null;
457476
completed_at?: string | null;
477+
blockedBy?: string[];
478+
blocks?: string[];
458479
commit?: CommitMetadata;
459480
} {
460481
const metadata: ReturnType<typeof parseMetadataComments> = {};
@@ -504,6 +525,12 @@ function parseMetadataComments(content: string): {
504525
case "completed_at":
505526
metadata.completed_at = rawValue === "null" ? null : value;
506527
break;
528+
case "blockedBy":
529+
metadata.blockedBy = safeParseJsonArray(value);
530+
break;
531+
case "blocks":
532+
metadata.blocks = safeParseJsonArray(value);
533+
break;
507534
}
508535
}
509536

src/core/github/issue-rendering.ts

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ import type { EmbeddedSubtask, HierarchicalTask } from "./issue-parsing.js";
33
import { encodeMetadataValue, SUBTASKS_HEADER } from "./issue-parsing.js";
44

55
/** Common task fields needed for rendering metadata */
6-
interface TaskLike {
6+
export interface TaskLike {
77
id: string;
88
priority: number;
99
completed: boolean;
1010
description: string;
1111
result: string | null;
1212
created_at: string;
1313
updated_at: string;
14+
started_at: string | null;
1415
completed_at: string | null;
16+
blockedBy: string[];
17+
blocks: string[];
1518
metadata: { commit?: CommitMetadata } | null;
1619
}
1720

@@ -25,51 +28,83 @@ export function createSubtaskId(parentId: string, index: number): string {
2528
return `${parentId}-${index}`;
2629
}
2730

28-
/** Format a metadata comment line */
29-
function metaComment(key: string, value: string | number | boolean): string {
30-
return `<!-- dex:subtask:${key}:${value} -->`;
31+
/** Format a metadata comment line with the given prefix */
32+
function metaComment(
33+
prefix: string,
34+
key: string,
35+
value: string | number | boolean,
36+
): string {
37+
return `<!-- dex:${prefix}:${key}:${value} -->`;
3138
}
3239

3340
/**
34-
* Render the metadata comments, description, and result sections for a task block.
41+
* Render task metadata as HTML comments.
3542
* @param task - The task to render metadata for
36-
* @param parentId - Optional parent ID for hierarchical tasks
43+
* @param prefix - The comment prefix ("task" for root, "subtask" for children)
44+
* @param parentId - Optional parent ID for subtasks
45+
* @returns Array of HTML comment lines
3746
*/
38-
function renderTaskMetadataAndContent(
47+
export function renderTaskMetadataComments(
3948
task: TaskLike,
49+
prefix: string,
4050
parentId?: string | null,
4151
): string[] {
4252
const lines: string[] = [];
4353

44-
lines.push(metaComment("id", task.id));
54+
lines.push(metaComment(prefix, "id", task.id));
4555
if (parentId) {
46-
lines.push(metaComment("parent", parentId));
56+
lines.push(metaComment(prefix, "parent", parentId));
57+
}
58+
lines.push(metaComment(prefix, "priority", task.priority));
59+
lines.push(metaComment(prefix, "completed", task.completed));
60+
lines.push(metaComment(prefix, "created_at", task.created_at));
61+
lines.push(metaComment(prefix, "updated_at", task.updated_at));
62+
lines.push(metaComment(prefix, "started_at", task.started_at ?? "null"));
63+
lines.push(metaComment(prefix, "completed_at", task.completed_at ?? "null"));
64+
lines.push(metaComment(prefix, "blockedBy", JSON.stringify(task.blockedBy)));
65+
lines.push(metaComment(prefix, "blocks", JSON.stringify(task.blocks)));
66+
67+
if (task.result) {
68+
lines.push(metaComment(prefix, "result", encodeMetadataValue(task.result)));
4769
}
48-
lines.push(metaComment("priority", task.priority));
49-
lines.push(metaComment("completed", task.completed));
50-
lines.push(metaComment("created_at", task.created_at));
51-
lines.push(metaComment("updated_at", task.updated_at));
52-
lines.push(metaComment("completed_at", task.completed_at ?? "null"));
5370

5471
if (task.metadata?.commit) {
5572
const commit = task.metadata.commit;
56-
lines.push(metaComment("commit_sha", commit.sha));
73+
lines.push(metaComment(prefix, "commit_sha", commit.sha));
5774
if (commit.message) {
5875
lines.push(
59-
metaComment("commit_message", encodeMetadataValue(commit.message)),
76+
metaComment(
77+
prefix,
78+
"commit_message",
79+
encodeMetadataValue(commit.message),
80+
),
6081
);
6182
}
6283
if (commit.branch) {
63-
lines.push(metaComment("commit_branch", commit.branch));
84+
lines.push(metaComment(prefix, "commit_branch", commit.branch));
6485
}
6586
if (commit.url) {
66-
lines.push(metaComment("commit_url", commit.url));
87+
lines.push(metaComment(prefix, "commit_url", commit.url));
6788
}
6889
if (commit.timestamp) {
69-
lines.push(metaComment("commit_timestamp", commit.timestamp));
90+
lines.push(metaComment(prefix, "commit_timestamp", commit.timestamp));
7091
}
7192
}
7293

94+
return lines;
95+
}
96+
97+
/**
98+
* Render the metadata comments, description, and result sections for a subtask block.
99+
* @param task - The task to render metadata for
100+
* @param parentId - Optional parent ID for hierarchical tasks
101+
*/
102+
function renderTaskMetadataAndContent(
103+
task: TaskLike,
104+
parentId?: string | null,
105+
): string[] {
106+
const lines = renderTaskMetadataComments(task, "subtask", parentId);
107+
73108
lines.push("");
74109

75110
if (task.description) {

src/core/github/sync.ts

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { HierarchicalTask } from "./issue-markdown.js";
55
import {
66
collectDescendants,
77
renderHierarchicalIssueBody,
8-
encodeMetadataValue,
8+
renderTaskMetadataComments,
99
} from "./issue-markdown.js";
1010
import { isCommitOnRemote } from "../git-utils.js";
1111

@@ -534,43 +534,7 @@ export class GitHubSyncService {
534534
* Includes root task metadata encoded in HTML comments for round-trip support.
535535
*/
536536
private renderBody(task: Task, descendants: HierarchicalTask[]): string {
537-
// Build root task metadata comments
538-
const rootMeta: string[] = [
539-
`<!-- dex:task:id:${task.id} -->`,
540-
`<!-- dex:task:priority:${task.priority} -->`,
541-
`<!-- dex:task:completed:${task.completed} -->`,
542-
`<!-- dex:task:created_at:${task.created_at} -->`,
543-
`<!-- dex:task:updated_at:${task.updated_at} -->`,
544-
`<!-- dex:task:completed_at:${task.completed_at ?? "null"} -->`,
545-
];
546-
547-
// Add result if present (base64 encoded for multi-line support)
548-
if (task.result) {
549-
rootMeta.push(
550-
`<!-- dex:task:result:${encodeMetadataValue(task.result)} -->`,
551-
);
552-
}
553-
554-
// Add commit metadata if present
555-
if (task.metadata?.commit) {
556-
const commit = task.metadata.commit;
557-
rootMeta.push(`<!-- dex:task:commit_sha:${commit.sha} -->`);
558-
if (commit.message) {
559-
rootMeta.push(
560-
`<!-- dex:task:commit_message:${encodeMetadataValue(commit.message)} -->`,
561-
);
562-
}
563-
if (commit.branch) {
564-
rootMeta.push(`<!-- dex:task:commit_branch:${commit.branch} -->`);
565-
}
566-
if (commit.url) {
567-
rootMeta.push(`<!-- dex:task:commit_url:${commit.url} -->`);
568-
}
569-
if (commit.timestamp) {
570-
rootMeta.push(`<!-- dex:task:commit_timestamp:${commit.timestamp} -->`);
571-
}
572-
}
573-
537+
const rootMeta = renderTaskMetadataComments(task, "task");
574538
const body = renderHierarchicalIssueBody(task.description, descendants);
575539
return `${rootMeta.join("\n")}\n${body}`;
576540
}

0 commit comments

Comments
 (0)