Skip to content

Commit 729e51b

Browse files
hugocasaclaude
andauthored
feat: scope linear watcher per team and pick ticket team in UI (#240)
* feat: scope linear watcher per team and pick ticket team in UI Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: address pr review for linear team scope Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ad0b1dc commit 729e51b

12 files changed

Lines changed: 205 additions & 39 deletions

File tree

backend/src/__tests__/api-validation.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "bun:test";
2-
import { NotificationIdParamsSchema, RunIdParamsSchema, WorktreeNameParamsSchema } from "@webmux/api-contract";
2+
import { CreateWorktreeRequestSchema, NotificationIdParamsSchema, RunIdParamsSchema, WorktreeNameParamsSchema } from "@webmux/api-contract";
33
import { z } from "zod";
44
import { parseParams } from "../api-validation";
55

@@ -55,3 +55,36 @@ describe("parseParams", () => {
5555
});
5656
});
5757
});
58+
59+
describe("CreateWorktreeRequestSchema linearTeamKey", () => {
60+
it("uppercases and accepts a valid team key", () => {
61+
const parsed = CreateWorktreeRequestSchema.safeParse({
62+
createLinearTicket: true,
63+
linearTeamKey: "eng",
64+
});
65+
expect(parsed.success).toBe(true);
66+
if (parsed.success) expect(parsed.data.linearTeamKey).toBe("ENG");
67+
});
68+
69+
it("rejects an issue-shaped key like ENG-123", () => {
70+
const parsed = CreateWorktreeRequestSchema.safeParse({
71+
createLinearTicket: true,
72+
linearTeamKey: "ENG-123",
73+
});
74+
expect(parsed.success).toBe(false);
75+
});
76+
77+
it("rejects non-alpha characters", () => {
78+
const parsed = CreateWorktreeRequestSchema.safeParse({
79+
createLinearTicket: true,
80+
linearTeamKey: "ENG2",
81+
});
82+
expect(parsed.success).toBe(false);
83+
});
84+
85+
it("allows omitting linearTeamKey", () => {
86+
const parsed = CreateWorktreeRequestSchema.safeParse({});
87+
expect(parsed.success).toBe(true);
88+
if (parsed.success) expect(parsed.data.linearTeamKey).toBeUndefined();
89+
});
90+
});

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 (uppercase, normalized) allowlist", () => {
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: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFileSync } from "node:fs";
22
import { dirname, join, resolve } from "node:path";
33
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4+
import { log } from "../lib/log";
45
import type {
56
AgentId,
67
AgentKind,
@@ -393,25 +394,7 @@ function parseProjectConfig(parsed: Record<string, unknown>): ProjectConfig {
393394
? parsed.integrations.github.autoRemoveOnMerge
394395
: DEFAULT_CONFIG.integrations.github.autoRemoveOnMerge,
395396
},
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-
},
397+
linear: parseLinearIntegration(parsed),
415398
},
416399
lifecycleHooks: parseLifecycleHooks(parsed.lifecycleHooks),
417400
autoName: parseAutoName(parsed.auto_name),
@@ -423,6 +406,46 @@ function defaultConfig(): ProjectConfig {
423406
return parseProjectConfig({});
424407
}
425408

409+
function parseTeamKeyList(raw: unknown): string[] | undefined {
410+
if (!Array.isArray(raw)) return undefined;
411+
const keys = raw
412+
.filter((entry): entry is string => typeof entry === "string")
413+
.map((entry) => entry.trim().toUpperCase())
414+
.filter((entry) => entry.length > 0);
415+
return keys.length > 0 ? Array.from(new Set(keys)) : undefined;
416+
}
417+
418+
/** Track whether the deprecation warning for `integrations.linear.teamId` has
419+
* already been logged this process so config reloads don't spam the log. */
420+
let warnedLegacyLinearTeamId = false;
421+
422+
function parseLinearIntegration(parsed: Record<string, unknown>): LinearIntegrationConfig {
423+
const defaults = DEFAULT_CONFIG.integrations.linear;
424+
const linear = isRecord(parsed.integrations) && isRecord(parsed.integrations.linear)
425+
? parsed.integrations.linear
426+
: null;
427+
428+
if (!linear) return { ...defaults };
429+
430+
if (typeof linear.teamId === "string" && !warnedLegacyLinearTeamId) {
431+
warnedLegacyLinearTeamId = true;
432+
log.warn("[config] integrations.linear.teamId is no longer used — the ticket team is now picked at creation time in the dashboard");
433+
}
434+
435+
const watchTeams = parseTeamKeyList(linear.watchTeams);
436+
437+
return {
438+
enabled: typeof linear.enabled === "boolean" ? linear.enabled : defaults.enabled,
439+
autoCreateWorktrees: typeof linear.autoCreateWorktrees === "boolean"
440+
? linear.autoCreateWorktrees
441+
: defaults.autoCreateWorktrees,
442+
createTicketOption: typeof linear.createTicketOption === "boolean"
443+
? linear.createTicketOption
444+
: defaults.createTicketOption,
445+
...(watchTeams ? { watchTeams } : {}),
446+
};
447+
}
448+
426449
function parseLocalLinearOverlay(parsed: Record<string, unknown>): Partial<LinearIntegrationConfig> | null {
427450
if (!isRecord(parsed.integrations)) return null;
428451
const linear = parsed.integrations.linear;
@@ -432,7 +455,8 @@ function parseLocalLinearOverlay(parsed: Record<string, unknown>): Partial<Linea
432455
if (typeof linear.enabled === "boolean") overlay.enabled = linear.enabled;
433456
if (typeof linear.autoCreateWorktrees === "boolean") overlay.autoCreateWorktrees = linear.autoCreateWorktrees;
434457
if (typeof linear.createTicketOption === "boolean") overlay.createTicketOption = linear.createTicketOption;
435-
if (typeof linear.teamId === "string" && linear.teamId.trim()) overlay.teamId = linear.teamId.trim();
458+
const watchTeams = parseTeamKeyList(linear.watchTeams);
459+
if (watchTeams) overlay.watchTeams = watchTeams;
436460
return Object.keys(overlay).length > 0 ? overlay : null;
437461
}
438462

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: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,13 @@ async function runOneshotForIssue(issueId: string): Promise<void> {
165165
/** Safe to call multiple times — the guard prevents duplicate monitors. */
166166
function startLinearAutoCreate(): void {
167167
if (stopLinearAutoCreate) return;
168+
const watchTeamKeys = config.integrations.linear.watchTeams;
168169
stopLinearAutoCreate = startLinearAutoCreateMonitor({
169170
lifecycleService,
170171
git,
171172
projectRoot: PROJECT_DIR,
172173
runOneshotForIssue,
174+
...(watchTeamKeys && watchTeamKeys.length > 0 ? { watchTeamKeys } : {}),
173175
});
174176
}
175177

@@ -884,6 +886,9 @@ async function apiCreateWorktree(req: Request): Promise<Response> {
884886
const agents = body.agents;
885887
const createLinearTicket = body.createLinearTicket === true;
886888
const linearTitle = body.linearTitle?.trim() ? body.linearTitle.trim() : undefined;
889+
// CreateWorktreeRequestSchema already trims, uppercases, and validates the
890+
// team key shape — body.linearTeamKey is either a valid key or undefined.
891+
const linearTeamKey = body.linearTeamKey;
887892
const mode = body.mode;
888893
const selectedAgents = agents
889894
? agents
@@ -960,15 +965,22 @@ async function apiCreateWorktree(req: Request): Promise<Response> {
960965
return errorResponse("Linear ticket title could not be derived from the prompt", 400);
961966
}
962967

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

968980
const linearResult = await createLinearIssue({
969981
title,
970982
description: resolvedPrompt ?? "",
971-
teamId,
983+
teamId: teamResult.data.id,
972984
});
973985
if (!linearResult.ok) {
974986
return errorResponse(linearResult.error, 502);

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

Lines changed: 20 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,26 @@ 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+
// watchTeamKeys is expected already-uppercase: parseTeamKeyList normalizes
46+
// and dedupes before this code is reached.
47+
return watchTeamKeys.includes(issue.team.key.toUpperCase());
48+
}
49+
4050
/** Shared filter: Todo state, the label rule supplied by the caller, not yet
41-
* processed, and no existing worktree on the branch. */
51+
* processed, no existing worktree on the branch, and (when configured) the
52+
* issue's team is in the watch list. */
4253
function filterTriggerableIssues(
4354
issues: LinearIssue[],
4455
existingBranches: string[],
4556
matchesLabelRule: (issue: LinearIssue) => boolean,
57+
watchTeamKeys?: string[],
4658
): LinearIssue[] {
4759
return issues.filter((issue) => {
4860
if (issue.state.name !== "Todo") return false;
4961
if (!matchesLabelRule(issue)) return false;
62+
if (!matchesTeamFilter(issue, watchTeamKeys)) return false;
5063
if (processedIssueIds.has(issue.id)) return false;
5164
return !existingBranches.some((branch) => branchMatchesIssue(branch, issue.branchName));
5265
});
@@ -57,11 +70,13 @@ function filterTriggerableIssues(
5770
export function filterAutoCreateIssues(
5871
issues: LinearIssue[],
5972
existingBranches: string[],
73+
watchTeamKeys?: string[],
6074
): LinearIssue[] {
6175
return filterTriggerableIssues(
6276
issues,
6377
existingBranches,
6478
(issue) => hasLabel(issue, AUTO_CREATE_LABEL) && !hasLabel(issue, AUTO_ONESHOT_LABEL),
79+
watchTeamKeys,
6580
);
6681
}
6782

@@ -71,11 +86,13 @@ export function filterAutoCreateIssues(
7186
export function filterAutoOneshotIssues(
7287
issues: LinearIssue[],
7388
existingBranches: string[],
89+
watchTeamKeys?: string[],
7490
): LinearIssue[] {
7591
return filterTriggerableIssues(
7692
issues,
7793
existingBranches,
7894
(issue) => hasLabel(issue, AUTO_ONESHOT_LABEL),
95+
watchTeamKeys,
7996
);
8097
}
8198

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

116133
const oneshotIssues = deps.runOneshotForIssue
117-
? filterAutoOneshotIssues(result.data, existingBranches)
134+
? filterAutoOneshotIssues(result.data, existingBranches, deps.watchTeamKeys)
118135
: [];
119-
const createIssues = filterAutoCreateIssues(result.data, existingBranches);
136+
const createIssues = filterAutoCreateIssues(result.data, existingBranches, deps.watchTeamKeys);
120137

121138
if (oneshotIssues.length === 0 && createIssues.length === 0) {
122139
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)