Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ program
.option('--figma-target <path>', 'Target directory for content mode')
.option('--figma-preview', 'Show content changes without applying (content mode)')
.option('--figma-mapping <file>', 'Custom content mapping file (content mode)')
.option(
'--linear-sync <issue>',
'Sync loop status to a Linear issue (e.g., ENG-42). Moves to In Progress/Done/In Review.'
)
.option(
'--model-selector',
'Interactive model picker with live OpenRouter pricing and context info'
Expand Down
3 changes: 3 additions & 0 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ export interface RunCommandOptions {
designImage?: string;
// Visual comparison
visualCheck?: boolean;
// Linear status sync
linearSync?: string;
// Swarm mode
swarm?: boolean;
strategy?: 'race' | 'consensus' | 'pipeline';
Expand Down Expand Up @@ -1491,6 +1493,7 @@ Focus on one task at a time. After completing a task, update IMPLEMENTATION_PLAN
designImagePath,
visualValidation,
figmaScreenshotPaths,
linearSync: options.linearSync,
ampMode: options.ampMode,
review: options.review,
headless,
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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 { appendProjectMemory, readProjectMemory } from './loop/memory.js';
export type { ReviewFinding, ReviewResult, ReviewSeverity } from './loop/reviewer.js';
export { runReview } from './loop/reviewer.js';
Expand Down
95 changes: 95 additions & 0 deletions src/loop/__tests__/linear-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
62 changes: 53 additions & 9 deletions src/loop/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
type PlanBudget,
} from './cost-tracker.js';
import { estimateLoop, formatEstimateDetailed } from './estimator.js';
import { createLinearSync } from './linear-sync.js';
import { appendProjectMemory, formatMemoryPrompt, readProjectMemory } from './memory.js';
import { checkFileBasedCompletion, createProgressTracker, type ProgressEntry } from './progress.js';
import { RateLimiter } from './rate-limiter.js';
Expand Down Expand Up @@ -269,6 +270,8 @@
headless?: boolean;
onIterationComplete?: (update: IterationUpdate) => void;
env?: Record<string, string>;
/** Linear issue ID to sync status to (e.g., "ENG-42"). Requires LINEAR_API_KEY. */
linearSync?: string;
/** Amp agent mode: smart, rush, deep */
ampMode?: import('./agents.js').AmpMode;
/** Run LLM-powered diff review after validation passes (before commit) */
Expand Down Expand Up @@ -556,6 +559,11 @@
})
: 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
Expand Down Expand Up @@ -1169,6 +1177,17 @@

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,
Expand Down Expand Up @@ -1774,17 +1793,42 @@
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',
Comment thread Fixed

Check warning

Code scanning / CodeQL

Useless conditional Warning

This use of variable 'exitReason' always evaluates to true.

Copilot Autofix

AI 16 days ago

General fix: when a variable is known (or intended) to be always defined/truthy in a given context, avoid using a || fallback that will never be taken; instead, either (a) remove the fallback, or (b) explicitly normalize potentially falsy values before use. This removes useless conditionals and clarifies the intended invariants.

Best fix here: in the Linear sync “failed” payload, replace error: exitReason || 'unknown' with error: String(exitReason || 'unknown'). This preserves the intention of always sending a non-empty error string to Linear, while making the conditional meaningful again. If exitReason is truthy (as CodeQL suggests in most paths), behavior is unchanged apart from explicit string coercion. If there is a rare path where exitReason is falsy, we still get 'unknown' as intended. The important part for the CodeQL warning is that the expression now has a clearly justified fallback and is not merely a misleading conditional that assumes exitReason is always truthy; and by using String(...) we make the payload type explicit.

Concretely, in src/loop/executor.ts, around lines 1810–1815, update the error field assignment in the linearSyncHandler “failed” case. No new imports or additional helpers are needed.

Suggested changeset 1
src/loop/executor.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/loop/executor.ts b/src/loop/executor.ts
--- a/src/loop/executor.ts
+++ b/src/loop/executor.ts
@@ -1810,7 +1810,7 @@
     } else {
       await linearSyncHandler({
         type: 'failed',
-        error: exitReason || 'unknown',
+        error: String(exitReason || 'unknown'),
         iterations: finalIteration,
       });
     }
EOF
@@ -1810,7 +1810,7 @@
} else {
await linearSyncHandler({
type: 'failed',
error: exitReason || 'unknown',
error: String(exitReason || 'unknown'),
iterations: finalIteration,
});
}
Copilot is powered by AI and may make mistakes. Always verify output.
iterations: finalIteration,
});
}
}

// Save a run summary to project memory for future runs
const isSuccess = exitReason === 'completed' || exitReason === 'file_signal';
const memorySummary = [
`Task: ${options.taskTitle || options.task.slice(0, 100)}`,
`Result: ${isSuccess ? 'success' : exitReason}`,
`Iterations: ${finalIteration}, Commits: ${commits.length}`,
];
if (costTracker) {
memorySummary.push(`Cost: ${formatCost(costTracker.getStats().totalCost.totalCost)}`);
{
const isSuccess = exitReason === 'completed' || exitReason === 'file_signal';
const memorySummary = [
`Task: ${options.taskTitle || options.task.slice(0, 100)}`,
`Result: ${isSuccess ? 'success' : exitReason}`,
`Iterations: ${finalIteration}, Commits: ${commits.length}`,
];
if (costTracker) {
memorySummary.push(`Cost: ${formatCost(costTracker.getStats().totalCost.totalCost)}`);
}
appendProjectMemory(options.cwd, memorySummary.join('\n'), dotDir);
}
appendProjectMemory(options.cwd, memorySummary.join('\n'), dotDir);

return {
success: exitReason === 'completed' || exitReason === 'file_signal',
Expand Down
92 changes: 92 additions & 0 deletions src/loop/linear-sync.ts
Original file line number Diff line number Diff line change
@@ -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 };
Comment thread
rubenmarcus marked this conversation as resolved.

/**
* 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<void>) | null> {
const linear = new LinearIntegration();
Comment thread
rubenmarcus marked this conversation as resolved.
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'));
Comment thread
rubenmarcus marked this conversation as resolved.
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}`);
}
}
};
}
Loading