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;
}
/**