Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/provider-id-contacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Changed: Made `NewContact.email` optional to support provider-ID-based contact resolution
3 changes: 2 additions & 1 deletion connectors/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 15 additions & 10 deletions connectors/asana/src/asana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,18 +340,20 @@ export class Asana extends Connector<Asana> {
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 } } : {}),
};
}

Expand Down Expand Up @@ -597,18 +599,20 @@ export class Asana extends Connector<Asana> {
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 } } : {}),
};
}

Expand Down Expand Up @@ -687,11 +691,12 @@ export class Asana extends Connector<Asana> {
// 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 } } : {}),
};
}

Expand Down
1 change: 1 addition & 0 deletions connectors/github/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export class GitHub extends Connector<GitHub> {
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) },
};
}

Expand Down
26 changes: 13 additions & 13 deletions connectors/jira/src/jira.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,17 +335,17 @@ export class Jira extends Connector<Jira> {
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),
Expand Down Expand Up @@ -406,9 +406,9 @@ export class Jira extends Connector<Jira> {
// 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),
Expand Down Expand Up @@ -647,17 +647,17 @@ export class Jira extends Connector<Jira> {
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),
Expand Down Expand Up @@ -740,9 +740,9 @@ export class Jira extends Connector<Jira> {
// 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),
Expand All @@ -764,7 +764,7 @@ export class Jira extends Connector<Jira> {
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}`,
Expand Down
67 changes: 29 additions & 38 deletions connectors/linear/src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,32 +109,30 @@ export class Linear extends Connector<Linear> {

/**
* 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> {
): 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<ViewerInfo>(`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;
Expand Down Expand Up @@ -444,14 +442,15 @@ export class Linear extends Connector<Linear> {
}

// 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 } } : {}),
};
}

Expand Down Expand Up @@ -485,13 +484,7 @@ export class Linear extends Connector<Linear> {
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:",
Expand Down Expand Up @@ -733,14 +726,15 @@ export class Linear extends Connector<Linear> {
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 } } : {}),
};
}

Expand Down Expand Up @@ -789,23 +783,20 @@ export class Linear extends Connector<Linear> {
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
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
const threadSource = `linear:issue:${issueId}`;
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}`,
Expand Down
7 changes: 5 additions & 2 deletions connectors/slack/src/slack-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
NewLinkWithNotes,
NewActor,
} from "@plotday/twister/plot";
import { AuthProvider } from "@plotday/twister/tools/integrations";

export type SlackChannel = {
id: string;
Expand Down Expand Up @@ -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 },
}));
}

Expand All @@ -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 },
};
}

Expand Down
19 changes: 13 additions & 6 deletions twister/src/plot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand Down Expand Up @@ -902,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<NewNote, "thread">[];
Expand Down
Loading