From 92ed33a26528c09c8c106f42eee84c1a324b60fc Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Sun, 8 Mar 2026 14:34:41 +0000 Subject: [PATCH] feat: add --linear-sync flag for real-time Linear status updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Syncs loop execution status to a Linear issue during the run: - Loop start → moves issue to "In Progress" - Loop success → moves to "Done" + adds summary comment - Loop failure → moves to "In Review" + adds error comment Non-blocking: gracefully skips if no API key or issue not found. Exposed as SDK export (createLinearSync) for programmatic use. Closes ENG-1472 Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 4 ++ src/commands/run.ts | 3 + src/index.ts | 2 + src/loop/__tests__/linear-sync.test.ts | 95 ++++++++++++++++++++++++++ src/loop/executor.ts | 42 ++++++++++++ src/loop/linear-sync.ts | 92 +++++++++++++++++++++++++ 6 files changed, 238 insertions(+) create mode 100644 src/loop/__tests__/linear-sync.test.ts create mode 100644 src/loop/linear-sync.ts diff --git a/src/cli.ts b/src/cli.ts index 102dfcef..ee78292d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -108,6 +108,10 @@ program .option('--figma-target ', 'Target directory for content mode') .option('--figma-preview', 'Show content changes without applying (content mode)') .option('--figma-mapping ', 'Custom content mapping file (content mode)') + .option( + '--linear-sync ', + 'Sync loop status to a Linear issue (e.g., ENG-42). Moves to In Progress/Done/In Review.' + ) .option( '--design-image ', 'Design reference image (screenshot of the target design for pixel-perfect matching)' diff --git a/src/commands/run.ts b/src/commands/run.ts index cc21e74c..b1684486 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -297,6 +297,8 @@ export interface RunCommandOptions { designImage?: string; // Visual comparison visualCheck?: boolean; + // Linear status sync + linearSync?: string; } export async function runCommand( @@ -1292,6 +1294,7 @@ Focus on one task at a time. After completing a task, update IMPLEMENTATION_PLAN designImagePath, visualValidation, figmaScreenshotPaths, + linearSync: options.linearSync, }; const result = await runLoop(loopOptions); diff --git a/src/index.ts b/src/index.ts index 8f94a05f..099bf0b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,8 @@ export type { export { CostTracker, resolveModelPricing } from './loop/cost-tracker.js'; export type { IterationUpdate, LoopOptions, LoopResult } from './loop/executor.js'; export { runLoop } from './loop/executor.js'; +export type { LinearSyncConfig, LinearSyncEvent } from './loop/linear-sync.js'; +export { createLinearSync } from './loop/linear-sync.js'; export { detectValidationCommands, runAllValidations, runValidation } from './loop/validation.js'; export type { InitCoreOptions, InitCoreResult } from './mcp/core/init.js'; export { initCore } from './mcp/core/init.js'; diff --git a/src/loop/__tests__/linear-sync.test.ts b/src/loop/__tests__/linear-sync.test.ts new file mode 100644 index 00000000..f17868e8 --- /dev/null +++ b/src/loop/__tests__/linear-sync.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createLinearSync } from '../linear-sync.js'; + +const updateTask = vi.fn().mockResolvedValue({ + id: 'uuid-123', + identifier: 'ENG-42', + title: 'Test issue', + url: 'https://linear.app/team/ENG-42', + status: 'In Progress', + source: 'linear', +}); +const addComment = vi.fn().mockResolvedValue(undefined); + +// Mock the LinearIntegration class +vi.mock('../../integrations/linear/source.js', () => ({ + LinearIntegration: class MockLinearIntegration { + updateTask = updateTask; + addComment = addComment; + }, +})); + +describe('createLinearSync', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset default resolved value + updateTask.mockResolvedValue({ + id: 'uuid-123', + identifier: 'ENG-42', + title: 'Test issue', + url: 'https://linear.app/team/ENG-42', + status: 'In Progress', + source: 'linear', + }); + }); + + it('should move issue to In Progress on creation', async () => { + const handler = await createLinearSync({ issueId: 'ENG-42', headless: true }); + + expect(handler).not.toBeNull(); + expect(updateTask).toHaveBeenCalledWith('ENG-42', { status: 'In Progress' }); + }); + + it('should return null if updateTask fails (no auth)', async () => { + updateTask.mockRejectedValueOnce(new Error('No API key')); + + const handler = await createLinearSync({ issueId: 'ENG-42', headless: true }); + expect(handler).toBeNull(); + }); + + it('should move issue to Done on complete event', async () => { + const handler = await createLinearSync({ issueId: 'ENG-42', headless: true }); + + await handler!({ + type: 'complete', + summary: 'Implemented feature X', + commits: 3, + iterations: 5, + cost: '$0.42', + }); + + expect(updateTask).toHaveBeenCalledWith('ENG-42', { status: 'Done' }); + expect(addComment).toHaveBeenCalledWith( + 'ENG-42', + expect.stringContaining('Loop completed successfully') + ); + expect(addComment).toHaveBeenCalledWith('ENG-42', expect.stringContaining('Commits: 3')); + expect(addComment).toHaveBeenCalledWith('ENG-42', expect.stringContaining('$0.42')); + }); + + it('should move issue to In Review on failed event', async () => { + const handler = await createLinearSync({ issueId: 'ENG-42', headless: true }); + + await handler!({ + type: 'failed', + error: 'circuit_breaker', + iterations: 3, + }); + + expect(updateTask).toHaveBeenCalledWith('ENG-42', { status: 'In Review' }); + expect(addComment).toHaveBeenCalledWith('ENG-42', expect.stringContaining('Loop stopped')); + expect(addComment).toHaveBeenCalledWith('ENG-42', expect.stringContaining('circuit_breaker')); + }); + + it('should not throw on event handler errors', async () => { + const handler = await createLinearSync({ issueId: 'ENG-42', headless: true }); + + // Make the next updateTask call fail + updateTask.mockRejectedValueOnce(new Error('Network error')); + + // Should not throw + await expect( + handler!({ type: 'complete', summary: 'done', commits: 1, iterations: 1 }) + ).resolves.not.toThrow(); + }); +}); diff --git a/src/loop/executor.ts b/src/loop/executor.ts index ddd3c4b1..31411a5c 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -35,6 +35,7 @@ import { type PlanBudget, } from './cost-tracker.js'; import { estimateLoop, formatEstimateDetailed } from './estimator.js'; +import { createLinearSync } from './linear-sync.js'; import { checkFileBasedCompletion, createProgressTracker, type ProgressEntry } from './progress.js'; import { RateLimiter } from './rate-limiter.js'; import { analyzeResponse, hasExitSignal } from './semantic-analyzer.js'; @@ -266,6 +267,8 @@ export type LoopOptions = { headless?: boolean; onIterationComplete?: (update: IterationUpdate) => void; env?: Record; + /** Linear issue ID to sync status to (e.g., "ENG-42"). Requires LINEAR_API_KEY. */ + linearSync?: string; }; export type LoopResult = { @@ -533,6 +536,11 @@ export async function runLoop(options: LoopOptions): Promise { }) : null; + // Initialize Linear status sync (non-blocking — skipped if no API key or issue not found) + const linearSyncHandler = options.linearSync + ? await createLinearSync({ issueId: options.linearSync, headless }) + : null; + // Detect validation commands if validation is enabled // In batch-auto mode, skip test commands — only run build/lint to avoid loops on pre-existing test failures // Note: fixCommand also sets auto=true but always sets fixMode, so we use that to distinguish @@ -1136,6 +1144,17 @@ export async function runLoop(options: LoopOptions): Promise { finalIteration = i; exitReason = 'blocked'; + + // Sync blocked status to Linear + if (linearSyncHandler) { + const reason = isRateLimit + ? 'Rate limit reached' + : isPermission + ? 'Permission denied' + : 'Task blocked'; + await linearSyncHandler({ type: 'failed', error: reason, iterations: i }); + } + return { success: false, iterations: i, @@ -1665,6 +1684,29 @@ export async function runLoop(options: LoopOptions): Promise { log(chalk.dim(costTracker.formatStats())); } + // Sync final status to Linear + if (linearSyncHandler) { + const isSuccess = exitReason === 'completed' || exitReason === 'file_signal'; + const costLabel = costTracker + ? formatCost(costTracker.getStats().totalCost.totalCost) + : undefined; + if (isSuccess) { + await linearSyncHandler({ + type: 'complete', + summary: lastAgentOutput?.slice(-200) || '', + commits: commits.length, + iterations: finalIteration, + cost: costLabel, + }); + } else { + await linearSyncHandler({ + type: 'failed', + error: exitReason || 'unknown', + iterations: finalIteration, + }); + } + } + return { success: exitReason === 'completed' || exitReason === 'file_signal', iterations: finalIteration, diff --git a/src/loop/linear-sync.ts b/src/loop/linear-sync.ts new file mode 100644 index 00000000..624fba88 --- /dev/null +++ b/src/loop/linear-sync.ts @@ -0,0 +1,92 @@ +/** + * Linear Status Sync + * + * Syncs loop execution status to a Linear issue in real-time. + * Updates issue state at key transitions: start → In Progress, complete → Done, failed → In Review. + */ + +import chalk from 'chalk'; +import { LinearIntegration } from '../integrations/linear/source.js'; + +export type LinearSyncConfig = { + /** Linear issue identifier (e.g., "ENG-42") or UUID */ + issueId: string; + /** Suppress console output */ + headless?: boolean; +}; + +export type LinearSyncEvent = + | { type: 'start' } + | { type: 'iteration'; iteration: number; totalIterations: number; success: boolean } + | { type: 'complete'; summary: string; commits: number; iterations: number; cost?: string } + | { type: 'failed'; error: string; iterations: number }; + +/** + * Creates a Linear sync handler that updates issue status at key loop transitions. + * + * Returns null if auth is missing or the issue can't be found (non-blocking). + */ +export async function createLinearSync( + config: LinearSyncConfig +): Promise<((event: LinearSyncEvent) => Promise) | null> { + const linear = new LinearIntegration(); + const log = config.headless ? (..._args: unknown[]) => {} : console.log.bind(console); + + // Verify auth + issue exist by moving to "In Progress" (non-blocking on failure) + try { + await linear.updateTask(config.issueId, { status: 'In Progress' }); + log(chalk.dim(` Linear sync: ${config.issueId} → In Progress`)); + } catch (err) { + log( + chalk.yellow(` Linear sync: could not update ${config.issueId} — ${(err as Error).message}`) + ); + return null; + } + + return async (event: LinearSyncEvent) => { + try { + switch (event.type) { + case 'start': + // Already moved to "In Progress" during init + break; + + case 'iteration': + // No status change per iteration + break; + + case 'complete': { + const lines = ['**Loop completed successfully**', '']; + lines.push(`- Iterations: ${event.iterations}`); + if (event.commits > 0) lines.push(`- Commits: ${event.commits}`); + if (event.cost) lines.push(`- Cost: ${event.cost}`); + if (event.summary) { + lines.push('', `**Summary:** ${event.summary.slice(0, 500)}`); + } + + await linear.updateTask(config.issueId, { status: 'Done' }); + await linear.addComment(config.issueId, lines.join('\n')); + log(chalk.dim(` Linear sync: ${config.issueId} → Done`)); + break; + } + + case 'failed': { + const lines = ['**Loop stopped**', '']; + lines.push(`- Iterations: ${event.iterations}`); + if (event.error) { + lines.push(`- Reason: ${event.error.slice(0, 300)}`); + } + + await linear.updateTask(config.issueId, { status: 'In Review' }); + await linear.addComment(config.issueId, lines.join('\n')); + log(chalk.dim(` Linear sync: ${config.issueId} → In Review`)); + break; + } + } + } catch (err) { + // Non-blocking — log and continue + if (process.env.RALPH_DEBUG) { + console.error(`[DEBUG] Linear sync error: ${(err as Error).message}`); + } + } + }; +}