diff --git a/apps/api/src/automation-settings-update.test.ts b/apps/api/src/automation-settings-update.test.ts index 7e441277..af049b2e 100644 --- a/apps/api/src/automation-settings-update.test.ts +++ b/apps/api/src/automation-settings-update.test.ts @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { type AutomationSettingsUpdateValues, + InvalidLinearDefaultTeamError, buildAutomationSettingsConflictUpdate, + linearDefaultTeamExists, + parseLinearDefaultTeamId, } from "./automation-settings-update.js"; const NOW = new Date("2026-07-21T12:00:00.000Z"); @@ -23,6 +26,7 @@ function values( prPolicy: "on_ready_to_pr", approvalPromptsEnabled: true, createLinearTicketOnResolve: false, + linearDefaultTeamId: null, autoResolveStaleIncidentsEnabled: true, prBaseBranch: null, autoMergeFixPrs: "never", @@ -50,3 +54,54 @@ test("independent automation PATCHes update only the supplied setting", () => { updatedAt: NOW, }); }); + +test("a linearDefaultTeamId PATCH writes only the team, including clearing it", () => { + assert.deepEqual( + buildAutomationSettingsConflictUpdate( + { linearDefaultTeamId: "team-engg" }, + values({ linearDefaultTeamId: "team-engg" }), + NOW, + ), + { linearDefaultTeamId: "team-engg", updatedAt: NOW }, + ); + assert.deepEqual( + buildAutomationSettingsConflictUpdate( + { linearDefaultTeamId: null }, + values({ linearDefaultTeamId: null }), + NOW, + ), + { linearDefaultTeamId: null, updatedAt: NOW }, + ); +}); + +test("PATCHes that omit linearDefaultTeamId leave the stored team alone", () => { + const update = buildAutomationSettingsConflictUpdate( + { chatEnabled: false }, + values({ chatEnabled: false, linearDefaultTeamId: "team-engg" }), + NOW, + ); + assert.equal("linearDefaultTeamId" in update, false); +}); + +test("parseLinearDefaultTeamId keeps, clears, trims, or rejects the input", () => { + assert.equal(parseLinearDefaultTeamId(undefined, "team-a"), "team-a"); + assert.equal(parseLinearDefaultTeamId(null, "team-a"), null); + assert.equal(parseLinearDefaultTeamId(" ", "team-a"), null); + assert.equal(parseLinearDefaultTeamId(" team-b ", "team-a"), "team-b"); + assert.throws(() => parseLinearDefaultTeamId(42, "team-a"), InvalidLinearDefaultTeamError); + assert.throws( + () => parseLinearDefaultTeamId("x".repeat(201), null), + InvalidLinearDefaultTeamError, + ); +}); + +test("linearDefaultTeamExists matches by id and accepts the workspace fallback", () => { + const teams = [ + { id: "team-onboard", key: "ONBOARD", name: "Onboarding" }, + { id: "team-engg", key: "ENGG", name: "Engineering" }, + ]; + assert.equal(linearDefaultTeamExists("team-engg", teams), true); + assert.equal(linearDefaultTeamExists("ENGG", teams), false); + assert.equal(linearDefaultTeamExists("team-missing", teams), false); + assert.equal(linearDefaultTeamExists(null, teams), true); +}); diff --git a/apps/api/src/automation-settings-update.ts b/apps/api/src/automation-settings-update.ts index e66ff34c..7f66e231 100644 --- a/apps/api/src/automation-settings-update.ts +++ b/apps/api/src/automation-settings-update.ts @@ -11,6 +11,7 @@ export type AutomationSettingsUpdateValues = { prPolicy: string; approvalPromptsEnabled: boolean; createLinearTicketOnResolve: boolean; + linearDefaultTeamId: string | null; autoResolveStaleIncidentsEnabled: boolean; prBaseBranch: string | null; autoMergeFixPrs: string; @@ -21,6 +22,33 @@ export type AutomationSettingsUpdateValues = { const AGENT_POLICIES = new Set(["never", "on_ready_to_pr", "always"]); const AUTO_MERGE_POLICIES = new Set(["never", "when_checks_pass", "immediately"]); const AUTO_MERGE_METHODS = new Set(["squash", "merge", "rebase"]); +const MAX_LINEAR_TEAM_ID_LEN = 200; + +export class InvalidLinearDefaultTeamError extends Error {} + +// undefined keeps the stored team; null or blank clears it so ticket delivery +// falls back to the workspace's first team. +export function parseLinearDefaultTeamId(input: unknown, current: string | null): string | null { + if (input === undefined) return current; + if (input === null) return null; + if (typeof input !== "string") { + throw new InvalidLinearDefaultTeamError("linearDefaultTeamId must be a string or null"); + } + const teamId = input.trim(); + if (!teamId) return null; + if (teamId.length > MAX_LINEAR_TEAM_ID_LEN) { + throw new InvalidLinearDefaultTeamError("linearDefaultTeamId is too long"); + } + return teamId; +} + +export function linearDefaultTeamExists( + teamId: string | null, + teams: ReadonlyArray<{ id: string }>, +): boolean { + if (!teamId) return true; + return teams.some((team) => team.id === teamId); +} export function buildAutomationSettingsConflictUpdate( body: Record, @@ -62,6 +90,9 @@ export function buildAutomationSettingsConflictUpdate { return c.json({ branches: result.branches }); }); +// Teams in the project's connected Linear workspace. Powers the default-team +// picker for agent-filed tickets in Settings. +app.get("/api/projects/:projectId/linear/teams", async (c) => { + const projectId = c.req.param("projectId"); + await requireProjectAccess(c, projectId); + + const result = await listProjectLinearTeams(projectId); + if (!result.installed) return c.json({ installed: false, teams: [] }); + if (result.errored) return c.json({ error: "failed to load teams from Linear" }, 502); + return c.json({ installed: true, teams: result.teams }); +}); + app.patch("/api/projects/:projectId/automation", async (c) => { const projectId = c.req.param("projectId"); await requireProjectManager(c, projectId); @@ -2075,6 +2094,7 @@ app.patch("/api/projects/:projectId/automation", async (c) => { prPolicy?: unknown; approvalPromptsEnabled?: unknown; createLinearTicketOnResolve?: unknown; + linearDefaultTeamId?: unknown; autoResolveStaleIncidentsEnabled?: unknown; prBaseBranch?: unknown; autoMergeFixPrs?: unknown; @@ -2139,6 +2159,16 @@ app.patch("/api/projects/:projectId/automation", async (c) => { typeof body.createLinearTicketOnResolve === "boolean" ? body.createLinearTicketOnResolve : current.createLinearTicketOnResolve; + const linearDefaultTeamId = (() => { + try { + return parseLinearDefaultTeamId(body.linearDefaultTeamId, current.linearDefaultTeamId); + } catch (err) { + if (err instanceof InvalidLinearDefaultTeamError) { + throw new HTTPException(400, { message: err.message }); + } + throw err; + } + })(); const autoResolveStaleIncidentsEnabled = typeof body.autoResolveStaleIncidentsEnabled === "boolean" ? body.autoResolveStaleIncidentsEnabled @@ -2187,6 +2217,23 @@ app.patch("/api/projects/:projectId/automation", async (c) => { } } + // Same shape as the branch check: a team that doesn't exist would make ticket + // filing fail, so reject it up front, but don't block the save when Linear + // can't be reached. + if (linearDefaultTeamId && linearDefaultTeamId !== current.linearDefaultTeamId) { + const linearTeams = await listProjectLinearTeams(projectId); + if (!linearTeams.installed) { + throw new HTTPException(400, { + message: "Connect Linear to this project before choosing a default team.", + }); + } + if (!linearTeams.errored && !linearDefaultTeamExists(linearDefaultTeamId, linearTeams.teams)) { + throw new HTTPException(400, { + message: `Linear team "${linearDefaultTeamId}" was not found in the connected workspace.`, + }); + } + } + const updatedAt = new Date(); const values = { projectId, @@ -2202,6 +2249,7 @@ app.patch("/api/projects/:projectId/automation", async (c) => { prPolicy, approvalPromptsEnabled, createLinearTicketOnResolve, + linearDefaultTeamId, autoResolveStaleIncidentsEnabled, prBaseBranch, autoMergeFixPrs, diff --git a/apps/api/src/linear.ts b/apps/api/src/linear.ts index 2d7f9594..5953f538 100644 --- a/apps/api/src/linear.ts +++ b/apps/api/src/linear.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { + type LinearTeam, type ResolveIncidentInput, captureAgentPrLifecycleEvent, clearedLinearCredentialFields, @@ -13,13 +14,14 @@ import { enqueueIncidentCreated, ensureFreshLinearToken, exchangeLinearCode, - findActiveLinearInstallationByWebhookId, fetchLinearAgentSessionSourceType, fetchLinearViewer, + findActiveLinearInstallationByWebhookId, hydrateLinearInstallation, integrationSecretEncryptionConfigured, linearCredentialFields, linearTicketAcceptanceUnit, + listLinearTeams, recordInboundInteraction, recordInboundLinearChatMessage, resolveDefaultAgentRunProvider, @@ -898,6 +900,24 @@ export function mountLinearAuthed(app: Hono): void { }); } +export type ProjectLinearTeams = + | { installed: false } + | { installed: true; teams: LinearTeam[]; errored: boolean }; + +// Teams in the project's connected Linear workspace. Powers the default-team +// picker in Settings and validates the saved team id. +export async function listProjectLinearTeams(projectId: string): Promise { + const install = await findCurrentInstallation(projectId); + if (!install) return { installed: false }; + try { + const teams = await listLinearTeams(await resolveLinearAccessToken(install)); + return { installed: true, teams, errored: false }; + } catch (err) { + log.warn({ err, project_id: projectId }, "linear teams query failed"); + return { installed: true, teams: [], errored: true }; + } +} + async function findCurrentInstallation(projectId: string) { const row = await db.query.linearInstallations.findFirst({ where: and( diff --git a/apps/web/src/Settings.tsx b/apps/web/src/Settings.tsx index d9db01d6..9aa9814b 100644 --- a/apps/web/src/Settings.tsx +++ b/apps/web/src/Settings.tsx @@ -10,6 +10,7 @@ import { type IssueFilterClause, type IssueFilterConfig, type IssueFilterPreviewEvent, + type LinearTeam, type LinearTicketInstruction, type PrPolicy, type RenderOwner, @@ -43,6 +44,7 @@ import { useIssueFilterPreview, useKeys, useLinearInstallation, + useLinearTeams, useMcpTokens, useMe, useMintOrgApiKey, @@ -3529,6 +3531,7 @@ function AgentFlowchart({ projectId }: { projectId: string | undefined }) { const linearNeedsReauth = linear.data?.installed === true && linear.data.needsReauth; const githubConnected = github.data?.installed === true; const branches = useGithubBranches(projectId, githubConnected); + const linearTeams = useLinearTeams(projectId); const data: AgentSettings = settings.data ?? { customInstructions: "", @@ -3538,6 +3541,7 @@ function AgentFlowchart({ projectId }: { projectId: string | undefined }) { prPolicy: "on_ready_to_pr", approvalPromptsEnabled: true, createLinearTicketOnResolve: false, + linearDefaultTeamId: null, autoResolveStaleIncidentsEnabled: true, prBaseBranch: null, autoMergeFixPrs: "never", @@ -3640,6 +3644,15 @@ function AgentFlowchart({ projectId }: { projectId: string | undefined }) { incident. Paused and failed investigations do not create tickets; resolve-time filing is controlled below.

+ patch({ linearDefaultTeamId: v })} + />
@@ -3742,6 +3755,94 @@ function InactiveIncidentResolutionSettings({ ); } +// The empty-string option means "use the workspace's first team" — it maps to +// a null linearDefaultTeamId on save. +const LINEAR_WORKSPACE_FIRST_TEAM = ""; + +function LinearDefaultTeamField({ + value, + connected, + teams, + loading, + loadError, + disabled, + onSave, +}: { + value: string | null; + connected: boolean; + teams: LinearTeam[]; + loading: boolean; + loadError: boolean; + disabled: boolean; + onSave: (value: string | null) => void; +}) { + const [draft, setDraft] = useState(value ?? LINEAR_WORKSPACE_FIRST_TEAM); + useEffect(() => setDraft(value ?? LINEAR_WORKSPACE_FIRST_TEAM), [value]); + const dirty = draft !== (value ?? LINEAR_WORKSPACE_FIRST_TEAM); + const pickerDisabled = disabled || loading || loadError || !connected; + + const options: DropdownOption[] = [ + { + value: LINEAR_WORKSPACE_FIRST_TEAM, + label: "Workspace's first team", + searchText: "Workspace's first team", + }, + ...teams.map((team) => ({ + value: team.id, + searchText: `${team.name} ${team.key}`, + label: ( + + {team.name} + {team.key} + + ), + })), + ]; + // Keep a saved team visible even if it's no longer in the workspace list. + if (value && !teams.some((team) => team.id === value) && !loading && !loadError) { + options.push({ value, searchText: value, label: {value} }); + } + + return ( +
+ Default team +
+
+ +
+ onSave(draft || null)} + > + Save + +
+

+ {!connected + ? "Linear isn't connected to this project." + : loadError + ? "Couldn't load teams from Linear. Try again, or reconnect Linear." + : "The Linear team agent tickets are filed under."} +

+
+ ); +} + // The empty-string option means "use the repository default branch" — it maps // to a null prBaseBranch on save. const REPO_DEFAULT_BRANCH = ""; diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index ac2877a5..943ded04 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1641,6 +1641,22 @@ export function useLinearInstallation() { }); } +export type LinearTeam = { id: string; key: string; name: string }; + +// Teams in the given project's connected Linear workspace. Used by the +// default-team picker for agent-filed tickets in Settings. +export function useLinearTeams(projectId: string | undefined) { + const fetcher = useFetcher(); + return useQuery({ + queryKey: ["linear-teams", projectId], + queryFn: () => + fetcher<{ installed: boolean; teams: LinearTeam[] }>( + `/api/projects/${projectId}/linear/teams`, + ), + enabled: !!projectId, + }); +} + export function useStartLinearInstall() { const fetcher = useFetcher(); return useMutation({ @@ -1959,6 +1975,7 @@ export type AgentSettings = { prPolicy: PrPolicy; approvalPromptsEnabled: boolean; createLinearTicketOnResolve: boolean; + linearDefaultTeamId: string | null; autoResolveStaleIncidentsEnabled: boolean; prBaseBranch: string | null; autoMergeFixPrs: AutoMergePolicy; diff --git a/apps/worker/src/agent-runs/linear-delivery.test.ts b/apps/worker/src/agent-runs/linear-delivery.test.ts index 95263720..501d709d 100644 --- a/apps/worker/src/agent-runs/linear-delivery.test.ts +++ b/apps/worker/src/agent-runs/linear-delivery.test.ts @@ -101,6 +101,50 @@ test("uses the configured default team without listing teams", async () => { assert.equal((deps.calls.createIssue[0] as { teamId: string }).teamId, "team-9"); }); +test("falls back to the workspace's first team when the configured team is gone", async () => { + const warnings: string[] = []; + const deps = makeDeps({ + log: (level, _fields, msg) => { + if (level === "warn") warnings.push(msg); + }, + }); + const createIssue = deps.createIssue; + deps.createIssue = async (args) => { + if (args.teamId === "team-deleted") + throw new Error("linear issueCreate failed: team not found"); + return createIssue(args); + }; + const ticket = await deliverLinearTicketWithDeps( + { ...BASE_ARGS, defaultTeamId: "team-deleted" }, + RESULT, + deps, + ); + assert.equal(ticket?.created, true); + assert.equal(deps.calls.listTeams.length, 1); + assert.deepEqual( + deps.calls.createIssue.map((c) => (c as { teamId: string }).teamId), + ["team-1"], + ); + assert.equal(warnings.length, 1); +}); + +test("does not retry under another team when the configured team still exists", async () => { + const attempted: string[] = []; + const deps = makeDeps({ + createIssue: async (args) => { + attempted.push(args.teamId); + throw new Error("linear issueCreate failed: 500"); + }, + }); + const ticket = await deliverLinearTicketWithDeps( + { ...BASE_ARGS, defaultTeamId: "team-1" }, + RESULT, + deps, + ); + assert.equal(ticket, null); + assert.deepEqual(attempted, ["team-1"]); +}); + test("reuses the ticket already recorded for the same completed investigation", async () => { const deps = makeDeps({ findKnownTicket: async () => ({ diff --git a/apps/worker/src/agent-runs/linear-delivery.ts b/apps/worker/src/agent-runs/linear-delivery.ts index cb944a1f..5482097a 100644 --- a/apps/worker/src/agent-runs/linear-delivery.ts +++ b/apps/worker/src/agent-runs/linear-delivery.ts @@ -166,10 +166,44 @@ export async function deliverLinearTicketWithDeps( }; } - let teamId = args.defaultTeamId; - if (!teamId) { - const teams = await deps.listTeams(); - teamId = teams[0]?.id ?? null; + const description = ticketDescription( + { + incidentId: args.incidentId, + agentRunId: args.agentRunId, + incidentTitle: args.incidentTitle, + orgSlug: args.orgSlug, + projectSlug: args.projectSlug, + }, + result, + args.prUrls, + ); + const createUnder = (teamId: string) => + deps.createIssue({ teamId, title: args.incidentTitle, description }); + + let issue: LinearIssueRef; + if (args.defaultTeamId) { + const configuredTeamId = args.defaultTeamId; + try { + issue = await createUnder(configuredTeamId); + } catch (err) { + // A configured team that was deleted, or belongs to a workspace the + // project has since been reconnected away from, would otherwise fail + // every delivery. Fall back to the first team only when the configured + // team is confirmed missing; any other failure propagates. + if (isRevokedTokenError(err)) throw err; + const teams = await deps.listTeams(); + if (teams.some((team) => team.id === configuredTeamId)) throw err; + const fallbackTeamId = teams[0]?.id; + if (!fallbackTeamId) throw err; + deps.log( + "warn", + { configured_team_id: configuredTeamId, team_id: fallbackTeamId }, + "configured linearDefaultTeamId not found in the workspace; using the workspace's first team", + ); + issue = await createUnder(fallbackTeamId); + } + } else { + const teamId = (await deps.listTeams())[0]?.id; if (!teamId) { deps.log("warn", {}, "linear workspace has no teams; skipping ticket delivery"); return null; @@ -179,22 +213,8 @@ export async function deliverLinearTicketWithDeps( { team_id: teamId }, "no linearDefaultTeamId configured; using the workspace's first team", ); + issue = await createUnder(teamId); } - const issue = await deps.createIssue({ - teamId, - title: args.incidentTitle, - description: ticketDescription( - { - incidentId: args.incidentId, - agentRunId: args.agentRunId, - incidentTitle: args.incidentTitle, - orgSlug: args.orgSlug, - projectSlug: args.projectSlug, - }, - result, - args.prUrls, - ), - }); return { ticketId: issue.id, identifier: issue.identifier, url: issue.url, created: true }; } catch (err) { if (isRevokedTokenError(err)) { diff --git a/packages/db/src/linear-agent.test.ts b/packages/db/src/linear-agent.test.ts index 908e33b7..64fe6c27 100644 --- a/packages/db/src/linear-agent.test.ts +++ b/packages/db/src/linear-agent.test.ts @@ -75,3 +75,41 @@ test("updateLinearAgentSession links the delegated issue to its incident", async globalThis.fetch = originalFetch; } }); + +test("listLinearTeams follows pagination until the last page", async () => { + const { listLinearTeams } = await import("./linear.js"); + const originalFetch = globalThis.fetch; + const cursors: unknown[] = []; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { variables: { after: string | null } }; + cursors.push(body.variables.after); + const firstPage = body.variables.after === null; + return new Response( + JSON.stringify({ + data: { + teams: firstPage + ? { + nodes: [{ id: "t1", key: "ONE", name: "One" }], + pageInfo: { hasNextPage: true, endCursor: "c1" }, + } + : { + nodes: [{ id: "t2", key: "TWO", name: "Two" }], + pageInfo: { hasNextPage: false, endCursor: "c2" }, + }, + }, + }), + { status: 200 }, + ); + }; + + try { + const teams = await listLinearTeams("token"); + assert.deepEqual( + teams.map((t) => t.id), + ["t1", "t2"], + ); + assert.deepEqual(cursors, [null, "c1"]); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/db/src/linear.ts b/packages/db/src/linear.ts index adcba47e..62c9a5e3 100644 --- a/packages/db/src/linear.ts +++ b/packages/db/src/linear.ts @@ -212,14 +212,28 @@ async function linearGraphql( export type LinearTeam = { id: string; key: string; name: string }; +const LINEAR_TEAMS_PAGE_SIZE = 250; +const LINEAR_TEAMS_MAX_PAGES = 20; + +// Every team in the workspace, in Linear's default order (the first entry is +// the ticket-delivery fallback). export async function listLinearTeams(accessToken: string): Promise { - const data = await linearGraphql<{ teams: { nodes: LinearTeam[] } }>( - accessToken, - "query { teams(first: 50) { nodes { id key name } } }", - {}, - "teams query", - ); - return data.teams.nodes; + const teams: LinearTeam[] = []; + let after: string | null = null; + for (let page = 0; page < LINEAR_TEAMS_MAX_PAGES; page++) { + const data: { + teams: { nodes: LinearTeam[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } }; + } = await linearGraphql( + accessToken, + "query($first: Int!, $after: String) { teams(first: $first, after: $after) { nodes { id key name } pageInfo { hasNextPage endCursor } } }", + { first: LINEAR_TEAMS_PAGE_SIZE, after }, + "teams query", + ); + teams.push(...data.teams.nodes); + if (!data.teams.pageInfo.hasNextPage || !data.teams.pageInfo.endCursor) break; + after = data.teams.pageInfo.endCursor; + } + return teams; } /**