From f6b85493b9314297c0b8bf54f3505037164f4f92 Mon Sep 17 00:00:00 2001
From: Arseniy Shishaev <35579920+arseniycodes@users.noreply.github.com>
Date: Thu, 15 Oct 2026 10:43:24 +0200
Subject: [PATCH 1/2] Let projects choose the Linear team for agent tickets
The worker already reads project_automation_settings.linear_default_team_id
when filing agent tickets, but nothing could write it, so every workspace got
its first team.
- GET /api/projects/:projectId/linear/teams lists the connected workspace's
teams.
- PATCH /api/projects/:projectId/automation accepts linearDefaultTeamId
(null or blank clears it) and rejects ids that aren't in the workspace,
skipping the check when Linear can't be reached.
- Settings > File Linear ticket gets a Default team picker.
- The teams query pages 250 instead of 50 so larger workspaces list fully.
---
.../src/automation-settings-update.test.ts | 55 +++++++++++
apps/api/src/automation-settings-update.ts | 31 ++++++
apps/api/src/index.ts | 52 +++++++++-
apps/api/src/linear.ts | 20 ++++
apps/web/src/Settings.tsx | 96 +++++++++++++++++++
apps/web/src/api.ts | 14 +++
packages/db/src/linear.ts | 2 +-
7 files changed, 267 insertions(+), 3 deletions(-)
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({ error: "linear not connected" }, 404);
+ if (result.errored) return c.json({ error: "failed to load teams from Linear" }, 502);
+ return c.json({ 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..f24ec8f6 100644
--- a/apps/api/src/linear.ts
+++ b/apps/api/src/linear.ts
@@ -18,8 +18,10 @@ import {
fetchLinearViewer,
hydrateLinearInstallation,
integrationSecretEncryptionConfigured,
+ type LinearTeam,
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..b2246e5e 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, linearConnected);
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,14 @@ 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 +3754,90 @@ 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,
+ teams,
+ loading,
+ loadError,
+ disabled,
+ onSave,
+}: {
+ value: string | null;
+ 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;
+
+ 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
+
+
+
+ {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..7c88d979 100644
--- a/apps/web/src/api.ts
+++ b/apps/web/src/api.ts
@@ -1641,6 +1641,19 @@ export function useLinearInstallation() {
});
}
+export type LinearTeam = { id: string; key: string; name: string };
+
+// Teams in the project's connected Linear workspace. Used by the default-team
+// picker for agent-filed tickets in Settings.
+export function useLinearTeams(projectId: string | undefined, enabled: boolean) {
+ const fetcher = useFetcher();
+ return useQuery({
+ queryKey: ["linear-teams", projectId],
+ queryFn: () => fetcher<{ teams: LinearTeam[] }>(`/api/projects/${projectId}/linear/teams`),
+ enabled: enabled && !!projectId,
+ });
+}
+
export function useStartLinearInstall() {
const fetcher = useFetcher();
return useMutation({
@@ -1959,6 +1972,7 @@ export type AgentSettings = {
prPolicy: PrPolicy;
approvalPromptsEnabled: boolean;
createLinearTicketOnResolve: boolean;
+ linearDefaultTeamId: string | null;
autoResolveStaleIncidentsEnabled: boolean;
prBaseBranch: string | null;
autoMergeFixPrs: AutoMergePolicy;
diff --git a/packages/db/src/linear.ts b/packages/db/src/linear.ts
index adcba47e..cefe8152 100644
--- a/packages/db/src/linear.ts
+++ b/packages/db/src/linear.ts
@@ -215,7 +215,7 @@ export type LinearTeam = { id: string; key: string; name: string };
export async function listLinearTeams(accessToken: string): Promise
{
const data = await linearGraphql<{ teams: { nodes: LinearTeam[] } }>(
accessToken,
- "query { teams(first: 50) { nodes { id key name } } }",
+ "query { teams(first: 250) { nodes { id key name } } }",
{},
"teams query",
);
From 86b2dda1ee42bd07d855ba5b22610382c84f317c Mon Sep 17 00:00:00 2001
From: Arseniy Shishaev <35579920+arseniycodes@users.noreply.github.com>
Date: Thu, 15 Oct 2026 10:49:05 +0200
Subject: [PATCH 2/2] Address review: project-scoped picker, team pagination,
stale-team fallback
- The teams endpoint returns { installed: false } for a project without
Linear, and the picker queries the selected project directly instead of
gating on the active project's installation.
- listLinearTeams follows pageInfo cursors instead of reading one page.
- Ticket delivery falls back to the workspace's first team when the
configured team is confirmed missing (deleted, or the project was
reconnected to another workspace). Other create failures still propagate,
and the happy path makes no extra teams call.
---
apps/api/src/index.ts | 4 +-
apps/api/src/linear.ts | 4 +-
apps/web/src/Settings.tsx | 15 +++--
apps/web/src/api.ts | 13 +++--
.../src/agent-runs/linear-delivery.test.ts | 44 ++++++++++++++
apps/worker/src/agent-runs/linear-delivery.ts | 58 +++++++++++++------
packages/db/src/linear-agent.test.ts | 38 ++++++++++++
packages/db/src/linear.ts | 28 ++++++---
8 files changed, 164 insertions(+), 40 deletions(-)
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 11f003ae..d87194b3 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -2071,9 +2071,9 @@ app.get("/api/projects/:projectId/linear/teams", async (c) => {
await requireProjectAccess(c, projectId);
const result = await listProjectLinearTeams(projectId);
- if (!result.installed) return c.json({ error: "linear not connected" }, 404);
+ 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({ teams: result.teams });
+ return c.json({ installed: true, teams: result.teams });
});
app.patch("/api/projects/:projectId/automation", async (c) => {
diff --git a/apps/api/src/linear.ts b/apps/api/src/linear.ts
index f24ec8f6..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,12 +14,11 @@ import {
enqueueIncidentCreated,
ensureFreshLinearToken,
exchangeLinearCode,
- findActiveLinearInstallationByWebhookId,
fetchLinearAgentSessionSourceType,
fetchLinearViewer,
+ findActiveLinearInstallationByWebhookId,
hydrateLinearInstallation,
integrationSecretEncryptionConfigured,
- type LinearTeam,
linearCredentialFields,
linearTicketAcceptanceUnit,
listLinearTeams,
diff --git a/apps/web/src/Settings.tsx b/apps/web/src/Settings.tsx
index b2246e5e..9aa9814b 100644
--- a/apps/web/src/Settings.tsx
+++ b/apps/web/src/Settings.tsx
@@ -3531,7 +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, linearConnected);
+ const linearTeams = useLinearTeams(projectId);
const data: AgentSettings = settings.data ?? {
customInstructions: "",
@@ -3646,6 +3646,7 @@ function AgentFlowchart({ projectId }: { projectId: string | undefined }) {
setDraft(value ?? LINEAR_WORKSPACE_FIRST_TEAM), [value]);
const dirty = draft !== (value ?? LINEAR_WORKSPACE_FIRST_TEAM);
- const pickerDisabled = disabled || loading || loadError;
+ const pickerDisabled = disabled || loading || loadError || !connected;
const options: DropdownOption[] = [
{
@@ -3830,9 +3833,11 @@ function LinearDefaultTeamField({
- {loadError
- ? "Couldn't load teams from Linear. Try again, or reconnect Linear."
- : "The Linear team agent tickets are filed under."}
+ {!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."}
);
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts
index 7c88d979..943ded04 100644
--- a/apps/web/src/api.ts
+++ b/apps/web/src/api.ts
@@ -1643,14 +1643,17 @@ export function useLinearInstallation() {
export type LinearTeam = { id: string; key: string; name: string };
-// Teams in the project's connected Linear workspace. Used by the default-team
-// picker for agent-filed tickets in Settings.
-export function useLinearTeams(projectId: string | undefined, enabled: boolean) {
+// 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<{ teams: LinearTeam[] }>(`/api/projects/${projectId}/linear/teams`),
- enabled: enabled && !!projectId,
+ queryFn: () =>
+ fetcher<{ installed: boolean; teams: LinearTeam[] }>(
+ `/api/projects/${projectId}/linear/teams`,
+ ),
+ enabled: !!projectId,
});
}
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 cefe8152..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: 250) { 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;
}
/**