Skip to content

Commit 2b69552

Browse files
hugocasaclaude
andcommitted
feat: scope linear watcher per team and pick ticket team in UI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8173814 commit 2b69552

11 files changed

Lines changed: 150 additions & 38 deletions

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,37 @@ describe("filterAutoCreateIssues", () => {
153153
});
154154
});
155155

156+
describe("watchTeamKeys filter", () => {
157+
beforeEach(() => {
158+
resetProcessedIssues();
159+
});
160+
161+
it("keeps every team when watchTeamKeys is undefined or empty", () => {
162+
const eng = createIssue({ id: "eng", identifier: "ENG-1", branchName: "eng-1", team: { name: "Engineering", key: "ENG" } });
163+
const ops = createIssue({ id: "ops", identifier: "OPS-1", branchName: "ops-1", team: { name: "Ops", key: "OPS" } });
164+
expect(filterAutoCreateIssues([eng, ops], []).map((i) => i.identifier)).toEqual(["ENG-1", "OPS-1"]);
165+
expect(filterAutoCreateIssues([eng, ops], [], []).map((i) => i.identifier)).toEqual(["ENG-1", "OPS-1"]);
166+
});
167+
168+
it("drops issues whose team key is not in the allowlist (case-insensitive)", () => {
169+
const eng = createIssue({ id: "eng", identifier: "ENG-1", branchName: "eng-1", team: { name: "Engineering", key: "ENG" } });
170+
const ops = createIssue({ id: "ops", identifier: "OPS-1", branchName: "ops-1", team: { name: "Ops", key: "OPS" } });
171+
const design = createIssue({ id: "des", identifier: "DES-1", branchName: "des-1", team: { name: "Design", key: "DES" } });
172+
expect(filterAutoCreateIssues([eng, ops, design], [], ["eng", "OPS"]).map((i) => i.identifier))
173+
.toEqual(["ENG-1", "OPS-1"]);
174+
});
175+
176+
it("applies the same filter to the oneshot variant", () => {
177+
const ops = createIssue({
178+
id: "ops", identifier: "OPS-1", branchName: "ops-1",
179+
labels: [{ name: "webmux_oneshot", color: "#fff" }],
180+
team: { name: "Ops", key: "OPS" },
181+
});
182+
expect(filterAutoOneshotIssues([ops], [], ["ENG"])).toEqual([]);
183+
expect(filterAutoOneshotIssues([ops], [], ["OPS"]).map((i) => i.identifier)).toEqual(["OPS-1"]);
184+
});
185+
});
186+
156187
describe("filterAutoOneshotIssues", () => {
157188
beforeEach(() => {
158189
resetProcessedIssues();

backend/src/__tests__/setup.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describe("loadConfig", () => {
8080
" linear:",
8181
" enabled: false",
8282
" createTicketOption: true",
83-
" teamId: team-123",
83+
" watchTeams: [ENG, ops]",
8484
"",
8585
].join("\n"),
8686
);
@@ -110,7 +110,7 @@ describe("loadConfig", () => {
110110
expect(config.integrations.github.linkedRepos).toEqual([{ repo: "acme/linked", alias: "linked" }]);
111111
expect(config.integrations.linear.enabled).toBe(false);
112112
expect(config.integrations.linear.createTicketOption).toBe(true);
113-
expect(config.integrations.linear.teamId).toBe("team-123");
113+
expect(config.integrations.linear.watchTeams).toEqual(["ENG", "OPS"]);
114114
});
115115

116116
it("uses the first configured profile when no default profile exists", async () => {
@@ -199,7 +199,7 @@ describe("loadConfig", () => {
199199

200200
expect(config.integrations.linear.enabled).toBe(true);
201201
expect(config.integrations.linear.createTicketOption).toBe(false);
202-
expect(config.integrations.linear.teamId).toBeUndefined();
202+
expect(config.integrations.linear.watchTeams).toBeUndefined();
203203
});
204204

205205
it("adds local profiles and appends local lifecycle hooks after project hooks", async () => {

backend/src/adapters/config.ts

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -393,25 +393,7 @@ function parseProjectConfig(parsed: Record<string, unknown>): ProjectConfig {
393393
? parsed.integrations.github.autoRemoveOnMerge
394394
: DEFAULT_CONFIG.integrations.github.autoRemoveOnMerge,
395395
},
396-
linear: {
397-
enabled: isRecord(parsed.integrations) && isRecord(parsed.integrations.linear) && typeof parsed.integrations.linear.enabled === "boolean"
398-
? parsed.integrations.linear.enabled
399-
: DEFAULT_CONFIG.integrations.linear.enabled,
400-
autoCreateWorktrees: isRecord(parsed.integrations) && isRecord(parsed.integrations.linear) && typeof parsed.integrations.linear.autoCreateWorktrees === "boolean"
401-
? parsed.integrations.linear.autoCreateWorktrees
402-
: DEFAULT_CONFIG.integrations.linear.autoCreateWorktrees,
403-
createTicketOption: isRecord(parsed.integrations) &&
404-
isRecord(parsed.integrations.linear) &&
405-
typeof parsed.integrations.linear.createTicketOption === "boolean"
406-
? parsed.integrations.linear.createTicketOption
407-
: DEFAULT_CONFIG.integrations.linear.createTicketOption,
408-
...(isRecord(parsed.integrations) &&
409-
isRecord(parsed.integrations.linear) &&
410-
typeof parsed.integrations.linear.teamId === "string" &&
411-
parsed.integrations.linear.teamId.trim()
412-
? { teamId: parsed.integrations.linear.teamId.trim() }
413-
: {}),
414-
},
396+
linear: parseLinearIntegration(parsed),
415397
},
416398
lifecycleHooks: parseLifecycleHooks(parsed.lifecycleHooks),
417399
autoName: parseAutoName(parsed.auto_name),
@@ -423,6 +405,37 @@ function defaultConfig(): ProjectConfig {
423405
return parseProjectConfig({});
424406
}
425407

408+
function parseTeamKeyList(raw: unknown): string[] | undefined {
409+
if (!Array.isArray(raw)) return undefined;
410+
const keys = raw
411+
.filter((entry): entry is string => typeof entry === "string")
412+
.map((entry) => entry.trim().toUpperCase())
413+
.filter((entry) => entry.length > 0);
414+
return keys.length > 0 ? Array.from(new Set(keys)) : undefined;
415+
}
416+
417+
function parseLinearIntegration(parsed: Record<string, unknown>): LinearIntegrationConfig {
418+
const defaults = DEFAULT_CONFIG.integrations.linear;
419+
const linear = isRecord(parsed.integrations) && isRecord(parsed.integrations.linear)
420+
? parsed.integrations.linear
421+
: null;
422+
423+
if (!linear) return { ...defaults };
424+
425+
const watchTeams = parseTeamKeyList(linear.watchTeams);
426+
427+
return {
428+
enabled: typeof linear.enabled === "boolean" ? linear.enabled : defaults.enabled,
429+
autoCreateWorktrees: typeof linear.autoCreateWorktrees === "boolean"
430+
? linear.autoCreateWorktrees
431+
: defaults.autoCreateWorktrees,
432+
createTicketOption: typeof linear.createTicketOption === "boolean"
433+
? linear.createTicketOption
434+
: defaults.createTicketOption,
435+
...(watchTeams ? { watchTeams } : {}),
436+
};
437+
}
438+
426439
function parseLocalLinearOverlay(parsed: Record<string, unknown>): Partial<LinearIntegrationConfig> | null {
427440
if (!isRecord(parsed.integrations)) return null;
428441
const linear = parsed.integrations.linear;
@@ -432,7 +445,8 @@ function parseLocalLinearOverlay(parsed: Record<string, unknown>): Partial<Linea
432445
if (typeof linear.enabled === "boolean") overlay.enabled = linear.enabled;
433446
if (typeof linear.autoCreateWorktrees === "boolean") overlay.autoCreateWorktrees = linear.autoCreateWorktrees;
434447
if (typeof linear.createTicketOption === "boolean") overlay.createTicketOption = linear.createTicketOption;
435-
if (typeof linear.teamId === "string" && linear.teamId.trim()) overlay.teamId = linear.teamId.trim();
448+
const watchTeams = parseTeamKeyList(linear.watchTeams);
449+
if (watchTeams) overlay.watchTeams = watchTeams;
436450
return Object.keys(overlay).length > 0 ? overlay : null;
437451
}
438452

backend/src/domain/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ export interface LinearIntegrationConfig {
7272
enabled: boolean;
7373
autoCreateWorktrees: boolean;
7474
createTicketOption: boolean;
75-
teamId?: string;
75+
/** Restrict the auto-create watcher to issues from these team keys (e.g. ["ENG", "OPS"]).
76+
* When unset, all teams the authenticated user is assigned in are watched. */
77+
watchTeams?: string[];
7678
}
7779

7880
export interface IntegrationConfig {

backend/src/server.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,13 @@ async function runOneshotForIssue(issueId: string): Promise<void> {
163163
/** Safe to call multiple times — the guard prevents duplicate monitors. */
164164
function startLinearAutoCreate(): void {
165165
if (stopLinearAutoCreate) return;
166+
const watchTeamKeys = config.integrations.linear.watchTeams;
166167
stopLinearAutoCreate = startLinearAutoCreateMonitor({
167168
lifecycleService,
168169
git,
169170
projectRoot: PROJECT_DIR,
170171
runOneshotForIssue,
172+
...(watchTeamKeys && watchTeamKeys.length > 0 ? { watchTeamKeys } : {}),
171173
});
172174
}
173175

@@ -882,6 +884,7 @@ async function apiCreateWorktree(req: Request): Promise<Response> {
882884
const agents = body.agents;
883885
const createLinearTicket = body.createLinearTicket === true;
884886
const linearTitle = body.linearTitle?.trim() ? body.linearTitle.trim() : undefined;
887+
const linearTeamKey = body.linearTeamKey?.trim() ? body.linearTeamKey.trim().toUpperCase() : undefined;
885888
const mode = body.mode;
886889
const selectedAgents = agents
887890
? agents
@@ -958,15 +961,22 @@ async function apiCreateWorktree(req: Request): Promise<Response> {
958961
return errorResponse("Linear ticket title could not be derived from the prompt", 400);
959962
}
960963

961-
const teamId = config.integrations.linear.teamId;
962-
if (!teamId) {
963-
return errorResponse("Linear teamId is not configured", 503);
964+
if (!linearTeamKey) {
965+
return errorResponse(
966+
"Linear team is required to create a ticket. Provide `linearTeamKey` (e.g. \"ENG\").",
967+
400,
968+
);
969+
}
970+
971+
const teamResult = await fetchTeamByKey(linearTeamKey);
972+
if (!teamResult.ok) {
973+
return errorResponse(teamResult.error, teamResult.status);
964974
}
965975

966976
const linearResult = await createLinearIssue({
967977
title,
968978
description: resolvedPrompt ?? "",
969-
teamId,
979+
teamId: teamResult.data.id,
970980
});
971981
if (!linearResult.ok) {
972982
return errorResponse(linearResult.error, 502);

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export interface LinearAutoCreateDependencies {
2020
fetchIssues?: typeof fetchAssignedIssues;
2121
/** Optional handler for the `webmux_oneshot` label variant. When omitted, oneshot triggering is skipped. */
2222
runOneshotForIssue?: (issueId: string) => Promise<void>;
23+
/** Restrict triggering to issues whose team.key is in this list (uppercase).
24+
* Undefined or empty → no team filter (all teams). */
25+
watchTeamKeys?: string[];
2326
}
2427

2528
export interface LinearAutoCreateMonitorOptions {
@@ -37,16 +40,25 @@ function hasLabel(issue: LinearIssue, name: string): boolean {
3740
return issue.labels.some((l) => l.name.toLowerCase() === name);
3841
}
3942

43+
function matchesTeamFilter(issue: LinearIssue, watchTeamKeys: string[] | undefined): boolean {
44+
if (!watchTeamKeys || watchTeamKeys.length === 0) return true;
45+
const issueKey = issue.team.key.toUpperCase();
46+
return watchTeamKeys.some((key) => key.toUpperCase() === issueKey);
47+
}
48+
4049
/** Shared filter: Todo state, the label rule supplied by the caller, not yet
41-
* processed, and no existing worktree on the branch. */
50+
* processed, no existing worktree on the branch, and (when configured) the
51+
* issue's team is in the watch list. */
4252
function filterTriggerableIssues(
4353
issues: LinearIssue[],
4454
existingBranches: string[],
4555
matchesLabelRule: (issue: LinearIssue) => boolean,
56+
watchTeamKeys?: string[],
4657
): LinearIssue[] {
4758
return issues.filter((issue) => {
4859
if (issue.state.name !== "Todo") return false;
4960
if (!matchesLabelRule(issue)) return false;
61+
if (!matchesTeamFilter(issue, watchTeamKeys)) return false;
5062
if (processedIssueIds.has(issue.id)) return false;
5163
return !existingBranches.some((branch) => branchMatchesIssue(branch, issue.branchName));
5264
});
@@ -57,11 +69,13 @@ function filterTriggerableIssues(
5769
export function filterAutoCreateIssues(
5870
issues: LinearIssue[],
5971
existingBranches: string[],
72+
watchTeamKeys?: string[],
6073
): LinearIssue[] {
6174
return filterTriggerableIssues(
6275
issues,
6376
existingBranches,
6477
(issue) => hasLabel(issue, AUTO_CREATE_LABEL) && !hasLabel(issue, AUTO_ONESHOT_LABEL),
78+
watchTeamKeys,
6579
);
6680
}
6781

@@ -71,11 +85,13 @@ export function filterAutoCreateIssues(
7185
export function filterAutoOneshotIssues(
7286
issues: LinearIssue[],
7387
existingBranches: string[],
88+
watchTeamKeys?: string[],
7489
): LinearIssue[] {
7590
return filterTriggerableIssues(
7691
issues,
7792
existingBranches,
7893
(issue) => hasLabel(issue, AUTO_ONESHOT_LABEL),
94+
watchTeamKeys,
7995
);
8096
}
8197

@@ -114,9 +130,9 @@ export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies
114130
.map((entry) => entry.branch as string);
115131

116132
const oneshotIssues = deps.runOneshotForIssue
117-
? filterAutoOneshotIssues(result.data, existingBranches)
133+
? filterAutoOneshotIssues(result.data, existingBranches, deps.watchTeamKeys)
118134
: [];
119-
const createIssues = filterAutoCreateIssues(result.data, existingBranches);
135+
const createIssues = filterAutoCreateIssues(result.data, existingBranches, deps.watchTeamKeys);
120136

121137
if (oneshotIssues.length === 0 && createIssues.length === 0) {
122138
log.debug(`[linear-auto-create] no new labeled issues (${result.data.length} assigned, ${existingBranches.length} worktrees)`);

bin/src/init-helpers.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ describe("buildStarterTemplate", () => {
242242
expect(template).toContain("# autoRemoveOnMerge: true");
243243
expect(template).toContain("# autoCreateWorktrees: true");
244244
expect(template).toContain("# createTicketOption: true");
245-
expect(template).toContain("# teamId: team-123");
245+
expect(template).toContain("# watchTeams: [ENG, OPS]");
246246
expect(template).toContain("# lifecycleHooks:");
247247
expect(template).toContain("# auto_name:");
248248
expect(template).toContain("# startupEnvs become runtime env vars for panes, agents, and hooks.");

bin/src/init-helpers.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -677,12 +677,15 @@ integrations:
677677
linear:
678678
# Enable Linear issue lookup and linking in the UI.
679679
enabled: true
680-
# Auto-create worktrees for assigned issues.
680+
# Auto-create worktrees for assigned issues labeled "webmux" or "webmux_oneshot".
681681
# autoCreateWorktrees: true
682-
# Show a create-ticket action in the dashboard.
682+
# Show a create-ticket action in the dashboard. The team to file into is
683+
# picked in the dialog at creation time.
683684
# createTicketOption: true
684-
# Restrict issue sync to a specific Linear team id.
685-
# teamId: team-123
685+
# Restrict the auto-create watcher to issues from these teams. Useful when
686+
# the authenticated Linear user is in multiple teams or when running webmux
687+
# in several projects on the same machine that share a Linear account.
688+
# watchTeams: [ENG, OPS]
686689
687690
# startupEnvs become runtime env vars for panes, agents, and hooks.
688691
startupEnvs:

frontend/src/App.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,9 @@ describe("App create selection", () => {
598598
await fireEvent.input(screen.getByLabelText(/Prompt/i), {
599599
target: { value: "Implement the new flow" },
600600
});
601+
await fireEvent.input(screen.getByLabelText(/Team key/i), {
602+
target: { value: "ENG" },
603+
});
601604
await fireEvent.input(screen.getByLabelText(/Linear ticket title/i), {
602605
target: { value: "Ship Linear-backed worktree creation" },
603606
});
@@ -614,6 +617,7 @@ describe("App create selection", () => {
614617
agents: ["claude"],
615618
prompt: "Implement the new flow",
616619
createLinearTicket: true,
620+
linearTeamKey: "ENG",
617621
linearTitle: "Ship Linear-backed worktree creation",
618622
},
619623
});

0 commit comments

Comments
 (0)