Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/api/src/automation-settings-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -23,6 +26,7 @@ function values(
prPolicy: "on_ready_to_pr",
approvalPromptsEnabled: true,
createLinearTicketOnResolve: false,
linearDefaultTeamId: null,
autoResolveStaleIncidentsEnabled: true,
prBaseBranch: null,
autoMergeFixPrs: "never",
Expand Down Expand Up @@ -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);
});
31 changes: 31 additions & 0 deletions apps/api/src/automation-settings-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type AutomationSettingsUpdateValues = {
prPolicy: string;
approvalPromptsEnabled: boolean;
createLinearTicketOnResolve: boolean;
linearDefaultTeamId: string | null;
autoResolveStaleIncidentsEnabled: boolean;
prBaseBranch: string | null;
autoMergeFixPrs: string;
Expand All @@ -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<T extends AutomationSettingsUpdateValues>(
body: Record<string, unknown>,
Expand Down Expand Up @@ -62,6 +90,9 @@ export function buildAutomationSettingsConflictUpdate<T extends AutomationSettin
...(typeof body.createLinearTicketOnResolve === "boolean"
? { createLinearTicketOnResolve: values.createLinearTicketOnResolve }
: {}),
...(body.linearDefaultTeamId !== undefined
? { linearDefaultTeamId: values.linearDefaultTeamId }
: {}),
...(typeof body.autoResolveStaleIncidentsEnabled === "boolean"
? { autoResolveStaleIncidentsEnabled: values.autoResolveStaleIncidentsEnabled }
: {}),
Expand Down
52 changes: 50 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ import { startAuthRateLimitCleanup } from "./auth-rate-limit-cleanup.js";
import { recordAuthRateLimited } from "./auth-rate-limit-metrics.js";
import { createDrizzleAuthRateLimitRepository } from "./auth-rate-limit-repository.js";
import { auth } from "./auth.js";
import { buildAutomationSettingsConflictUpdate } from "./automation-settings-update.js";
import {
InvalidLinearDefaultTeamError,
buildAutomationSettingsConflictUpdate,
linearDefaultTeamExists,
parseLinearDefaultTeamId,
} from "./automation-settings-update.js";
import { shouldRunMigrationsOnBoot } from "./boot-migrations.js";
import { createApiClickHouseClient } from "./clickhouse-client.js";
import { mountCloudConnectionsAuthed } from "./cloud-connections.js";
Expand Down Expand Up @@ -114,7 +119,7 @@ import {
buildIssueListItems,
parseIssueListWindow,
} from "./issues/list.js";
import { mountLinearAuthed, mountLinearPublic } from "./linear.js";
import { listProjectLinearTeams, mountLinearAuthed, mountLinearPublic } from "./linear.js";
import { logger } from "./logger.js";
import { mountManagementApi, mountOrgKeyManagementAuthed } from "./management.js";
import {
Expand Down Expand Up @@ -1433,6 +1438,7 @@ async function getProjectAutomation(projectId: string): Promise<{
prPolicy: schema.PrPolicy;
approvalPromptsEnabled: boolean;
createLinearTicketOnResolve: boolean;
linearDefaultTeamId: string | null;
autoResolveStaleIncidentsEnabled: boolean;
prBaseBranch: string | null;
autoMergeFixPrs: schema.AutoMergePolicy;
Expand All @@ -1455,6 +1461,7 @@ async function getProjectAutomation(projectId: string): Promise<{
prPolicy: row?.prPolicy ?? "on_ready_to_pr",
approvalPromptsEnabled: row?.approvalPromptsEnabled ?? true,
createLinearTicketOnResolve: row?.createLinearTicketOnResolve ?? false,
linearDefaultTeamId: row?.linearDefaultTeamId ?? null,
autoResolveStaleIncidentsEnabled: row?.autoResolveStaleIncidentsEnabled ?? true,
prBaseBranch: schema.normalizePrBaseBranch(row?.prBaseBranch),
autoMergeFixPrs: row?.autoMergeFixPrs ?? "never",
Expand Down Expand Up @@ -2057,6 +2064,18 @@ app.get("/api/projects/:projectId/github/branches", async (c) => {
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);
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -2202,6 +2249,7 @@ app.patch("/api/projects/:projectId/automation", async (c) => {
prPolicy,
approvalPromptsEnabled,
createLinearTicketOnResolve,
linearDefaultTeamId,
autoResolveStaleIncidentsEnabled,
prBaseBranch,
autoMergeFixPrs,
Expand Down
22 changes: 21 additions & 1 deletion apps/api/src/linear.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import {
type LinearTeam,
type ResolveIncidentInput,
captureAgentPrLifecycleEvent,
clearedLinearCredentialFields,
Expand All @@ -13,13 +14,14 @@ import {
enqueueIncidentCreated,
ensureFreshLinearToken,
exchangeLinearCode,
findActiveLinearInstallationByWebhookId,
fetchLinearAgentSessionSourceType,
fetchLinearViewer,
findActiveLinearInstallationByWebhookId,
hydrateLinearInstallation,
integrationSecretEncryptionConfigured,
linearCredentialFields,
linearTicketAcceptanceUnit,
listLinearTeams,
recordInboundInteraction,
recordInboundLinearChatMessage,
resolveDefaultAgentRunProvider,
Expand Down Expand Up @@ -898,6 +900,24 @@ export function mountLinearAuthed(app: Hono<any>): 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<ProjectLinearTeams> {
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(
Expand Down
Loading
Loading