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
234 changes: 234 additions & 0 deletions backend/src/__tests__/linear-auto-create-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it } from "bun:test";
import type { CreateLifecycleWorktreeInput } from "../services/lifecycle-service";
import type { FetchIssuesResult, LinearIssue } from "../services/linear-service";
import {
filterAutoCreateIssues,
LINEAR_AUTO_CREATE_POLL_INTERVAL_MS,
resetProcessedIssues,
runLinearAutoCreateOnce,
startLinearAutoCreateMonitor,
type LinearAutoCreateDependencies,
} from "../services/linear-auto-create-service";

function createIssue(overrides: Partial<LinearIssue> = {}): LinearIssue {
const issue: LinearIssue = {
id: "issue-1",
identifier: "ENG-123",
title: "Auto create this",
description: "Details",
priority: 0,
priorityLabel: "No priority",
url: "https://linear.app/acme/issue/ENG-123",
branchName: "eng-123-auto-create",
dueDate: null,
updatedAt: "2026-05-13T10:00:00.000Z",
state: {
name: "Todo",
color: "#999999",
type: "unstarted",
},
team: {
name: "Engineering",
key: "ENG",
},
labels: [
{
name: "webmux",
color: "#2563eb",
},
],
project: null,
};

return {
...issue,
...overrides,
};
}

interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
}

function createDeferred<T>(): Deferred<T> {
let resolveDeferred: ((value: T) => void) | null = null;
const promise = new Promise<T>((resolve) => {
resolveDeferred = resolve;
});

return {
promise,
resolve(value) {
if (!resolveDeferred) throw new Error("deferred resolver not initialized");
resolveDeferred(value);
},
};
}

function createDeps(input: {
issues?: LinearIssue[];
existingBranches?: string[];
fetchResult?: FetchIssuesResult;
onFetch?: (options: { skipCache?: boolean } | undefined) => void;
} = {}): {
deps: LinearAutoCreateDependencies;
created: CreateLifecycleWorktreeInput[];
fetchOptions: Array<{ skipCache?: boolean } | undefined>;
} {
const created: CreateLifecycleWorktreeInput[] = [];
const fetchOptions: Array<{ skipCache?: boolean } | undefined> = [];
const issues = input.issues ?? [];
const existingBranches = input.existingBranches ?? [];

return {
created,
fetchOptions,
deps: {
lifecycleService: {
async createWorktree(worktreeInput): Promise<{ branch: string; worktreeId: string }> {
created.push(worktreeInput);
return {
branch: worktreeInput.branch ?? "generated-branch",
worktreeId: `wt-${created.length}`,
};
},
},
git: {
listWorktrees: () =>
existingBranches.map((branch) => ({
path: `/repo/__worktrees/${branch}`,
branch,
head: "abc123",
detached: false,
bare: false,
})),
},
projectRoot: "/repo",
fetchIssues: async (options) => {
fetchOptions.push(options);
input.onFetch?.(options);
return input.fetchResult ?? {
ok: true,
data: issues,
};
},
},
};
}

describe("filterAutoCreateIssues", () => {
beforeEach(() => {
resetProcessedIssues();
});

it("keeps Todo issues with the webmux label that do not already have a worktree", () => {
const issue = createIssue();
const inProgress = createIssue({
id: "issue-2",
identifier: "ENG-124",
branchName: "eng-124-started",
state: {
name: "In Progress",
color: "#f59e0b",
type: "started",
},
});
const missingLabel = createIssue({
id: "issue-3",
identifier: "ENG-125",
branchName: "eng-125-no-label",
labels: [],
});
const existing = createIssue({
id: "issue-4",
identifier: "ENG-126",
branchName: "eng-126-existing",
});

expect(filterAutoCreateIssues([issue, inProgress, missingLabel, existing], ["eng-126-existing"])).toEqual([issue]);
});
});

describe("runLinearAutoCreateOnce", () => {
beforeEach(() => {
resetProcessedIssues();
});

it("creates worktrees without requiring dashboard activity", async () => {
const issue = createIssue();
const { deps, created, fetchOptions } = createDeps({ issues: [issue] });

await runLinearAutoCreateOnce(deps);

expect(fetchOptions).toEqual([{ skipCache: true }]);
expect(created).toEqual([
{
mode: "new",
branch: issue.branchName,
prompt: `${issue.title}\n\n${issue.description}`,
},
]);
});

it("does not create duplicate worktrees for processed issues", async () => {
const issue = createIssue();
const { deps, created, fetchOptions } = createDeps({ issues: [issue] });

await runLinearAutoCreateOnce(deps);
await runLinearAutoCreateOnce(deps);

expect(fetchOptions).toEqual([{ skipCache: true }, { skipCache: true }]);
expect(created).toEqual([
{
mode: "new",
branch: issue.branchName,
prompt: `${issue.title}\n\n${issue.description}`,
},
]);
});

it("does not create worktrees when the Linear fetch fails", async () => {
const { deps, created, fetchOptions } = createDeps({
fetchResult: {
ok: false,
error: "Linear API 401: Unauthorized",
},
});

await runLinearAutoCreateOnce(deps);

expect(fetchOptions).toEqual([{ skipCache: true }]);
expect(created).toEqual([]);
});
});

describe("startLinearAutoCreateMonitor", () => {
beforeEach(() => {
resetProcessedIssues();
});

it("uses a 60 second poll interval", async () => {
let scheduledInterval = -1;
const fetchStarted = createDeferred<void>();
const { deps } = createDeps({
onFetch: () => fetchStarted.resolve(undefined),
});

const stop = startLinearAutoCreateMonitor(deps, {
intervalDeps: {
scheduleEvery: (_handler, intervalMs) => {
scheduledInterval = intervalMs;
return 1;
},
cancelSchedule: () => {},
},
});

await fetchStarted.promise;
stop();

expect(scheduledInterval).toBe(LINEAR_AUTO_CREATE_POLL_INTERVAL_MS);
expect(scheduledInterval).toBe(60_000);
});
});
1 change: 0 additions & 1 deletion backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ function startLinearAutoCreate(): void {
lifecycleService,
git,
projectRoot: PROJECT_DIR,
isActive: hasRecentDashboardActivity,
});
}

Expand Down
43 changes: 26 additions & 17 deletions backend/src/services/linear-auto-create-service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import { startSerializedInterval } from "../lib/async";
import { startSerializedInterval, type SerializedIntervalDependencies } from "../lib/async";
import { log } from "../lib/log";
import { branchMatchesIssue, fetchAssignedIssues, type LinearIssue } from "./linear-service";
import type { LifecycleService } from "./lifecycle-service";
import type { CreateLifecycleWorktreeInput } from "./lifecycle-service";
import type { GitGateway } from "../adapters/git";

const POLL_INTERVAL_MS = 15_000;
export const LINEAR_AUTO_CREATE_POLL_INTERVAL_MS = 60_000;

export interface LinearAutoCreateLifecycleService {
createWorktree(input: CreateLifecycleWorktreeInput): Promise<{
branch: string;
worktreeId: string;
}>;
}

export interface LinearAutoCreateDependencies {
lifecycleService: LifecycleService;
git: GitGateway;
lifecycleService: LinearAutoCreateLifecycleService;
git: Pick<GitGateway, "listWorktrees">;
projectRoot: string;
isActive: () => boolean;
fetchIssues?: typeof fetchAssignedIssues;
}

export interface LinearAutoCreateMonitorOptions {
intervalDeps?: SerializedIntervalDependencies<unknown>;
}

/** Issue IDs for which worktrees have been successfully created.
Expand All @@ -32,13 +43,9 @@ export function filterAutoCreateIssues(
});
}

async function runAutoCreate(deps: LinearAutoCreateDependencies): Promise<void> {
if (!deps.isActive()) {
log.debug("[linear-auto-create] skipping: no active clients");
return;
}

const result = await fetchAssignedIssues({ skipCache: true });
export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies): Promise<void> {
const fetchIssues = deps.fetchIssues ?? fetchAssignedIssues;
const result = await fetchIssues({ skipCache: true });
if (!result.ok) {
log.error(`[linear-auto-create] failed to fetch issues: ${result.error}`);
return;
Expand Down Expand Up @@ -79,11 +86,13 @@ async function runAutoCreate(deps: LinearAutoCreateDependencies): Promise<void>
* Returns a cleanup function that stops the monitor. */
export function startLinearAutoCreateMonitor(
deps: LinearAutoCreateDependencies,
options: LinearAutoCreateMonitorOptions = {},
): () => void {
log.info("[linear-auto-create] monitor started");
return startSerializedInterval(
() => runAutoCreate(deps),
POLL_INTERVAL_MS,
log.info(`[linear-auto-create] monitor started (interval: ${LINEAR_AUTO_CREATE_POLL_INTERVAL_MS}ms)`);
return startSerializedInterval<unknown>(
() => runLinearAutoCreateOnce(deps),
LINEAR_AUTO_CREATE_POLL_INTERVAL_MS,
options.intervalDeps,
);
}

Expand Down
Loading