Skip to content
Open
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
29 changes: 29 additions & 0 deletions apps/api/migrations/172-org-repo-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Kysely } from "kysely";

/**
* Per-repo overrides of the review flags, keyed by lowercased `owner/name`.
*
* The three review toggles (QA Agent, Code Reviewer, auto-merge) were org-wide,
* so a workspace with several repos had to pick one review setup for all of
* them. This bag holds only the deviations: `{"decocms/studio": {"auto_merge":
* false}}`. A repo with no entry — every repo, before anyone configures one —
* reads the org flag exactly as it did, which is what makes the column
* additive.
*
* Shape and the read path live in ONE place,
* `@decocms/shared/organization/schema.ts` (`OrgRepoFlagsSchema`,
* `flagsForRepo`). Nullable on purpose: NULL means "no repo ever overridden".
*/
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.alterTable("organization_settings")
.addColumn("repo_flags", "jsonb")
.execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema
.alterTable("organization_settings")
.dropColumn("repo_flags")
.execute();
}
2 changes: 2 additions & 0 deletions apps/api/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ import * as migration168orgreposync from "./168-org-repo-sync.ts";
import * as migration169taskboardmergefailedactivity from "./169-task-board-merge-failed-activity.ts";
import * as migration170taskboarditemrepo from "./170-task-board-item-repo.ts";
import * as migration171jiraintegration from "./171-jira-integration.ts";
import * as migration172orgrepoflags from "./172-org-repo-flags.ts";

/**
* Core migrations for the Studio application.
Expand Down Expand Up @@ -369,6 +370,7 @@ const migrations: Record<string, Migration> = {
migration169taskboardmergefailedactivity,
"170-task-board-item-repo": migration170taskboarditemrepo,
"171-jira-integration": migration171jiraintegration,
"172-org-repo-flags": migration172orgrepoflags,
};

export default migrations;
71 changes: 71 additions & 0 deletions apps/api/src/storage/organization-settings.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,74 @@ describe("OrganizationSettingsStorage — flags bag", () => {
expect((await storage.get("org_1"))?.flags).toBeNull();
});
});

describe("OrganizationSettingsStorage — per-repo flags", () => {
let database: StudioDatabase;
let storage: OrganizationSettingsStorage;

beforeAll(async () => {
database = await connectTestPgDatabase();
await resetTestPgDatabase(database);
await seedCommonTestPgFixtures(database);
storage = new OrganizationSettingsStorage(database.db);
});

afterAll(async () => {
await closeTestPgDatabase(database);
});

beforeEach(async () => {
await database.db.deleteFrom("organization_settings").execute();
});

it("round-trips a repo override through insert", async () => {
await storage.upsert("org_1", {
repo_flags: { "decocms/studio": { auto_merge: false } },
});
const got = await storage.get("org_1");
expect(got?.repo_flags).toEqual({
"decocms/studio": { auto_merge: false },
});
});

it("merges two levels deep: other repos AND the repo's other flags survive", async () => {
await storage.upsert("org_1", {
repo_flags: {
"decocms/studio": { auto_merge: true, qa_agent_enabled: false },
"decocms/context": { auto_merge: true },
},
});
await storage.upsert("org_1", {
repo_flags: { "decocms/studio": { auto_merge: false } },
});

const got = await storage.get("org_1");
expect(got?.repo_flags).toEqual({
"decocms/studio": { auto_merge: false, qa_agent_enabled: false },
"decocms/context": { auto_merge: true },
});
});

it("a null override persists — that's how a repo goes back to inheriting", async () => {
await storage.upsert("org_1", {
repo_flags: { "decocms/studio": { auto_merge: true } },
});
await storage.upsert("org_1", {
repo_flags: { "decocms/studio": { auto_merge: null } },
});
const got = await storage.get("org_1");
expect(got?.repo_flags).toEqual({ "decocms/studio": { auto_merge: null } });
});

it("writing org flags leaves repo overrides untouched, and vice versa", async () => {
await storage.upsert("org_1", {
repo_flags: { "decocms/studio": { auto_merge: false } },
});
await storage.upsert("org_1", { flags: { auto_merge: true } });
const got = await storage.get("org_1");
expect(got?.flags).toEqual({ auto_merge: true });
expect(got?.repo_flags).toEqual({
"decocms/studio": { auto_merge: false },
});
});
});
38 changes: 38 additions & 0 deletions apps/api/src/storage/organization-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@ import { sql, type Kysely } from "kysely";
import type { Database, OrganizationSettings } from "./types";
import type { OrganizationSettingsStoragePort } from "./ports";

/**
* Atomic two-level merge for `repo_flags`: repos absent from the write keep
* their stored entry, and a written repo's entry merges key-by-key so setting
* one toggle never clears that repo's other two.
*
* The org-level `flags || $new` concat can't do this — at the top level it would
* REPLACE a repo's whole entry. The repo keys are known here (they come from the
* caller's write), so the expression is built per key rather than in a
* correlated subquery over `jsonb_each`.
*/
function mergeRepoFlagsSql(repoFlags: Record<string, unknown>) {
const entries = Object.entries(repoFlags);
if (entries.length === 0) {
return sql<string>`coalesce("organization_settings"."repo_flags", '{}'::jsonb)`;
}
const merged = entries.map(
([repo, flags]) =>
sql`${repo}::text, coalesce("organization_settings"."repo_flags" -> ${repo}::text, '{}'::jsonb) || ${JSON.stringify(flags)}::jsonb`,
);
return sql<string>`coalesce("organization_settings"."repo_flags", '{}'::jsonb) || jsonb_build_object(${sql.join(merged, sql`, `)})`;
}

export class OrganizationSettingsStorage
implements OrganizationSettingsStoragePort
{
Expand Down Expand Up @@ -50,6 +72,11 @@ export class OrganizationSettingsStorage
? JSON.parse(record.flags)
: record.flags
: null,
repo_flags: record.repo_flags
? typeof record.repo_flags === "string"
? JSON.parse(record.repo_flags)
: record.repo_flags
: null,
main_agent_id: record.main_agent_id ?? null,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
Expand All @@ -67,6 +94,7 @@ export class OrganizationSettingsStorage
| "simple_mode"
| "default_home_agents"
| "flags"
| "repo_flags"
| "main_agent_id"
>
>,
Expand All @@ -88,6 +116,12 @@ export class OrganizationSettingsStorage
? JSON.stringify(data.default_home_agents)
: null;
const flagsJson = data?.flags ? JSON.stringify(data.flags) : null;
const repoFlagsJson = data?.repo_flags
? JSON.stringify(data.repo_flags)
: null;
const repoFlagsMerge = data?.repo_flags
? mergeRepoFlagsSql(data.repo_flags)
: undefined;
await this.db
.insertInto("organization_settings")
.values({
Expand All @@ -98,6 +132,7 @@ export class OrganizationSettingsStorage
simple_mode: simpleModeJson,
default_home_agents: defaultHomeAgentsJson,
flags: flagsJson,
repo_flags: repoFlagsJson,
main_agent_id: data?.main_agent_id ?? null,
createdAt: now,
updatedAt: now,
Expand All @@ -117,6 +152,8 @@ export class OrganizationSettingsStorage
flags: flagsJson
? sql<string>`coalesce("organization_settings"."flags", '{}'::jsonb) || ${flagsJson}::jsonb`
: undefined,
// Per-repo overrides merge one level DEEPER — see mergeRepoFlagsSql.
repo_flags: repoFlagsMerge,
// Nullable id: explicit `null` clears the main agent; `undefined`
// (field absent) skips the column so partial updates don't wipe it.
main_agent_id: data?.main_agent_id,
Expand All @@ -136,6 +173,7 @@ export class OrganizationSettingsStorage
simple_mode: data?.simple_mode ?? null,
default_home_agents: data?.default_home_agents ?? null,
flags: data?.flags ?? null,
repo_flags: data?.repo_flags ?? null,
main_agent_id: data?.main_agent_id ?? null,
createdAt: now,
updatedAt: now,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/storage/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export interface OrganizationSettingsStoragePort {
| "simple_mode"
| "default_home_agents"
| "flags"
| "repo_flags"
| "main_agent_id"
>
>,
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { ChatMessage } from "../api/routes/decopilot/types";
import type { ProviderId, ThreadStatus } from "@decocms/shared/sdk";
import type {
OrgFlags,
OrgRepoFlags,
UserModelPreferences,
} from "@decocms/shared/organization/schema";
import type { ThreadMetadata } from "@decocms/shared/entities";
Expand Down Expand Up @@ -184,6 +185,9 @@ export interface OrganizationSettingsTable {
// Boolean toggles bag — the flag set lives in OrgFlagsSchema
// (@decocms/shared/organization/schema); updates shallow-merge.
flags: JsonObject<OrgFlags> | null;
/** Per-repo overrides of the review flags, keyed by lowercased `owner/name`
* (OrgRepoFlagsSchema); a repo with no entry inherits `flags`. */
repo_flags: JsonObject<OrgRepoFlags> | null;
// Virtual MCP id the org lands on (`/$org`) instead of the Super Agent.
main_agent_id: string | null;
createdAt: ColumnType<Date, Date | string, never>;
Expand All @@ -198,6 +202,7 @@ export interface OrganizationSettings {
simple_mode: SimpleModeConfig | null;
default_home_agents: DefaultHomeAgentsConfig | null;
flags: OrgFlags | null;
repo_flags: OrgRepoFlags | null;
main_agent_id: string | null;
createdAt: Date | string;
updatedAt: Date | string;
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/tools/organization/settings-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
SimpleModeConfigSchema,
DefaultHomeAgentsConfigSchema,
OrgFlagsSchema,
OrgRepoFlagsSchema,
} from "@decocms/shared/organization/schema";

export const ORGANIZATION_SETTINGS_GET = defineTool({
Expand All @@ -30,6 +31,7 @@ export const ORGANIZATION_SETTINGS_GET = defineTool({
simple_mode: SimpleModeConfigSchema.nullable().optional(),
default_home_agents: DefaultHomeAgentsConfigSchema.nullable().optional(),
flags: OrgFlagsSchema.nullable().optional(),
repo_flags: OrgRepoFlagsSchema.nullable().optional(),
main_agent_id: z.string().nullable().optional(),
createdAt: z.string().datetime().optional().describe("ISO 8601 timestamp"),
updatedAt: z.string().datetime().optional().describe("ISO 8601 timestamp"),
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/tools/organization/settings-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
SimpleModeConfigSchema,
DefaultHomeAgentsConfigSchema,
OrgFlagsSchema,
OrgRepoFlagsSchema,
RepoFlagsSchema,
repoFlagsKey,
} from "@decocms/shared/organization/schema";

export const ORGANIZATION_SETTINGS_UPDATE = defineTool({
Expand Down Expand Up @@ -35,6 +38,22 @@ export const ORGANIZATION_SETTINGS_UPDATE = defineTool({
.describe(
"Org boolean toggles. Shallow-merged into the stored flags: keys you pass win (explicit false persists), omitted keys keep their value.",
),
repo_flags: z
// Keys are validated as `owner/name` so a typo'd or bogus key can't
// accumulate as junk in the bag (nothing ever reads it back).
.record(
z
.string()
.regex(
/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/,
"Repo key must be `owner/name`",
),
RepoFlagsSchema.strict(),
)
.optional()
.describe(
"Per-repo overrides of the review flags, keyed by `owner/name`. Merged two levels deep: repos you omit keep their overrides, and within a repo you pass, omitted keys keep their value. Pass a flag as null to drop the override and inherit the org default.",
),
main_agent_id: z
.string()
.nullable()
Expand All @@ -52,6 +71,7 @@ export const ORGANIZATION_SETTINGS_UPDATE = defineTool({
simple_mode: SimpleModeConfigSchema.nullable().optional(),
default_home_agents: DefaultHomeAgentsConfigSchema.nullable().optional(),
flags: OrgFlagsSchema.nullable().optional(),
repo_flags: OrgRepoFlagsSchema.nullable().optional(),
main_agent_id: z.string().nullable().optional(),
createdAt: z.string().datetime().describe("ISO 8601 timestamp"),
updatedAt: z.string().datetime().describe("ISO 8601 timestamp"),
Expand All @@ -70,6 +90,16 @@ export const ORGANIZATION_SETTINGS_UPDATE = defineTool({
throw new Error("Cannot update settings for a different organization");
}

// Stored lowercased so a task's `owner/name` matches however it was cased.
const repoFlags = input.repo_flags
? Object.fromEntries(
Object.entries(input.repo_flags).map(([repo, flags]) => [
repoFlagsKey(repo) ?? repo,
flags,
]),
)
: undefined;

const settings = await ctx.storage.organizationSettings.upsert(
input.organizationId,
{
Expand All @@ -79,6 +109,7 @@ export const ORGANIZATION_SETTINGS_UPDATE = defineTool({
simple_mode: input.simple_mode,
default_home_agents: input.default_home_agents,
flags: input.flags,
repo_flags: repoFlags,
main_agent_id: input.main_agent_id,
},
);
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/tools/task-board/conflict-reaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ReviewCycleActivity,
SUPER_AGENT_ASSIGNEE_ID,
} from "@decocms/shared/task-board";
import { flagsForRepo } from "@decocms/shared/organization/schema";
import { recordTaskActivity } from "./activity";
import { emitTaskBoardUpdated, parkOnRunsExhausted } from "./run-reactions";
import { enqueueSuperAgentForTask } from "./enqueue-super-agent";
Expand Down Expand Up @@ -75,7 +76,7 @@ export async function reactToApprovedPrConflict(
if (opts.conflict !== true) return false;

const settings = await ctx.storage.organizationSettings.get(orgId);
const flags = settings?.flags ?? {};
const flags = flagsForRepo(settings, item.repo);
if (flags.auto_merge !== true) return false;

// Same gate as the auto-merge: EVERY enabled reviewer must have a
Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/tools/task-board/enqueue-reviewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { enqueueAgentRunForTask } from "./enqueue-task-run";
import { resolveTaskRepoChoice } from "./claude-code-task-run";
import { isThreadRunStale } from "@/tools/thread/helpers";
import { mintReviewToken } from "./review-token";
import { orgFlagEnabled } from "@decocms/shared/organization/schema";
import {
flagsForRepo,
orgFlagEnabled,
} from "@decocms/shared/organization/schema";
import type { ClaudeCodeModelClass } from "@/harnesses/claude-code-env";

/** Thread statuses past which a reviewer run is done — a live run has a
Expand Down Expand Up @@ -143,7 +146,7 @@ export async function enqueueEnabledReviewers(
const settings = await ctx.storage.organizationSettings.get(
task.organizationId,
);
const enabled = enabledReviewerKinds(settings?.flags);
const enabled = enabledReviewerKinds(flagsForRepo(settings, task.repo));
if (enabled.length === 0) return;
const modelClass: ClaudeCodeModelClass = orgFlagEnabled(
settings?.flags,
Expand Down
Loading
Loading