mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 09:38:27 +00:00
600 lines
17 KiB
TypeScript
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);
|
|
}
|