/* eslint-disable @typescript-eslint/no-explicit-any */ import axios from 'axios'; interface LinearActivityCreateParams { text: string; sourceURL: string; } interface LinearSettings { lastIssuesSync?: string; lastCommentsSync?: string; lastUserActionsSync?: string; } /** * Creates an activity message based on Linear data */ function createActivityMessage(params: LinearActivityCreateParams) { return { type: 'activity', data: { text: params.text, sourceURL: params.sourceURL, }, }; } /** * Fetches user information from Linear */ async function fetchUserInfo(apiKey: string) { try { const query = ` query { viewer { id name email } } `; const response = await axios.post( 'https://api.linear.app/graphql', { query }, { headers: { 'Content-Type': 'application/json', Authorization: apiKey, }, }, ); return response.data.data.viewer; } catch (error) { throw error; } } /** * Fetches recent issues relevant to the user (created, assigned, or subscribed) */ async function fetchRecentIssues(apiKey: string, lastSyncTime: string) { try { const query = ` query RecentIssues($lastSyncTime: DateTimeOrDuration) { issues( filter: { updatedAt: { gt: $lastSyncTime } }, first: 50, orderBy: updatedAt ) { nodes { 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 createdAt updatedAt fromStateId toStateId fromAssigneeId toAssigneeId fromPriority toPriority } } } pageInfo { hasNextPage endCursor } } } `; const response = await axios.post( 'https://api.linear.app/graphql', { query, variables: { lastSyncTime, }, }, { headers: { 'Content-Type': 'application/json', Authorization: apiKey, }, }, ); return response.data.data.issues; } catch (error) { throw error; } } /** * Fetches recent comments on issues relevant to the user */ async function fetchRecentComments(apiKey: string, lastSyncTime: string) { try { const query = ` query RecentComments($lastSyncTime: DateTimeOrDuration) { comments( filter: { updatedAt: { gt: $lastSyncTime } }, first: 50, orderBy: updatedAt ) { nodes { 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 endCursor } } } `; const response = await axios.post( 'https://api.linear.app/graphql', { query, variables: { lastSyncTime, }, }, { headers: { 'Content-Type': 'application/json', Authorization: apiKey, }, }, ); return response.data.data.comments; } catch (error) { throw error; } } /** * Process issue activities and create appropriate activity records */ async function processIssueActivities(issues: any[], userId: string) { const activities = []; for (const issue of issues) { try { // Skip issues that don't involve the user const isAssignee = issue.assignee?.id === userId; const isCreatedByUser = issue.creator?.id === userId; // Check if user is subscribed to the issue const isSubscribed = issue.subscribers?.nodes?.some((subscriber: any) => subscriber.id === userId) || false; if (!isAssignee && !isCreatedByUser && !isSubscribed) { continue; } // Process history to determine what actually changed let activityCreated = false; // 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) { if (!isAssignee && !isCreatedByUser && !isSubscribed) { continue; } const stateType = issue.state?.type; let statusText = `moved to ${issue.state?.name || 'a new status'}`; if (stateType === 'completed') { statusText = 'completed'; } else if (stateType === 'canceled') { statusText = 'canceled'; } let title; if (isCreatedByUser || isAssignee) { title = `${issue.identifier} (${issue.title}) Issue ${statusText}`; } else { title = `${issue.identifier} (${issue.title}) Issue ${statusText}`; } 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) { // Silently ignore errors to prevent stdout pollution } } return activities; } /** * Process comment activities and create appropriate activity records */ async function processCommentActivities(comments: any[], userId: string, userInfo: any) { const activities = []; for (const comment of comments) { try { const isCommenter = comment.user?.id === userId; const isIssueCreator = comment.issue?.creator?.id === userId; const isAssignee = comment.issue?.assignee?.id === userId; // Check if user is subscribed to the issue const isSubscribed = 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 && !isMentioned) { continue; } let title; if (isCommenter) { // Comment created by the user title = `You commented on issue ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`; } 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'; if (isAssignee) { relation = 'your assigned issue'; } else if (isIssueCreator) { relation = 'your issue'; } else if (isSubscribed) { relation = "an issue you're subscribed to"; } title = `${comment.user?.name || 'Someone'} commented on ${relation} ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`; } if (title) { activities.push( createActivityMessage({ text: title, sourceURL: `${comment.issue.url}#comment-${comment.id}`, }), ); } } catch (error) { // Silently ignore errors to prevent stdout pollution } } return activities; } /** * 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)); } /** * Helper function to truncate text with ellipsis */ function truncateText(text: string, maxLength: number): string { if (!text) return ''; return text.length > maxLength ? text.substring(0, maxLength) + '...' : text; } /** * Helper function to get default sync time (24 hours ago) */ function getDefaultSyncTime(): string { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); return yesterday.toISOString(); } /** * Main function to handle scheduled sync for Linear integration */ export async function handleSchedule(config: any, state: any) { try { const integrationConfiguration = config; // Check if we have a valid access token if (!integrationConfiguration?.apiKey) { return []; } // Get settings or initialize if not present 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 let user; try { user = await fetchUserInfo(integrationConfiguration.apiKey); } catch (error) { return []; } if (!user || !user.id) { return []; } // Collect all messages const messages = []; // Process all issue activities (created, assigned, updated, etc.) try { const issues = await fetchRecentIssues(integrationConfiguration.apiKey, lastIssuesSync); if (issues && issues.nodes) { const issueActivities = await processIssueActivities(issues.nodes, user.id); messages.push(...issueActivities); } } catch (error) { // Silently ignore errors to prevent stdout pollution } // Process all comment activities try { const comments = await fetchRecentComments(integrationConfiguration.apiKey, lastCommentsSync); if (comments && comments.nodes) { const commentActivities = await processCommentActivities(comments.nodes, user.id, user); messages.push(...commentActivities); } } catch (error) { // Silently ignore errors to prevent stdout pollution } // Update last sync times const newSyncTime = new Date().toISOString(); // Add state message for saving settings messages.push({ type: 'state', data: { ...settings, lastIssuesSync: newSyncTime, lastCommentsSync: newSyncTime, }, }); return messages; } catch (error) { return []; } } /** * The main handler for the scheduled sync event */ export async function scheduleHandler(config: any, state: any) { return handleSchedule(config, state); }