mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-12 10:18:27 +00:00
520 lines
14 KiB
TypeScript
520 lines
14 KiB
TypeScript
/* 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);
|
|
}
|