2025-07-15 22:02:41 +05:30

600 lines
17 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from 'axios';
import { IntegrationAccount } from '@redplanethq/sol-sdk';
interface LinearActivityCreateParams {
url: string;
title: string;
sourceId: string;
sourceURL: string;
integrationAccountId: string;
}
interface LinearSettings {
lastIssuesSync?: string;
lastCommentsSync?: string;
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
*/
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);
}
}
/**
* Fetches user information from Linear
*/
async function fetchUserInfo(accessToken: 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: `Bearer ${accessToken}`,
},
},
);
return response.data.data.viewer;
} catch (error) {
console.error('Error fetching user info:', error);
throw error;
}
}
/**
* Fetches recent issues relevant to the user (created, assigned, or subscribed)
*/
async function fetchRecentIssues(accessToken: string, lastSyncTime: string) {
try {
const query = `
query RecentIssues($lastSyncTime: DateTime) {
issues(
filter: {
updatedAt: { gt: $lastSyncTime }
},
first: 50,
orderBy: updatedAt
) {
nodes {
...IssueFields
history {
nodes {
id
createdAt
updatedAt
fromStateId
toStateId
fromAssigneeId
toAssigneeId
fromPriority
toPriority
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
${ISSUE_FRAGMENT}
`;
const response = await axios.post(
'https://api.linear.app/graphql',
{
query,
variables: {
lastSyncTime,
},
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data.data.issues;
} catch (error) {
console.error('Error fetching recent issues:', error);
throw error;
}
}
/**
* Fetches recent comments on issues relevant to the user
*/
async function fetchRecentComments(accessToken: string, lastSyncTime: string) {
try {
const query = `
query RecentComments($lastSyncTime: DateTime) {
comments(
filter: {
updatedAt: { gt: $lastSyncTime }
},
first: 50,
orderBy: updatedAt
) {
nodes {
...CommentFields
}
pageInfo {
hasNextPage
endCursor
}
}
}
${COMMENT_FRAGMENT}
`;
const response = await axios.post(
'https://api.linear.app/graphql',
{
query,
variables: {
lastSyncTime,
},
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data.data.comments;
} catch (error) {
console.error('Error fetching recent comments:', error);
throw error;
}
}
/**
* Process issue activities and create appropriate activity records
*/
async function processIssueActivities(
issues: any[],
userId: string,
integrationAccount: IntegrationAccount,
isCreator: boolean = false,
) {
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 && !isCreator && !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 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
if (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';
} 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}`;
} else {
title = `${issue.assignee?.name || 'Someone'} ${statusText} issue ${issue.identifier}: ${issue.title}`;
}
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<number, string> = {
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,
});
}
}
}
} catch (error) {
console.error(`Error processing issue ${issue.id}:`, error);
}
}
// Create activities in the system
for (const activity of activities) {
await createActivity(activity);
}
return activities.length;
}
/**
* Process comment activities and create appropriate activity records
*/
async function processCommentActivities(
comments: any[],
userId: string,
integrationAccount: IntegrationAccount,
) {
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;
// Skip if not relevant to user
if (!isCommenter && !isIssueCreator && !isAssignee && !isSubscribed) {
// TODO: Check for mentions in the comment body
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 (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)}`;
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,
});
}
} catch (error) {
console.error(`Error processing comment ${comment.id}:`, error);
}
}
// Create activities in the system
for (const activity of activities) {
await createActivity(activity);
}
return activities.length;
}
/**
* 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(integrationAccount: IntegrationAccount) {
try {
const integrationConfiguration = integrationAccount.integrationConfiguration as any;
// 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' };
}
// Get settings or initialize if not present
const settings = (integrationAccount.settings || {}) 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' };
}
// 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);
}
} catch (error) {
console.error('Error processing issues:', error);
}
// 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);
}
} catch (error) {
console.error('Error processing comments:', error);
}
// 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,
};
return {
message: `Synced ${issueCount} issues and ${commentCount} comments from Linear`,
};
} catch (error) {
console.error('Error in Linear scheduled sync:', error);
return {
message: `Error syncing Linear activities: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
}
/**
* The main handler for the scheduled sync event
*/
export async function scheduleHandler(integrationAccount: IntegrationAccount) {
return handleSchedule(integrationAccount);
}