From 51000e7254c2312b40dcbd68b8c590fe64751292 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Thu, 17 Jul 2025 12:40:25 +0530 Subject: [PATCH] Fix: Linear integration sync --- .../conversation/conversation.client.tsx | 183 +++---- .../skill-extension/skill-component.tsx | 5 +- integrations/linear/src/index.ts | 2 +- integrations/linear/src/schedule.ts | 498 ++++++++---------- 4 files changed, 299 insertions(+), 389 deletions(-) diff --git a/apps/webapp/app/components/conversation/conversation.client.tsx b/apps/webapp/app/components/conversation/conversation.client.tsx index 022fa07..a0d38cf 100644 --- a/apps/webapp/app/components/conversation/conversation.client.tsx +++ b/apps/webapp/app/components/conversation/conversation.client.tsx @@ -8,11 +8,6 @@ import { History } from "@tiptap/extension-history"; import { Paragraph } from "@tiptap/extension-paragraph"; import { Text } from "@tiptap/extension-text"; import { Button } from "../ui"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "../ui/resizable"; export const ConversationNew = ({ user, @@ -45,106 +40,96 @@ export const ConversationNew = ({ ); return ( - - +
submitForm(e)} + className="h-[calc(100vh_-_56px)] pt-2" + > +
+
+
+
+

+ Hello {user.name} +

- - submitForm(e)} - className="pt-2" - > -
-
-
-
-

- Hello {user.name} -

+

+ Demo UI: basic conversation to showcase memory integration. +

+
+ + { + return "Ask CORE ..."; + }, + includeChildren: true, + }), + Document, + Paragraph, + Text, + HardBreak.configure({ + keepMarks: true, + }), + History, + ]} + editorProps={{ + attributes: { + class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`, + }, + handleKeyDown: (_view: any, event: KeyboardEvent) => { + // This is the ProseMirror event, not React's + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); -

- Demo UI: basic conversation to showcase memory integration. -

-
- - { - return "Ask CORE ..."; - }, - includeChildren: true, - }), - Document, - Paragraph, - Text, - HardBreak.configure({ - keepMarks: true, - }), - History, - ]} - editorProps={{ - attributes: { - class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`, - }, - handleKeyDown: (_view: any, event: KeyboardEvent) => { - // This is the ProseMirror event, not React's - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); + if (content) { + submit( + { message: content, title: content }, + { + action: "/home/conversation", + method: "post", + }, + ); - if (content) { - submit( - { message: content, title: content }, - { - action: "/home/conversation", - method: "post", - }, - ); - - setContent(""); - setTitle(""); - } - return true; - } - return false; - }, - }} - immediatelyRender={false} - className={cn( - "editor-container text-md max-h-[400px] min-h-[30px] w-full min-w-full overflow-auto px-3 pt-1 sm:rounded-lg", - )} - onUpdate={({ editor }: { editor: any }) => { - const html = editor.getHTML(); - const text = editor.getText(); - setContent(html); - setTitle(text); - }} - /> - -
- -
-
+ setContent(""); + setTitle(""); + } + return true; + } + return false; + }, + }} + immediatelyRender={false} + className={cn( + "editor-container text-md max-h-[400px] min-h-[30px] w-full min-w-full overflow-auto px-3 pt-1 sm:rounded-lg", + )} + onUpdate={({ editor }: { editor: any }) => { + const html = editor.getHTML(); + const text = editor.getText(); + setContent(html); + setTitle(text); + }} + /> +
+
+
- - - +
+
+ ); }; diff --git a/apps/webapp/app/components/editor/skill-extension/skill-component.tsx b/apps/webapp/app/components/editor/skill-extension/skill-component.tsx index 14d2b63..1f63f85 100644 --- a/apps/webapp/app/components/editor/skill-extension/skill-component.tsx +++ b/apps/webapp/app/components/editor/skill-extension/skill-component.tsx @@ -44,13 +44,10 @@ export const SkillComponent = (props: any) => { const getComponent = () => { return ( <> -
+
{getIcon()} {snakeToTitleCase(name)}
-
- {!open ? : } -
); }; diff --git a/integrations/linear/src/index.ts b/integrations/linear/src/index.ts index 7856f92..c17267b 100644 --- a/integrations/linear/src/index.ts +++ b/integrations/linear/src/index.ts @@ -16,7 +16,7 @@ export async function run(eventPayload: IntegrationEventPayload) { : await integrationCreate(eventPayload.eventBody); case IntegrationEventType.SYNC: - return await handleSchedule(eventPayload.config); + return await handleSchedule(eventPayload.config, eventPayload.state); default: return { message: `The event payload type is ${eventPayload.event}` }; diff --git a/integrations/linear/src/schedule.ts b/integrations/linear/src/schedule.ts index 1465905..ae49d38 100644 --- a/integrations/linear/src/schedule.ts +++ b/integrations/linear/src/schedule.ts @@ -2,11 +2,8 @@ import axios from 'axios'; interface LinearActivityCreateParams { - url: string; - title: string; - sourceId: string; + text: string; sourceURL: string; - integrationAccountId: string; } interface LinearSettings { @@ -15,95 +12,17 @@ interface LinearSettings { lastUserActionsSync?: string; } -// Event types to track for user activities -enum LinearEventType { - ISSUE_CREATED = 'issue_created', - ISSUE_UPDATED = 'issue_updated', - ISSUE_COMMENTED = 'issue_commented', - ISSUE_ASSIGNED = 'issue_assigned', - ISSUE_STATUS_CHANGED = 'issue_status_changed', - ISSUE_COMPLETED = 'issue_completed', - ISSUE_REOPENED = 'issue_reopened', - USER_MENTIONED = 'user_mentioned', - REACTION_ADDED = 'reaction_added', - ISSUE_SUBSCRIBED = 'issue_subscribed', - ISSUE_PRIORITY_CHANGED = 'issue_priority_changed', - PROJECT_UPDATED = 'project_updated', - CYCLE_UPDATED = 'cycle_updated', -} - -// GraphQL fragments for reuse -const USER_FRAGMENT = ` - fragment UserFields on User { - id - name - displayName - } -`; - -const ISSUE_FRAGMENT = ` - fragment IssueFields on Issue { - id - identifier - title - description - url - createdAt - updatedAt - archivedAt - state { - id - name - type - } - team { - id - name - } - assignee { - ...UserFields - } - creator { - ...UserFields - } - subscribers { - nodes { - ...UserFields - } - } - priority - } - ${USER_FRAGMENT} -`; - -const COMMENT_FRAGMENT = ` - fragment CommentFields on Comment { - id - body - createdAt - updatedAt - user { - ...UserFields - } - issue { - ...IssueFields - } - } - ${USER_FRAGMENT} - ${ISSUE_FRAGMENT} -`; - /** - * Creates an activity in the system based on Linear data + * Creates an activity message based on Linear data */ -async function createActivity(params: LinearActivityCreateParams) { - try { - // This would call the Sol SDK to create an activity - console.log(`Creating activity: ${params.title}`); - // Would be implemented via Sol SDK similar to GitHub integration - } catch (error) { - console.error('Error creating activity:', error); - } +function createActivityMessage(params: LinearActivityCreateParams) { + return { + type: 'activity', + data: { + text: params.text, + sourceURL: params.sourceURL, + }, + }; } /** @@ -127,14 +46,13 @@ async function fetchUserInfo(accessToken: string) { { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, + Authorization: accessToken, }, }, ); return response.data.data.viewer; } catch (error) { - console.error('Error fetching user info:', error); throw error; } } @@ -145,7 +63,7 @@ async function fetchUserInfo(accessToken: string) { async function fetchRecentIssues(accessToken: string, lastSyncTime: string) { try { const query = ` - query RecentIssues($lastSyncTime: DateTime) { + query RecentIssues($lastSyncTime: DateTimeOrDuration) { issues( filter: { updatedAt: { gt: $lastSyncTime } @@ -154,7 +72,39 @@ async function fetchRecentIssues(accessToken: string, lastSyncTime: string) { orderBy: updatedAt ) { nodes { - ...IssueFields + id + identifier + title + url + createdAt + updatedAt + state { + id + name + type + } + team { + id + name + } + assignee { + id + name + displayName + } + creator { + id + name + displayName + } + subscribers { + nodes { + id + name + displayName + } + } + priority history { nodes { id @@ -175,7 +125,6 @@ async function fetchRecentIssues(accessToken: string, lastSyncTime: string) { } } } - ${ISSUE_FRAGMENT} `; const response = await axios.post( @@ -189,14 +138,13 @@ async function fetchRecentIssues(accessToken: string, lastSyncTime: string) { { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, + Authorization: accessToken, }, }, ); return response.data.data.issues; } catch (error) { - console.error('Error fetching recent issues:', error); throw error; } } @@ -207,7 +155,7 @@ async function fetchRecentIssues(accessToken: string, lastSyncTime: string) { async function fetchRecentComments(accessToken: string, lastSyncTime: string) { try { const query = ` - query RecentComments($lastSyncTime: DateTime) { + query RecentComments($lastSyncTime: DateTimeOrDuration) { comments( filter: { updatedAt: { gt: $lastSyncTime } @@ -216,7 +164,38 @@ async function fetchRecentComments(accessToken: string, lastSyncTime: string) { orderBy: updatedAt ) { nodes { - ...CommentFields + id + body + createdAt + updatedAt + user { + id + name + displayName + } + issue { + id + identifier + title + url + creator { + id + name + displayName + } + assignee { + id + name + displayName + } + subscribers { + nodes { + id + name + displayName + } + } + } } pageInfo { hasNextPage @@ -224,7 +203,6 @@ async function fetchRecentComments(accessToken: string, lastSyncTime: string) { } } } - ${COMMENT_FRAGMENT} `; const response = await axios.post( @@ -238,14 +216,13 @@ async function fetchRecentComments(accessToken: string, lastSyncTime: string) { { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, + Authorization: accessToken, }, }, ); return response.data.data.comments; } catch (error) { - console.error('Error fetching recent comments:', error); throw error; } } @@ -253,12 +230,7 @@ async function fetchRecentComments(accessToken: string, lastSyncTime: string) { /** * Process issue activities and create appropriate activity records */ -async function processIssueActivities( - issues: any[], - userId: string, - integrationAccount: any, - isCreator: boolean = false, -) { +async function processIssueActivities(issues: any[], userId: string) { const activities = []; for (const issue of issues) { @@ -271,161 +243,106 @@ async function processIssueActivities( const isSubscribed = issue.subscribers?.nodes?.some((subscriber: any) => subscriber.id === userId) || false; - if (!isAssignee && !isCreatedByUser && !isCreator && !isSubscribed) { + if (!isAssignee && !isCreatedByUser && !isSubscribed) { continue; } - // Process new issues created by the user - if (isCreatedByUser) { - activities.push({ - url: `https://api.linear.app/issue/${issue.id}`, - title: `You created issue ${issue.identifier}: ${issue.title}`, - sourceId: `linear-issue-created-${issue.id}`, - sourceURL: issue.url, - integrationAccountId: integrationAccount.id, - }); - } + // Process history to determine what actually changed + let activityCreated = false; - // Process issues assigned to the user (if not created by them) - if (isAssignee && !isCreatedByUser) { - activities.push({ - url: `https://api.linear.app/issue/${issue.id}`, - title: `${issue.creator?.name || 'Someone'} assigned you issue ${issue.identifier}: ${issue.title}`, - sourceId: `linear-issue-assigned-${issue.id}`, - sourceURL: issue.url, - integrationAccountId: integrationAccount.id, - }); - } - - // Process issues where the user is subscribed (if not creator or assignee) - if (isSubscribed && !isCreatedByUser && !isAssignee) { - activities.push({ - url: `https://api.linear.app/issue/${issue.id}`, - title: `Update on issue ${issue.identifier} you're subscribed to: ${issue.title}`, - sourceId: `linear-issue-subscribed-${issue.id}`, - sourceURL: issue.url, - integrationAccountId: integrationAccount.id, - }); - } - - // Process status changes + // Process assignment changes first (highest priority) if (issue.history && issue.history.nodes) { + for (const historyItem of issue.history.nodes) { + if (historyItem.toAssigneeId && historyItem.fromAssigneeId !== historyItem.toAssigneeId) { + if (historyItem.toAssigneeId === userId) { + activities.push( + createActivityMessage({ + text: `${issue.identifier} (${issue.title}) Issue assigned to you`, + sourceURL: issue.url, + }), + ); + activityCreated = true; + break; + } else if (isCreatedByUser && historyItem.fromAssigneeId === userId) { + activities.push( + createActivityMessage({ + text: `${issue.identifier} (${issue.title}) Issue unassigned from you`, + sourceURL: issue.url, + }), + ); + activityCreated = true; + break; + } + } + } + } + + // If no assignment change, check for status changes + if (!activityCreated && issue.history && issue.history.nodes) { for (const historyItem of issue.history.nodes) { if (historyItem.toStateId && historyItem.fromStateId !== historyItem.toStateId) { - // Skip if not relevant to the user if (!isAssignee && !isCreatedByUser && !isSubscribed) { continue; } const stateType = issue.state?.type; - let eventType = LinearEventType.ISSUE_STATUS_CHANGED; let statusText = `moved to ${issue.state?.name || 'a new status'}`; - // Special handling for completion and reopening if (stateType === 'completed') { - eventType = LinearEventType.ISSUE_COMPLETED; - statusText = 'marked as completed'; + statusText = 'completed'; } else if (stateType === 'canceled') { statusText = 'canceled'; - } else if (historyItem.fromStateId && !historyItem.toStateId) { - eventType = LinearEventType.ISSUE_REOPENED; - statusText = 'reopened'; } let title; if (isCreatedByUser || isAssignee) { - title = `You ${statusText} issue ${issue.identifier}: ${issue.title}`; - } else if (isSubscribed) { - title = `Issue ${issue.identifier} you're subscribed to was ${statusText}: ${issue.title}`; + title = `${issue.identifier} (${issue.title}) Issue ${statusText}`; } else { - title = `${issue.assignee?.name || 'Someone'} ${statusText} issue ${issue.identifier}: ${issue.title}`; + title = `${issue.identifier} (${issue.title}) Issue ${statusText}`; } - activities.push({ - url: `https://api.linear.app/issue/${issue.id}`, - title, - sourceId: `linear-${eventType}-${issue.id}-${historyItem.id}`, - sourceURL: issue.url, - integrationAccountId: integrationAccount.id, - }); - } - - // Process priority changes - if (historyItem.toPriority && historyItem.fromPriority !== historyItem.toPriority) { - // Skip if not relevant to the user - if (!isAssignee && !isCreatedByUser && !isSubscribed) { - continue; - } - - const priorityMap: Record = { - 0: 'No priority', - 1: 'Urgent', - 2: 'High', - 3: 'Medium', - 4: 'Low', - }; - - const newPriority = priorityMap[historyItem.toPriority] || 'a new priority'; - - let title; - if (isCreatedByUser) { - title = `You changed priority of issue ${issue.identifier} to ${newPriority}`; - } else if (isAssignee) { - title = `${issue.creator?.name || 'Someone'} changed priority of your assigned issue ${issue.identifier} to ${newPriority}`; - } else if (isSubscribed) { - title = `Priority of issue ${issue.identifier} you're subscribed to changed to ${newPriority}`; - } else { - title = `${issue.creator?.name || 'Someone'} changed priority of issue ${issue.identifier} to ${newPriority}`; - } - - activities.push({ - url: `https://api.linear.app/issue/${issue.id}`, - title, - sourceId: `linear-issue-priority-${issue.id}-${historyItem.id}`, - sourceURL: issue.url, - integrationAccountId: integrationAccount.id, - }); - } - - // Process assignment changes - if (historyItem.toAssigneeId && historyItem.fromAssigneeId !== historyItem.toAssigneeId) { - // Only relevant if user is newly assigned or is the creator - if (historyItem.toAssigneeId !== userId && !isCreatedByUser) { - continue; - } - - const title = - historyItem.toAssigneeId === userId - ? `You were assigned issue ${issue.identifier}: ${issue.title}` - : `You assigned issue ${issue.identifier} to ${issue.assignee?.name || 'someone'}`; - - activities.push({ - url: `https://api.linear.app/issue/${issue.id}`, - title, - sourceId: `linear-issue-reassigned-${issue.id}-${historyItem.id}`, - sourceURL: issue.url, - integrationAccountId: integrationAccount.id, - }); + activities.push( + createActivityMessage({ + text: title, + sourceURL: issue.url, + }), + ); + activityCreated = true; + break; } } } + + // If no history changes, check if it's a new issue creation + if (!activityCreated && isCreatedByUser) { + // Only create activity if issue was created recently (within sync window) + const createdAt = new Date(issue.createdAt); + const updatedAt = new Date(issue.updatedAt); + + // If created and updated times are very close, it's likely a new issue + if (Math.abs(createdAt.getTime() - updatedAt.getTime()) < 60000) { + // within 1 minute + activities.push( + createActivityMessage({ + text: `${issue.identifier} (${issue.title}) Issue created`, + sourceURL: issue.url, + }), + ); + activityCreated = true; + } + } } catch (error) { - console.error(`Error processing issue ${issue.id}:`, error); + // Silently ignore errors to prevent stdout pollution } } - // Create activities in the system - for (const activity of activities) { - await createActivity(activity); - } - - return activities.length; + return activities; } /** * Process comment activities and create appropriate activity records */ -async function processCommentActivities(comments: any[], userId: string, integrationAccount: any) { +async function processCommentActivities(comments: any[], userId: string, userInfo: any) { const activities = []; for (const comment of comments) { @@ -439,19 +356,22 @@ async function processCommentActivities(comments: any[], userId: string, integra comment.issue?.subscribers?.nodes?.some((subscriber: any) => subscriber.id === userId) || false; + // Check for mentions in the comment body + const isMentioned = checkForUserMentions(comment.body, userInfo); + // Skip if not relevant to user - if (!isCommenter && !isIssueCreator && !isAssignee && !isSubscribed) { - // TODO: Check for mentions in the comment body + if (!isCommenter && !isIssueCreator && !isAssignee && !isSubscribed && !isMentioned) { continue; } let title; - let sourceId; if (isCommenter) { // Comment created by the user title = `You commented on issue ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`; - sourceId = `linear-comment-created-${comment.id}`; + } else if (isMentioned) { + // User was mentioned in the comment + title = `${comment.user?.name || 'Someone'} mentioned you in issue ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`; } else if (isAssignee || isIssueCreator || isSubscribed) { // Comment on issue where user is assignee, creator, or subscriber let relation = 'an issue'; @@ -463,29 +383,40 @@ async function processCommentActivities(comments: any[], userId: string, integra relation = "an issue you're subscribed to"; } title = `${comment.user?.name || 'Someone'} commented on ${relation} ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`; - sourceId = `linear-comment-received-${comment.id}`; } - if (title && sourceId) { - activities.push({ - url: `https://api.linear.app/comment/${comment.id}`, - title, - sourceId, - sourceURL: `${comment.issue.url}#comment-${comment.id}`, - integrationAccountId: integrationAccount.id, - }); + if (title) { + activities.push( + createActivityMessage({ + text: title, + sourceURL: `${comment.issue.url}#comment-${comment.id}`, + }), + ); } } catch (error) { - console.error(`Error processing comment ${comment.id}:`, error); + // Silently ignore errors to prevent stdout pollution } } - // Create activities in the system - for (const activity of activities) { - await createActivity(activity); - } + return activities; +} - return activities.length; +/** + * Helper function to check for user mentions in text + */ +function checkForUserMentions(text: string, userInfo: any): boolean { + if (!text || !userInfo) return false; + + const lowerText = text.toLowerCase(); + + // Check for @username, @display name, or @email mentions + const mentionPatterns = [ + userInfo.name && `@${userInfo.name.toLowerCase()}`, + userInfo.displayName && `@${userInfo.displayName.toLowerCase()}`, + userInfo.email && `@${userInfo.email.toLowerCase()}`, + ].filter(Boolean); + + return mentionPatterns.some((pattern) => lowerText.includes(pattern)); } /** @@ -508,87 +439,84 @@ function getDefaultSyncTime(): string { /** * Main function to handle scheduled sync for Linear integration */ -export async function handleSchedule(integrationAccount: any) { +export async function handleSchedule(config: any, state: any) { try { - const integrationConfiguration = integrationAccount.integrationConfiguration as any; + const integrationConfiguration = config; // Check if we have a valid access token if (!integrationConfiguration?.accessToken) { - console.error('No access token found for Linear integration'); - return { message: 'No access token found' }; + return []; } // Get settings or initialize if not present - const settings = (integrationAccount.settings || {}) as LinearSettings; + let settings = (state || {}) as LinearSettings; // Default to 24 hours ago if no last sync times const lastIssuesSync = settings.lastIssuesSync || getDefaultSyncTime(); const lastCommentsSync = settings.lastCommentsSync || getDefaultSyncTime(); // Fetch user info to identify activities relevant to them - const user = await fetchUserInfo(integrationConfiguration.accessToken); - - if (!user || !user.id) { - console.error('Failed to fetch user info from Linear'); - return { message: 'Failed to fetch user info' }; + let user; + try { + user = await fetchUserInfo(integrationConfiguration.accessToken); + } catch (error) { + return []; } + if (!user || !user.id) { + return []; + } + + // Collect all messages + const messages = []; + // Process all issue activities (created, assigned, updated, etc.) - let issueCount = 0; try { const issues = await fetchRecentIssues(integrationConfiguration.accessToken, lastIssuesSync); if (issues && issues.nodes) { - issueCount = await processIssueActivities(issues.nodes, user.id, integrationAccount); + const issueActivities = await processIssueActivities(issues.nodes, user.id); + messages.push(...issueActivities); } } catch (error) { - console.error('Error processing issues:', error); + // Silently ignore errors to prevent stdout pollution } // Process all comment activities - let commentCount = 0; try { const comments = await fetchRecentComments( integrationConfiguration.accessToken, lastCommentsSync, ); if (comments && comments.nodes) { - commentCount = await processCommentActivities(comments.nodes, user.id, integrationAccount); + const commentActivities = await processCommentActivities(comments.nodes, user.id, user); + messages.push(...commentActivities); } } catch (error) { - console.error('Error processing comments:', error); + // Silently ignore errors to prevent stdout pollution } - // TODO: Implement additional activity types: - // - Reaction tracking - // - PR/Merge request tracking (if supported by Linear) - // - Project and cycle updates - // - Team updates and notifications - // - Mention detection in descriptions and comments - // Update last sync times const newSyncTime = new Date().toISOString(); - // Save new settings - integrationAccount.settings = { - ...settings, - lastIssuesSync: newSyncTime, - lastCommentsSync: newSyncTime, - }; + // Add state message for saving settings + messages.push({ + type: 'state', + data: { + ...settings, + lastIssuesSync: newSyncTime, + lastCommentsSync: newSyncTime, + }, + }); - return { - message: `Synced ${issueCount} issues and ${commentCount} comments from Linear`, - }; + return messages; } catch (error) { - console.error('Error in Linear scheduled sync:', error); - return { - message: `Error syncing Linear activities: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; + return []; } } /** * The main handler for the scheduled sync event */ -export async function scheduleHandler(integrationAccount: any) { - return handleSchedule(integrationAccount); +export async function scheduleHandler(config: any, state: any) { + return handleSchedule(config, state); }