Skip to content

Commit f53a610

Browse files
dcramerclaude
andauthored
feat(sync): Close remote issues when deleting synced tasks (#100)
When running `dex remove` on a task that has been synced to GitHub, the corresponding GitHub issue is now automatically closed. This maintains consistency with how create/update operations sync to remote integrations. - Add optional `closeRemote` method to `RegisterableSyncService` interface - Implement `closeRemote` in `GitHubSyncService` to close issues - Update `TaskService.delete` to close remote issues before deleting - Close operations run in parallel for efficiency - Errors closing remote issues are logged but don't block local delete Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 143e69b commit f53a610

File tree

3 files changed

+59
-0
lines changed

3 files changed

+59
-0
lines changed

src/core/github/sync.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,22 @@ export class GitHubSyncService {
142142
return null;
143143
}
144144

145+
/**
146+
* Close the GitHub issue for a task (e.g., when the task is deleted locally).
147+
* If the task has no associated issue, this is a no-op.
148+
*/
149+
async closeRemote(task: Task): Promise<void> {
150+
const issueNumber = this.getRemoteId(task);
151+
if (!issueNumber) return;
152+
153+
await this.octokit.issues.update({
154+
owner: this.owner,
155+
repo: this.repo,
156+
issue_number: issueNumber,
157+
state: "closed",
158+
});
159+
}
160+
145161
/**
146162
* Sync a single task to GitHub.
147163
* For subtasks, syncs the parent issue instead.

src/core/sync/registry.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export interface RegisterableSyncService {
2626
store: TaskStore,
2727
options?: SyncAllOptions,
2828
): Promise<LegacySyncResult[]>;
29+
/**
30+
* Close the remote item for a task (e.g., when the task is deleted locally).
31+
* Optional - services that don't support this will be skipped.
32+
*/
33+
closeRemote?(task: Task): Promise<void>;
2934
}
3035

3136
/**

src/core/task-service.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ export class TaskService {
476476

477477
/**
478478
* Delete a task and all its descendants.
479+
* Closes any associated remote issues (e.g., GitHub issues) before deleting.
479480
* @param id The task ID to delete
480481
* @returns The deleted task
481482
* @throws NotFoundError if the task does not exist
@@ -494,6 +495,9 @@ export class TaskService {
494495
const toDelete = new Set<string>([id]);
495496
collectDescendantIds(store.tasks, id, toDelete);
496497

498+
// Close remote issues for all tasks being deleted
499+
await this.closeRemoteIssues(store, toDelete);
500+
497501
// Clean up references to all deleted tasks
498502
for (const taskId of toDelete) {
499503
cleanupTaskReferences(store, taskId);
@@ -504,6 +508,40 @@ export class TaskService {
504508
return deletedTask;
505509
}
506510

511+
/**
512+
* Close remote issues for a set of tasks being deleted.
513+
* Errors are caught and logged but don't fail the delete operation.
514+
*/
515+
private async closeRemoteIssues(
516+
store: TaskStore,
517+
taskIds: Set<string>,
518+
): Promise<void> {
519+
if (!this.syncRegistry?.hasServices()) return;
520+
521+
const services = this.syncRegistry
522+
.getAll()
523+
.filter((s) => s.closeRemote !== undefined);
524+
if (services.length === 0) return;
525+
526+
const tasksToClose = store.tasks.filter((t) => taskIds.has(t.id));
527+
528+
// Close all issues in parallel - each task/service combination is independent
529+
const closeOperations = tasksToClose.flatMap((task) =>
530+
services.map(async (service) => {
531+
try {
532+
await service.closeRemote!(task);
533+
} catch (err) {
534+
console.warn(
535+
`Failed to close ${service.displayName} issue for task ${task.id}:`,
536+
err instanceof Error ? err.message : err,
537+
);
538+
}
539+
}),
540+
);
541+
542+
await Promise.all(closeOperations);
543+
}
544+
507545
async getChildren(id: string): Promise<Task[]> {
508546
const store = await this.storage.readAsync();
509547
return getChildren(store.tasks, id);

0 commit comments

Comments
 (0)