Skip to content

Commit 6d872b4

Browse files
committed
fix: round-trip task assignee through mirror layer
The mirror writer hard-coded 'assignee: null' regardless of the actual record.assigneeId, and the mirror reader (importTaskFromFile) parsed the frontmatter assignee slug into ParsedTaskFile but never applied it back to the store on create/update. End result: assignee was invisible in the markdown mirror and survived neither edit-via-file nor a re-import from a mirror checkout. Wire both directions through the team_members table: * store-manager.buildMirrorTaskAttrs(record): resolve record.assigneeId via store.team.get(...).slug for the markdown frontmatter. Falls back to null when the team_members row is missing (orphaned FK from a deleted member). All 6 task mirror call sites (createTask, updateTask, moveTask, reorderTask, bulkMoveTasks, bulkPriorityTasks) now go through this helper instead of duplicating the inline literal — eliminates ~50 lines of repetition. * importTaskFromFile: resolve parsed.assignee (slug) → numeric assigneeId via store.team.getBySlug. Unknown slug or empty string both produce assigneeId=null (orphaned reference, not an error — the alternative would be silently dropping the import). Apply the resolved id in both the create and update branches. Tests (4 new, in store-manager.test.ts under tasks): * createTask with assigneeId writes 'assignee: <slug>' to task.md frontmatter * updateTask after team.delete(member) writes 'assignee: null' (orphan handling) * importTaskFromFile with known slug round-trips to numeric assigneeId on the record * importTaskFromFile with unknown slug yields assigneeId=null without throwing
1 parent 2df746c commit 6d872b4

2 files changed

Lines changed: 133 additions & 46 deletions

File tree

src/lib/store-manager.ts

Lines changed: 35 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -188,17 +188,31 @@ export class StoreManager {
188188
// Tasks
189189
// =========================================================================
190190

191-
async createTask(data: TaskCreate): Promise<TaskRecord> {
192-
const embedding = await this.embedFn(`${data.title} ${data.description ?? ''}`);
193-
const record = this.scoped.tasks.create(data, embedding);
194-
mirrorTaskCreate(`${this.projectDir}/.tasks`, record.slug, {
191+
/**
192+
* Build the TaskAttrs object passed to mirrorTaskCreate/mirrorTaskUpdate.
193+
* Resolves the numeric `assigneeId` to a human-readable team-member slug for
194+
* the markdown frontmatter — falls back to `null` when the team_members row
195+
* has been removed (orphaned assignment).
196+
*/
197+
private buildMirrorTaskAttrs(record: TaskRecord) {
198+
let assignee: string | null = null;
199+
if (record.assigneeId != null) {
200+
assignee = this.store.team.get(record.assigneeId)?.slug ?? null;
201+
}
202+
return {
195203
title: record.title, description: record.description,
196204
status: record.status, priority: record.priority,
197-
tags: record.tags, order: record.order, assignee: null,
205+
tags: record.tags, order: record.order, assignee,
198206
dueDate: record.dueDate, estimate: record.estimate,
199207
completedAt: record.completedAt,
200208
createdAt: record.createdAt, updatedAt: record.updatedAt, version: record.version,
201-
}, []);
209+
};
210+
}
211+
212+
async createTask(data: TaskCreate): Promise<TaskRecord> {
213+
const embedding = await this.embedFn(`${data.title} ${data.description ?? ''}`);
214+
const record = this.scoped.tasks.create(data, embedding);
215+
mirrorTaskCreate(`${this.projectDir}/.tasks`, record.slug, this.buildMirrorTaskAttrs(record), []);
202216
this.recordTaskMirrorWrites(record.slug);
203217
this.emit('task:created', { projectId: this.projectId, taskId: record.id });
204218
return record;
@@ -215,14 +229,7 @@ export class StoreManager {
215229
embedding = await this.embedFn(`${title} ${description}`);
216230
}
217231
const record = this.scoped.tasks.update(taskId, patch, embedding, authorId, expectedVersion);
218-
mirrorTaskUpdate(`${this.projectDir}/.tasks`, record.slug, patch, {
219-
title: record.title, description: record.description,
220-
status: record.status, priority: record.priority,
221-
tags: record.tags, order: record.order, assignee: null,
222-
dueDate: record.dueDate, estimate: record.estimate,
223-
completedAt: record.completedAt,
224-
createdAt: record.createdAt, updatedAt: record.updatedAt, version: record.version,
225-
}, []);
232+
mirrorTaskUpdate(`${this.projectDir}/.tasks`, record.slug, patch, this.buildMirrorTaskAttrs(record), []);
226233
this.recordTaskMirrorWrites(record.slug);
227234
this.emit('task:updated', { projectId: this.projectId, taskId });
228235
return record;
@@ -238,14 +245,7 @@ export class StoreManager {
238245

239246
moveTask(taskId: number, status: TaskStatus, targetOrder?: number, authorId?: number, expectedVersion?: number): TaskRecord {
240247
const record = this.scoped.tasks.move(taskId, status, targetOrder, authorId, expectedVersion);
241-
mirrorTaskUpdate(`${this.projectDir}/.tasks`, record.slug, { status }, {
242-
title: record.title, description: record.description,
243-
status: record.status, priority: record.priority,
244-
tags: record.tags, order: record.order, assignee: null,
245-
dueDate: record.dueDate, estimate: record.estimate,
246-
completedAt: record.completedAt,
247-
createdAt: record.createdAt, updatedAt: record.updatedAt, version: record.version,
248-
}, []);
248+
mirrorTaskUpdate(`${this.projectDir}/.tasks`, record.slug, { status }, this.buildMirrorTaskAttrs(record), []);
249249
this.recordTaskMirrorWrites(record.slug);
250250
this.emit('task:updated', { projectId: this.projectId, taskId });
251251
this.emit('task:moved', { projectId: this.projectId, taskId, status });
@@ -256,14 +256,7 @@ export class StoreManager {
256256
const record = this.scoped.tasks.reorder(taskId, order, status, authorId);
257257
const patch: TaskPatch = { order };
258258
if (status) patch.status = status;
259-
mirrorTaskUpdate(`${this.projectDir}/.tasks`, record.slug, patch, {
260-
title: record.title, description: record.description,
261-
status: record.status, priority: record.priority,
262-
tags: record.tags, order: record.order, assignee: null,
263-
dueDate: record.dueDate, estimate: record.estimate,
264-
completedAt: record.completedAt,
265-
createdAt: record.createdAt, updatedAt: record.updatedAt, version: record.version,
266-
}, []);
259+
mirrorTaskUpdate(`${this.projectDir}/.tasks`, record.slug, patch, this.buildMirrorTaskAttrs(record), []);
267260
this.recordTaskMirrorWrites(record.slug);
268261
this.emit('task:updated', { projectId: this.projectId, taskId });
269262
this.emit('task:reordered', { projectId: this.projectId, taskId, order });
@@ -310,14 +303,7 @@ export class StoreManager {
310303
for (const id of taskIds) {
311304
const record = this.scoped.tasks.get(id);
312305
if (!record) continue;
313-
mirrorTaskUpdate(`${this.projectDir}/.tasks`, record.slug, { status }, {
314-
title: record.title, description: record.description,
315-
status: record.status, priority: record.priority,
316-
tags: record.tags, order: record.order, assignee: null,
317-
dueDate: record.dueDate, estimate: record.estimate,
318-
completedAt: record.completedAt,
319-
createdAt: record.createdAt, updatedAt: record.updatedAt, version: record.version,
320-
}, []);
306+
mirrorTaskUpdate(`${this.projectDir}/.tasks`, record.slug, { status }, this.buildMirrorTaskAttrs(record), []);
321307
this.recordTaskMirrorWrites(record.slug);
322308
}
323309
this.emit('task:bulk_moved', { projectId: this.projectId, taskIds, status, count });
@@ -330,14 +316,7 @@ export class StoreManager {
330316
for (const id of taskIds) {
331317
const record = this.scoped.tasks.get(id);
332318
if (!record) continue;
333-
mirrorTaskUpdate(`${this.projectDir}/.tasks`, record.slug, { priority }, {
334-
title: record.title, description: record.description,
335-
status: record.status, priority: record.priority,
336-
tags: record.tags, order: record.order, assignee: null,
337-
dueDate: record.dueDate, estimate: record.estimate,
338-
completedAt: record.completedAt,
339-
createdAt: record.createdAt, updatedAt: record.updatedAt, version: record.version,
340-
}, []);
319+
mirrorTaskUpdate(`${this.projectDir}/.tasks`, record.slug, { priority }, this.buildMirrorTaskAttrs(record), []);
341320
this.recordTaskMirrorWrites(record.slug);
342321
}
343322
this.emit('task:bulk_priority', { projectId: this.projectId, taskIds, priority, count });
@@ -619,6 +598,14 @@ export class StoreManager {
619598
const existing = this.scoped.tasks.getBySlug(parsed.id);
620599
const embedding = await this.embedFn(`${parsed.title} ${parsed.description}`);
621600

601+
// Resolve frontmatter `assignee: <slug>` → numeric team_members.id.
602+
// Unknown slug → null (orphaned mirror file references a member that no
603+
// longer exists). Empty string from frontmatter is also treated as unset.
604+
let assigneeId: number | null = null;
605+
if (parsed.assignee) {
606+
assigneeId = this.store.team.getBySlug(parsed.assignee)?.id ?? null;
607+
}
608+
622609
let record: TaskRecord;
623610
if (existing) {
624611
record = this.scoped.tasks.update(existing.id, {
@@ -630,6 +617,7 @@ export class StoreManager {
630617
dueDate: parsed.dueDate,
631618
estimate: parsed.estimate,
632619
completedAt: parsed.completedAt,
620+
assigneeId,
633621
}, embedding);
634622
} else {
635623
record = this.scoped.tasks.create({
@@ -641,6 +629,7 @@ export class StoreManager {
641629
dueDate: parsed.dueDate,
642630
estimate: parsed.estimate,
643631
completedAt: parsed.completedAt,
632+
assigneeId: assigneeId ?? undefined,
644633
slug: parsed.id,
645634
createdAt: parsed.createdAt ?? undefined,
646635
updatedAt: parsed.updatedAt ?? undefined,

src/tests/store/store-manager.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,104 @@ describe('StoreManager', () => {
179179
expect(manager.getTask(t1.id)!.priority).toBe('critical');
180180
expect(manager.getTask(t2.id)!.priority).toBe('critical');
181181
});
182+
183+
it('mirror writer resolves assigneeId → slug in task.md frontmatter', async () => {
184+
// Seed a team member directly via the store
185+
const member = store.team.create({ slug: 'qa-bot', name: 'QA Bot' });
186+
187+
const record = await manager.createTask({
188+
title: 'Mirrored',
189+
description: '',
190+
priority: 'medium',
191+
assigneeId: member.id,
192+
});
193+
194+
// Read the task.md snapshot and check its frontmatter
195+
const fs = await import('fs');
196+
const path = await import('path');
197+
const taskMd = fs.readFileSync(path.join(projectDir, '.tasks', record.slug, 'task.md'), 'utf-8');
198+
// Frontmatter is YAML — quoted or unquoted slug both acceptable.
199+
expect(taskMd).toMatch(/^assignee:\s*['"]?qa-bot['"]?\s*$/m);
200+
});
201+
202+
it('mirror writer falls back to null when assigneeId references missing member', async () => {
203+
// Create a member, use it, then delete the row — leaving the task with an orphan FK
204+
const member = store.team.create({ slug: 'temp', name: 'Temp' });
205+
const record = await manager.createTask({
206+
title: 'Orphaned',
207+
description: '',
208+
priority: 'low',
209+
assigneeId: member.id,
210+
});
211+
store.team.delete(member.id);
212+
213+
// Trigger a re-mirror via update — buildMirrorTaskAttrs should now write null
214+
await manager.updateTask(record.id, { description: 'touch' });
215+
216+
const fs = await import('fs');
217+
const path = await import('path');
218+
const taskMd = fs.readFileSync(path.join(projectDir, '.tasks', record.slug, 'task.md'), 'utf-8');
219+
expect(taskMd).toMatch(/^assignee:\s*(null|~)\s*$/m);
220+
});
221+
222+
it('importTaskFromFile resolves assignee slug → numeric assigneeId', async () => {
223+
const member = store.team.create({ slug: 'imported-bot', name: 'Imported Bot' });
224+
225+
// Construct a parsed task as the file-mirror watcher would have produced
226+
const parsed = {
227+
id: 'mirror-import-test',
228+
title: 'Imported Task',
229+
description: 'from mirror',
230+
status: 'todo' as const,
231+
priority: 'medium' as const,
232+
tags: [],
233+
dueDate: null,
234+
estimate: null,
235+
completedAt: null,
236+
assignee: 'imported-bot',
237+
createdAt: null,
238+
updatedAt: null,
239+
version: null,
240+
createdBy: null,
241+
updatedBy: null,
242+
relations: [],
243+
attachments: [],
244+
};
245+
246+
await manager.importTaskFromFile(parsed);
247+
248+
const created = manager.getTaskBySlug('mirror-import-test');
249+
expect(created).not.toBeNull();
250+
expect(created!.assigneeId).toBe(member.id);
251+
});
252+
253+
it('importTaskFromFile sets assigneeId=null when slug is unknown', async () => {
254+
const parsed = {
255+
id: 'mirror-import-orphan',
256+
title: 'Orphan Import',
257+
description: '',
258+
status: 'todo' as const,
259+
priority: 'low' as const,
260+
tags: [],
261+
dueDate: null,
262+
estimate: null,
263+
completedAt: null,
264+
assignee: 'no-such-slug',
265+
createdAt: null,
266+
updatedAt: null,
267+
version: null,
268+
createdBy: null,
269+
updatedBy: null,
270+
relations: [],
271+
attachments: [],
272+
};
273+
274+
await manager.importTaskFromFile(parsed);
275+
276+
const created = manager.getTaskBySlug('mirror-import-orphan');
277+
expect(created).not.toBeNull();
278+
expect(created!.assigneeId).toBeNull();
279+
});
182280
});
183281

184282
// =========================================================================

0 commit comments

Comments
 (0)