From 56d01e4f4e276ad09d915c5042ad0de6fde4435a Mon Sep 17 00:00:00 2001 From: Deco Bot <114028756+decobot@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:43:22 +0000 Subject: [PATCH 1/5] Allow per-repo overrides of the review toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QA Agent / Code Reviewer / auto-merge toggles were workspace-wide, so a workspace with several repos had to run one review setup on all of them — a repo that wants reviewers but not auto-merge forced that choice on the rest. Adds an `organization_settings.repo_flags` jsonb bag keyed by lowercased `owner/name`, holding only the deviations, plus `flagsForRepo` as the single reader every review gate now goes through (enqueue, auto-merge, conflict handback, re-run, manual ship). A repo with no entry reads the org flag exactly as before, so the change is additive. Settings grows a "Per-repository overrides" section listing the connected repos with the three switches each, showing the effective value and a Reset that drops the row back to the workspace defaults. Co-Authored-By: Claude Opus 5 --- apps/api/migrations/172-org-repo-flags.ts | 29 ++++ apps/api/migrations/index.ts | 2 + .../organization-settings.integration.test.ts | 71 ++++++++ apps/api/src/storage/organization-settings.ts | 38 +++++ apps/api/src/storage/ports.ts | 1 + apps/api/src/storage/types.ts | 5 + .../src/tools/organization/settings-get.ts | 2 + .../src/tools/organization/settings-update.ts | 21 +++ .../src/tools/task-board/conflict-reaction.ts | 3 +- .../src/tools/task-board/enqueue-reviewer.ts | 7 +- apps/api/src/tools/task-board/merge-pr.ts | 12 +- .../tools/task-board/promote-to-production.ts | 3 +- apps/api/src/tools/task-board/rerun.ts | 11 +- .../src/tools/task-board/review-decision.ts | 5 +- .../settings/repo-review-settings.tsx | 155 ++++++++++++++++++ .../src/hooks/use-organization-settings.ts | 68 ++++++++ apps/web/src/i18n/en/settings.ts | 10 ++ apps/web/src/i18n/pt-br/settings.ts | 10 ++ apps/web/src/views/settings/org-general.tsx | 2 + .../shared/src/organization/schema.test.ts | 48 +++++- packages/shared/src/organization/schema.ts | 97 +++++++++++ packages/shared/src/tools/tool-io.ts | 32 ++++ 22 files changed, 620 insertions(+), 12 deletions(-) create mode 100644 apps/api/migrations/172-org-repo-flags.ts create mode 100644 apps/web/src/components/settings/repo-review-settings.tsx diff --git a/apps/api/migrations/172-org-repo-flags.ts b/apps/api/migrations/172-org-repo-flags.ts new file mode 100644 index 0000000000..7f8058c71c --- /dev/null +++ b/apps/api/migrations/172-org-repo-flags.ts @@ -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): Promise { + await db.schema + .alterTable("organization_settings") + .addColumn("repo_flags", "jsonb") + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("organization_settings") + .dropColumn("repo_flags") + .execute(); +} diff --git a/apps/api/migrations/index.ts b/apps/api/migrations/index.ts index e66aed5025..79fa962ffb 100644 --- a/apps/api/migrations/index.ts +++ b/apps/api/migrations/index.ts @@ -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. @@ -369,6 +370,7 @@ const migrations: Record = { migration169taskboardmergefailedactivity, "170-task-board-item-repo": migration170taskboarditemrepo, "171-jira-integration": migration171jiraintegration, + "172-org-repo-flags": migration172orgrepoflags, }; export default migrations; diff --git a/apps/api/src/storage/organization-settings.integration.test.ts b/apps/api/src/storage/organization-settings.integration.test.ts index 95b897fa67..d057460a5a 100644 --- a/apps/api/src/storage/organization-settings.integration.test.ts +++ b/apps/api/src/storage/organization-settings.integration.test.ts @@ -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 }, + }); + }); +}); diff --git a/apps/api/src/storage/organization-settings.ts b/apps/api/src/storage/organization-settings.ts index dd47eb2d67..b5fec53921 100644 --- a/apps/api/src/storage/organization-settings.ts +++ b/apps/api/src/storage/organization-settings.ts @@ -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) { + const entries = Object.entries(repoFlags); + if (entries.length === 0) { + return sql`coalesce("organization_settings"."repo_flags", '{}'::jsonb)`; + } + const merged = entries.map( + ([repo, flags]) => + sql`${repo}::text, coalesce("organization_settings"."repo_flags" -> ${repo}, '{}'::jsonb) || ${JSON.stringify(flags)}::jsonb`, + ); + return sql`coalesce("organization_settings"."repo_flags", '{}'::jsonb) || jsonb_build_object(${sql.join(merged, sql`, `)})`; +} + export class OrganizationSettingsStorage implements OrganizationSettingsStoragePort { @@ -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, @@ -67,6 +94,7 @@ export class OrganizationSettingsStorage | "simple_mode" | "default_home_agents" | "flags" + | "repo_flags" | "main_agent_id" > >, @@ -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({ @@ -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, @@ -117,6 +152,8 @@ export class OrganizationSettingsStorage flags: flagsJson ? sql`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, @@ -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, diff --git a/apps/api/src/storage/ports.ts b/apps/api/src/storage/ports.ts index 04a86e533f..7a73b16d61 100644 --- a/apps/api/src/storage/ports.ts +++ b/apps/api/src/storage/ports.ts @@ -330,6 +330,7 @@ export interface OrganizationSettingsStoragePort { | "simple_mode" | "default_home_agents" | "flags" + | "repo_flags" | "main_agent_id" > >, diff --git a/apps/api/src/storage/types.ts b/apps/api/src/storage/types.ts index 15ba9ae03e..39ffe57918 100644 --- a/apps/api/src/storage/types.ts +++ b/apps/api/src/storage/types.ts @@ -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"; @@ -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 | null; + /** Per-repo overrides of the review flags, keyed by lowercased `owner/name` + * (OrgRepoFlagsSchema); a repo with no entry inherits `flags`. */ + repo_flags: JsonObject | null; // Virtual MCP id the org lands on (`/$org`) instead of the Super Agent. main_agent_id: string | null; createdAt: ColumnType; @@ -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; diff --git a/apps/api/src/tools/organization/settings-get.ts b/apps/api/src/tools/organization/settings-get.ts index 62c57d41ef..9cb403d68e 100644 --- a/apps/api/src/tools/organization/settings-get.ts +++ b/apps/api/src/tools/organization/settings-get.ts @@ -7,6 +7,7 @@ import { SimpleModeConfigSchema, DefaultHomeAgentsConfigSchema, OrgFlagsSchema, + OrgRepoFlagsSchema, } from "@decocms/shared/organization/schema"; export const ORGANIZATION_SETTINGS_GET = defineTool({ @@ -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"), diff --git a/apps/api/src/tools/organization/settings-update.ts b/apps/api/src/tools/organization/settings-update.ts index 07bc14611c..a58a5f5ee7 100644 --- a/apps/api/src/tools/organization/settings-update.ts +++ b/apps/api/src/tools/organization/settings-update.ts @@ -7,6 +7,9 @@ import { SimpleModeConfigSchema, DefaultHomeAgentsConfigSchema, OrgFlagsSchema, + OrgRepoFlagsSchema, + RepoFlagsSchema, + repoFlagsKey, } from "@decocms/shared/organization/schema"; export const ORGANIZATION_SETTINGS_UPDATE = defineTool({ @@ -35,6 +38,12 @@ 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 + .record(z.string(), 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() @@ -52,6 +61,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"), @@ -70,6 +80,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, { @@ -79,6 +99,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, }, ); diff --git a/apps/api/src/tools/task-board/conflict-reaction.ts b/apps/api/src/tools/task-board/conflict-reaction.ts index 59f3d91342..8db6f6fca0 100644 --- a/apps/api/src/tools/task-board/conflict-reaction.ts +++ b/apps/api/src/tools/task-board/conflict-reaction.ts @@ -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"; @@ -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 diff --git a/apps/api/src/tools/task-board/enqueue-reviewer.ts b/apps/api/src/tools/task-board/enqueue-reviewer.ts index bf88ab0b09..222ca45c01 100644 --- a/apps/api/src/tools/task-board/enqueue-reviewer.ts +++ b/apps/api/src/tools/task-board/enqueue-reviewer.ts @@ -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 @@ -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, diff --git a/apps/api/src/tools/task-board/merge-pr.ts b/apps/api/src/tools/task-board/merge-pr.ts index 709892907b..73067208ab 100644 --- a/apps/api/src/tools/task-board/merge-pr.ts +++ b/apps/api/src/tools/task-board/merge-pr.ts @@ -6,6 +6,7 @@ import { approvedButUnverified, enabledReviewerKinds, } from "@decocms/shared/task-board"; +import { flagsForRepo } from "@decocms/shared/organization/schema"; import { recordTaskActivity } from "./activity"; import { reactToApprovedPrConflict } from "./conflict-reaction"; import { @@ -295,9 +296,10 @@ export async function allEnabledReviewersVerifiedApproved( ctx: StudioContext, orgId: string, taskBoardItemId: string, + repo?: string | null, ): Promise { const settings = await ctx.storage.organizationSettings.get(orgId); - const enabled = enabledReviewerKinds(settings?.flags); + const enabled = enabledReviewerKinds(flagsForRepo(settings, repo)); const activity = await ctx.storage.taskBoard.listActivity( taskBoardItemId, orgId, @@ -324,7 +326,7 @@ async function handUnverifiedApprovalToHuman( const settings = await ctx.storage.organizationSettings.get( item.organizationId, ); - const enabled = enabledReviewerKinds(settings?.flags); + const enabled = enabledReviewerKinds(flagsForRepo(settings, item.repo)); const activity = await ctx.storage.taskBoard.listActivity( item.id, item.organizationId, @@ -432,12 +434,14 @@ export async function retryAutoMergeIfApproved( const orgId = item.organizationId; if (item.status !== "in_review") return false; const settings = await ctx.storage.organizationSettings.get(orgId); - if (settings?.flags?.auto_merge !== true) return false; + if (flagsForRepo(settings, item.repo).auto_merge !== true) return false; // Same human-override guard `review-decision.ts` and `prs-get` honor. if (await ctx.storage.taskBoard.hasHumanRejectedDone(item.id, orgId)) { return false; } - if (!(await allEnabledReviewersVerifiedApproved(ctx, orgId, item.id))) { + if ( + !(await allEnabledReviewersVerifiedApproved(ctx, orgId, item.id, item.repo)) + ) { await handUnverifiedApprovalToHuman(ctx, item); return false; } diff --git a/apps/api/src/tools/task-board/promote-to-production.ts b/apps/api/src/tools/task-board/promote-to-production.ts index 72ffea21cd..fe3b9bef7d 100644 --- a/apps/api/src/tools/task-board/promote-to-production.ts +++ b/apps/api/src/tools/task-board/promote-to-production.ts @@ -8,6 +8,7 @@ import { type ReviewCycleActivity, type ReviewerKind, } from "@decocms/shared/task-board"; +import { flagsForRepo } from "@decocms/shared/organization/schema"; import { TaskBoardItemStatusSchema } from "./schema"; import { recordTaskActivity } from "./activity"; import { emitTaskBoardUpdated } from "./run-reactions"; @@ -69,7 +70,7 @@ export const TASK_BOARD_PROMOTE_TO_PRODUCTION = defineTool({ } const settings = await ctx.storage.organizationSettings.get(organizationId); - const enabled = enabledReviewerKinds(settings?.flags); + const enabled = enabledReviewerKinds(flagsForRepo(settings, item.repo)); const activity = await ctx.storage.taskBoard.listActivity( taskBoardItemId, organizationId, diff --git a/apps/api/src/tools/task-board/rerun.ts b/apps/api/src/tools/task-board/rerun.ts index c293a8d255..e36f4e6acd 100644 --- a/apps/api/src/tools/task-board/rerun.ts +++ b/apps/api/src/tools/task-board/rerun.ts @@ -42,6 +42,7 @@ import { reviewCycleStart, SUPER_AGENT_ASSIGNEE_ID, } from "@decocms/shared/task-board"; +import { flagsForRepo } from "@decocms/shared/organization/schema"; import { TERMINAL_THREAD_STATUSES } from "@/storage/task-board"; import { broadcastRunCancel } from "@/api/routes/decopilot/cancel-registry"; import { cancelHostedHarness } from "@/dispatch-queue"; @@ -249,17 +250,23 @@ export function mergeRetryExpired( */ export async function refuseIfMergePending( ctx: StudioContext, - item: { id: string; status: string; organizationId: string }, + item: { + id: string; + status: string; + organizationId: string; + repo?: string | null; + }, ): Promise { if (item.status !== "in_review") return; const settings = await ctx.storage.organizationSettings.get( item.organizationId, ); - if (settings?.flags?.auto_merge !== true) return; + if (flagsForRepo(settings, item.repo).auto_merge !== true) return; const approved = await allEnabledReviewersVerifiedApproved( ctx, item.organizationId, item.id, + item.repo, ); if (!approved) return; const activity = await ctx.storage.taskBoard diff --git a/apps/api/src/tools/task-board/review-decision.ts b/apps/api/src/tools/task-board/review-decision.ts index e2ae658b7b..bada2b0dcb 100644 --- a/apps/api/src/tools/task-board/review-decision.ts +++ b/apps/api/src/tools/task-board/review-decision.ts @@ -10,6 +10,7 @@ import { reviewCycleVerdicts, type ReviewerKind, } from "@decocms/shared/task-board"; +import { flagsForRepo } from "@decocms/shared/organization/schema"; import { TaskBoardItemStatusSchema } from "./schema"; import { recordTaskActivity } from "./activity"; import { @@ -326,6 +327,7 @@ export const TASK_BOARD_REVIEW_DECISION = defineTool({ ctx, organizationId, taskBoardItemId, + item.repo, ); if (!complete) { const refreshed = @@ -338,7 +340,8 @@ export const TASK_BOARD_REVIEW_DECISION = defineTool({ } const settings = await ctx.storage.organizationSettings.get(organizationId); - const autoMergeEnabled = settings?.flags?.auto_merge === true; + const autoMergeEnabled = + flagsForRepo(settings, item.repo).auto_merge === true; const humanRejectedDone = await ctx.storage.taskBoard.hasHumanRejectedDone( taskBoardItemId, organizationId, diff --git a/apps/web/src/components/settings/repo-review-settings.tsx b/apps/web/src/components/settings/repo-review-settings.tsx new file mode 100644 index 0000000000..85d50a9270 --- /dev/null +++ b/apps/web/src/components/settings/repo-review-settings.tsx @@ -0,0 +1,155 @@ +import { toast } from "sonner"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { Switch } from "@decocms/ui/components/switch.tsx"; +import { FileSearch02, GitMerge, ShieldTick } from "@untitledui/icons"; +import { + SettingsCard, + SettingsCardItem, + SettingsSection, +} from "@/components/settings/settings-section"; +import { + useRepoFlag, + useRepoHasOverrides, + useSetRepoFlag, +} from "@/hooks/use-organization-settings"; +import { useConnections } from "@/sdk"; +import { listRepoScopeLabels } from "@decocms/shared/github-repo-scope"; +import type { RepoOverridableFlag } from "@decocms/shared/organization/schema"; +import { useT } from "@/i18n/use-t.ts"; +import type { TranslationKey } from "@/i18n/use-t.ts"; +import type { ReactNode } from "react"; + +/** The three review toggles a single repository may decide for itself. */ +const REPO_TOGGLES: { + flag: RepoOverridableFlag; + icon: ReactNode; + labelKey: TranslationKey; +}[] = [ + { + flag: "qa_agent_enabled", + icon: , + labelKey: "settings.review.qaAgentShort", + }, + { + flag: "code_reviewer_enabled", + icon: , + labelKey: "settings.review.codeReviewerShort", + }, + { + flag: "auto_merge", + icon: , + labelKey: "settings.review.autoMergeShort", + }, +]; + +/** + * Per-repo overrides of the review settings above. + * + * The three toggles were workspace-wide, so a workspace with several + * repositories had to run the same review setup on all of them — one repo + * wanting reviewers but not auto-merge forced that choice on the rest. Each row + * starts on the workspace default and only stores what it deviates on, so a + * workspace that never touches this section behaves exactly as before. + */ +export function RepoReviewSettings() { + const t = useT(); + const githubConnections = useConnections({ slug: "mcp-github" }) ?? []; + const repos = listRepoScopeLabels(githubConnections); + + if (repos.length === 0) return null; + + return ( + + + {repos.map((repo) => ( + + ))} + + + ); +} + +function RepoRow({ repo }: { repo: string }) { + const t = useT(); + const hasOverrides = useRepoHasOverrides(repo); + const setRepoFlag = useSetRepoFlag(); + return ( + + {REPO_TOGGLES.map(({ flag, icon, labelKey }) => ( + + ))} + + + } + /> + ); +} + +/** One repo's one toggle. Shows the EFFECTIVE value (inherited or overridden); + * flipping it writes an explicit override for this repo only. */ +function RepoFlagToggle({ + repo, + flag, + icon, + labelKey, +}: { + repo: string; + flag: RepoOverridableFlag; + icon: ReactNode; + labelKey: TranslationKey; +}) { + const t = useT(); + const { enabled, overridden } = useRepoFlag(repo, flag); + const setRepoFlag = useSetRepoFlag(); + const label = `${t(labelKey)} — ${repo}`; + return ( +
+ + {icon} + {t(labelKey)} + {overridden && *} + + + setRepoFlag.mutate(repo, flag, next, { + onError: () => toast.error(t("settings.review.updateError")), + }) + } + /> +
+ ); +} diff --git a/apps/web/src/hooks/use-organization-settings.ts b/apps/web/src/hooks/use-organization-settings.ts index 68217c68f7..7e6d381272 100644 --- a/apps/web/src/hooks/use-organization-settings.ts +++ b/apps/web/src/hooks/use-organization-settings.ts @@ -15,10 +15,14 @@ import type { StudioToolInput as ToolInput } from "@decocms/shared/tools/tool-io export type { SimpleModeTier } from "@decocms/shared/organization/schema"; import { DEFAULT_ON_FLAGS, + flagsForRepo, orgFlagEnabled, + repoFlagsKey, } from "@decocms/shared/organization/schema"; import type { OrgFlags, + OrgRepoFlags, + RepoOverridableFlag, SimpleModeTier, } from "@decocms/shared/organization/schema"; @@ -49,6 +53,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?: string; updatedAt?: string; @@ -62,6 +67,7 @@ const EMPTY_SETTINGS: OrganizationSettings = { simple_mode: null, default_home_agents: null, flags: null, + repo_flags: null, main_agent_id: null, }; @@ -146,6 +152,7 @@ type OrgSettingsUpdateInput = Partial< | "simple_mode" | "default_home_agents" | "flags" + | "repo_flags" | "main_agent_id" > >; @@ -297,6 +304,67 @@ export function useSetOrgFlag() { }; } +/** + * Effective value of one review flag FOR ONE REPO: the org flag with that + * repo's override layered on top, plus whether an override is what produced it. + * Same resolver the server gates read (`flagsForRepo`), so Settings shows + * exactly what will run. + */ +export function useRepoFlag( + repo: string, + flag: RepoOverridableFlag, +): { enabled: boolean; overridden: boolean } { + const { data } = useOrganizationSettings((s) => ({ + enabled: orgFlagEnabled(flagsForRepo(s, repo), flag), + overridden: + typeof s.repo_flags?.[repoFlagsKey(repo) ?? repo]?.[flag] === "boolean", + })); + return data ?? { enabled: DEFAULT_ON_FLAGS.has(flag), overridden: false }; +} + +/** True when this repo overrides at least one of the review flags. */ +export function useRepoHasOverrides(repo: string): boolean { + const { data } = useOrganizationSettings((s) => + Object.values(s.repo_flags?.[repoFlagsKey(repo) ?? repo] ?? {}).some( + (v) => typeof v === "boolean", + ), + ); + return data ?? false; +} + +/** + * Writer for one repo's override of a review flag. `null` drops the override so + * the repo inherits the workspace default again. The server merges two levels + * deep, so writing one repo's one flag never disturbs another repo — or that + * repo's other flags. + */ +export function useSetRepoFlag() { + const mutation = useUpdateOrganizationSettings(); + const write = ( + repo: string, + flags: Partial>, + ) => ({ repo_flags: { [repoFlagsKey(repo) ?? repo]: flags } }); + return { + ...mutation, + mutate: ( + repo: string, + flag: RepoOverridableFlag, + value: boolean | null, + options?: OrgSettingsMutateOptions, + ) => mutation.mutate(write(repo, { [flag]: value }), options), + /** Clear every override for this repo — back to the workspace defaults. */ + reset: (repo: string, options?: OrgSettingsMutateOptions) => + mutation.mutate( + write(repo, { + qa_agent_enabled: null, + code_reviewer_enabled: null, + auto_merge: null, + }), + options, + ), + }; +} + export function useRegistryConfig(): RegistryConfig | null { const { data } = useOrganizationSettings((s) => s.registry_config); return data ?? null; diff --git a/apps/web/src/i18n/en/settings.ts b/apps/web/src/i18n/en/settings.ts index a68df45e18..3ff612d0cd 100644 --- a/apps/web/src/i18n/en/settings.ts +++ b/apps/web/src/i18n/en/settings.ts @@ -484,6 +484,16 @@ export const settings = { "Auto-assign report tasks to the Super Agent", "settings.review.autoAssignReportTasksDescription": "Tasks created from a report are delegated to the Super Agent automatically instead of landing unassigned.", + "settings.review.qaAgentShort": "QA Agent", + "settings.review.codeReviewerShort": "Code Reviewer", + "settings.review.autoMergeShort": "Auto-merge", + "settings.review.perRepoTitle": "Per-repository overrides", + "settings.review.perRepoDescription": + "Each repository starts on the settings above. Override any of the three here — for example reviewers on, auto-merge off for one repo — without changing the others.", + "settings.review.perRepoInherited": "Using workspace defaults", + "settings.review.perRepoCustom": "Custom for this repository", + "settings.review.perRepoOverridden": "Overrides the workspace default", + "settings.review.perRepoReset": "Reset", "settings.review.updateError": "Couldn't update the setting", "settings.orgRoleDetail.addMember": "Add Member", "settings.orgRoleDetail.addMembersToGrantPermissions": diff --git a/apps/web/src/i18n/pt-br/settings.ts b/apps/web/src/i18n/pt-br/settings.ts index 86166612a4..437a9c6250 100644 --- a/apps/web/src/i18n/pt-br/settings.ts +++ b/apps/web/src/i18n/pt-br/settings.ts @@ -502,6 +502,16 @@ export const settings = { "Atribuir tarefas de relat\u00f3rio ao Super Agent automaticamente", "settings.review.autoAssignReportTasksDescription": "Tarefas criadas a partir de um relat\u00f3rio s\u00e3o delegadas ao Super Agent automaticamente, em vez de ficarem sem respons\u00e1vel.", + "settings.review.qaAgentShort": "QA Agent", + "settings.review.codeReviewerShort": "Code Reviewer", + "settings.review.autoMergeShort": "Auto-merge", + "settings.review.perRepoTitle": "Ajustes por repositório", + "settings.review.perRepoDescription": + "Cada repositório começa com os ajustes acima. Sobrescreva qualquer um dos três aqui — por exemplo revisores ligados e auto-merge desligado em um repositório — sem afetar os outros.", + "settings.review.perRepoInherited": "Usando os padrões do workspace", + "settings.review.perRepoCustom": "Personalizado para este repositório", + "settings.review.perRepoOverridden": "Sobrescreve o padrão do workspace", + "settings.review.perRepoReset": "Redefinir", "settings.review.updateError": "N\u00e3o foi poss\u00edvel atualizar a configura\u00e7\u00e3o", "settings.orgRoleDetail.addMember": "Adicionar Membro", diff --git a/apps/web/src/views/settings/org-general.tsx b/apps/web/src/views/settings/org-general.tsx index b11a0ac249..6f897a18dc 100644 --- a/apps/web/src/views/settings/org-general.tsx +++ b/apps/web/src/views/settings/org-general.tsx @@ -3,6 +3,7 @@ import { ConnectBanner } from "@/components/connect/connect-banner"; import { OrganizationForm } from "@/components/settings/organization-form"; import { MainAgentSettings } from "@/components/settings/main-agent-settings"; import { ReviewSettings } from "@/components/settings/review-settings"; +import { RepoReviewSettings } from "@/components/settings/repo-review-settings"; import { NavigationSettings } from "@/components/settings/navigation-settings"; import { DomainSettings } from "@/components/settings/domain-settings"; import { DeleteOrganizationSection } from "@/components/settings/delete-organization-section"; @@ -21,6 +22,7 @@ export function OrgGeneralPage() { + diff --git a/packages/shared/src/organization/schema.test.ts b/packages/shared/src/organization/schema.test.ts index 166e630272..d26330ee6d 100644 --- a/packages/shared/src/organization/schema.test.ts +++ b/packages/shared/src/organization/schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { DEFAULT_ON_FLAGS, orgFlagEnabled } from "./schema"; +import { DEFAULT_ON_FLAGS, flagsForRepo, orgFlagEnabled } from "./schema"; describe("orgFlagEnabled", () => { it("default-on flags read as enabled unless stored exactly false", () => { @@ -37,3 +37,49 @@ describe("orgFlagEnabled", () => { expect(orgFlagEnabled({ auto_merge: "true" }, "auto_merge")).toBe(false); }); }); + +describe("flagsForRepo", () => { + const settings = { + flags: { auto_merge: true, qa_agent_enabled: false, nav_v2: true }, + repo_flags: { + "decocms/studio": { auto_merge: false, qa_agent_enabled: true }, + }, + }; + + it("layers a repo's overrides over the org flags", () => { + const flags = flagsForRepo(settings, "decocms/studio"); + expect(orgFlagEnabled(flags, "auto_merge")).toBe(false); + expect(orgFlagEnabled(flags, "qa_agent_enabled")).toBe(true); + // Not overridden → the org value (default-on, never stored false). + expect(orgFlagEnabled(flags, "code_reviewer_enabled")).toBe(true); + // Untouched org-only flags survive the merge. + expect(flags.nav_v2).toBe(true); + }); + + it("a repo with no entry — and an org-wide task — reads the org flags", () => { + expect(flagsForRepo(settings, "decocms/other")).toEqual(settings.flags); + expect(flagsForRepo(settings, null)).toEqual(settings.flags); + expect(flagsForRepo(settings, " ")).toEqual(settings.flags); + expect(flagsForRepo(null, "decocms/studio")).toEqual({}); + }); + + it("matches the repo key case-insensitively", () => { + expect(flagsForRepo(settings, "DecoCMS/Studio").auto_merge).toBe(false); + }); + + it("only booleans override: a null override inherits the org value", () => { + const withNull = { + flags: { auto_merge: true }, + repo_flags: { "decocms/studio": { auto_merge: null } }, + }; + expect(flagsForRepo(withNull, "decocms/studio").auto_merge).toBe(true); + }); + + it("ignores keys outside the overridable set", () => { + const rogue = { + flags: { nav_v2: true }, + repo_flags: { "decocms/studio": { nav_v2: false } }, + }; + expect(flagsForRepo(rogue, "decocms/studio").nav_v2).toBe(true); + }); +}); diff --git a/packages/shared/src/organization/schema.ts b/packages/shared/src/organization/schema.ts index 9cd3e1218f..11d1e3c2eb 100644 --- a/packages/shared/src/organization/schema.ts +++ b/packages/shared/src/organization/schema.ts @@ -191,6 +191,103 @@ export const DEFAULT_ON_FLAGS: ReadonlySet = new Set([ "code_reviewer_enabled", ]); +/** + * The flags a single repository may override, i.e. the per-repo review setup. + * + * These three are the ones whose right answer differs per repository: a repo + * can want the Code Reviewer and QA Agent but not auto-merge, while the next + * one wants all three. Everything else in {@link OrgFlagsSchema} is a + * workspace-wide product/cosmetic toggle and stays org-only. + */ +export const REPO_OVERRIDABLE_FLAGS = [ + "qa_agent_enabled", + "code_reviewer_enabled", + "auto_merge", +] as const; + +export type RepoOverridableFlag = (typeof REPO_OVERRIDABLE_FLAGS)[number]; + +/** + * One repository's overrides. Every key is tri-state: `true`/`false` means "this + * repo decides", and absent OR null means "inherit the org default" — null is + * how a write DROPS an override, since under the two-level merge an omitted key + * keeps whatever is stored. + */ +export const RepoFlagsSchema = z.object({ + qa_agent_enabled: z + .boolean() + .nullable() + .optional() + .describe("Override for the QA Agent on this repo. Null inherits the org."), + code_reviewer_enabled: z + .boolean() + .nullable() + .optional() + .describe( + "Override for the Code Reviewer on this repo. Null inherits the org.", + ), + auto_merge: z + .boolean() + .nullable() + .optional() + .describe("Override for auto-merge on this repo. Null inherits the org."), +}); + +export type RepoFlags = z.infer; + +/** + * Per-repo overrides of the review flags, stored in the + * `organization_settings.repo_flags` jsonb column, keyed by the repository's + * lowercased `owner/name` (see {@link repoFlagsKey} — GitHub repo names are + * case-insensitive, and the key a task carries must match the key Settings + * wrote). + * + * Additive by construction: an org with no overrides behaves exactly as before, + * and a repo with no entry (or with a key left unset) falls back to the org + * flag. Reads go through {@link flagsForRepo}, never the raw bag. + */ +export const OrgRepoFlagsSchema = z.record(z.string(), RepoFlagsSchema); + +export type OrgRepoFlags = z.infer; + +/** The `repo_flags` key for a `owner/name` repo, or null when there's no repo + * (org-wide tasks carry none — those always read the org defaults). */ +export function repoFlagsKey(repo: string | null | undefined): string | null { + const trimmed = repo?.trim().toLowerCase(); + return trimmed ? trimmed : null; +} + +/** + * The effective flag bag for one repository: the org flags with that repo's + * overrides layered on top. THE single reader for every review gate — pass the + * result anywhere `settings.flags` used to go (`orgFlagEnabled`, + * `enabledReviewerKinds`, the `auto_merge` check) and per-repo config applies + * without each call site knowing the override shape. + * + * `repo` of null (an org-wide task) resolves to the org flags untouched. Only + * booleans override, so a stored null never shadows the org value. + */ +export function flagsForRepo( + settings: + | { + flags?: Record | null; + repo_flags?: Record> | null; + } + | null + | undefined, + repo: string | null | undefined, +): Record { + const flags = settings?.flags ?? {}; + const key = repoFlagsKey(repo); + const overrides = key ? settings?.repo_flags?.[key] : undefined; + if (!overrides) return flags; + const applied: Record = { ...flags }; + for (const flag of REPO_OVERRIDABLE_FLAGS) { + if (typeof overrides[flag] === "boolean") applied[flag] = overrides[flag]; + } + return applied; +} + /** * Resolve one org flag to its effective boolean. Honors {@link DEFAULT_ON_FLAGS} * — a default-on flag is enabled unless stored as exactly `false`; every other diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 000cecd911..3fd32d37e8 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -130,6 +130,17 @@ export interface StudioToolIO { } | null | undefined; + repo_flags?: + | Record< + string, + { + qa_agent_enabled?: boolean | null | undefined; + code_reviewer_enabled?: boolean | null | undefined; + auto_merge?: boolean | null | undefined; + } + > + | null + | undefined; main_agent_id?: string | null | undefined; createdAt?: string | undefined; updatedAt?: string | undefined; @@ -197,6 +208,16 @@ export interface StudioToolIO { auto_assign_report_tasks_to_super_agent?: boolean | undefined; } | undefined; + repo_flags?: + | Record< + string, + { + qa_agent_enabled?: boolean | null | undefined; + code_reviewer_enabled?: boolean | null | undefined; + auto_merge?: boolean | null | undefined; + } + > + | undefined; main_agent_id?: string | null | undefined; }; output: { @@ -266,6 +287,17 @@ export interface StudioToolIO { } | null | undefined; + repo_flags?: + | Record< + string, + { + qa_agent_enabled?: boolean | null | undefined; + code_reviewer_enabled?: boolean | null | undefined; + auto_merge?: boolean | null | undefined; + } + > + | null + | undefined; main_agent_id?: string | null | undefined; }; }; From c395da826052f64dd053dc5a4ea358a40f27dc49 Mon Sep 17 00:00:00 2001 From: Deco Bot <114028756+decobot@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:46:25 +0000 Subject: [PATCH 2/5] Suspend the repo list locally so the settings page still renders Co-Authored-By: Claude Opus 5 --- .../src/components/settings/repo-review-settings.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/src/components/settings/repo-review-settings.tsx b/apps/web/src/components/settings/repo-review-settings.tsx index 85d50a9270..3de4fdc685 100644 --- a/apps/web/src/components/settings/repo-review-settings.tsx +++ b/apps/web/src/components/settings/repo-review-settings.tsx @@ -1,5 +1,7 @@ +import { Suspense } from "react"; import { toast } from "sonner"; import { Button } from "@decocms/ui/components/button.tsx"; +import { Skeleton } from "@decocms/ui/components/skeleton.tsx"; import { Switch } from "@decocms/ui/components/switch.tsx"; import { FileSearch02, GitMerge, ShieldTick } from "@untitledui/icons"; import { @@ -52,6 +54,16 @@ const REPO_TOGGLES: { * workspace that never touches this section behaves exactly as before. */ export function RepoReviewSettings() { + return ( + }> + + + ); +} + +/** Split out because the connection list suspends — same shape as the main-agent + * select, so the rest of the settings page renders while repos load. */ +function RepoRows() { const t = useT(); const githubConnections = useConnections({ slug: "mcp-github" }) ?? []; const repos = listRepoScopeLabels(githubConnections); From 5f33cb9a746d5082180053ba330586a613e709e9 Mon Sep 17 00:00:00 2001 From: Deco Bot <114028756+decobot@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:48:30 +0000 Subject: [PATCH 3/5] Cast the jsonb key lookup so the -> operator resolves unambiguously Co-Authored-By: Claude Opus 5 --- apps/api/src/storage/organization-settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/storage/organization-settings.ts b/apps/api/src/storage/organization-settings.ts index b5fec53921..34492b5526 100644 --- a/apps/api/src/storage/organization-settings.ts +++ b/apps/api/src/storage/organization-settings.ts @@ -19,7 +19,7 @@ function mergeRepoFlagsSql(repoFlags: Record) { } const merged = entries.map( ([repo, flags]) => - sql`${repo}::text, coalesce("organization_settings"."repo_flags" -> ${repo}, '{}'::jsonb) || ${JSON.stringify(flags)}::jsonb`, + sql`${repo}::text, coalesce("organization_settings"."repo_flags" -> ${repo}::text, '{}'::jsonb) || ${JSON.stringify(flags)}::jsonb`, ); return sql`coalesce("organization_settings"."repo_flags", '{}'::jsonb) || jsonb_build_object(${sql.join(merged, sql`, `)})`; } From 3fe2c3e24b4c003441daf4841178ebfc1b7cdc63 Mon Sep 17 00:00:00 2001 From: Deco Bot <114028756+decobot@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:01:54 +0000 Subject: [PATCH 4/5] fix(task-board): resolve ship-button reviewer gate per repo LinksSection read the org flags while the server gate (PROMOTE_TO_PRODUCTION) reads flagsForRepo, so a repo overriding a reviewer made the two disagree. Also validate repo_flags keys as `owner/name` in ORGANIZATION_SETTINGS_UPDATE. Co-Authored-By: Claude Opus 5 --- apps/api/src/tools/organization/settings-update.ts | 12 +++++++++++- apps/web/src/layouts/task-board/task-dialog.tsx | 14 +++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/api/src/tools/organization/settings-update.ts b/apps/api/src/tools/organization/settings-update.ts index a58a5f5ee7..883121f5ec 100644 --- a/apps/api/src/tools/organization/settings-update.ts +++ b/apps/api/src/tools/organization/settings-update.ts @@ -39,7 +39,17 @@ export const ORGANIZATION_SETTINGS_UPDATE = defineTool({ "Org boolean toggles. Shallow-merged into the stored flags: keys you pass win (explicit false persists), omitted keys keep their value.", ), repo_flags: z - .record(z.string(), RepoFlagsSchema.strict()) + // 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.", diff --git a/apps/web/src/layouts/task-board/task-dialog.tsx b/apps/web/src/layouts/task-board/task-dialog.tsx index ae61dbfb10..e1b2216d87 100644 --- a/apps/web/src/layouts/task-board/task-dialog.tsx +++ b/apps/web/src/layouts/task-board/task-dialog.tsx @@ -83,7 +83,7 @@ import { useTaskBoardActivity, type TaskBoardActivity, } from "@/hooks/use-task-board-activity"; -import { useOrgFlag } from "@/hooks/use-organization-settings"; +import { useRepoFlag } from "@/hooks/use-organization-settings"; import { usePromoteToProduction } from "@/hooks/use-promote-to-production"; import { enabledReviewers, @@ -1377,8 +1377,16 @@ function LinksSection({ const t = useT(); const { data: prs, isLoading: prsLoading } = useTaskBoardItemPrs(item.id); const { data: activity } = useTaskBoardActivity(item.id); - const qaEnabled = useOrgFlag("qa_agent_enabled"); - const codeReviewerEnabled = useOrgFlag("code_reviewer_enabled"); + // Per-repo resolver, matching the server gate in TASK_BOARD_PROMOTE_TO_PRODUCTION + // (`flagsForRepo`): an org-wide task (no repo) falls back to the org flags. + const { enabled: qaEnabled } = useRepoFlag( + item.repo ?? "", + "qa_agent_enabled", + ); + const { enabled: codeReviewerEnabled } = useRepoFlag( + item.repo ?? "", + "code_reviewer_enabled", + ); const promote = usePromoteToProduction(item.id); const links = extractDescriptionLinks(description); // Keep the section up (with a skeleton) while the PR enrichment loads; a From 5d32ca3a67b1ebafbf9d213c1603f02a374f20db Mon Sep 17 00:00:00 2001 From: Deco Bot <114028756+decobot@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:05:02 +0000 Subject: [PATCH 5/5] fix(settings): stack the per-repo override row on small screens Co-Authored-By: Claude Opus 5 --- apps/web/src/components/settings/repo-review-settings.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/repo-review-settings.tsx b/apps/web/src/components/settings/repo-review-settings.tsx index 3de4fdc685..91e79a0831 100644 --- a/apps/web/src/components/settings/repo-review-settings.tsx +++ b/apps/web/src/components/settings/repo-review-settings.tsx @@ -90,6 +90,10 @@ function RepoRow({ repo }: { repo: string }) { const setRepoFlag = useSetRepoFlag(); return ( +
{REPO_TOGGLES.map(({ flag, icon, labelKey }) => ( +