From 823d6ec45af8ee896fd8c08caba515ff6f49f27b Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Wed, 11 Mar 2026 10:36:25 -0400 Subject: [PATCH 1/3] Make NewContact.email optional for provider-ID contact resolution Co-Authored-By: Claude Opus 4.6 --- .changeset/provider-id-contacts.md | 5 +++++ twister/src/plot.ts | 12 +++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 .changeset/provider-id-contacts.md diff --git a/.changeset/provider-id-contacts.md b/.changeset/provider-id-contacts.md new file mode 100644 index 0000000..4a5f389 --- /dev/null +++ b/.changeset/provider-id-contacts.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: Made `NewContact.email` optional to support provider-ID-based contact resolution diff --git a/twister/src/plot.ts b/twister/src/plot.ts index 3845aa8..ef8f10a 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -773,16 +773,18 @@ export enum ActorType { * ``` */ export type NewContact = { - /** Email address of the contact (required) */ - email: string; + /** + * Email address of the contact. + * Either email or source must be provided for contact resolution. + */ + email?: string; /** Optional display name for the contact */ name?: string; /** Optional avatar image URL for the contact */ avatar?: string; /** - * External provider account source. Used for privacy compliance - * (e.g. Atlassian personal data reporting for GDPR account closure). - * Required for contacts sourced from providers that mandate personal data reporting. + * External provider account source. Used for identity resolution + * when email is unavailable and for privacy compliance reporting. */ source?: { provider: AuthProvider; accountId: string }; }; From c89a24d7d2a208ef47f8134a986dd7203eec2099 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Wed, 11 Mar 2026 10:49:38 -0400 Subject: [PATCH 2/3] Update connectors to use source field for provider-ID contact resolution - Linear: resolveAuthorContact uses source instead of viewer-info fallback - GitHub: userToContact includes source with GitHub user ID - Jira: contacts created even without email, using atlassianSource - Asana: contacts created even without email, using Asana gid - Slack: slackUserToNewActor uses NewContact with source instead of opaque ID Co-Authored-By: Claude Opus 4.6 --- connectors/asana/src/asana.ts | 25 ++++++++----- connectors/github/src/github.ts | 1 + connectors/jira/src/jira.ts | 24 ++++++------ connectors/linear/src/linear.ts | 61 ++++++++++++------------------- connectors/slack/src/slack-api.ts | 7 +++- 5 files changed, 57 insertions(+), 61 deletions(-) diff --git a/connectors/asana/src/asana.ts b/connectors/asana/src/asana.ts index bd39f83..6f7080e 100644 --- a/connectors/asana/src/asana.ts +++ b/connectors/asana/src/asana.ts @@ -340,18 +340,20 @@ export class Asana extends Connector { let authorContact: NewContact | undefined; let assigneeContact: NewContact | undefined; - if (createdBy?.email) { + if (createdBy) { authorContact = { - email: createdBy.email, + ...(createdBy.email ? { email: createdBy.email } : {}), name: createdBy.name, avatar: createdBy.photo?.image_128x128, + ...(createdBy.gid ? { source: { provider: AuthProvider.Asana, accountId: createdBy.gid } } : {}), }; } - if (assignee?.email) { + if (assignee) { assigneeContact = { - email: assignee.email, + ...(assignee.email ? { email: assignee.email } : {}), name: assignee.name, avatar: assignee.photo?.image_128x128, + ...(assignee.gid ? { source: { provider: AuthProvider.Asana, accountId: assignee.gid } } : {}), }; } @@ -597,18 +599,20 @@ export class Asana extends Connector { let authorContact: NewContact | undefined; let assigneeContact: NewContact | undefined; - if (createdBy?.email) { + if (createdBy) { authorContact = { - email: createdBy.email, + ...(createdBy.email ? { email: createdBy.email } : {}), name: createdBy.name, avatar: createdBy.photo?.image_128x128, + ...(createdBy.gid ? { source: { provider: AuthProvider.Asana, accountId: createdBy.gid } } : {}), }; } - if (assignee?.email) { + if (assignee) { assigneeContact = { - email: assignee.email, + ...(assignee.email ? { email: assignee.email } : {}), name: assignee.name, avatar: assignee.photo?.image_128x128, + ...(assignee.gid ? { source: { provider: AuthProvider.Asana, accountId: assignee.gid } } : {}), }; } @@ -687,11 +691,12 @@ export class Asana extends Connector { // Extract story author let storyAuthor: NewContact | undefined; const author: any = latestStory.created_by; - if (author?.email) { + if (author) { storyAuthor = { - email: author.email, + ...(author.email ? { email: author.email } : {}), name: author.name, avatar: author.photo?.image_128x128, + ...(author.gid ? { source: { provider: AuthProvider.Asana, accountId: author.gid } } : {}), }; } diff --git a/connectors/github/src/github.ts b/connectors/github/src/github.ts index afe95d1..0567f4e 100644 --- a/connectors/github/src/github.ts +++ b/connectors/github/src/github.ts @@ -196,6 +196,7 @@ export class GitHub extends Connector { email: `${user.id}+${user.login}@users.noreply.github.com`, name: user.login, avatar: user.avatar_url ?? undefined, + source: { provider: AuthProvider.GitHub, accountId: String(user.id) }, }; } diff --git a/connectors/jira/src/jira.ts b/connectors/jira/src/jira.ts index b9db1bd..6b91058 100644 --- a/connectors/jira/src/jira.ts +++ b/connectors/jira/src/jira.ts @@ -335,17 +335,17 @@ export class Jira extends Connector { let authorContact: NewContact | undefined; let assigneeContact: NewContact | undefined; - if (reporter?.emailAddress) { + if (reporter) { authorContact = { - email: reporter.emailAddress, + ...(reporter.emailAddress ? { email: reporter.emailAddress } : {}), name: reporter.displayName, avatar: reporter.avatarUrls?.["48x48"], ...atlassianSource(reporter.accountId), }; } - if (assignee?.emailAddress) { + if (assignee) { assigneeContact = { - email: assignee.emailAddress, + ...(assignee.emailAddress ? { email: assignee.emailAddress } : {}), name: assignee.displayName, avatar: assignee.avatarUrls?.["48x48"], ...atlassianSource(assignee.accountId), @@ -406,9 +406,9 @@ export class Jira extends Connector { // Extract comment author let commentAuthor: NewContact | undefined; const author = comment.author; - if (author?.emailAddress) { + if (author) { commentAuthor = { - email: author.emailAddress, + ...(author.emailAddress ? { email: author.emailAddress } : {}), name: author.displayName, avatar: author.avatarUrl, ...atlassianSource(author.accountId), @@ -647,17 +647,17 @@ export class Jira extends Connector { let authorContact: NewContact | undefined; let assigneeContact: NewContact | undefined; - if (reporter?.emailAddress) { + if (reporter) { authorContact = { - email: reporter.emailAddress, + ...(reporter.emailAddress ? { email: reporter.emailAddress } : {}), name: reporter.displayName, avatar: reporter.avatarUrls?.["48x48"], ...atlassianSource(reporter.accountId), }; } - if (assignee?.emailAddress) { + if (assignee) { assigneeContact = { - email: assignee.emailAddress, + ...(assignee.emailAddress ? { email: assignee.emailAddress } : {}), name: assignee.displayName, avatar: assignee.avatarUrls?.["48x48"], ...atlassianSource(assignee.accountId), @@ -740,9 +740,9 @@ export class Jira extends Connector { // Extract comment author let commentAuthor: NewContact | undefined; const author = comment.author; - if (author?.emailAddress) { + if (author) { commentAuthor = { - email: author.emailAddress, + ...(author.emailAddress ? { email: author.emailAddress } : {}), name: author.displayName, avatar: author.avatarUrls?.["48x48"], ...atlassianSource(author.accountId), diff --git a/connectors/linear/src/linear.ts b/connectors/linear/src/linear.ts index d17c33b..ec13a11 100644 --- a/connectors/linear/src/linear.ts +++ b/connectors/linear/src/linear.ts @@ -109,32 +109,30 @@ export class Linear extends Connector { /** * Resolve author contact from a Linear user object. - * Falls back to cached viewer info when the API doesn't return an email. + * Uses provider ID for contact resolution when available. */ - private async resolveAuthorContact( + private resolveAuthorContact( user: { id?: string; email?: string; name?: string; avatarUrl?: string | null } | null | undefined, - projectId: string - ): Promise { + ): NewContact | undefined { if (!user) return undefined; - if (user.email) { + // Always use provider ID for contact resolution when available + if (user.id) { return { - email: user.email, + ...(user.email ? { email: user.email } : {}), name: user.name ?? "", avatar: user.avatarUrl ?? undefined, + source: { provider: AuthProvider.Linear, accountId: user.id }, }; } - // Linear API often omits email on creator relations — check if it's the authenticated user - if (user.id) { - const viewerInfo = await this.get(`viewer_info_${projectId}`); - if (viewerInfo?.email && user.id === viewerInfo.linearId) { - return { - email: viewerInfo.email, - name: user.name || viewerInfo.name, - avatar: user.avatarUrl ?? viewerInfo.avatar, - }; - } + // Fallback: email only (no provider ID) + if (user.email) { + return { + email: user.email, + name: user.name ?? "", + avatar: user.avatarUrl ?? undefined, + }; } return undefined; @@ -444,14 +442,15 @@ export class Linear extends Connector { } // Prepare author and assignee contacts - will be passed directly as NewContact - const authorContact = await this.resolveAuthorContact(creator, projectId); + const authorContact = this.resolveAuthorContact(creator); let assigneeContact: NewContact | undefined; - if (assignee?.email) { + if (assignee) { assigneeContact = { - email: assignee.email, + ...(assignee.email ? { email: assignee.email } : {}), name: assignee.name, avatar: assignee.avatarUrl ?? undefined, + ...(assignee.id ? { source: { provider: AuthProvider.Linear, accountId: assignee.id } } : {}), }; } @@ -485,13 +484,7 @@ export class Linear extends Connector { let commentAuthor: NewContact | undefined; try { const user = await comment.user; - if (user?.email) { - commentAuthor = { - email: user.email, - name: user.name, - avatar: user.avatarUrl ?? undefined, - }; - } + commentAuthor = this.resolveAuthorContact(user); } catch (error) { console.error( "Error fetching comment user:", @@ -733,14 +726,15 @@ export class Linear extends Connector { const assignee = issue.assignee || null; // Build thread update with only issue fields (no notes) - const authorContact = await this.resolveAuthorContact(creator, projectId); + const authorContact = this.resolveAuthorContact(creator); let assigneeContact: NewContact | undefined; - if (assignee?.email) { + if (assignee) { assigneeContact = { - email: assignee.email, + ...(assignee.email ? { email: assignee.email } : {}), name: assignee.name, avatar: assignee.avatarUrl ?? undefined, + ...(assignee.id ? { source: { provider: AuthProvider.Linear, accountId: assignee.id } } : {}), }; } @@ -790,14 +784,7 @@ export class Linear extends Connector { } // Extract comment author from webhook payload - let commentAuthor: NewContact | undefined; - if (comment.user?.email) { - commentAuthor = { - email: comment.user.email, - name: comment.user.name, - avatar: comment.user.avatarUrl ?? undefined, - }; - } + const commentAuthor = this.resolveAuthorContact(comment.user); // Create thread update with single comment note // Type is required by NewThread, but upsert will use existing thread's type diff --git a/connectors/slack/src/slack-api.ts b/connectors/slack/src/slack-api.ts index aff0f38..716846f 100644 --- a/connectors/slack/src/slack-api.ts +++ b/connectors/slack/src/slack-api.ts @@ -2,6 +2,7 @@ import type { NewLinkWithNotes, NewActor, } from "@plotday/twister/plot"; +import { AuthProvider } from "@plotday/twister/tools/integrations"; export type SlackChannel = { id: string; @@ -194,7 +195,8 @@ function parseUserMentions(text: string): string[] { function parseUserMentionNewActors(text: string): NewActor[] { const userIds = parseUserMentions(text); return userIds.map((userId) => ({ - id: `slack:${userId}` as any, + name: userId, + source: { provider: AuthProvider.Slack, accountId: userId }, })); } @@ -203,7 +205,8 @@ function parseUserMentionNewActors(text: string): NewActor[] { */ function slackUserToNewActor(userId: string): NewActor { return { - id: `slack:${userId}` as any, + name: userId, + source: { provider: AuthProvider.Slack, accountId: userId }, }; } From 49ecc44c8ae961d0e656c62e28e73a4f7965fbb8 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Wed, 11 Mar 2026 17:31:50 -0400 Subject: [PATCH 3/3] Fix connectors overwriting link titles on comment updates --- connectors/AGENTS.md | 3 ++- connectors/jira/src/jira.ts | 2 +- connectors/linear/src/linear.ts | 6 +++++- twister/src/plot.ts | 7 ++++++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/connectors/AGENTS.md b/connectors/AGENTS.md index e438d51..5fd552c 100644 --- a/connectors/AGENTS.md +++ b/connectors/AGENTS.md @@ -903,7 +903,8 @@ After creating a new connector, add it to `pnpm-workspace.yaml` if not already c 12. **❌ Forgetting to clean up on disable** — Delete callbacks, webhooks, and stored state 13. **❌ Two-way sync without metadata correlation** — Embed Plot ID in external item metadata to prevent duplicates from race conditions (see SYNC_STRATEGIES.md §6) 14. **❌ Stripping HTML tags locally** — Pass raw HTML with `contentType: "html"` for server-side conversion. Local regex stripping breaks encoding and loses links -15. **❌ Not setting `created` on notes from external data** — Always pass the external system's timestamp (e.g., `internalDate` from Gmail, `created_at` from an API) as the note's `created` field. Omitting it defaults to sync time, making all notes appear to have been created "just now" +15. **❌ Using placeholder titles in comment/update webhooks** — `title` always overwrites on upsert. Always use the real entity title (fetch from API if not in the webhook payload). Never use IDs or keys as placeholder titles +16. **❌ Not setting `created` on notes from external data** — Always pass the external system's timestamp (e.g., `internalDate` from Gmail, `created_at` from an API) as the note's `created` field. Omitting it defaults to sync time, making all notes appear to have been created "just now" ## Study These Examples diff --git a/connectors/jira/src/jira.ts b/connectors/jira/src/jira.ts index 6b91058..bb6a766 100644 --- a/connectors/jira/src/jira.ts +++ b/connectors/jira/src/jira.ts @@ -764,7 +764,7 @@ export class Jira extends Connector { const link: NewLinkWithNotes = { ...(source ? { source } : {}), type: "issue", - title: issue.key, // Placeholder; upsert by source will preserve existing title + title: issue.fields?.summary || issue.key, notes: [ { key: `comment-${comment.id}`, diff --git a/connectors/linear/src/linear.ts b/connectors/linear/src/linear.ts index ec13a11..b527a98 100644 --- a/connectors/linear/src/linear.ts +++ b/connectors/linear/src/linear.ts @@ -783,6 +783,10 @@ export class Linear extends Connector { return; } + // Fetch issue title from Linear API (title always overwrites on upsert) + const client = await this.getClient(projectId); + const issue = await client.issue(issueId); + // Extract comment author from webhook payload const commentAuthor = this.resolveAuthorContact(comment.user); @@ -792,7 +796,7 @@ export class Linear extends Connector { const newLink: NewLinkWithNotes = { source: threadSource, type: "issue", - title: issueId, // Placeholder; upsert by source will preserve existing title + title: issue.title, notes: [ { key: `comment-${comment.id}`, diff --git a/twister/src/plot.ts b/twister/src/plot.ts index ef8f10a..52c34f7 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -904,7 +904,12 @@ export type NewLink = ( * Creates a thread+link pair, with notes attached to the thread. */ export type NewLinkWithNotes = NewLink & { - /** Title for the link and its thread container */ + /** + * Title for the link and its thread container. + * Must be the real entity title (e.g. issue title, message subject), + * never a placeholder or ID. This value overwrites the existing title on upsert. + * If the title is not available in the webhook payload, fetch it from the API. + */ title: string; /** Notes to attach to the thread */ notes?: Omit[];