Skip to content

Commit 3fcfccf

Browse files
KrisBraunclaude
andcommitted
Add thread todo sync, pass Thread to source callbacks, and Gmail star↔todo sync
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 00372d1 commit 3fcfccf

6 files changed

Lines changed: 113 additions & 13 deletions

File tree

sources/github/src/github.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
type NewLinkWithNotes,
44
type Note,
55
Source,
6-
type ThreadMeta,
6+
type Thread,
77
type ToolBuilder,
88
} from "@plotday/twister";
99
import type { NewContact } from "@plotday/twister/plot";
@@ -350,7 +350,8 @@ export class GitHub extends Source<GitHub> {
350350
/**
351351
* Called when a note is created on a thread owned by this source.
352352
*/
353-
async onNoteCreated(note: Note, meta: ThreadMeta): Promise<void> {
353+
async onNoteCreated(note: Note, thread: Thread): Promise<void> {
354+
const meta = thread.meta ?? {};
354355
if (meta.prNumber) {
355356
await addPRComment(this, meta, note.content ?? "");
356357
} else if (meta.issueNumber) {

sources/gmail/src/gmail-api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,13 @@ export class GmailApi {
204204
});
205205
}
206206

207+
/**
208+
* Checks if any message in a Gmail thread has the STARRED label.
209+
*/
210+
static isStarred(thread: GmailThread): boolean {
211+
return thread.messages?.some(m => m.labelIds?.includes("STARRED")) ?? false;
212+
}
213+
207214
public async getHistory(
208215
startHistoryId: string,
209216
labelId?: string,

sources/gmail/src/gmail.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Source, type ToolBuilder } from "@plotday/twister";
2-
import type { Actor, Note, Thread, ThreadMeta } from "@plotday/twister/plot";
2+
import type { Actor, ActorId, Note, Thread } from "@plotday/twister/plot";
33
import {
44
AuthProvider,
55
type AuthToken,
@@ -70,6 +70,10 @@ export class Gmail extends Source<Gmail> {
7070
};
7171
}
7272

73+
override async activate(context: { auth: Authorization; actor: Actor }): Promise<void> {
74+
await this.set("auth_actor_id", context.actor.id);
75+
}
76+
7377
async getChannels(
7478
_auth: Authorization,
7579
token: AuthToken
@@ -326,14 +330,40 @@ export class Gmail extends Source<Gmail> {
326330

327331
// Save link directly via integrations
328332
await this.tools.integrations.saveLink(plotThread);
333+
334+
// Star ↔ todo sync: detect star changes and update Plot todo status
335+
const isStarred = GmailApi.isStarred(thread);
336+
const wasStarred = await this.get<boolean>(`starred:${thread.id}`);
337+
338+
if (isStarred !== !!wasStarred) {
339+
// Skip if this change originated from Plot todo writeback
340+
if (await this.get(`skip_star_sync:${thread.id}`)) {
341+
await this.clear(`skip_star_sync:${thread.id}`);
342+
} else {
343+
const actorId = await this.get<ActorId>("auth_actor_id");
344+
// Use the canonical Gmail thread URL as the source identifier
345+
const sourceUrl = `https://mail.google.com/mail/u/0/#inbox/${thread.id}`;
346+
if (actorId) {
347+
await this.tools.integrations.setThreadToDo(
348+
sourceUrl,
349+
actorId,
350+
isStarred
351+
);
352+
// Prevent the onThreadToDo callback from echoing back
353+
await this.set(`skip_todo_writeback:${thread.id}`, true);
354+
}
355+
}
356+
await this.set(`starred:${thread.id}`, isStarred);
357+
}
329358
} catch (error) {
330359
console.error(`Failed to process Gmail thread ${thread.id}:`, error);
331360
// Continue processing other threads
332361
}
333362
}
334363
}
335364

336-
async onNoteCreated(note: Note, meta: ThreadMeta): Promise<void> {
365+
async onNoteCreated(note: Note, thread: Thread): Promise<void> {
366+
const meta = thread.meta ?? {};
337367
const channelId = (meta.channelId ?? meta.syncableId) as string;
338368
if (!channelId) {
339369
console.error("No channelId in meta for Gmail reply");
@@ -430,11 +460,11 @@ export class Gmail extends Source<Gmail> {
430460
}
431461

432462
async onThreadRead(
433-
_thread: Thread,
463+
thread: Thread,
434464
_actor: Actor,
435-
unread: boolean,
436-
meta: ThreadMeta
465+
unread: boolean
437466
): Promise<void> {
467+
const meta = thread.meta ?? {};
438468
const channelId = (meta.channelId ?? meta.syncableId) as string;
439469
if (!channelId) return;
440470

@@ -450,6 +480,34 @@ export class Gmail extends Source<Gmail> {
450480
}
451481
}
452482

483+
async onThreadToDo(
484+
thread: Thread,
485+
_actor: Actor,
486+
todo: boolean,
487+
_options: { date?: Date }
488+
): Promise<void> {
489+
const meta = thread.meta ?? {};
490+
const threadId = meta.threadId as string;
491+
const channelId = (meta.channelId ?? meta.syncableId) as string;
492+
if (!threadId || !channelId) return;
493+
494+
// Loop prevention: skip if this change originated from Gmail star sync
495+
if (await this.get(`skip_todo_writeback:${threadId}`)) {
496+
await this.clear(`skip_todo_writeback:${threadId}`);
497+
return;
498+
}
499+
500+
const api = await this.getApi(channelId);
501+
if (todo) {
502+
await api.modifyThread(threadId, ["STARRED"]);
503+
} else {
504+
await api.modifyThread(threadId, undefined, ["STARRED"]);
505+
}
506+
507+
// Prevent the Gmail webhook from echoing this change back
508+
await this.set(`skip_star_sync:${threadId}`, true);
509+
}
510+
453511
async onGmailWebhook(
454512
request: WebhookRequest,
455513
channelId: string

twister/src/plot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,8 @@ type ThreadFields = ThreadCommon & {
373373
priority: Priority;
374374
/** The schedule associated with this thread, if any */
375375
schedule?: Schedule;
376+
/** Source-specific metadata from the thread's link, populated on callbacks */
377+
meta?: ThreadMeta;
376378
};
377379

378380
export type Thread = ThreadFields;

twister/src/source.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type Actor, type Link, type Note, type Thread, type ThreadMeta } from "./plot";
1+
import { type Actor, type Link, type Note, type Thread } from "./plot";
22
import {
33
type AuthProvider,
44
type AuthToken,
@@ -127,10 +127,10 @@ export abstract class Source<TSelf> extends Twist<TSelf> {
127127
* (e.g., adding a comment to a Linear issue).
128128
*
129129
* @param note - The created note
130-
* @param meta - Metadata from the thread's link
130+
* @param thread - The thread the note belongs to (includes thread.meta with source-specific data)
131131
*/
132132
// eslint-disable-next-line @typescript-eslint/no-unused-vars
133-
onNoteCreated(note: Note, meta: ThreadMeta): Promise<void> {
133+
onNoteCreated(note: Note, thread: Thread): Promise<void> {
134134
return Promise.resolve();
135135
}
136136

@@ -139,13 +139,28 @@ export abstract class Source<TSelf> extends Twist<TSelf> {
139139
* Override to write back read status to the external service
140140
* (e.g., marking an email as read in Gmail).
141141
*
142-
* @param thread - The thread that was read/unread
142+
* @param thread - The thread that was read/unread (includes thread.meta with source-specific data)
143143
* @param actor - The user who performed the action
144144
* @param unread - false when marked as read, true when marked as unread
145-
* @param meta - Metadata from the thread's link (contains channelId, threadId, etc.)
146145
*/
147146
// eslint-disable-next-line @typescript-eslint/no-unused-vars
148-
onThreadRead(thread: Thread, actor: Actor, unread: boolean, meta: ThreadMeta): Promise<void> {
147+
onThreadRead(thread: Thread, actor: Actor, unread: boolean): Promise<void> {
148+
return Promise.resolve();
149+
}
150+
151+
/**
152+
* Called when a user marks or unmarks a thread as todo.
153+
* Override to sync todo status to the external service
154+
* (e.g., starring an email in Gmail when marked as todo).
155+
*
156+
* @param thread - The thread (includes thread.meta with source-specific data)
157+
* @param actor - The user who changed the todo status
158+
* @param todo - true when marked as todo, false when done or removed
159+
* @param options - Additional context
160+
* @param options.date - The todo date (when todo=true)
161+
*/
162+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
163+
onThreadToDo(thread: Thread, actor: Actor, todo: boolean, options: { date?: Date }): Promise<void> {
149164
return Promise.resolve();
150165
}
151166

twister/src/tools/integrations.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,23 @@ export abstract class Integrations extends ITool {
180180
// eslint-disable-next-line @typescript-eslint/no-unused-vars
181181
abstract archiveLinks(filter: ArchiveLinkFilter): Promise<void>;
182182

183+
/**
184+
* Sets or clears todo status on a thread owned by this source.
185+
*
186+
* @param source - The link source URL identifying the thread
187+
* @param actorId - The user to set the todo for
188+
* @param todo - true to mark as todo, false to clear/complete
189+
* @param options - Additional options
190+
* @param options.date - The todo date (when todo=true). Defaults to today.
191+
*/
192+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
193+
abstract setThreadToDo(
194+
source: string,
195+
actorId: ActorId,
196+
todo: boolean,
197+
options?: { date?: Date | string }
198+
): Promise<void>;
199+
183200
}
184201

185202
/**

0 commit comments

Comments
 (0)