diff --git a/src-tauri/src/engine/scanner.rs b/src-tauri/src/engine/scanner.rs index f51dc5a..e1255bd 100644 --- a/src-tauri/src/engine/scanner.rs +++ b/src-tauri/src/engine/scanner.rs @@ -434,6 +434,52 @@ fn parse_scan_output(output: &str) -> Result, String> { )) } +/// Extract the raw JSON array substring from text that may contain surrounding prose. +/// Returns the raw JSON string (including brackets). +pub fn extract_json_array_raw(text: &str) -> Option { + let start = text.find('[')?; + let mut depth = 0; + let mut end = None; + let mut in_string = false; + let mut escape_next = false; + + for (i, ch) in text[start..].char_indices() { + if escape_next { + escape_next = false; + continue; + } + + if ch == '\\' && in_string { + escape_next = true; + continue; + } + + if ch == '"' { + in_string = !in_string; + continue; + } + + if in_string { + continue; + } + + match ch { + '[' => depth += 1, + ']' => { + depth -= 1; + if depth == 0 { + end = Some(start + i + 1); + break; + } + } + _ => {} + } + } + + let end = end?; + Some(text[start..end].to_string()) +} + /// Extract a JSON array from text that may contain surrounding prose. /// String-literal-aware: skips brackets inside JSON string values. fn extract_json_array(text: &str) -> Option> { diff --git a/src-tauri/src/engine_commands.rs b/src-tauri/src/engine_commands.rs index 688f6c4..5d6b07d 100644 --- a/src-tauri/src/engine_commands.rs +++ b/src-tauri/src/engine_commands.rs @@ -464,6 +464,92 @@ end tell"#, Ok(()) } +/// Augment imported tasks with codebase context using Claude CLI. +/// Accepts a batch of tasks and returns enriched metadata for each. +#[tauri::command] +pub async fn engine_augment_tasks( + repo_path: String, + tasks: Vec, +) -> Result, String> { + println!( + "[engine_augment_tasks] augmenting {} tasks for repo={}", + tasks.len(), + repo_path + ); + + // Collect source files for context + let context = scanner::collect_source_files(&repo_path)?; + + // Build prompt with all tasks + let mut task_list = String::new(); + for (i, t) in tasks.iter().enumerate() { + task_list.push_str(&format!( + "Task {}: {}\nDescription: {}\n\n", + i + 1, + t.title, + t.description.as_deref().unwrap_or("(no description)") + )); + } + + let prompt = format!( + r#"You are analyzing tasks imported from an issue tracker in the context of a codebase. +For each task below, analyze the codebase and return enriched metadata. + +{} + +Output ONLY a JSON array (one entry per task, same order) with no markdown formatting: +[{{ + "files_involved": ["path/to/file.ts"], + "estimated_effort": "low" | "medium" | "high", + "enriched_description": "Enhanced description with codebase context...", + "category": "feature" | "tech_debt" | "tests" | "docs" | "security" | "performance" | "dx" | "observability" | "general" +}}]"#, + task_list + ); + + let result = engine::invoke_claude_cli( + &repo_path, + &prompt, + 300, // 5 min timeout + Some(&context), + None, + None, + ) + .await?; + + if !result.success { + return Err(format!( + "Claude CLI failed: {}", + result.stderr + )); + } + + // Parse the JSON array from stdout + let json_str = scanner::extract_json_array_raw(&result.stdout) + .ok_or_else(|| "Failed to extract JSON array from augmentation response".to_string())?; + + let results: Vec = serde_json::from_str(&json_str) + .map_err(|e| format!("Failed to parse augmentation results: {e}"))?; + + Ok(results) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AugmentTaskInput { + pub title: String, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AugmentTaskResult { + pub files_involved: Vec, + pub estimated_effort: String, + pub enriched_description: String, + pub category: String, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EngineStatusResponse { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5bdd3a9..563c87e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -95,6 +95,7 @@ pub fn run() { engine_commands::engine_get_diff, engine_commands::engine_get_diff_stat, engine_commands::engine_create_pr, + engine_commands::engine_augment_tasks, engine_commands::run_terminal_command, command::set_dock_badge, ]) diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index 318dd1d..3da993f 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -251,5 +251,46 @@ pub fn migrations() -> Vec { "#, kind: MigrationKind::Up, }, + Migration { + version: 13, + description: "add Linear integration columns and sync config table", + sql: r#" + ALTER TABLE tasks ADD COLUMN linear_issue_id TEXT; + ALTER TABLE tasks ADD COLUMN linear_identifier TEXT; + ALTER TABLE tasks ADD COLUMN linear_url TEXT; + + CREATE INDEX IF NOT EXISTS idx_tasks_linear_issue + ON tasks(linear_issue_id) WHERE linear_issue_id IS NOT NULL; + + CREATE TABLE IF NOT EXISTS linear_sync_config ( + id TEXT PRIMARY KEY NOT NULL, + repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + linear_team_id TEXT NOT NULL, + linear_team_name TEXT NOT NULL, + linear_project_id TEXT, + linear_project_name TEXT, + auto_sync INTEGER NOT NULL DEFAULT 0, + filter_labels TEXT, + last_sync_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_linear_sync_repo + ON linear_sync_config(repository_id); + + INSERT OR IGNORE INTO global_settings (key, value) VALUES + ('linear_api_key', ''), + ('linear_enabled', 'false'); + "#, + kind: MigrationKind::Up, + }, + Migration { + version: 14, + description: "add sync_schedule to linear_sync_config", + sql: r#" + ALTER TABLE linear_sync_config ADD COLUMN sync_schedule TEXT NOT NULL DEFAULT 'manual'; + "#, + kind: MigrationKind::Up, + }, ] } diff --git a/src/core/api/useEngine.ts b/src/core/api/useEngine.ts index ce1425e..9bc21b3 100644 --- a/src/core/api/useEngine.ts +++ b/src/core/api/useEngine.ts @@ -17,6 +17,7 @@ import { updateTask as dbUpdateTask, } from "@core/db/tasks"; import { listRepositories } from "@core/db/repositories"; +import { addComment as addLinearComment } from "@core/services/linear"; import type { BudgetConfig, BudgetStatus, @@ -447,6 +448,29 @@ async function handleTaskResult( } } + // Link PR back to Linear if this is a Linear-sourced task + if (prUrl) { + try { + const task = await getTask(variables.taskId); + if (task?.linearIssueId && settings.linearApiKey) { + await addLinearComment( + settings.linearApiKey, + task.linearIssueId, + `PR created by [SUSTN](https://sustn.app): ${prUrl}`, + ); + console.log( + "[handleTaskResult] linked PR to Linear issue:", + task.linearIdentifier, + ); + } + } catch (linearErr) { + console.error( + "[handleTaskResult] Linear link-back failed:", + linearErr, + ); + } + } + await dbUpdateTaskWithRetry(variables.taskId, { state: prUrl ? ("done" as const) : ("review" as const), baseBranch: variables.baseBranch, diff --git a/src/core/api/useLinear.ts b/src/core/api/useLinear.ts new file mode 100644 index 0000000..cd28d10 --- /dev/null +++ b/src/core/api/useLinear.ts @@ -0,0 +1,332 @@ +import { useEffect, useRef } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; +import { toast } from "sonner"; +import { useGlobalSettings } from "@core/api/useSettings"; +import type { SyncResult } from "@core/services/linear-sync"; +import { + testConnection, + fetchTeams, + fetchProjects, +} from "@core/services/linear"; +import { syncLinearIssues } from "@core/services/linear-sync"; +import { + getLinearSyncConfigs, + getAllLinearSyncConfigs, + createLinearSyncConfig as dbCreateSyncConfig, + deleteLinearSyncConfig as dbDeleteSyncConfig, + updateLastSyncAt, + updateSyncSchedule as dbUpdateSyncSchedule, +} from "@core/db/linear-sync"; +import { listRepositories } from "@core/db/repositories"; +import type { LinearSyncConfig, LinearSyncSchedule } from "@core/types/linear"; + +// ── Linear Connection ───────────────────────────────────── + +export function useLinearTeams() { + const { data: settings } = useGlobalSettings(); + const apiKey = settings?.linearApiKey; + + return useQuery({ + queryKey: ["linear-teams", apiKey], + queryFn: () => fetchTeams(apiKey!), + enabled: !!apiKey && apiKey.length > 0 && settings?.linearEnabled, + staleTime: 5 * 60 * 1000, + }); +} + +export function useLinearProjects(teamId: string | undefined) { + const { data: settings } = useGlobalSettings(); + const apiKey = settings?.linearApiKey; + + return useQuery({ + queryKey: ["linear-projects", teamId], + queryFn: () => fetchProjects(apiKey!, teamId!), + enabled: + !!apiKey && + apiKey.length > 0 && + !!teamId && + settings?.linearEnabled, + staleTime: 5 * 60 * 1000, + }); +} + +export function useTestLinearConnection() { + return useMutation({ + mutationFn: (apiKey: string) => testConnection(apiKey), + }); +} + +// ── Sync Configs ────────────────────────────────────────── + +export function useLinearSyncConfigs(repositoryId: string | undefined) { + return useQuery({ + queryKey: ["linear-sync-configs", repositoryId], + queryFn: () => getLinearSyncConfigs(repositoryId!), + enabled: !!repositoryId, + }); +} + +export function useCreateLinearSyncConfig() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (syncConfig: { + repositoryId: string; + linearTeamId: string; + linearTeamName: string; + linearProjectId?: string; + linearProjectName?: string; + filterLabels?: string[]; + }) => dbCreateSyncConfig(syncConfig), + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ + queryKey: ["linear-sync-configs", variables.repositoryId], + }); + }, + }); +} + +export function useDeleteLinearSyncConfig() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + repositoryId: _repositoryId, + }: { + id: string; + repositoryId: string; + }) => dbDeleteSyncConfig(id), + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ + queryKey: ["linear-sync-configs", variables.repositoryId], + }); + }, + }); +} + +export function useUpdateSyncSchedule() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + schedule, + repositoryId: _repositoryId, + }: { + id: string; + schedule: LinearSyncSchedule; + repositoryId: string; + }) => dbUpdateSyncSchedule(id, schedule), + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ + queryKey: ["linear-sync-configs", variables.repositoryId], + }); + }, + }); +} + +// ── Sync Operation ──────────────────────────────────────── + +export function useSyncLinear() { + const queryClient = useQueryClient(); + const { data: settings } = useGlobalSettings(); + + return useMutation({ + mutationFn: async ({ + syncConfig, + repositoryId, + baseBranch, + }: { + syncConfig: LinearSyncConfig; + repositoryId: string; + baseBranch?: string; + }) => { + const apiKey = settings?.linearApiKey; + if (!apiKey) throw new Error("Linear API key not configured"); + + const result = await syncLinearIssues( + apiKey, + syncConfig, + repositoryId, + baseBranch, + ); + + await updateLastSyncAt(syncConfig.id); + + return result; + }, + onSuccess: (data: SyncResult, variables) => { + // Force refetch of all task queries for this repo + void queryClient.invalidateQueries({ + queryKey: ["tasks", variables.repositoryId], + }); + void queryClient.invalidateQueries({ + queryKey: ["linear-sync-configs", variables.repositoryId], + }); + + // Show feedback + if (data.imported > 0) { + toast.success( + `Imported ${data.imported} issue${data.imported > 1 ? "s" : ""} from Linear`, + ); + } else if (data.skipped > 0) { + toast.info("All issues already imported — nothing new to sync"); + } else { + toast.info("No matching issues found in Linear"); + } + if (data.errors.length > 0) { + toast.error( + `${data.errors.length} issue${data.errors.length > 1 ? "s" : ""} failed to import`, + ); + console.error("[useSyncLinear] sync errors:", data.errors); + } + }, + onError: (error) => { + toast.error( + `Linear sync failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }, + }); +} + +// ── Auto-Sync ───────────────────────────────────────────── + +const SCHEDULE_INTERVALS: Record = { + "6h": 6 * 60 * 60 * 1000, + "12h": 12 * 60 * 60 * 1000, + daily: 24 * 60 * 60 * 1000, +}; + +let startupSyncDone = false; + +/** + * Runs scheduled Linear syncs. Mount once in AppShell. + * + * - "on_start" configs sync once when the app starts. + * - Interval configs ("6h", "12h", "daily") sync when enough + * time has elapsed since lastSyncAt. + * + * Checks every 5 minutes. + */ +export function useLinearAutoSync() { + const { data: settings } = useGlobalSettings(); + const intervalRef = useRef | undefined>( + undefined, + ); + + useEffect(() => { + if (!settings?.linearEnabled || !settings?.linearApiKey) return; + const apiKey = settings.linearApiKey; + + async function runScheduledSyncs() { + const configs = await getAllLinearSyncConfigs(); + const repos = await listRepositories(); + + for (const sc of configs) { + const repo = repos.find((r) => r.id === sc.repositoryId); + if (!repo) continue; + + let shouldSync = false; + + if (sc.syncSchedule === "on_start" && !startupSyncDone) { + shouldSync = true; + } + + const interval = SCHEDULE_INTERVALS[sc.syncSchedule]; + if (interval) { + const lastSync = sc.lastSyncAt + ? new Date(sc.lastSyncAt).getTime() + : 0; + shouldSync = Date.now() - lastSync >= interval; + } + + if (shouldSync) { + try { + console.log( + `[linear-auto-sync] syncing ${sc.linearTeamName} → ${repo.name}`, + ); + const { syncLinearIssues } = + await import("@core/services/linear-sync"); + const result = await syncLinearIssues( + apiKey, + sc, + sc.repositoryId, + repo.defaultBranch, + ); + await updateLastSyncAt(sc.id); + if (result.imported > 0) { + toast.success( + `Auto-synced ${result.imported} issue${result.imported > 1 ? "s" : ""} from Linear → ${repo.name}`, + ); + } + } catch (err) { + console.error( + "[linear-auto-sync] failed:", + sc.linearTeamName, + err, + ); + } + } + } + + startupSyncDone = true; + } + + // Run once on mount (handles "on_start" + any overdue intervals) + const timeout = setTimeout(() => void runScheduledSyncs(), 5000); + + // Check every 5 minutes for interval-based schedules + intervalRef.current = setInterval( + () => void runScheduledSyncs(), + 5 * 60 * 1000, + ); + + return () => { + clearTimeout(timeout); + clearInterval(intervalRef.current); + }; + }, [settings?.linearEnabled, settings?.linearApiKey]); +} + +// ── Augmentation ────────────────────────────────────────── + +interface AugmentTaskInput { + title: string; + description: string | undefined; +} + +interface AugmentTaskResult { + filesInvolved: string[]; + estimatedEffort: string; + enrichedDescription: string; + category: string; +} + +export function useAugmentTasks() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + repoPath, + repositoryId, + tasks, + }: { + repoPath: string; + repositoryId: string; + tasks: AugmentTaskInput[]; + }) => { + const results = await invoke( + "engine_augment_tasks", + { repoPath, tasks }, + ); + return { results, repositoryId }; + }, + onSuccess: (data) => { + void queryClient.invalidateQueries({ + queryKey: ["tasks", data.repositoryId], + }); + }, + }); +} diff --git a/src/core/api/useScheduler.ts b/src/core/api/useScheduler.ts index acc92a6..2930fad 100644 --- a/src/core/api/useScheduler.ts +++ b/src/core/api/useScheduler.ts @@ -192,6 +192,7 @@ async function runSchedulerTick( nextTask.id, settings, overrides, + nextTask, ); const baseBranch = effectiveBaseBranch( nextTask.baseBranch, diff --git a/src/core/db/linear-sync.ts b/src/core/db/linear-sync.ts new file mode 100644 index 0000000..0e3debb --- /dev/null +++ b/src/core/db/linear-sync.ts @@ -0,0 +1,130 @@ +import Database from "@tauri-apps/plugin-sql"; +import { invoke } from "@tauri-apps/api/core"; +import { config } from "@core/config"; +import type { LinearSyncConfig, LinearSyncSchedule } from "@core/types/linear"; + +interface LinearSyncConfigRow { + id: string; + repository_id: string; + linear_team_id: string; + linear_team_name: string; + linear_project_id: string | null; + linear_project_name: string | null; + auto_sync: number; + sync_schedule: string; + filter_labels: string | null; + last_sync_at: string | null; + created_at: string; +} + +async function getDb() { + return await Database.load(config.dbUrl); +} + +function ensureUtc(ts: string): string { + if (ts.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(ts)) return ts; + return ts.replace(" ", "T") + "Z"; +} + +function rowToLinearSyncConfig(row: LinearSyncConfigRow): LinearSyncConfig { + let filterLabels: string[] | undefined; + if (row.filter_labels) { + try { + filterLabels = JSON.parse(row.filter_labels) as string[]; + } catch { + filterLabels = undefined; + } + } + + return { + id: row.id, + repositoryId: row.repository_id, + linearTeamId: row.linear_team_id, + linearTeamName: row.linear_team_name, + linearProjectId: row.linear_project_id ?? undefined, + linearProjectName: row.linear_project_name ?? undefined, + autoSync: row.auto_sync === 1, + syncSchedule: (row.sync_schedule ?? "manual") as LinearSyncSchedule, + filterLabels, + lastSyncAt: row.last_sync_at ? ensureUtc(row.last_sync_at) : undefined, + createdAt: ensureUtc(row.created_at), + }; +} + +export async function getLinearSyncConfigs( + repositoryId: string, +): Promise { + const db = await getDb(); + const rows = await db.select( + "SELECT * FROM linear_sync_config WHERE repository_id = $1 ORDER BY created_at ASC", + [repositoryId], + ); + return rows.map(rowToLinearSyncConfig); +} + +export async function createLinearSyncConfig(syncConfig: { + repositoryId: string; + linearTeamId: string; + linearTeamName: string; + linearProjectId?: string; + linearProjectName?: string; + filterLabels?: string[]; +}): Promise { + const db = await getDb(); + const id = await invoke("generate_task_id"); + + await db.execute( + `INSERT INTO linear_sync_config (id, repository_id, linear_team_id, linear_team_name, linear_project_id, linear_project_name, filter_labels) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + id, + syncConfig.repositoryId, + syncConfig.linearTeamId, + syncConfig.linearTeamName, + syncConfig.linearProjectId ?? null, + syncConfig.linearProjectName ?? null, + syncConfig.filterLabels + ? JSON.stringify(syncConfig.filterLabels) + : null, + ], + ); + + const rows = await db.select( + "SELECT * FROM linear_sync_config WHERE id = $1", + [id], + ); + + return rowToLinearSyncConfig(rows[0]); +} + +export async function deleteLinearSyncConfig(id: string): Promise { + const db = await getDb(); + await db.execute("DELETE FROM linear_sync_config WHERE id = $1", [id]); +} + +export async function getAllLinearSyncConfigs(): Promise { + const db = await getDb(); + const rows = await db.select( + "SELECT * FROM linear_sync_config ORDER BY created_at ASC", + ); + return rows.map(rowToLinearSyncConfig); +} + +export async function updateSyncSchedule( + id: string, + schedule: LinearSyncSchedule, +): Promise { + const db = await getDb(); + await db.execute( + "UPDATE linear_sync_config SET sync_schedule = $1 WHERE id = $2", + [schedule, id], + ); +} + +export async function updateLastSyncAt(id: string): Promise { + const db = await getDb(); + await db.execute( + "UPDATE linear_sync_config SET last_sync_at = CURRENT_TIMESTAMP WHERE id = $1", + [id], + ); +} diff --git a/src/core/db/settings.ts b/src/core/db/settings.ts index 4f2a4ac..ab82ce6 100644 --- a/src/core/db/settings.ts +++ b/src/core/db/settings.ts @@ -48,6 +48,8 @@ const KEY_MAP: Record = { scan_frequency: "scanFrequency", budget_ceiling_percent: "budgetCeilingPercent", show_budget_in_sidebar: "showBudgetInSidebar", + linear_api_key: "linearApiKey", + linear_enabled: "linearEnabled", }; const REVERSE_KEY_MAP: Record = Object.fromEntries( @@ -60,6 +62,7 @@ const BOOLEAN_KEYS = new Set([ "autoCreatePrs", "deleteBranchOnDismiss", "showBudgetInSidebar", + "linearEnabled", ]); function parseValue(camelKey: string, raw: string): unknown { @@ -97,6 +100,8 @@ const DEFAULTS: GlobalSettings = { scanFrequency: "daily", budgetCeilingPercent: 75, showBudgetInSidebar: true, + linearApiKey: "", + linearEnabled: false, }; export async function getGlobalSettings(): Promise { diff --git a/src/core/db/tasks.ts b/src/core/db/tasks.ts index 95309d2..36531b2 100644 --- a/src/core/db/tasks.ts +++ b/src/core/db/tasks.ts @@ -31,6 +31,9 @@ interface TaskRow { branch_name: string | null; commit_sha: string | null; session_id: string | null; + linear_issue_id: string | null; + linear_identifier: string | null; + linear_url: string | null; tokens_used: number | null; retry_count: number | null; last_error: string | null; @@ -104,6 +107,9 @@ function rowToTask(row: TaskRow): Task { branchName: row.branch_name ?? undefined, commitSha: row.commit_sha ?? undefined, sessionId: row.session_id ?? undefined, + linearIssueId: row.linear_issue_id ?? undefined, + linearIdentifier: row.linear_identifier ?? undefined, + linearUrl: row.linear_url ?? undefined, tokensUsed: row.tokens_used ?? 0, retryCount: row.retry_count ?? 0, lastError: row.last_error ?? undefined, @@ -530,13 +536,105 @@ export async function createScannedTask( return rowToTask(rows[0]); } +/** + * Insert a single Linear-sourced task into the DB. + */ +export async function createLinearTask( + repositoryId: string, + task: { + title: string; + description: string | undefined; + category: string; + estimatedEffort: string; + filesInvolved?: string[]; + linearIssueId: string; + linearIdentifier: string; + linearUrl: string; + }, + sortOrder: number, + baseBranch?: string, +): Promise { + const db = await getDb(); + const id = await invoke("generate_task_id"); + + await db.execute( + `INSERT INTO tasks (id, repository_id, title, description, category, sort_order, source, estimated_effort, files_involved, base_branch, linear_issue_id, linear_identifier, linear_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + [ + id, + repositoryId, + task.title, + task.description ?? null, + task.category, + sortOrder, + "linear", + task.estimatedEffort, + task.filesInvolved ? JSON.stringify(task.filesInvolved) : null, + baseBranch ?? null, + task.linearIssueId, + task.linearIdentifier, + task.linearUrl, + ], + ); + + await recordEvent( + db, + id, + "created", + undefined, + undefined, + undefined, + `Imported from Linear: ${task.linearIdentifier}`, + ); + + const rows = await db.select( + "SELECT * FROM tasks WHERE id = $1", + [id], + ); + + return rowToTask(rows[0]); +} + +/** + * Get the set of Linear issue IDs already imported for a repository (for dedup). + */ +export async function getLinearIssueIds( + repositoryId: string, +): Promise> { + const db = await getDb(); + const rows = await db.select<{ linear_issue_id: string }[]>( + "SELECT linear_issue_id FROM tasks WHERE repository_id = $1 AND linear_issue_id IS NOT NULL AND state IN ('pending', 'in_progress', 'review', 'failed')", + [repositoryId], + ); + return new Set(rows.map((r) => r.linear_issue_id)); +} + +/** + * Fix Linear tasks that have a mismatched base_branch. + * Updates them to the correct branch so they appear in the task list. + */ +export async function fixOrphanedLinearTasks( + repositoryId: string, + correctBaseBranch?: string, +): Promise { + if (!correctBaseBranch) return; + const db = await getDb(); + await db.execute( + `UPDATE tasks SET base_branch = $1, updated_at = CURRENT_TIMESTAMP + WHERE repository_id = $2 AND source = 'linear' + AND base_branch IS NOT NULL AND base_branch != $1`, + [correctBaseBranch, repositoryId], + ); +} + /** * Get the set of existing non-terminal task titles for dedup, - * plus the current max sort_order. + * plus the current min/max sort_order. */ export async function getDeduplicationContext(repositoryId: string): Promise<{ existingTitles: Set; maxSortOrder: number; + minSortOrder: number; }> { const db = await getDb(); @@ -549,14 +647,17 @@ export async function getDeduplicationContext(repositoryId: string): Promise<{ existing.map((t) => t.title.toLowerCase().trim()), ); - const maxOrderRows = await db.select<{ max_order: number | null }[]>( - "SELECT MAX(sort_order) as max_order FROM tasks WHERE repository_id = $1", + const orderRows = await db.select< + { max_order: number | null; min_order: number | null }[] + >( + "SELECT MAX(sort_order) as max_order, MIN(sort_order) as min_order FROM tasks WHERE repository_id = $1", [repositoryId], ); return { existingTitles, - maxSortOrder: maxOrderRows[0]?.max_order ?? 0, + maxSortOrder: orderRows[0]?.max_order ?? 0, + minSortOrder: orderRows[0]?.min_order ?? 0, }; } diff --git a/src/core/services/linear-sync.ts b/src/core/services/linear-sync.ts new file mode 100644 index 0000000..cc203c0 --- /dev/null +++ b/src/core/services/linear-sync.ts @@ -0,0 +1,139 @@ +import type { LinearSyncConfig } from "@core/types/linear"; +import type { TaskCategory, EstimatedEffort } from "@core/types/task"; +import { fetchIssues } from "@core/services/linear"; +import { + createLinearTask, + getLinearIssueIds, + getDeduplicationContext, + fixOrphanedLinearTasks, +} from "@core/db/tasks"; + +/** + * Map Linear labels to SUSTN task categories. + */ +function inferCategory(labels: { name: string }[]): TaskCategory { + const names = new Set(labels.map((l) => l.name.toLowerCase())); + + if (names.has("bug") || names.has("security")) return "security"; + if (names.has("feature") || names.has("enhancement")) return "feature"; + if (names.has("test") || names.has("testing")) return "tests"; + if (names.has("docs") || names.has("documentation")) return "docs"; + if (names.has("performance") || names.has("perf")) return "performance"; + if ( + names.has("tech-debt") || + names.has("tech debt") || + names.has("refactor") + ) + return "tech_debt"; + if (names.has("dx") || names.has("developer experience")) return "dx"; + if ( + names.has("observability") || + names.has("logging") || + names.has("monitoring") + ) + return "observability"; + + return "general"; +} + +/** + * Map Linear priority (0=none, 1=urgent, 2=high, 3=medium, 4=low) + * to SUSTN estimated effort. + */ +function inferEffort(priority: number): EstimatedEffort { + if (priority <= 1) return "high"; + if (priority <= 2) return "high"; + if (priority <= 3) return "medium"; + return "low"; +} + +export interface SyncResult { + imported: number; + skipped: number; + errors: string[]; +} + +/** + * Sync issues from Linear into SUSTN tasks for a given repository. + */ +export async function syncLinearIssues( + apiKey: string, + syncConfig: LinearSyncConfig, + repositoryId: string, + baseBranch?: string, +): Promise { + const result: SyncResult = { imported: 0, skipped: 0, errors: [] }; + + console.log( + "[linear-sync] starting sync — team:", + syncConfig.linearTeamName, + "repo:", + repositoryId, + "baseBranch:", + baseBranch, + ); + + // Fix any previously imported Linear tasks with mismatched base_branch + await fixOrphanedLinearTasks(repositoryId, baseBranch); + + // Load existing Linear issue IDs for dedup + const existingIds = await getLinearIssueIds(repositoryId); + const { minSortOrder } = await getDeduplicationContext(repositoryId); + + let cursor: string | undefined; + // Linear tasks go to the TOP of the list (lower sort order = higher priority) + let sortOrder = minSortOrder - 1; + + // Paginate through all matching issues + do { + const page = await fetchIssues(apiKey, syncConfig.linearTeamId, { + projectId: syncConfig.linearProjectId, + labelNames: syncConfig.filterLabels, + cursor, + limit: 50, + }); + + for (const issue of page.issues) { + if (existingIds.has(issue.id)) { + result.skipped++; + continue; + } + + try { + await createLinearTask( + repositoryId, + { + title: `${issue.identifier} ${issue.title}`, + description: issue.description, + category: inferCategory(issue.labels), + estimatedEffort: inferEffort(issue.priority), + linearIssueId: issue.id, + linearIdentifier: issue.identifier, + linearUrl: issue.url, + }, + sortOrder--, + baseBranch, + ); + existingIds.add(issue.id); + result.imported++; + } catch (err) { + result.errors.push( + `Failed to import ${issue.identifier}: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } + } + + cursor = page.hasMore ? page.endCursor : undefined; + } while (cursor); + + console.log( + "[linear-sync] sync complete — imported:", + result.imported, + "skipped:", + result.skipped, + "errors:", + result.errors.length, + ); + + return result; +} diff --git a/src/core/services/linear.ts b/src/core/services/linear.ts new file mode 100644 index 0000000..0358fbe --- /dev/null +++ b/src/core/services/linear.ts @@ -0,0 +1,218 @@ +import type { + LinearTeam, + LinearProject, + LinearIssue, +} from "@core/types/linear"; + +const LINEAR_API_URL = "https://api.linear.app/graphql"; + +async function linearQuery( + apiKey: string, + query: string, + variables?: Record, +): Promise { + const response = await fetch(LINEAR_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: apiKey, + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Linear API error (${response.status}): ${text}`); + } + + const json = (await response.json()) as { + data?: T; + errors?: { message: string }[]; + }; + + if (json.errors?.length) { + throw new Error( + `Linear GraphQL error: ${json.errors.map((e) => e.message).join(", ")}`, + ); + } + + if (!json.data) { + throw new Error("Linear API returned no data"); + } + + return json.data; +} + +/** + * Test the API key by fetching the authenticated user's name. + */ +export async function testConnection( + apiKey: string, +): Promise<{ success: boolean; userName?: string; error?: string }> { + try { + const data = await linearQuery<{ viewer: { name: string } }>( + apiKey, + `query { viewer { name } }`, + ); + return { success: true, userName: data.viewer.name }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }; + } +} + +/** + * Fetch all teams accessible to the authenticated user. + */ +export async function fetchTeams(apiKey: string): Promise { + const data = await linearQuery<{ + teams: { nodes: { id: string; name: string; key: string }[] }; + }>(apiKey, `query { teams { nodes { id name key } } }`); + + return data.teams.nodes; +} + +/** + * Fetch projects within a team. + */ +export async function fetchProjects( + apiKey: string, + teamId: string, +): Promise { + const data = await linearQuery<{ + team: { projects: { nodes: { id: string; name: string }[] } }; + }>( + apiKey, + `query ($teamId: String!) { + team(id: $teamId) { + projects { nodes { id name } } + } + }`, + { teamId }, + ); + + return data.team.projects.nodes; +} + +/** + * Fetch active issues from a team, optionally filtered by project and labels. + * Returns paginated results. + */ +export async function fetchIssues( + apiKey: string, + teamId: string, + options?: { + projectId?: string; + labelNames?: string[]; + cursor?: string; + limit?: number; + }, +): Promise<{ + issues: LinearIssue[]; + hasMore: boolean; + endCursor: string | undefined; +}> { + const limit = options?.limit ?? 50; + + const filterParts: string[] = [ + `team: { id: { eq: "${teamId}" } }`, + `state: { type: { nin: ["canceled", "completed"] } }`, + ]; + + if (options?.projectId) { + filterParts.push(`project: { id: { eq: "${options.projectId}" } }`); + } + + if (options?.labelNames?.length) { + const labelsStr = options.labelNames.map((l) => `"${l}"`).join(", "); + filterParts.push(`labels: { name: { in: [${labelsStr}] } }`); + } + + const filterStr = filterParts.join(", "); + const afterClause = options?.cursor ? `, after: "${options.cursor}"` : ""; + + const data = await linearQuery<{ + issues: { + nodes: { + id: string; + identifier: string; + title: string; + description: string | null; + url: string; + priority: number; + state: { name: string; type: string }; + labels: { nodes: { name: string }[] }; + assignee: { name: string } | null; + }[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + }; + }>( + apiKey, + `query { + issues( + filter: { ${filterStr} } + first: ${limit} + ${afterClause} + orderBy: updatedAt + ) { + nodes { + id + identifier + title + description + url + priority + state { name type } + labels { nodes { name } } + assignee { name } + } + pageInfo { + hasNextPage + endCursor + } + } + }`, + ); + + const issues: LinearIssue[] = data.issues.nodes.map((node) => ({ + id: node.id, + identifier: node.identifier, + title: node.title, + description: node.description ?? undefined, + url: node.url, + priority: node.priority, + state: node.state, + labels: node.labels.nodes, + assignee: node.assignee ?? undefined, + })); + + return { + issues, + hasMore: data.issues.pageInfo.hasNextPage, + endCursor: data.issues.pageInfo.endCursor ?? undefined, + }; +} + +/** + * Post a comment on a Linear issue (e.g., to link a PR). + */ +export async function addComment( + apiKey: string, + issueId: string, + body: string, +): Promise { + await linearQuery( + apiKey, + `mutation ($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + } + }`, + { issueId, body }, + ); +} diff --git a/src/core/types/linear.ts b/src/core/types/linear.ts new file mode 100644 index 0000000..494b36d --- /dev/null +++ b/src/core/types/linear.ts @@ -0,0 +1,38 @@ +export interface LinearTeam { + id: string; + name: string; + key: string; +} + +export interface LinearProject { + id: string; + name: string; +} + +export interface LinearIssue { + id: string; + identifier: string; + title: string; + description: string | undefined; + url: string; + priority: number; + state: { name: string; type: string }; + labels: { name: string }[]; + assignee: { name: string } | undefined; +} + +export type LinearSyncSchedule = "manual" | "on_start" | "6h" | "12h" | "daily"; + +export interface LinearSyncConfig { + id: string; + repositoryId: string; + linearTeamId: string; + linearTeamName: string; + linearProjectId: string | undefined; + linearProjectName: string | undefined; + autoSync: boolean; + syncSchedule: LinearSyncSchedule; + filterLabels: string[] | undefined; + lastSyncAt: string | undefined; + createdAt: string; +} diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts index 06b5645..427993e 100644 --- a/src/core/types/settings.ts +++ b/src/core/types/settings.ts @@ -30,6 +30,10 @@ export interface GlobalSettings { // Budget budgetCeilingPercent: number; showBudgetInSidebar: boolean; + + // Integrations + linearApiKey: string; + linearEnabled: boolean; } export interface ProjectOverrides { @@ -48,5 +52,6 @@ export type SettingsSection = | "git" | "scheduling" | "budget" + | "integrations" | "account" | `project-${string}`; diff --git a/src/core/types/task.ts b/src/core/types/task.ts index 59880a1..2ef6b08 100644 --- a/src/core/types/task.ts +++ b/src/core/types/task.ts @@ -17,7 +17,7 @@ export type TaskState = | "dismissed" | "failed"; -export type TaskSource = "manual" | "scan"; +export type TaskSource = "manual" | "scan" | "linear"; export type EstimatedEffort = "low" | "medium" | "high"; export interface Task { @@ -44,6 +44,9 @@ export interface Task { lastError: string | undefined; startedAt: string | undefined; completedAt: string | undefined; + linearIssueId: string | undefined; + linearIdentifier: string | undefined; + linearUrl: string | undefined; createdAt: string; updatedAt: string; } diff --git a/src/core/utils/branch.ts b/src/core/utils/branch.ts index 725286b..c46ddf0 100644 --- a/src/core/utils/branch.ts +++ b/src/core/utils/branch.ts @@ -4,11 +4,15 @@ import type { GlobalSettings, ProjectOverrides, } from "@core/types/settings"; +import type { Task } from "@core/types/task"; /** * Generate a git branch name from task data + user settings. * - * Examples: + * For Linear-sourced tasks, uses the Linear identifier style: + * "sustn/syn-460-improve-accounts-table-load-time" + * + * For other tasks: * slug style: "sustn/fix-auth-middleware-error" * short-hash style: "sustn/d23cd321" * task-id style: "sustn/task-d23cd321" @@ -18,6 +22,7 @@ export function generateBranchName( taskId: string, settings: GlobalSettings, overrides?: ProjectOverrides, + task?: Pick, ): string { const prefixMode: BranchPrefixMode = overrides?.overrideBranchPrefixMode ?? settings.branchPrefixMode; @@ -32,6 +37,17 @@ export function generateBranchName( ? `${prefixCustom || "my"}/` : ""; + // Linear-sourced tasks: use identifier-slug style (e.g., "syn-460-improve-accounts") + if (task?.source === "linear" && task.linearIdentifier) { + const identifier = task.linearIdentifier.toLowerCase(); + // Strip the identifier prefix from the title if present (e.g., "SYN-460 Fix bug" → "Fix bug") + const titleWithoutId = taskTitle + .replace(new RegExp(`^${task.linearIdentifier}\\s*`, "i"), "") + .trim(); + const slug = titleWithoutId ? `-${slugify(titleWithoutId)}` : ""; + return `${prefix}${identifier}${slug}`; + } + const shortId = taskId.slice(0, 8); let name: string; diff --git a/src/ui/components/layout/AppShell.tsx b/src/ui/components/layout/AppShell.tsx index b2c2d09..4bf60ea 100644 --- a/src/ui/components/layout/AppShell.tsx +++ b/src/ui/components/layout/AppShell.tsx @@ -9,6 +9,7 @@ import { } from "@core/api/useEngine"; import { useAuth } from "@core/api/useAuth"; import { useScheduler } from "@core/api/useScheduler"; +import { useLinearAutoSync } from "@core/api/useLinear"; import { startSessionTracking } from "@core/services/session-tracker"; import { initNotificationPermission } from "@core/services/notifications"; @@ -21,6 +22,7 @@ export function AppShell() { useStartupRecovery(); useStartupScan(); useScheduler(); + useLinearAutoSync(); useQueueProcessor(); useGlobalTaskNotifications(); diff --git a/src/ui/components/settings/SettingsContent.tsx b/src/ui/components/settings/SettingsContent.tsx index 0ce3b55..294c7af 100644 --- a/src/ui/components/settings/SettingsContent.tsx +++ b/src/ui/components/settings/SettingsContent.tsx @@ -3,6 +3,7 @@ import { GeneralSection } from "./sections/GeneralSection"; import { GitBranchesSection } from "./sections/GitBranchesSection"; import { SchedulingSection } from "./sections/SchedulingSection"; import { BudgetSection } from "./sections/BudgetSection"; +import { IntegrationsSection } from "./sections/IntegrationsSection"; import { AccountSection } from "./sections/AccountSection"; import { ProjectSection } from "./sections/ProjectSection"; @@ -22,6 +23,7 @@ export function SettingsContent({ {activeSection === "git" && } {activeSection === "scheduling" && } {activeSection === "budget" && } + {activeSection === "integrations" && } {activeSection === "account" && } {activeSection.startsWith("project-") && ( (undefined); + + if (!settings) return null; + + const displayKey = keyInput ?? settings.linearApiKey; + const hasKey = displayKey.length > 0; + const isDirty = + keyInput !== undefined && keyInput !== settings.linearApiKey; + + function handleSaveKey() { + if (keyInput === undefined) return; + updateSetting({ key: "linearApiKey", value: keyInput }); + setKeyInput(undefined); + } + + function handleTestConnection() { + const key = keyInput ?? settings?.linearApiKey; + if (!key) return; + testConnection.mutate(key); + } + + return ( +
+
+

+ Integrations +

+

+ Connect external tools to import tasks automatically. +

+
+ +
+ {/* Linear header */} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ Linear +

+
+

+ Import issues from Linear and let SUSTN work through + them automatically. +

+
+ + {/* Enable toggle */} +
+ + + updateSetting({ + key: "linearEnabled", + value: checked, + }) + } + /> + +
+ + {/* API Key */} +
+ +
+
+ + setKeyInput(e.target.value) + } + className="h-8 text-xs pr-8 font-mono" + /> + +
+ {isDirty && ( + + )} + +
+ + {/* Connection result */} + {testConnection.data && ( +
+ {testConnection.data.success ? ( +
+ + Connected as{" "} + {testConnection.data.userName} +
+ ) : ( +
+ + {testConnection.data.error} +
+ )} +
+ )} +
+
+ + {!hasKey && ( +
+

+ Add your API key to get started. You can then + configure Linear sync per project in each project's + settings. +

+
+ )} +
+
+ ); +} diff --git a/src/ui/components/settings/sections/ProjectSection.tsx b/src/ui/components/settings/sections/ProjectSection.tsx index a68b194..5ed7b68 100644 --- a/src/ui/components/settings/sections/ProjectSection.tsx +++ b/src/ui/components/settings/sections/ProjectSection.tsx @@ -8,6 +8,16 @@ import { useRemoveProject, } from "@core/api/useSettings"; import { useAgentConfig, useUpdateAgentConfig } from "@core/api/useEngine"; +import { + useLinearTeams, + useLinearProjects, + useLinearSyncConfigs, + useCreateLinearSyncConfig, + useDeleteLinearSyncConfig, + useUpdateSyncSchedule, + useSyncLinear, +} from "@core/api/useLinear"; +import type { LinearSyncSchedule } from "@core/types/linear"; import { Select, SelectContent, @@ -15,10 +25,11 @@ import { SelectTrigger, SelectValue, } from "@ui/components/ui/select"; +import { Button } from "@ui/components/ui/button"; import { Slider } from "@ui/components/ui/slider"; import type { BranchPrefixMode } from "@core/types/settings"; import type { ScheduleMode } from "@core/types/agent"; -import { Trash2, Clock, Zap, Hand } from "lucide-react"; +import { Trash2, Clock, Zap, Hand, RefreshCw, Loader2, X } from "lucide-react"; interface ProjectSectionProps { repositoryId: string; @@ -39,6 +50,20 @@ export function ProjectSection({ const { mutate: doRemoveProject, isPending: isRemoving } = useRemoveProject(); + // Linear sync + const { data: linearSyncConfigs } = useLinearSyncConfigs(repositoryId); + const { data: linearTeams } = useLinearTeams(); + const { mutate: createSyncConfig, isPending: isCreatingSync } = + useCreateLinearSyncConfig(); + const { mutate: deleteSyncConfig } = useDeleteLinearSyncConfig(); + const { mutate: updateSchedule } = useUpdateSyncSchedule(); + const { mutate: syncLinear, isPending: isSyncing } = useSyncLinear(); + const [selectedTeamId, setSelectedTeamId] = useState(""); + const [selectedProjectId, setSelectedProjectId] = useState(""); + const { data: linearProjects } = useLinearProjects( + selectedTeamId || undefined, + ); + const [showConfirmRemove, setShowConfirmRemove] = useState(false); // Debounced text areas @@ -528,6 +553,231 @@ export function ProjectSection({ + {/* ── Linear Sync ── */} + {globalSettings.linearEnabled && globalSettings.linearApiKey && ( +
+

+ Linear Sync +

+ + {/* Existing sync configs */} + {linearSyncConfigs && linearSyncConfigs.length > 0 && ( +
+ {linearSyncConfigs.map((sc) => ( +
+
+
+

+ {sc.linearTeamName} + {sc.linearProjectName + ? ` / ${sc.linearProjectName}` + : ""} +

+ {sc.lastSyncAt && ( +

+ Last synced{" "} + {new Date( + sc.lastSyncAt, + ).toLocaleString()} +

+ )} +
+
+ + +
+
+ {/* Schedule selector */} +
+ {( + [ + { + value: "manual", + label: "Manual", + }, + { + value: "on_start", + label: "On launch", + }, + { + value: "6h", + label: "Every 6h", + }, + { + value: "12h", + label: "Every 12h", + }, + { + value: "daily", + label: "Daily", + }, + ] as const + ).map((opt) => { + const isSelected = + sc.syncSchedule === opt.value; + return ( + + ); + })} +
+
+ ))} +
+ )} + + {/* Add new sync */} +
+

+ Add Linear team +

+

+ Import issues from a Linear team into this project. +

+
+ + + {selectedTeamId && + linearProjects && + linearProjects.length > 0 && ( + + )} + + +
+
+
+ )} + {/* ── Instructions ── */}
+ {/* Linear badge */} + {task.source === "linear" && task.linearIdentifier && ( + + + + + + +

Open in Linear

+
+
+
+ )} + {/* Branch chip */} {task.branchName && ( diff --git a/src/ui/components/tasks/TaskDetailView.tsx b/src/ui/components/tasks/TaskDetailView.tsx index 470ceac..4ca4e9b 100644 --- a/src/ui/components/tasks/TaskDetailView.tsx +++ b/src/ui/components/tasks/TaskDetailView.tsx @@ -489,6 +489,7 @@ export function TaskDetailView({ taskId }: TaskDetailViewProps) { task.id, globalSettings, projectOverrides, + task, ); // Collect all user messages as context for the agent @@ -568,6 +569,26 @@ export function TaskDetailView({ taskId }: TaskDetailViewProps) { role: "system", content: `Branch pushed and PR created: ${pr.url}`, }); + // Link PR to Linear issue if applicable + if ( + task.linearIssueId && + globalSettings?.linearApiKey + ) { + void import("@core/services/linear") + .then((m) => + m.addComment( + globalSettings.linearApiKey, + task.linearIssueId!, + `PR created by [SUSTN](https://sustn.app): ${pr.url}`, + ), + ) + .catch((err) => + console.error( + "[TaskDetailView] Linear link-back failed:", + err, + ), + ); + } handleUpdateState("done"); }, onError: (err) => { diff --git a/src/ui/components/tasks/TaskRow.tsx b/src/ui/components/tasks/TaskRow.tsx index 862c58e..0ea22a8 100644 --- a/src/ui/components/tasks/TaskRow.tsx +++ b/src/ui/components/tasks/TaskRow.tsx @@ -271,6 +271,27 @@ export function TaskRow({ )} + {task.source === "linear" && + task.linearIdentifier && ( + { + e.stopPropagation(); + if (task.linearUrl) { + void import("@tauri-apps/plugin-opener").then( + (m) => + m.openUrl( + task.linearUrl!, + ), + ); + } + }} + > + {task.linearIdentifier} + + )} + {task.category !== "general" && categoryLabels[task.category] && (