diff --git a/.changeset/free-ducks-drive.md b/.changeset/free-ducks-drive.md new file mode 100644 index 0000000..f62d0e5 --- /dev/null +++ b/.changeset/free-ducks-drive.md @@ -0,0 +1,10 @@ +--- +"@plotday/tool-outlook-calendar": patch +"@plotday/tool-google-calendar": patch +"@plotday/tool-linear": patch +"@plotday/tool-asana": patch +"@plotday/tool-slack": patch +"@plotday/tool-jira": patch +--- + +Fixed: Set author and assignee diff --git a/.changeset/quiet-ties-look.md b/.changeset/quiet-ties-look.md new file mode 100644 index 0000000..521600c --- /dev/null +++ b/.changeset/quiet-ties-look.md @@ -0,0 +1,11 @@ +--- +"@plotday/tool-outlook-calendar": patch +"@plotday/tool-google-calendar": patch +"@plotday/tool-linear": patch +"@plotday/tool-asana": patch +"@plotday/tool-slack": patch +"@plotday/tool-jira": patch +"@plotday/twister": patch +--- + +Added: created_at for item's original creation time in the source system diff --git a/tools/asana/src/asana.ts b/tools/asana/src/asana.ts index 5e0ec68..f120ae4 100644 --- a/tools/asana/src/asana.ts +++ b/tools/asana/src/asana.ts @@ -5,6 +5,7 @@ import { ActivityType, type NewActivityWithNotes, } from "@plotday/twister"; +import type { Actor, ActorId, NewContact } from "@plotday/twister/plot"; import type { Project, ProjectAuth, @@ -252,6 +253,14 @@ export class Asana extends Tool implements ProjectTool { "completed_at", "created_at", "modified_at", + "assignee", + "assignee.email", + "assignee.name", + "assignee.photo", + "created_by", + "created_by.email", + "created_by.name", + "created_by.photo", ].join(","), }; @@ -317,6 +326,39 @@ export class Asana extends Tool implements ProjectTool { task: any, projectId: string ): Promise { + const createdBy = task.created_by; + const assignee = task.assignee; + + // Create contacts for created_by and assignee + const contacts: NewContact[] = []; + if (createdBy?.email) { + contacts.push({ + email: createdBy.email, + name: createdBy.name, + avatar: createdBy.photo?.image_128x128, + }); + } + if (assignee?.email && assignee.email !== createdBy?.email) { + contacts.push({ + email: assignee.email, + name: assignee.name, + avatar: assignee.photo?.image_128x128, + }); + } + + let authorActor: Actor | undefined; + let assigneeActor: Actor | undefined; + + if (contacts.length > 0) { + const actors = await this.tools.plot.addContacts(contacts); + if (createdBy?.email) { + authorActor = actors.find((a) => a.email === createdBy.email); + } + if (assignee?.email) { + assigneeActor = actors.find((a) => a.email === assignee.email); + } + } + // Build notes array: description const notes: Array<{ content: string }> = []; @@ -333,6 +375,8 @@ export class Asana extends Tool implements ProjectTool { type: ActivityType.Action, title: task.name, source: `asana:task:${projectId}:${task.gid}`, + author: authorActor, + assignee: assigneeActor, doneAt: task.completed ? new Date(task.completed_at || Date.now()) : null, notes, }; @@ -489,6 +533,14 @@ export class Asana extends Tool implements ProjectTool { "completed_at", "created_at", "modified_at", + "assignee", + "assignee.email", + "assignee.name", + "assignee.photo", + "created_by", + "created_by.email", + "created_by.name", + "created_by.photo", ].join(","), }); diff --git a/tools/google-calendar/src/google-calendar.ts b/tools/google-calendar/src/google-calendar.ts index 17452c1..e513bbe 100644 --- a/tools/google-calendar/src/google-calendar.ts +++ b/tools/google-calendar/src/google-calendar.ts @@ -428,9 +428,22 @@ export class GoogleCalendar continue; } - // Extract and create contacts from attendees + // Extract and create contacts from organizer and attendees let actorIds: ActorId[] = []; let validAttendees: typeof event.attendees = []; + let authorActor = undefined; + + // Create contact for organizer (author) + if (event.organizer?.email) { + const organizerContact: NewContact = { + email: event.organizer.email, + name: event.organizer.displayName, + }; + const [author] = await this.tools.plot.addContacts([organizerContact]); + authorActor = author; + } + + // Create contacts for attendees if (event.attendees && event.attendees.length > 0) { // Filter to get only valid attendees (with email, not resources) validAttendees = event.attendees.filter( @@ -557,6 +570,7 @@ export class GoogleCalendar recurrenceCount: activityData.recurrenceCount || null, doneAt: null, title: activityData.title || null, + author: authorActor, recurrenceRule: activityData.recurrenceRule || null, recurrenceExdates: activityData.recurrenceExdates || null, recurrenceDates: activityData.recurrenceDates || null, diff --git a/tools/jira/src/jira.ts b/tools/jira/src/jira.ts index 5911de3..36eb6be 100644 --- a/tools/jira/src/jira.ts +++ b/tools/jira/src/jira.ts @@ -3,8 +3,10 @@ import { Version3Client } from "jira.js"; import { type ActivityLink, ActivityType, + ActivityUpdate, type NewActivityWithNotes, } from "@plotday/twister"; +import type { Actor, ActorId, NewContact } from "@plotday/twister/plot"; import type { Project, ProjectAuth, @@ -90,11 +92,7 @@ export class Jira extends Tool implements ProjectTool { ? R : [] ): Promise { - const jiraScopes = [ - "read:jira-work", - "write:jira-work", - "read:jira-user", - ]; + const jiraScopes = ["read:jira-work", "write:jira-work", "read:jira-user"]; // Generate opaque token for authorization const authToken = crypto.randomUUID(); @@ -155,19 +153,13 @@ export class Jira extends Tool implements ProjectTool { * Start syncing issues from a Jira project */ async startSync< - TCallback extends ( - issue: NewActivityWithNotes, - ...args: any[] - ) => any + TCallback extends (issue: NewActivityWithNotes, ...args: any[]) => any >( authToken: string, projectId: string, callback: TCallback, options?: ProjectSyncOptions, - ...extraArgs: TCallback extends ( - issue: any, - ...rest: infer R - ) => any + ...extraArgs: TCallback extends (issue: any, ...rest: infer R) => any ? R : [] ): Promise { @@ -265,6 +257,8 @@ export class Jira extends Tool implements ProjectTool { "description", "status", "assignee", + "reporter", + "creator", "comment", "created", "updated", @@ -280,10 +274,7 @@ export class Jira extends Tool implements ProjectTool { // Set unread based on sync type (false for initial sync to avoid notification overload) activityWithNotes.unread = !state.initialSync; // Execute the callback using the callback token - await this.tools.callbacks.run( - callbackToken, - activityWithNotes - ); + await this.tools.callbacks.run(callbackToken, activityWithNotes); } // Check if more pages @@ -294,7 +285,8 @@ export class Jira extends Tool implements ProjectTool { await this.set(`sync_state_${projectId}`, { startAt: nextStartAt, batchNumber: state.batchNumber + 1, - issuesProcessed: state.issuesProcessed + (searchResult.issues?.length || 0), + issuesProcessed: + state.issuesProcessed + (searchResult.issues?.length || 0), initialSync: state.initialSync, }); @@ -322,6 +314,38 @@ export class Jira extends Tool implements ProjectTool { const fields = issue.fields || {}; const status = fields.status?.name; const comments = fields.comment?.comments || []; + const reporter = fields.reporter || fields.creator; + const assignee = fields.assignee; + + // Create contacts for reporter and assignee + const contacts: NewContact[] = []; + if (reporter?.emailAddress) { + contacts.push({ + email: reporter.emailAddress, + name: reporter.displayName, + avatar: reporter.avatarUrls?.["48x48"], + }); + } + if (assignee?.emailAddress && assignee.emailAddress !== reporter?.emailAddress) { + contacts.push({ + email: assignee.emailAddress, + name: assignee.displayName, + avatar: assignee.avatarUrls?.["48x48"], + }); + } + + let authorActor: Actor | undefined; + let assigneeActor: Actor | undefined; + + if (contacts.length > 0) { + const actors = await this.tools.plot.addContacts(contacts); + if (reporter?.emailAddress) { + authorActor = actors.find((a) => a.email === reporter.emailAddress); + } + if (assignee?.emailAddress) { + assigneeActor = actors.find((a) => a.email === assignee.emailAddress); + } + } // Build notes array: description + comments const notes: Array<{ content: string }> = []; @@ -352,6 +376,8 @@ export class Jira extends Tool implements ProjectTool { type: ActivityType.Action, title: fields.summary || issue.key, source: `jira:issue:${projectId}:${issue.key}`, + author: authorActor, + assignee: assigneeActor, doneAt: status === "Done" || status === "Closed" || status === "Resolved" ? new Date() @@ -396,10 +422,7 @@ export class Jira extends Tool implements ProjectTool { * @param authToken - Authorization token * @param update - ActivityUpdate with changed fields */ - async updateIssue( - authToken: string, - update: import("@plotday/twister").ActivityUpdate - ): Promise { + async updateIssue(authToken: string, update: ActivityUpdate): Promise { // Extract Jira issue key from source const issueKey = update.source?.split(":").pop(); if (!issueKey) { diff --git a/tools/linear/src/linear.ts b/tools/linear/src/linear.ts index 45ac19f..732be2e 100644 --- a/tools/linear/src/linear.ts +++ b/tools/linear/src/linear.ts @@ -5,6 +5,7 @@ import { ActivityType, type NewActivityWithNotes, } from "@plotday/twister"; +import type { Actor, ActorId, NewContact } from "@plotday/twister/plot"; import type { Project, ProjectAuth, @@ -320,9 +321,40 @@ export class Linear extends Tool implements ProjectTool { projectId: string ): Promise { const state = await issue.state; + const creator = await issue.creator; const assignee = await issue.assignee; const comments = await issue.comments(); + // Create contacts for creator and assignee + const contacts: NewContact[] = []; + if (creator?.email) { + contacts.push({ + email: creator.email, + name: creator.name, + avatar: creator.avatarUrl, + }); + } + if (assignee?.email && assignee.email !== creator?.email) { + contacts.push({ + email: assignee.email, + name: assignee.name, + avatar: assignee.avatarUrl, + }); + } + + let authorActor: Actor | undefined; + let assigneeActor: Actor | undefined; + + if (contacts.length > 0) { + const actors = await this.tools.plot.addContacts(contacts); + if (creator?.email) { + authorActor = actors.find((a) => a.email === creator.email); + } + if (assignee?.email) { + assigneeActor = actors.find((a) => a.email === assignee.email); + } + } + // Build notes array: description + comments const notes: Array<{ content: string }> = []; @@ -338,6 +370,8 @@ export class Linear extends Tool implements ProjectTool { type: ActivityType.Action, title: issue.title, source: `linear:issue:${projectId}:${issue.id}`, + author: authorActor, + assignee: assigneeActor, doneAt: state?.name === "Done" || state?.name === "Completed" ? new Date() diff --git a/tools/outlook-calendar/src/outlook-calendar.ts b/tools/outlook-calendar/src/outlook-calendar.ts index 9012453..83378b1 100644 --- a/tools/outlook-calendar/src/outlook-calendar.ts +++ b/tools/outlook-calendar/src/outlook-calendar.ts @@ -379,9 +379,22 @@ export class OutlookCalendar continue; } - // Extract and create contacts from attendees + // Extract and create contacts from organizer and attendees let actorIds: ActorId[] = []; let validAttendees: typeof outlookEvent.attendees = []; + let authorActor = undefined; + + // Create contact for organizer (author) + if (outlookEvent.organizer?.emailAddress?.address) { + const organizerContact: NewContact = { + email: outlookEvent.organizer.emailAddress.address, + name: outlookEvent.organizer.emailAddress.name, + }; + const [author] = await this.tools.plot.addContacts([organizerContact]); + authorActor = author; + } + + // Create contacts for attendees if (outlookEvent.attendees && outlookEvent.attendees.length > 0) { // Filter to get only valid attendees (with email, not resources) validAttendees = outlookEvent.attendees.filter( @@ -491,6 +504,7 @@ export class OutlookCalendar // Build NewActivityWithNotes from the transformed activity const activityWithNotes: NewActivityWithNotes = { ...activity, + author: authorActor, meta: activity.meta, tags: tags && Object.keys(tags).length > 0 ? tags : activity.tags, notes, diff --git a/tools/slack/src/slack.ts b/tools/slack/src/slack.ts index 34f9611..bc4aa8a 100644 --- a/tools/slack/src/slack.ts +++ b/tools/slack/src/slack.ts @@ -393,21 +393,42 @@ export class Slack extends Tool implements MessagingTool { } // Fetch user info and create contacts + const userIdToActor = new Map(); for (const userId of userIdSet) { const user = await api.getUser(userId); if (user && user.profile?.email) { - await this.tools.plot.addContacts([ + const [actor] = await this.tools.plot.addContacts([ { email: user.profile.email, name: user.profile?.display_name || user.profile?.real_name || user.name, + avatar: user.profile?.image_72, }, ]); + if (actor) { + userIdToActor.set(userId, actor); + } + } + } + + // Update note authors with real Actor objects + for (const note of activityThread.notes) { + if (note.author.id.startsWith("slack:")) { + const userId = note.author.id.replace("slack:", ""); + const actor = userIdToActor.get(userId); + if (actor) { + note.author = actor; + } } } + // Set activity author to the first message's author + if (activityThread.notes.length > 0) { + activityThread.author = activityThread.notes[0].author; + } + // Call parent callback with single thread await this.run(callbackToken, activityThread); } catch (error) { diff --git a/twister/src/plot.ts b/twister/src/plot.ts index a36e20b..04441f3 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -236,46 +236,21 @@ export type ActivityMeta = { export type Tags = { [K in Tag]?: ActorId[] }; /** - * Represents a complete activity within the Plot system. - * - * Activities are the core entities in Plot, representing anything from simple notes - * to complex recurring events. They support rich metadata including scheduling, - * recurrence patterns, links, and external source tracking. - * - * @example - * ```typescript - * // Simple note - * const task: Activity = { - * type: ActivityType.Note, - * title: "New campaign brainstorming ideas", - * content: "We could rent a bouncy castle...", - * author: { id: "user-1", name: "John Doe", type: ActorType.User }, - * priority: { id: "work", title: "Work" }, - * // ... other fields - * }; - * - * // Simple action - * const action: Activity = { - * type: ActivityType.Action, - * title: "Review budget proposal", - * author: { id: "user-1", name: "John Doe", type: ActorType.User }, - * priority: { id: "work", title: "Work" }, - * // ... other fields - * }; - * - * // Recurring event - * const meeting: Activity = { - * type: ActivityType.Event, - * title: "Weekly standup", - * recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", - * recurrenceCount: 12, - * // ... other fields - * }; - * ``` + * Common fields shared by both Activity and Note entities. */ export type ActivityCommon = { /** Unique identifier for the activity */ id: string; + /** + * When this activity was originally created in its source system. + * + * For activities created in Plot, this is when the user created it. + * For activities synced from external systems (GitHub issues, emails, calendar events), + * this is the original creation time in that system. + * + * Defaults to the current time when creating new activities. + */ + createdAt: Date | null; /** Information about who created the activity */ author: Actor; /** Whether this activity is in draft state (not shown in do now view) */ @@ -592,7 +567,7 @@ export type ActivityUpdate = Pick & * Notes contain the detailed content (note text, links) associated with an activity. * They are always ordered by creation time within their parent activity. */ -export type Note = Omit & { +export type Note = ActivityCommon & { /** The parent activity this note belongs to */ activity: Activity; /** Primary content for the note (markdown) */ diff --git a/twists/project-sync/src/index.ts b/twists/project-sync/src/index.ts index fa530d6..4b96a0c 100644 --- a/twists/project-sync/src/index.ts +++ b/twists/project-sync/src/index.ts @@ -238,7 +238,11 @@ export default class ProjectSync extends Twist { /** * Check if a note is fully empty (no content, no links, no mentions) */ - private isNoteEmpty(note: { content?: string | null; links?: any[] | null; mentions?: any[] | null }): boolean { + private isNoteEmpty(note: { + content?: string | null; + links?: any[] | null; + mentions?: any[] | null; + }): boolean { return ( (!note.content || note.content.trim() === "") && (!note.links || note.links.length === 0) && @@ -271,17 +275,8 @@ export default class ProjectSync extends Twist { } } - // Default synced issues to "Do Someday" (start: null) unless already scheduled - // This prevents flooding the "Do Now" list with all synced backlog items - // The issue.start will only be set if the tool explicitly scheduled it - if (issue.type === ActivityType.Action && !("start" in issue)) { - issue.start = null; // "Do Someday" - backlog item by default - } - // Filter out empty notes to avoid warnings in Plot tool - if (issue.notes) { - issue.notes = issue.notes.filter((note) => !this.isNoteEmpty(note)); - } + issue.notes = issue.notes?.filter((note) => !this.isNoteEmpty(note)); // Create new activity for new issue (new thread with initial note) // Note: The unread field is already set by the tool based on sync type