/** * Linear ingestion module * * Fetches issue activity from Linear API and converts to timeline events. * Requires LINEAR_API_KEY environment variable. */ import type { LinearMetadata } from "../types/timeline"; export interface LinearIssueEvent { id: string; timestamp: Date; issueId: string; issueIdentifier: string; title: string; action: LinearMetadata["action"]; teamName?: string; projectName?: string; assigneeName?: string; url?: string; estimatedMinutes: number; } interface LinearIssueNode { id: string; identifier: string; title: string; url: string; createdAt: string; completedAt: string | null; updatedAt: string; state: { name: string }; assignee: { name: string } | null; team: { name: string }; project: { name: string } | null; comments: { nodes: Array<{ id: string; createdAt: string }> }; } const LINEAR_API_URL = "https://api.linear.app/graphql"; /** * Check if a Linear API key is available. * * Desktop has no environment variables, so callers pass the key explicitly * (read from app settings); the `process.env` fallback is for hosted/CLI use. */ export function isLinearConfigured(apiKey?: string): boolean { return !!(apiKey ?? process.env.LINEAR_API_KEY); } /** * Fetch issues from Linear API with activity since a given date. * * Pass `apiKey` explicitly on desktop (no env vars); falls back to * `process.env.LINEAR_API_KEY` for hosted/CLI contexts. */ export async function getLinearActivity(options: { apiKey?: string; since?: Date; until?: Date; teamId?: string; maxIssues?: number; }): Promise { const apiKey = options.apiKey ?? process.env.LINEAR_API_KEY; if (!apiKey) { throw new Error( "Linear API key not provided (pass options.apiKey or set LINEAR_API_KEY)", ); } const { since, until, maxIssues = 500 } = options; // Build filter for issues updated in the date range const filters: string[] = []; if (since) { filters.push(`updatedAt: { gte: "${since.toISOString()}" }`); } if (until) { filters.push(`updatedAt: { lte: "${until.toISOString()}" }`); } if (options.teamId) { filters.push(`team: { id: { eq: "${options.teamId}" } }`); } const filterStr = filters.length > 0 ? `filter: { ${filters.join(", ")} }` : ""; const query = ` query { issues(first: ${maxIssues}, ${filterStr}, orderBy: updatedAt) { nodes { id identifier title url createdAt completedAt updatedAt state { name } assignee { name } team { name } project { name } comments(first: 50) { nodes { id createdAt } } } } } `; const response = await fetch(LINEAR_API_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: apiKey, }, body: JSON.stringify({ query }), }); if (!response.ok) { throw new Error( `Linear API error: ${response.status} ${response.statusText}`, ); } const data = await response.json(); const issues: LinearIssueNode[] = data.data?.issues?.nodes || []; // Convert issues to timeline events const events: LinearIssueEvent[] = []; const sinceMs = since?.getTime() || 0; const untilMs = until?.getTime() || Date.now(); for (const issue of issues) { const teamName = issue.team.name; const projectName = issue.project?.name; const assigneeName = issue.assignee?.name; const url = issue.url; // Issue created event const createdAt = new Date(issue.createdAt); if (createdAt.getTime() >= sinceMs && createdAt.getTime() <= untilMs) { events.push({ id: `linear-created-${issue.id}`, timestamp: createdAt, issueId: issue.id, issueIdentifier: issue.identifier, title: issue.title, action: "created", teamName, projectName, assigneeName, url, estimatedMinutes: 5, }); } // Issue completed event if (issue.completedAt) { const completedAt = new Date(issue.completedAt); if ( completedAt.getTime() >= sinceMs && completedAt.getTime() <= untilMs ) { events.push({ id: `linear-completed-${issue.id}`, timestamp: completedAt, issueId: issue.id, issueIdentifier: issue.identifier, title: issue.title, action: "completed", teamName, projectName, assigneeName, url, estimatedMinutes: 5, }); } } // Comment events for (const comment of issue.comments.nodes) { const commentAt = new Date(comment.createdAt); if (commentAt.getTime() >= sinceMs && commentAt.getTime() <= untilMs) { events.push({ id: `linear-comment-${comment.id}`, timestamp: commentAt, issueId: issue.id, issueIdentifier: issue.identifier, title: issue.title, action: "commented", teamName, projectName, assigneeName, url, estimatedMinutes: 5, }); } } } // Sort by timestamp events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); return events; } /** * Shape of a timeline_events insert row produced from a Linear event. * Linear is an API source, so it has no local project root (`projectRootId` is null). * The ingest runner spreads this and adds `contentHash`. */ export interface LinearTimelineRow { timestamp: Date; source: "linear"; sourceId: string; projectRootId: null; metadata: LinearMetadata; content: string; estimatedMinutes: number; } /** * Map a fetched LinearIssueEvent to a timeline_events insert row. * Pure + deterministic so it can be unit-tested without the API. * `event.id` (e.g. `linear-created-`) is stable, so it is the dedup `sourceId`. */ export function linearEventToTimelineRow( event: LinearIssueEvent, ): LinearTimelineRow { return { timestamp: event.timestamp, source: "linear", sourceId: event.id, projectRootId: null, metadata: { issueId: event.issueId, issueIdentifier: event.issueIdentifier, title: event.title, action: event.action, teamName: event.teamName, projectName: event.projectName, assigneeName: event.assigneeName, url: event.url, }, content: `${event.issueIdentifier} ${event.title}`.trim(), estimatedMinutes: event.estimatedMinutes, }; }