Skip to content

Commit 14fe259

Browse files
centdixclaude
andauthored
fix: decouple linear auto-create from dashboard (#235)
* fix: decouple linear auto-create from dashboard Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address linear auto-create review Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 63130ac commit 14fe259

3 files changed

Lines changed: 260 additions & 18 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { beforeEach, describe, expect, it } from "bun:test";
2+
import type { CreateLifecycleWorktreeInput } from "../services/lifecycle-service";
3+
import type { FetchIssuesResult, LinearIssue } from "../services/linear-service";
4+
import {
5+
filterAutoCreateIssues,
6+
LINEAR_AUTO_CREATE_POLL_INTERVAL_MS,
7+
resetProcessedIssues,
8+
runLinearAutoCreateOnce,
9+
startLinearAutoCreateMonitor,
10+
type LinearAutoCreateDependencies,
11+
} from "../services/linear-auto-create-service";
12+
13+
function createIssue(overrides: Partial<LinearIssue> = {}): LinearIssue {
14+
const issue: LinearIssue = {
15+
id: "issue-1",
16+
identifier: "ENG-123",
17+
title: "Auto create this",
18+
description: "Details",
19+
priority: 0,
20+
priorityLabel: "No priority",
21+
url: "https://linear.app/acme/issue/ENG-123",
22+
branchName: "eng-123-auto-create",
23+
dueDate: null,
24+
updatedAt: "2026-05-13T10:00:00.000Z",
25+
state: {
26+
name: "Todo",
27+
color: "#999999",
28+
type: "unstarted",
29+
},
30+
team: {
31+
name: "Engineering",
32+
key: "ENG",
33+
},
34+
labels: [
35+
{
36+
name: "webmux",
37+
color: "#2563eb",
38+
},
39+
],
40+
project: null,
41+
};
42+
43+
return {
44+
...issue,
45+
...overrides,
46+
};
47+
}
48+
49+
interface Deferred<T> {
50+
promise: Promise<T>;
51+
resolve(value: T): void;
52+
}
53+
54+
function createDeferred<T>(): Deferred<T> {
55+
let resolveDeferred: ((value: T) => void) | null = null;
56+
const promise = new Promise<T>((resolve) => {
57+
resolveDeferred = resolve;
58+
});
59+
60+
return {
61+
promise,
62+
resolve(value) {
63+
if (!resolveDeferred) throw new Error("deferred resolver not initialized");
64+
resolveDeferred(value);
65+
},
66+
};
67+
}
68+
69+
function createDeps(input: {
70+
issues?: LinearIssue[];
71+
existingBranches?: string[];
72+
fetchResult?: FetchIssuesResult;
73+
onFetch?: (options: { skipCache?: boolean } | undefined) => void;
74+
} = {}): {
75+
deps: LinearAutoCreateDependencies;
76+
created: CreateLifecycleWorktreeInput[];
77+
fetchOptions: Array<{ skipCache?: boolean } | undefined>;
78+
} {
79+
const created: CreateLifecycleWorktreeInput[] = [];
80+
const fetchOptions: Array<{ skipCache?: boolean } | undefined> = [];
81+
const issues = input.issues ?? [];
82+
const existingBranches = input.existingBranches ?? [];
83+
84+
return {
85+
created,
86+
fetchOptions,
87+
deps: {
88+
lifecycleService: {
89+
async createWorktree(worktreeInput): Promise<{ branch: string; worktreeId: string }> {
90+
created.push(worktreeInput);
91+
return {
92+
branch: worktreeInput.branch ?? "generated-branch",
93+
worktreeId: `wt-${created.length}`,
94+
};
95+
},
96+
},
97+
git: {
98+
listWorktrees: () =>
99+
existingBranches.map((branch) => ({
100+
path: `/repo/__worktrees/${branch}`,
101+
branch,
102+
head: "abc123",
103+
detached: false,
104+
bare: false,
105+
})),
106+
},
107+
projectRoot: "/repo",
108+
fetchIssues: async (options) => {
109+
fetchOptions.push(options);
110+
input.onFetch?.(options);
111+
return input.fetchResult ?? {
112+
ok: true,
113+
data: issues,
114+
};
115+
},
116+
},
117+
};
118+
}
119+
120+
describe("filterAutoCreateIssues", () => {
121+
beforeEach(() => {
122+
resetProcessedIssues();
123+
});
124+
125+
it("keeps Todo issues with the webmux label that do not already have a worktree", () => {
126+
const issue = createIssue();
127+
const inProgress = createIssue({
128+
id: "issue-2",
129+
identifier: "ENG-124",
130+
branchName: "eng-124-started",
131+
state: {
132+
name: "In Progress",
133+
color: "#f59e0b",
134+
type: "started",
135+
},
136+
});
137+
const missingLabel = createIssue({
138+
id: "issue-3",
139+
identifier: "ENG-125",
140+
branchName: "eng-125-no-label",
141+
labels: [],
142+
});
143+
const existing = createIssue({
144+
id: "issue-4",
145+
identifier: "ENG-126",
146+
branchName: "eng-126-existing",
147+
});
148+
149+
expect(filterAutoCreateIssues([issue, inProgress, missingLabel, existing], ["eng-126-existing"])).toEqual([issue]);
150+
});
151+
});
152+
153+
describe("runLinearAutoCreateOnce", () => {
154+
beforeEach(() => {
155+
resetProcessedIssues();
156+
});
157+
158+
it("creates worktrees without requiring dashboard activity", async () => {
159+
const issue = createIssue();
160+
const { deps, created, fetchOptions } = createDeps({ issues: [issue] });
161+
162+
await runLinearAutoCreateOnce(deps);
163+
164+
expect(fetchOptions).toEqual([{ skipCache: true }]);
165+
expect(created).toEqual([
166+
{
167+
mode: "new",
168+
branch: issue.branchName,
169+
prompt: `${issue.title}\n\n${issue.description}`,
170+
},
171+
]);
172+
});
173+
174+
it("does not create duplicate worktrees for processed issues", async () => {
175+
const issue = createIssue();
176+
const { deps, created, fetchOptions } = createDeps({ issues: [issue] });
177+
178+
await runLinearAutoCreateOnce(deps);
179+
await runLinearAutoCreateOnce(deps);
180+
181+
expect(fetchOptions).toEqual([{ skipCache: true }, { skipCache: true }]);
182+
expect(created).toEqual([
183+
{
184+
mode: "new",
185+
branch: issue.branchName,
186+
prompt: `${issue.title}\n\n${issue.description}`,
187+
},
188+
]);
189+
});
190+
191+
it("does not create worktrees when the Linear fetch fails", async () => {
192+
const { deps, created, fetchOptions } = createDeps({
193+
fetchResult: {
194+
ok: false,
195+
error: "Linear API 401: Unauthorized",
196+
},
197+
});
198+
199+
await runLinearAutoCreateOnce(deps);
200+
201+
expect(fetchOptions).toEqual([{ skipCache: true }]);
202+
expect(created).toEqual([]);
203+
});
204+
});
205+
206+
describe("startLinearAutoCreateMonitor", () => {
207+
beforeEach(() => {
208+
resetProcessedIssues();
209+
});
210+
211+
it("uses a 60 second poll interval", async () => {
212+
let scheduledInterval = -1;
213+
const fetchStarted = createDeferred<void>();
214+
const { deps } = createDeps({
215+
onFetch: () => fetchStarted.resolve(undefined),
216+
});
217+
218+
const stop = startLinearAutoCreateMonitor(deps, {
219+
intervalDeps: {
220+
scheduleEvery: (_handler, intervalMs) => {
221+
scheduledInterval = intervalMs;
222+
return 1;
223+
},
224+
cancelSchedule: () => {},
225+
},
226+
});
227+
228+
await fetchStarted.promise;
229+
stop();
230+
231+
expect(scheduledInterval).toBe(LINEAR_AUTO_CREATE_POLL_INTERVAL_MS);
232+
expect(scheduledInterval).toBe(60_000);
233+
});
234+
});

backend/src/server.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,6 @@ function startLinearAutoCreate(): void {
122122
lifecycleService,
123123
git,
124124
projectRoot: PROJECT_DIR,
125-
isActive: hasRecentDashboardActivity,
126125
});
127126
}
128127

backend/src/services/linear-auto-create-service.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1-
import { startSerializedInterval } from "../lib/async";
1+
import { startSerializedInterval, type SerializedIntervalDependencies } from "../lib/async";
22
import { log } from "../lib/log";
33
import { branchMatchesIssue, fetchAssignedIssues, type LinearIssue } from "./linear-service";
4-
import type { LifecycleService } from "./lifecycle-service";
4+
import type { CreateLifecycleWorktreeInput } from "./lifecycle-service";
55
import type { GitGateway } from "../adapters/git";
66

7-
const POLL_INTERVAL_MS = 15_000;
7+
export const LINEAR_AUTO_CREATE_POLL_INTERVAL_MS = 60_000;
8+
9+
export interface LinearAutoCreateLifecycleService {
10+
createWorktree(input: CreateLifecycleWorktreeInput): Promise<{
11+
branch: string;
12+
worktreeId: string;
13+
}>;
14+
}
815

916
export interface LinearAutoCreateDependencies {
10-
lifecycleService: LifecycleService;
11-
git: GitGateway;
17+
lifecycleService: LinearAutoCreateLifecycleService;
18+
git: Pick<GitGateway, "listWorktrees">;
1219
projectRoot: string;
13-
isActive: () => boolean;
20+
fetchIssues?: typeof fetchAssignedIssues;
21+
}
22+
23+
export interface LinearAutoCreateMonitorOptions {
24+
intervalDeps?: SerializedIntervalDependencies<unknown>;
1425
}
1526

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

35-
async function runAutoCreate(deps: LinearAutoCreateDependencies): Promise<void> {
36-
if (!deps.isActive()) {
37-
log.debug("[linear-auto-create] skipping: no active clients");
38-
return;
39-
}
40-
41-
const result = await fetchAssignedIssues({ skipCache: true });
46+
export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies): Promise<void> {
47+
const fetchIssues = deps.fetchIssues ?? fetchAssignedIssues;
48+
const result = await fetchIssues({ skipCache: true });
4249
if (!result.ok) {
4350
log.error(`[linear-auto-create] failed to fetch issues: ${result.error}`);
4451
return;
@@ -79,11 +86,13 @@ async function runAutoCreate(deps: LinearAutoCreateDependencies): Promise<void>
7986
* Returns a cleanup function that stops the monitor. */
8087
export function startLinearAutoCreateMonitor(
8188
deps: LinearAutoCreateDependencies,
89+
options: LinearAutoCreateMonitorOptions = {},
8290
): () => void {
83-
log.info("[linear-auto-create] monitor started");
84-
return startSerializedInterval(
85-
() => runAutoCreate(deps),
86-
POLL_INTERVAL_MS,
91+
log.info(`[linear-auto-create] monitor started (interval: ${LINEAR_AUTO_CREATE_POLL_INTERVAL_MS}ms)`);
92+
return startSerializedInterval<unknown>(
93+
() => runLinearAutoCreateOnce(deps),
94+
LINEAR_AUTO_CREATE_POLL_INTERVAL_MS,
95+
options.intervalDeps,
8796
);
8897
}
8998

0 commit comments

Comments
 (0)