Skip to content

Commit 441dc3d

Browse files
authored
Merge pull request #107 from plotday/feature/provider-id-contacts
Provider IDs for contacts
2 parents f30e3ba + 49ecc44 commit 441dc3d

8 files changed

Lines changed: 83 additions & 70 deletions

File tree

.changeset/provider-id-contacts.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Changed: Made `NewContact.email` optional to support provider-ID-based contact resolution

connectors/AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,8 @@ After creating a new connector, add it to `pnpm-workspace.yaml` if not already c
903903
12. **❌ Forgetting to clean up on disable** — Delete callbacks, webhooks, and stored state
904904
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)
905905
14. **❌ Stripping HTML tags locally** — Pass raw HTML with `contentType: "html"` for server-side conversion. Local regex stripping breaks encoding and loses links
906-
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"
906+
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
907+
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"
907908
908909
## Study These Examples
909910

connectors/asana/src/asana.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -340,18 +340,20 @@ export class Asana extends Connector<Asana> {
340340
let authorContact: NewContact | undefined;
341341
let assigneeContact: NewContact | undefined;
342342

343-
if (createdBy?.email) {
343+
if (createdBy) {
344344
authorContact = {
345-
email: createdBy.email,
345+
...(createdBy.email ? { email: createdBy.email } : {}),
346346
name: createdBy.name,
347347
avatar: createdBy.photo?.image_128x128,
348+
...(createdBy.gid ? { source: { provider: AuthProvider.Asana, accountId: createdBy.gid } } : {}),
348349
};
349350
}
350-
if (assignee?.email) {
351+
if (assignee) {
351352
assigneeContact = {
352-
email: assignee.email,
353+
...(assignee.email ? { email: assignee.email } : {}),
353354
name: assignee.name,
354355
avatar: assignee.photo?.image_128x128,
356+
...(assignee.gid ? { source: { provider: AuthProvider.Asana, accountId: assignee.gid } } : {}),
355357
};
356358
}
357359

@@ -597,18 +599,20 @@ export class Asana extends Connector<Asana> {
597599
let authorContact: NewContact | undefined;
598600
let assigneeContact: NewContact | undefined;
599601

600-
if (createdBy?.email) {
602+
if (createdBy) {
601603
authorContact = {
602-
email: createdBy.email,
604+
...(createdBy.email ? { email: createdBy.email } : {}),
603605
name: createdBy.name,
604606
avatar: createdBy.photo?.image_128x128,
607+
...(createdBy.gid ? { source: { provider: AuthProvider.Asana, accountId: createdBy.gid } } : {}),
605608
};
606609
}
607-
if (assignee?.email) {
610+
if (assignee) {
608611
assigneeContact = {
609-
email: assignee.email,
612+
...(assignee.email ? { email: assignee.email } : {}),
610613
name: assignee.name,
611614
avatar: assignee.photo?.image_128x128,
615+
...(assignee.gid ? { source: { provider: AuthProvider.Asana, accountId: assignee.gid } } : {}),
612616
};
613617
}
614618

@@ -687,11 +691,12 @@ export class Asana extends Connector<Asana> {
687691
// Extract story author
688692
let storyAuthor: NewContact | undefined;
689693
const author: any = latestStory.created_by;
690-
if (author?.email) {
694+
if (author) {
691695
storyAuthor = {
692-
email: author.email,
696+
...(author.email ? { email: author.email } : {}),
693697
name: author.name,
694698
avatar: author.photo?.image_128x128,
699+
...(author.gid ? { source: { provider: AuthProvider.Asana, accountId: author.gid } } : {}),
695700
};
696701
}
697702

connectors/github/src/github.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export class GitHub extends Connector<GitHub> {
196196
email: `${user.id}+${user.login}@users.noreply.github.com`,
197197
name: user.login,
198198
avatar: user.avatar_url ?? undefined,
199+
source: { provider: AuthProvider.GitHub, accountId: String(user.id) },
199200
};
200201
}
201202

connectors/jira/src/jira.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -335,17 +335,17 @@ export class Jira extends Connector<Jira> {
335335
let authorContact: NewContact | undefined;
336336
let assigneeContact: NewContact | undefined;
337337

338-
if (reporter?.emailAddress) {
338+
if (reporter) {
339339
authorContact = {
340-
email: reporter.emailAddress,
340+
...(reporter.emailAddress ? { email: reporter.emailAddress } : {}),
341341
name: reporter.displayName,
342342
avatar: reporter.avatarUrls?.["48x48"],
343343
...atlassianSource(reporter.accountId),
344344
};
345345
}
346-
if (assignee?.emailAddress) {
346+
if (assignee) {
347347
assigneeContact = {
348-
email: assignee.emailAddress,
348+
...(assignee.emailAddress ? { email: assignee.emailAddress } : {}),
349349
name: assignee.displayName,
350350
avatar: assignee.avatarUrls?.["48x48"],
351351
...atlassianSource(assignee.accountId),
@@ -406,9 +406,9 @@ export class Jira extends Connector<Jira> {
406406
// Extract comment author
407407
let commentAuthor: NewContact | undefined;
408408
const author = comment.author;
409-
if (author?.emailAddress) {
409+
if (author) {
410410
commentAuthor = {
411-
email: author.emailAddress,
411+
...(author.emailAddress ? { email: author.emailAddress } : {}),
412412
name: author.displayName,
413413
avatar: author.avatarUrl,
414414
...atlassianSource(author.accountId),
@@ -647,17 +647,17 @@ export class Jira extends Connector<Jira> {
647647
let authorContact: NewContact | undefined;
648648
let assigneeContact: NewContact | undefined;
649649

650-
if (reporter?.emailAddress) {
650+
if (reporter) {
651651
authorContact = {
652-
email: reporter.emailAddress,
652+
...(reporter.emailAddress ? { email: reporter.emailAddress } : {}),
653653
name: reporter.displayName,
654654
avatar: reporter.avatarUrls?.["48x48"],
655655
...atlassianSource(reporter.accountId),
656656
};
657657
}
658-
if (assignee?.emailAddress) {
658+
if (assignee) {
659659
assigneeContact = {
660-
email: assignee.emailAddress,
660+
...(assignee.emailAddress ? { email: assignee.emailAddress } : {}),
661661
name: assignee.displayName,
662662
avatar: assignee.avatarUrls?.["48x48"],
663663
...atlassianSource(assignee.accountId),
@@ -740,9 +740,9 @@ export class Jira extends Connector<Jira> {
740740
// Extract comment author
741741
let commentAuthor: NewContact | undefined;
742742
const author = comment.author;
743-
if (author?.emailAddress) {
743+
if (author) {
744744
commentAuthor = {
745-
email: author.emailAddress,
745+
...(author.emailAddress ? { email: author.emailAddress } : {}),
746746
name: author.displayName,
747747
avatar: author.avatarUrls?.["48x48"],
748748
...atlassianSource(author.accountId),
@@ -764,7 +764,7 @@ export class Jira extends Connector<Jira> {
764764
const link: NewLinkWithNotes = {
765765
...(source ? { source } : {}),
766766
type: "issue",
767-
title: issue.key, // Placeholder; upsert by source will preserve existing title
767+
title: issue.fields?.summary || issue.key,
768768
notes: [
769769
{
770770
key: `comment-${comment.id}`,

connectors/linear/src/linear.ts

Lines changed: 29 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -109,32 +109,30 @@ export class Linear extends Connector<Linear> {
109109

110110
/**
111111
* Resolve author contact from a Linear user object.
112-
* Falls back to cached viewer info when the API doesn't return an email.
112+
* Uses provider ID for contact resolution when available.
113113
*/
114-
private async resolveAuthorContact(
114+
private resolveAuthorContact(
115115
user: { id?: string; email?: string; name?: string; avatarUrl?: string | null } | null | undefined,
116-
projectId: string
117-
): Promise<NewContact | undefined> {
116+
): NewContact | undefined {
118117
if (!user) return undefined;
119118

120-
if (user.email) {
119+
// Always use provider ID for contact resolution when available
120+
if (user.id) {
121121
return {
122-
email: user.email,
122+
...(user.email ? { email: user.email } : {}),
123123
name: user.name ?? "",
124124
avatar: user.avatarUrl ?? undefined,
125+
source: { provider: AuthProvider.Linear, accountId: user.id },
125126
};
126127
}
127128

128-
// Linear API often omits email on creator relations — check if it's the authenticated user
129-
if (user.id) {
130-
const viewerInfo = await this.get<ViewerInfo>(`viewer_info_${projectId}`);
131-
if (viewerInfo?.email && user.id === viewerInfo.linearId) {
132-
return {
133-
email: viewerInfo.email,
134-
name: user.name || viewerInfo.name,
135-
avatar: user.avatarUrl ?? viewerInfo.avatar,
136-
};
137-
}
129+
// Fallback: email only (no provider ID)
130+
if (user.email) {
131+
return {
132+
email: user.email,
133+
name: user.name ?? "",
134+
avatar: user.avatarUrl ?? undefined,
135+
};
138136
}
139137

140138
return undefined;
@@ -444,14 +442,15 @@ export class Linear extends Connector<Linear> {
444442
}
445443

446444
// Prepare author and assignee contacts - will be passed directly as NewContact
447-
const authorContact = await this.resolveAuthorContact(creator, projectId);
445+
const authorContact = this.resolveAuthorContact(creator);
448446
let assigneeContact: NewContact | undefined;
449447

450-
if (assignee?.email) {
448+
if (assignee) {
451449
assigneeContact = {
452-
email: assignee.email,
450+
...(assignee.email ? { email: assignee.email } : {}),
453451
name: assignee.name,
454452
avatar: assignee.avatarUrl ?? undefined,
453+
...(assignee.id ? { source: { provider: AuthProvider.Linear, accountId: assignee.id } } : {}),
455454
};
456455
}
457456

@@ -485,13 +484,7 @@ export class Linear extends Connector<Linear> {
485484
let commentAuthor: NewContact | undefined;
486485
try {
487486
const user = await comment.user;
488-
if (user?.email) {
489-
commentAuthor = {
490-
email: user.email,
491-
name: user.name,
492-
avatar: user.avatarUrl ?? undefined,
493-
};
494-
}
487+
commentAuthor = this.resolveAuthorContact(user);
495488
} catch (error) {
496489
console.error(
497490
"Error fetching comment user:",
@@ -733,14 +726,15 @@ export class Linear extends Connector<Linear> {
733726
const assignee = issue.assignee || null;
734727

735728
// Build thread update with only issue fields (no notes)
736-
const authorContact = await this.resolveAuthorContact(creator, projectId);
729+
const authorContact = this.resolveAuthorContact(creator);
737730
let assigneeContact: NewContact | undefined;
738731

739-
if (assignee?.email) {
732+
if (assignee) {
740733
assigneeContact = {
741-
email: assignee.email,
734+
...(assignee.email ? { email: assignee.email } : {}),
742735
name: assignee.name,
743736
avatar: assignee.avatarUrl ?? undefined,
737+
...(assignee.id ? { source: { provider: AuthProvider.Linear, accountId: assignee.id } } : {}),
744738
};
745739
}
746740

@@ -789,23 +783,20 @@ export class Linear extends Connector<Linear> {
789783
return;
790784
}
791785

786+
// Fetch issue title from Linear API (title always overwrites on upsert)
787+
const client = await this.getClient(projectId);
788+
const issue = await client.issue(issueId);
789+
792790
// Extract comment author from webhook payload
793-
let commentAuthor: NewContact | undefined;
794-
if (comment.user?.email) {
795-
commentAuthor = {
796-
email: comment.user.email,
797-
name: comment.user.name,
798-
avatar: comment.user.avatarUrl ?? undefined,
799-
};
800-
}
791+
const commentAuthor = this.resolveAuthorContact(comment.user);
801792

802793
// Create thread update with single comment note
803794
// Type is required by NewThread, but upsert will use existing thread's type
804795
const threadSource = `linear:issue:${issueId}`;
805796
const newLink: NewLinkWithNotes = {
806797
source: threadSource,
807798
type: "issue",
808-
title: issueId, // Placeholder; upsert by source will preserve existing title
799+
title: issue.title,
809800
notes: [
810801
{
811802
key: `comment-${comment.id}`,

connectors/slack/src/slack-api.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
NewLinkWithNotes,
33
NewActor,
44
} from "@plotday/twister/plot";
5+
import { AuthProvider } from "@plotday/twister/tools/integrations";
56

67
export type SlackChannel = {
78
id: string;
@@ -194,7 +195,8 @@ function parseUserMentions(text: string): string[] {
194195
function parseUserMentionNewActors(text: string): NewActor[] {
195196
const userIds = parseUserMentions(text);
196197
return userIds.map((userId) => ({
197-
id: `slack:${userId}` as any,
198+
name: userId,
199+
source: { provider: AuthProvider.Slack, accountId: userId },
198200
}));
199201
}
200202

@@ -203,7 +205,8 @@ function parseUserMentionNewActors(text: string): NewActor[] {
203205
*/
204206
function slackUserToNewActor(userId: string): NewActor {
205207
return {
206-
id: `slack:${userId}` as any,
208+
name: userId,
209+
source: { provider: AuthProvider.Slack, accountId: userId },
207210
};
208211
}
209212

twister/src/plot.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -773,16 +773,18 @@ export enum ActorType {
773773
* ```
774774
*/
775775
export type NewContact = {
776-
/** Email address of the contact (required) */
777-
email: string;
776+
/**
777+
* Email address of the contact.
778+
* Either email or source must be provided for contact resolution.
779+
*/
780+
email?: string;
778781
/** Optional display name for the contact */
779782
name?: string;
780783
/** Optional avatar image URL for the contact */
781784
avatar?: string;
782785
/**
783-
* External provider account source. Used for privacy compliance
784-
* (e.g. Atlassian personal data reporting for GDPR account closure).
785-
* Required for contacts sourced from providers that mandate personal data reporting.
786+
* External provider account source. Used for identity resolution
787+
* when email is unavailable and for privacy compliance reporting.
786788
*/
787789
source?: { provider: AuthProvider; accountId: string };
788790
};
@@ -902,7 +904,12 @@ export type NewLink = (
902904
* Creates a thread+link pair, with notes attached to the thread.
903905
*/
904906
export type NewLinkWithNotes = NewLink & {
905-
/** Title for the link and its thread container */
907+
/**
908+
* Title for the link and its thread container.
909+
* Must be the real entity title (e.g. issue title, message subject),
910+
* never a placeholder or ID. This value overwrites the existing title on upsert.
911+
* If the title is not available in the webhook payload, fetch it from the API.
912+
*/
906913
title: string;
907914
/** Notes to attach to the thread */
908915
notes?: Omit<NewNote, "thread">[];

0 commit comments

Comments
 (0)