Skip to content

Commit 2482c2e

Browse files
centdixclaude
andcommitted
fix: address linear auto-create review
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fcbe303 commit 2482c2e

2 files changed

Lines changed: 94 additions & 46 deletions

File tree

backend/src/__tests__/linear-auto-create-service.test.ts

Lines changed: 84 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { beforeEach, describe, expect, it } from "bun:test";
22
import type { CreateLifecycleWorktreeInput } from "../services/lifecycle-service";
3-
import type { LinearIssue } from "../services/linear-service";
3+
import type { FetchIssuesResult, LinearIssue } from "../services/linear-service";
44
import {
55
filterAutoCreateIssues,
66
LINEAR_AUTO_CREATE_POLL_INTERVAL_MS,
77
resetProcessedIssues,
8+
runLinearAutoCreateOnce,
89
startLinearAutoCreateMonitor,
910
type LinearAutoCreateDependencies,
1011
} from "../services/linear-auto-create-service";
@@ -45,15 +46,31 @@ function createIssue(overrides: Partial<LinearIssue> = {}): LinearIssue {
4546
};
4647
}
4748

48-
async function flushPromises(): Promise<void> {
49-
for (let i = 0; i < 5; i += 1) {
50-
await Promise.resolve();
51-
}
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+
};
5267
}
5368

5469
function createDeps(input: {
5570
issues?: LinearIssue[];
5671
existingBranches?: string[];
72+
fetchResult?: FetchIssuesResult;
73+
onFetch?: (options: { skipCache?: boolean } | undefined) => void;
5774
} = {}): {
5875
deps: LinearAutoCreateDependencies;
5976
created: CreateLifecycleWorktreeInput[];
@@ -90,7 +107,8 @@ function createDeps(input: {
90107
projectRoot: "/repo",
91108
fetchIssues: async (options) => {
92109
fetchOptions.push(options);
93-
return {
110+
input.onFetch?.(options);
111+
return input.fetchResult ?? {
94112
ok: true,
95113
data: issues,
96114
};
@@ -132,53 +150,85 @@ describe("filterAutoCreateIssues", () => {
132150
});
133151
});
134152

135-
describe("startLinearAutoCreateMonitor", () => {
153+
describe("runLinearAutoCreateOnce", () => {
136154
beforeEach(() => {
137155
resetProcessedIssues();
138156
});
139157

140-
it("uses a 30 second poll interval", async () => {
141-
let scheduledInterval: number | null = null;
142-
const { deps } = createDeps();
158+
it("creates worktrees without requiring dashboard activity", async () => {
159+
const issue = createIssue();
160+
const { deps, created, fetchOptions } = createDeps({ issues: [issue] });
143161

144-
const stop = startLinearAutoCreateMonitor(deps, {
145-
intervalDeps: {
146-
scheduleEvery: (_handler, intervalMs) => {
147-
scheduledInterval = intervalMs;
148-
return 1;
149-
},
150-
cancelSchedule: () => {},
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",
151196
},
152197
});
153198

154-
await flushPromises();
155-
stop();
199+
await runLinearAutoCreateOnce(deps);
156200

157-
expect(scheduledInterval).toBe(LINEAR_AUTO_CREATE_POLL_INTERVAL_MS);
158-
expect(scheduledInterval).toBe(30_000);
201+
expect(fetchOptions).toEqual([{ skipCache: true }]);
202+
expect(created).toEqual([]);
159203
});
204+
});
160205

161-
it("creates worktrees without requiring dashboard activity", async () => {
162-
const issue = createIssue();
163-
const { deps, created, fetchOptions } = createDeps({ issues: [issue] });
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+
});
164217

165218
const stop = startLinearAutoCreateMonitor(deps, {
166219
intervalDeps: {
167-
scheduleEvery: () => 1,
220+
scheduleEvery: (_handler, intervalMs) => {
221+
scheduledInterval = intervalMs;
222+
return 1;
223+
},
168224
cancelSchedule: () => {},
169225
},
170226
});
171227

172-
await flushPromises();
228+
await fetchStarted.promise;
173229
stop();
174230

175-
expect(fetchOptions).toEqual([{ skipCache: true }]);
176-
expect(created).toEqual([
177-
{
178-
mode: "new",
179-
branch: issue.branchName,
180-
prompt: `${issue.title}\n\n${issue.description}`,
181-
},
182-
]);
231+
expect(scheduledInterval).toBe(LINEAR_AUTO_CREATE_POLL_INTERVAL_MS);
232+
expect(scheduledInterval).toBe(60_000);
183233
});
184234
});

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

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { branchMatchesIssue, fetchAssignedIssues, type LinearIssue } from "./lin
44
import type { CreateLifecycleWorktreeInput } from "./lifecycle-service";
55
import type { GitGateway } from "../adapters/git";
66

7-
export const LINEAR_AUTO_CREATE_POLL_INTERVAL_MS = 30_000;
7+
export const LINEAR_AUTO_CREATE_POLL_INTERVAL_MS = 60_000;
88

99
export interface LinearAutoCreateLifecycleService {
1010
createWorktree(input: CreateLifecycleWorktreeInput): Promise<{
@@ -20,9 +20,8 @@ export interface LinearAutoCreateDependencies {
2020
fetchIssues?: typeof fetchAssignedIssues;
2121
}
2222

23-
export interface LinearAutoCreateMonitorOptions<THandle = ReturnType<typeof setInterval>> {
24-
intervalMs?: number;
25-
intervalDeps?: SerializedIntervalDependencies<THandle>;
23+
export interface LinearAutoCreateMonitorOptions {
24+
intervalDeps?: SerializedIntervalDependencies<unknown>;
2625
}
2726

2827
/** Issue IDs for which worktrees have been successfully created.
@@ -44,7 +43,7 @@ export function filterAutoCreateIssues(
4443
});
4544
}
4645

47-
async function runAutoCreate(deps: LinearAutoCreateDependencies): Promise<void> {
46+
export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies): Promise<void> {
4847
const fetchIssues = deps.fetchIssues ?? fetchAssignedIssues;
4948
const result = await fetchIssues({ skipCache: true });
5049
if (!result.ok) {
@@ -85,15 +84,14 @@ async function runAutoCreate(deps: LinearAutoCreateDependencies): Promise<void>
8584

8685
/** Start periodic polling for new Linear Todo issues and auto-create worktrees.
8786
* Returns a cleanup function that stops the monitor. */
88-
export function startLinearAutoCreateMonitor<THandle = ReturnType<typeof setInterval>>(
87+
export function startLinearAutoCreateMonitor(
8988
deps: LinearAutoCreateDependencies,
90-
options: LinearAutoCreateMonitorOptions<THandle> = {},
89+
options: LinearAutoCreateMonitorOptions = {},
9190
): () => void {
92-
const intervalMs = options.intervalMs ?? LINEAR_AUTO_CREATE_POLL_INTERVAL_MS;
93-
log.info(`[linear-auto-create] monitor started (interval: ${intervalMs}ms)`);
94-
return startSerializedInterval(
95-
() => runAutoCreate(deps),
96-
intervalMs,
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,
9795
options.intervalDeps,
9896
);
9997
}

0 commit comments

Comments
 (0)