From f9f2a6340f5cf30851227915b5fa57b0a6771b85 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:18:41 -0700 Subject: [PATCH] refactor(openapi): replace the settings-parity script with a compile-time schema/type assertion (#9531) scripts/check-openapi-settings-parity.ts diffed the top-level KEY SETS of RepositorySettingsSchema/RepoSettingsPreviewSchema against the two TS types by re-parsing src/types.ts and src/signals/settings-preview.ts with brace-and-indent heuristics. It could not see a field's optionality, nullability, or value type, and any reformatting of either type would have silently broken its extraction. test/unit/openapi-settings-schema-parity.test.ts replaces it with an exact-equality type assertion in both directions, compared per key so a failure still names the drifted field. Three published-spec defects the key-set diff had been structurally unable to see turned up the moment it compiled: - autonomy advertised "suggest"/"propose", removed from AutonomyLevel by #4620 and dropped on persist ever since; - contributorBlacklist omitted the immutable githubId the API has returned since #9125; - moderationRules omitted "copycat", a real ModerationRuleType since #1969. Also: expectedCiContexts is declared and read as nullable but the schema never said so, and the settings-preview's aiReviewConfirmedContributorsOnly advertised a null the builder's `?? false` makes unreachable. Reaching parity required the types to state what the read paths already guarantee. The thirteen config-as-code fields both getRepositorySettings paths always populate become required; the no-row defaults move into defaultRepositorySettings() so there is one home for them, and upsertRepositorySettings echoes linkedIssueHardRules/synchronizeClosePolicy so PUT's response matches GET's shape. resolveEffectiveSettings now skips undefined-valued manifest keys before the spread, so config-as-code can no longer punch a hole in a resolved DB value -- "not overridden" is what an absent value means at every other layer. --- .github/workflows/ci.yml | 6 - apps/loopover-ui/public/openapi.json | 42 +++--- package.json | 3 +- scripts/check-openapi-settings-parity.ts | 113 ---------------- scripts/check-schema-drift.ts | 4 +- src/db/repositories.ts | 28 +++- src/openapi/schemas.ts | 66 ++++++---- src/signals/focus-manifest.ts | 21 ++- src/signals/settings-preview.ts | 7 +- src/types.ts | 26 ++-- test/unit/check-manifest-drift-script.test.ts | 2 +- test/unit/check-schema-drift-script.test.ts | 4 +- test/unit/ci-openapi-settings-parity.test.ts | 101 --------------- ...fhost-troubleshooting-metric-names.test.ts | 3 +- test/unit/effective-config-summary.test.ts | 6 +- test/unit/focus-manifest.test.ts | 21 +++ test/unit/maintainer-activation.test.ts | 4 + .../openapi-settings-schema-parity.test.ts | 121 ++++++++++++++++++ test/unit/policy-sanitizer.test.ts | 4 + test/unit/queue-lifecycle-guards.test.ts | 19 ++- test/unit/registration-readiness.test.ts | 4 + test/unit/repo-policy-readiness.test.ts | 4 + .../repository-settings-enforcement.test.ts | 4 + .../self-dogfood-registration-pack.test.ts | 4 + test/unit/settings-preview.test.ts | 4 + test/unit/signals-edge-cases.test.ts | 4 + test/unit/signals-v2.test.ts | 2 + test/unit/signals.test.ts | 5 + test/unit/unified-comment-parity.test.ts | 2 + 29 files changed, 329 insertions(+), 305 deletions(-) delete mode 100644 scripts/check-openapi-settings-parity.ts delete mode 100644 test/unit/ci-openapi-settings-parity.test.ts create mode 100644 test/unit/openapi-settings-schema-parity.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 204afe19b1..353be0f468 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -790,12 +790,6 @@ jobs: - name: OpenAPI drift check if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.uiContract == 'true' }} run: npm run ui:openapi:check - # #2556: RepositorySettingsSchema (hand-authored Zod) can silently drift from the actual - # RepositorySettings TS type -- the OpenAPI drift check above only verifies the generated spec - # matches the Zod schema, never that the schema matches the type the API actually serializes. - - name: OpenAPI settings-parity check - if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.uiContract == 'true' }} - run: npm run ui:openapi:settings-parity # Checks apps/loopover-ui/src' known-latest MCP version string against the published package, so # its dependency is `ui` (the file it scans) + `mcp` (the package it checks against) -- NOT the # OpenAPI contract, which this script never reads. diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 1e1f1e91b8..1de5e0d65b 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -9206,7 +9206,8 @@ "type": "array", "items": { "type": "string" - } + }, + "nullable": true }, "advisoryCheckRuns": { "type": "array", @@ -9690,6 +9691,9 @@ }, "addedAt": { "type": "string" + }, + "githubId": { + "type": "integer" } }, "required": [ @@ -9704,8 +9708,6 @@ "type": "string", "enum": [ "observe", - "suggest", - "propose", "auto_with_approval", "auto" ] @@ -9714,8 +9716,6 @@ "type": "string", "enum": [ "observe", - "suggest", - "propose", "auto_with_approval", "auto" ] @@ -9724,8 +9724,6 @@ "type": "string", "enum": [ "observe", - "suggest", - "propose", "auto_with_approval", "auto" ] @@ -9734,8 +9732,6 @@ "type": "string", "enum": [ "observe", - "suggest", - "propose", "auto_with_approval", "auto" ] @@ -9744,8 +9740,6 @@ "type": "string", "enum": [ "observe", - "suggest", - "propose", "auto_with_approval", "auto" ] @@ -9754,8 +9748,6 @@ "type": "string", "enum": [ "observe", - "suggest", - "propose", "auto_with_approval", "auto" ] @@ -9764,8 +9756,6 @@ "type": "string", "enum": [ "observe", - "suggest", - "propose", "auto_with_approval", "auto" ] @@ -9774,8 +9764,6 @@ "type": "string", "enum": [ "observe", - "suggest", - "propose", "auto_with_approval", "auto" ] @@ -9784,8 +9772,6 @@ "type": "string", "enum": [ "observe", - "suggest", - "propose", "auto_with_approval", "auto" ] @@ -9954,7 +9940,8 @@ "contributor_cap", "blacklist", "review_nag", - "review_evasion" + "review_evasion", + "copycat" ] } }, @@ -10133,6 +10120,8 @@ "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", + "linkedIssueLabelPropagation", + "linkedIssueHardRules", "gittensorLabel", "blacklistLabel", "createMissingLabel", @@ -10140,7 +10129,15 @@ "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", - "commandAuthorization" + "commandAuthorization", + "contributorBlacklist", + "autonomy", + "moderationGateMode", + "fairnessAnalyticsMode", + "reviewEvasionProtection", + "draftPrClosePolicy", + "synchronizeClosePolicy", + "screenshotTableGate" ] }, "AutomationState": { @@ -10991,8 +10988,7 @@ "type": "boolean" }, "aiReviewConfirmedContributorsOnly": { - "type": "boolean", - "nullable": true + "type": "boolean" }, "commandAuthorization": { "type": "object", diff --git a/package.json b/package.json index 24b355dc8c..265c28907f 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "ui:test": "npm run ui:kit:build && npm --workspace @loopover/ui run test && npm --workspace @loopover/ui-miner run test", "ui:openapi": "tsx scripts/write-ui-openapi.ts", "ui:openapi:check": "tsx scripts/write-ui-openapi.ts --check", - "ui:openapi:settings-parity": "tsx scripts/check-openapi-settings-parity.ts", "cloudflare:schema": "tsx scripts/write-cloudflare-schema.ts", "ui:version-audit": "node --experimental-strip-types scripts/check-ui-mcp-version-copy.ts", "ui:version-audit:sync": "node --experimental-strip-types scripts/check-ui-mcp-version-copy.ts --write", @@ -122,7 +121,7 @@ "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts", "pretest:ci": "npm run check-node-version", - "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", + "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", "test:release": "npm run test:ci && npm run changelog:check", "test:release:mcp": "npm run test:ci", "test:watch": "vitest", diff --git a/scripts/check-openapi-settings-parity.ts b/scripts/check-openapi-settings-parity.ts deleted file mode 100644 index f05c5d5715..0000000000 --- a/scripts/check-openapi-settings-parity.ts +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env tsx -// #2556: RepositorySettingsSchema/RepoSettingsPreviewSchema (src/openapi/schemas.ts) are hand-authored Zod -// schemas -- ui:openapi:check only verifies the generated openapi.json matches THEM, never that they match -// the actual RepositorySettings TS type the API handler serializes. A field added to the TS type (and -// actually returned by GET /v1/repos/:owner/:repo/settings) can silently miss the Zod schema forever, with -// no CI signal -- breaking generated API clients (including @loopover/mcp) that have no way to -// know about a field the spec doesn't mention. This is a structural key-set diff, not a value/type check. -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { RepositorySettingsSchema, RepoSettingsPreviewSchema } from "../src/openapi/schemas"; - -export const TYPES_PATH = "src/types.ts"; -const TYPE_START = "export type RepositorySettings = {"; - -// #7011: RepoSettingsPreviewSchema.settings (the nested settings object of the settings-preview response) is a -// second hand-authored Zod schema this check's header names -- its source of truth is buildRepoSettingsPreview's -// return shape, whose named return type is RepoSettingsPreview (src/signals/settings-preview.ts). A field added -// to (or dropped from) that builder without a matching schema edit would otherwise drift with no CI signal, -// exactly as for RepositorySettings above. -export const SETTINGS_PREVIEW_PATH = "src/signals/settings-preview.ts"; -const PREVIEW_TYPE_START = "export type RepoSettingsPreview = {"; -const PREVIEW_SETTINGS_START = " settings: {"; - -/** Pure: extract the top-level field names of the `RepositorySettings` type from raw source text. Every - * field is a primitive/union/type-alias reference (never an inline nested object literal), so this never - * needs to track brace depth -- verified by direct inspection of the type at the time this check was added. */ -export function extractRepositorySettingsFieldNames(source: string): Set { - const startIndex = source.indexOf(TYPE_START); - if (startIndex === -1) throw new Error(`Could not find "${TYPE_START}" in the given source.`); - const endIndex = source.indexOf("\n};", startIndex); - if (endIndex === -1) throw new Error(`Could not find the closing "};" for RepositorySettings in the given source.`); - const body = source.slice(startIndex + TYPE_START.length, endIndex); - const fieldPattern = /^ {2}(\w+)\??:/gm; - const names = new Set(); - for (const match of body.matchAll(fieldPattern)) names.add(match[1]!); - return names; -} - -/** Pure: extract the field names of the nested `settings` object of the `RepoSettingsPreview` type from raw - * source text. Unlike RepositorySettings, this block nests one inline object literal (`commandAuthorization`), - * so we brace-match to bound the settings body precisely, then take only its direct children (4-space indent); - * the nested members sit deeper and never match the anchor. */ -export function extractRepoSettingsPreviewFieldNames(source: string): Set { - const typeIndex = source.indexOf(PREVIEW_TYPE_START); - if (typeIndex === -1) throw new Error(`Could not find "${PREVIEW_TYPE_START}" in the given source.`); - const blockIndex = source.indexOf(PREVIEW_SETTINGS_START, typeIndex); - if (blockIndex === -1) throw new Error(`Could not find the "settings" block of RepoSettingsPreview in the given source.`); - const openBrace = blockIndex + PREVIEW_SETTINGS_START.length - 1; - let depth = 0; - let endIndex = -1; - for (let i = openBrace; i < source.length; i++) { - if (source[i] === "{") depth++; - else if (source[i] === "}" && --depth === 0) { - endIndex = i; - break; - } - } - if (endIndex === -1) throw new Error(`Could not find the closing "}" for the RepoSettingsPreview settings block in the given source.`); - const body = source.slice(openBrace + 1, endIndex); - const fieldPattern = /^ {4}(\w+)\??:/gm; - const names = new Set(); - for (const match of body.matchAll(fieldPattern)) names.add(match[1]!); - return names; -} - -/** Pure: diff two field-name sets, returning the sorted asymmetric differences. */ -export function diffFieldSets(typeFields: ReadonlySet, schemaFields: ReadonlySet): { missingFromSchema: string[]; extraInSchema: string[] } { - return { - missingFromSchema: [...typeFields].filter((field) => !schemaFields.has(field)).sort(), - extraInSchema: [...schemaFields].filter((field) => !typeFields.has(field)).sort(), - }; -} - -function main() { - let failed = false; - - const typeFields = extractRepositorySettingsFieldNames(readFileSync(TYPES_PATH, "utf8")); - const schemaFields = new Set(Object.keys(RepositorySettingsSchema.shape)); - const repo = diffFieldSets(typeFields, schemaFields); - if (repo.missingFromSchema.length > 0 || repo.extraInSchema.length > 0) { - if (repo.missingFromSchema.length > 0) { - console.error(`RepositorySettingsSchema (src/openapi/schemas.ts) is missing field(s) present on the RepositorySettings type: ${repo.missingFromSchema.join(", ")}`); - } - if (repo.extraInSchema.length > 0) { - console.error(`RepositorySettingsSchema (src/openapi/schemas.ts) declares field(s) not present on the RepositorySettings type: ${repo.extraInSchema.join(", ")}`); - } - console.error("Update src/openapi/schemas.ts, then run: npm run ui:openapi"); - failed = true; - } else { - console.log(`RepositorySettingsSchema matches the RepositorySettings type (${typeFields.size} fields).`); - } - - const previewTypeFields = extractRepoSettingsPreviewFieldNames(readFileSync(SETTINGS_PREVIEW_PATH, "utf8")); - const previewSchemaFields = new Set(Object.keys(RepoSettingsPreviewSchema.shape.settings.shape)); - const preview = diffFieldSets(previewTypeFields, previewSchemaFields); - if (preview.missingFromSchema.length > 0 || preview.extraInSchema.length > 0) { - if (preview.missingFromSchema.length > 0) { - console.error(`RepoSettingsPreviewSchema.settings (src/openapi/schemas.ts) is missing field(s) present on buildRepoSettingsPreview's return shape (RepoSettingsPreview.settings, src/signals/settings-preview.ts): ${preview.missingFromSchema.join(", ")}`); - } - if (preview.extraInSchema.length > 0) { - console.error(`RepoSettingsPreviewSchema.settings (src/openapi/schemas.ts) declares field(s) not present on buildRepoSettingsPreview's return shape (RepoSettingsPreview.settings, src/signals/settings-preview.ts): ${preview.extraInSchema.join(", ")}`); - } - console.error("Update src/openapi/schemas.ts, then run: npm run ui:openapi"); - failed = true; - } else { - console.log(`RepoSettingsPreviewSchema.settings matches buildRepoSettingsPreview's return shape (${previewTypeFields.size} fields).`); - } - - if (failed) process.exit(1); -} - -// Guard so importing this module for its pure exports (tests) never triggers the file-read/exit side effects. -if (process.argv[1] === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/check-schema-drift.ts b/scripts/check-schema-drift.ts index 3976ebfe5c..ac23bf52ce 100644 --- a/scripts/check-schema-drift.ts +++ b/scripts/check-schema-drift.ts @@ -15,8 +15,8 @@ // drizzle-orm's getTableColumns (keyed by each column's .name -- the actual DB column name, not the JS // property name). Diff the two column-name sets per table. // -// Run via `tsx` (not plain `node`) for the same reason as check-migrations.ts and -// check-openapi-settings-parity.ts: this script imports src/db/schema.ts (a .ts module) directly, and a bare +// Run via `tsx` (not plain `node`) for the same reason as check-migrations.ts: this script imports +// src/db/schema.ts (a .ts module) directly, and a bare // `node` invocation can't resolve a `.ts` import without an experimental flag CI's pinned Node isn't // guaranteed to support. import { readdirSync, readFileSync } from "node:fs"; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index edfc08f76a..268c9b8339 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -712,11 +712,16 @@ export async function listRepositories(env: Env): Promise { return rows.map(toRepositoryRecord); } -export async function getRepositorySettings(env: Env, fullName: string): Promise { - const db = getDb(env.DB); - const [row] = await db.select().from(repositorySettings).where(eq(repositorySettings.repoFullName, fullName)).limit(1); - if (!row) { - return { +/** + * The complete built-in default settings for a repo LoopOver has no row for (#9531). + * + * Extracted from getRepositorySettings' no-row branch so it has ONE home: the read path returns it, + * and the test fixtures that build full RepositorySettings literals spread it instead of each + * hand-maintaining a private copy of every config-as-code default -- which is how ten of them came + * to silently omit the same thirteen fields. + */ +export function defaultRepositorySettings(fullName: string): RepositorySettings { + return { repoFullName: fullName, commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", @@ -805,7 +810,13 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise mergeTrainMode: "off", screenshotTableGate: { ...DEFAULT_SCREENSHOT_TABLE_GATE, whenLabels: [], whenPaths: [], requireViewports: [], requireThemes: [] }, }; - } +} + + +export async function getRepositorySettings(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select().from(repositorySettings).where(eq(repositorySettings.repoFullName, fullName)).limit(1); + if (!row) return defaultRepositorySettings(fullName); return { repoFullName: row.repoFullName, // Config-as-code only (Batch A, loopover#6442): no DB column backs these 9 fields anymore -- the @@ -1055,6 +1066,11 @@ export async function upsertRepositorySettings(env: Env, settings: Partial> -- a repo configures the + // classes it configures. And the LIVE level set: "suggest"/"propose" were removed server-side by + // #4620 and silently dropped on persist ever since, yet this schema kept advertising them -- the + // exact drift class #9517's enum notes warned about, republished here. The compile-time parity + // assertion in src/openapi/schema-type-parity.ts is what finally caught it. + autonomy: z.partialRecord( + z.enum(["review", "request_changes", "approve", "merge", "close", "label", "review_state_label", "update_branch", "assign"]), + z.enum(["observe", "auto_with_approval", "auto"]), + ), autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(), agentPaused: z.boolean().optional(), agentDryRun: z.boolean().optional(), @@ -987,16 +997,17 @@ export const RepositorySettingsSchema = z commandRateLimitWindowHours: z.number().int().positive().optional(), moderationGateMode: z .enum(["inherit", "off", "enabled"]) - .optional() .describe( "Gates ONLY the shared cross-repo violation tally -- does NOT disable the four underlying anti-abuse mechanisms (contributor cap, blacklist, review-nag, review-evasion), each of which runs on its own independent setting.", ), - moderationRules: z.array(z.enum(["contributor_cap", "blacklist", "review_nag", "review_evasion"])).optional(), + // #9531: "copycat" is a real member of the type's union and a real value this route can return -- the + // published enum simply never gained it when the mechanism landed, so a client generated from the spec + // rejected a payload the API legitimately produces. + moderationRules: z.array(z.enum(["contributor_cap", "blacklist", "review_nag", "review_evasion", "copycat"])).optional(), moderationWarningLabel: z.string().optional(), moderationBannedLabel: z.string().optional(), fairnessAnalyticsMode: z .enum(["inherit", "off", "enabled"]) - .optional() .describe( "Per-repo participation in cross-repo contributor fairness/accuracy analytics -- 'off' excludes this repo's gate decisions and moderation history from every aggregation, independent of whether the internal fairness-analytics routes are enabled fleet-wide.", ), @@ -1006,7 +1017,6 @@ export const RepositorySettingsSchema = z plannerMode: z.enum(["inherit", "off", "enabled"]).optional(), reviewEvasionProtection: z .enum(["off", "close"]) - .optional() .describe( "Effective default is \"close\" as of #4011 -- \"off\" is an explicit opt-out, not the default. \"off\" only suppresses the enforcement close; the ready<->draft cycling counter keeps incrementing regardless, so re-enabling can immediately treat a historical off-period cycle as \"repeated.\"", ), @@ -1014,13 +1024,11 @@ export const RepositorySettingsSchema = z reviewEvasionComment: z.boolean().optional(), draftPrClosePolicy: z .enum(["off", "close"]) - .optional() .describe( "Off by default (opt-in, unlike reviewEvasionProtection's default-close). \"close\" enforces on ANY draft PR, including the very first one, before a review pass has had a chance to run -- distinct from reviewEvasionProtection's family, which only enforces after a review already ran or on the 2nd+ draft conversion.", ), synchronizeClosePolicy: z .enum(["off", "close"]) - .optional() .describe( "Off by default (opt-in, config-as-code only -- no dashboard/DB column). \"close\" closes a contributor's own PR immediately when they push an additional commit (synchronize) before it's been merged or closed -- this repo's review is one-shot, so the first push is the only push. Never fires for a push that isn't from the PR's own author (e.g. the engine's own rebase-if-behind), nor for the repo owner/admin, a protected automation author, or anyone with write+ collaborator access.", ), @@ -1035,8 +1043,7 @@ export const RepositorySettingsSchema = z requireThemes: z.array(z.string()), message: z.string().optional(), skillFileUrl: z.string().optional(), - }) - .optional(), + }), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }) @@ -1187,7 +1194,10 @@ export const RepoSettingsPreviewSchema = z aiReviewProvider: z.string().nullable(), aiReviewModel: z.string().nullable(), aiReviewAllAuthors: z.boolean(), - aiReviewConfirmedContributorsOnly: z.boolean().nullable(), + // #9531: buildRepoSettingsPreview resolves this with `?? false`, so the preview payload NEVER carries + // null here (unlike RepositorySettings' own nullable field of the same name, which this one is derived + // from) -- the schema advertised a value the route cannot return. + aiReviewConfirmedContributorsOnly: z.boolean(), commandAuthorization: z.object({ defaultAllowed: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])), commandOverrides: z.array( diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index cb22c01317..4f5e81c3f1 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -541,6 +541,25 @@ function applyGateConfigOverrides(effective: RepositorySettings, gate: FocusMani if (gate.copycatMinScore !== null) effective.copycatGateMinScore = gate.copycatMinScore; } +/** + * Drop every key whose value is literally `undefined`, so spreading the result LAYERS over a base value + * instead of punching holes in it (#9531). + * + * `FocusManifestSettings` is a `Partial>`, so an explicitly-`undefined` key + * is indistinguishable from an absent one to the type system but emphatically NOT to a spread: it + * overwrites the DB-resolved value with `undefined`. That is wrong for every field (the manifest layer is + * an override, and "not overridden" is exactly what an absent value means) and now unrepresentable for the + * thirteen config-as-code fields both `getRepositorySettings` paths always populate, which `RepositorySettings` + * declares required. + */ +function withoutUndefinedValues(value: T): { [K in keyof T]?: Exclude } { + const defined: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (entry !== undefined) defined[key] = entry; + } + return defined as { [K in keyof T]?: Exclude }; +} + /** * Resolve the EFFECTIVE repository settings a webhook should act on: `.loopover.yml` > DB settings > * safe defaults. The generic `settings:` override applies first; the friendly `gate:` alias then wins @@ -573,7 +592,7 @@ export function resolveEffectiveSettings( agentDryRun: agentDryRunOverride, ...restManifestSettings } = manifest.settings; - const effective: RepositorySettings = { ...dbSettings, ...restManifestSettings }; + const effective: RepositorySettings = { ...dbSettings, ...withoutUndefinedValues(restManifestSettings) }; // #9049: verified live -- the global default private-config layer carried `agentDryRun: false`, and because // the manifest wins a plain spread, that silently discarded `agent_dry_run=1` on EVERY read for EVERY repo. // An operator flipping dry-run from the dashboard, `PUT /settings`, `loopover-mcp maintain`, or the diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 1107a703eb..f89ae4d23e 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -234,8 +234,11 @@ export type RepoSettingsPreview = { checkRunDetailLevel: RepositorySettings["checkRunDetailLevel"]; regateSweepOrderMode: RepositorySettings["regateSweepOrderMode"]; reviewCheckMode: RepositorySettings["reviewCheckMode"]; - autoProjectMilestoneMatch: RepositorySettings["autoProjectMilestoneMatch"]; - autoProjectMilestoneMatchBackend: RepositorySettings["autoProjectMilestoneMatchBackend"]; + /** Optional, not required-but-undefined (#9531): both are copied straight from settings, where they are + * themselves optional, so `JSON.stringify` drops the key entirely whenever the repo has not configured + * them -- the response genuinely omits the field rather than sending an explicit null. */ + autoProjectMilestoneMatch?: RepositorySettings["autoProjectMilestoneMatch"]; + autoProjectMilestoneMatchBackend?: RepositorySettings["autoProjectMilestoneMatchBackend"]; gatePack: RepositorySettings["gatePack"]; linkedIssueGateMode: RepositorySettings["linkedIssueGateMode"]; duplicatePrGateMode: RepositorySettings["duplicatePrGateMode"]; diff --git a/src/types.ts b/src/types.ts index 72661ed3e0..82e4ef4531 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1191,7 +1191,7 @@ export type RepositorySettings = { * Default TRUE (matches the prior de-facto behavior before this field existed, when type labels * were gated by `autoLabelEnabled` nested inside the public-surface check). Always populated by * the DB layer; optional so existing settings fixtures/callers need not be touched. */ - typeLabelsEnabled?: boolean | undefined; + typeLabelsEnabled: boolean; /** Per-repo override of the TYPE/taxonomy label NAMES, keyed by category (#priority-linked-issue-gate, * #label-modularity). Defaults to `DEFAULT_TYPE_LABELS` (`gittensor:bug`/`gittensor:feature`/ * `gittensor:priority`) in `settings/pr-type-label.ts` — a repo can override just one name (e.g. only @@ -1222,12 +1222,12 @@ export type RepositorySettings = { * inferred from a PR's title, changed files, AI output, or existing PR labels. Default disabled * (`enabled: false`, no mappings) — a self-hoster opts in per repo. Always populated by the DB * layer; optional so existing settings fixtures/callers need not be touched. */ - linkedIssueLabelPropagation?: LinkedIssueLabelPropagationConfig | undefined; + linkedIssueLabelPropagation: LinkedIssueLabelPropagationConfig; /** Deterministic linked-issue hard rules. Config-as-code only; set with * `.loopover.yml settings.linkedIssueHardRules` in private/global or per-repo config. These rules close * contributor PRs that link ineligible issues before spending AI review budget: owner/other-assigned, * maintainer-only, or missing point-label issues. Defaults all-off so self-hosters opt into their own policy. */ - linkedIssueHardRules?: LinkedIssueHardRulesConfig | undefined; + linkedIssueHardRules: LinkedIssueHardRulesConfig; /** Same-account issue-avoidance guardrail (#unlinked-issue-guardrail). Config-as-code only; set with * `.loopover.yml settings.unlinkedIssueGuardrail` in private/global or per-repo config. Defaults * all-off so a self-hoster opts into their own credibility-gate-farming defense. */ @@ -1257,19 +1257,19 @@ export type RepositorySettings = { /** Opt-in for the public per-repo review-quality page (#2568). Always populated by the DB layer * (default false); optional so existing settings fixtures/callers need not be touched. */ publicQualityMetrics?: boolean | undefined; - commandAuthorization?: RepositoryCommandAuthorizationPolicy | undefined; + commandAuthorization: RepositoryCommandAuthorizationPolicy; /** Per-repo contributor blacklist (#1425, anti-abuse): banned GitHub logins whose PRs/issues the engine * deterministically closes ahead of merit review. Layered the same as other settings (`.loopover.yml` > * DB) and unioned with the shared/global list at the point of use. Always populated by the DB layer * (default `[]`); optional so existing settings fixtures/callers need not be touched. */ - contributorBlacklist?: ContributorBlacklistEntry[] | undefined; + contributorBlacklist: ContributorBlacklistEntry[]; /** The label applied to a blacklisted contributor's PR (#1425). Configurable per-repo (dashboard/DB + * `.loopover.yml` `settings.blacklistLabel`); defaults to `"slop"` so the disposition works regardless of * the label a repo sets. Explicit `null` closes WITHOUT applying any label (the same load-bearing-null idiom * as {@link contributorOpenPrCap}) -- distinct from omitted/undefined, which uses the default. Always * populated by the DB layer (default `"slop"`); optional so existing settings fixtures/callers need not be * touched (mirrors the sibling `contributorBlacklist`). */ - blacklistLabel?: string | null | undefined; + blacklistLabel: string | null; /** Per-contributor open-PR cap (#2270, anti-abuse): the max PRs a single non-owner/admin/bot contributor may * have open on this repo at once. `null`/absent (default) = no cap, byte-identical to today. Layered like * every other settings field (`.loopover.yml` `settings.contributorOpenPrCap` > DB > `null`). Capped at @@ -1400,7 +1400,7 @@ export type RepositorySettings = { /** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default * `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers * need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */ - autonomy?: AutonomyPolicy | undefined; + autonomy: AutonomyPolicy; /** Auto-maintain policy (#774): merge method + approval count. Always populated by the DB layer with * defaults (squash / 1 approval); optional so existing settings fixtures/callers need not be touched. */ autoMaintain?: AutoMaintainPolicy | undefined; @@ -1422,7 +1422,7 @@ export type RepositorySettings = { * participation in the tally, opting it in/out and narrowing which mechanisms feed it, regardless of * the global default. Always populated by the DB layer; optional so existing settings fixtures/callers * need not be touched. */ - moderationGateMode?: "inherit" | "off" | "enabled" | undefined; + moderationGateMode: "inherit" | "off" | "enabled"; /** Moderation-rules engine: a per-repo override of WHICH of the anti-abuse mechanisms (contributor cap, * blacklist, review-nag, review-evasion) feed a contributor's shared, cross-repo violation tally. * `undefined`/absent ⇒ inherit the global rule set (`resolveEffectiveModerationRules`'s default shape). */ @@ -1439,7 +1439,7 @@ export type RepositorySettings = { * `"inherit"` (the default) participates; `"off"` excludes this repo's rows from every aggregation; * `"enabled"` is equivalent to `"inherit"`, kept for symmetry with moderationGateMode's tri-state shape. * Always populated by the DB layer; optional so existing settings fixtures/callers need not be touched. */ - fairnessAnalyticsMode?: "inherit" | "off" | "enabled" | undefined; + fairnessAnalyticsMode: "inherit" | "off" | "enabled"; /** Waste elimination for known automation authors (release-please's github-actions[bot], Renovate, * Dependabot -- settings/agent-actions.ts's PROTECTED_AUTOCLOSE_AUTHORS): skip AI review, gate evaluation, * and public-surface publish entirely for a PR/event genuinely triggered by one of these -- not just @@ -1483,7 +1483,7 @@ export type RepositorySettings = { * `converted_to_draft` handler, `bumpPullRequestDraftConversionCount`) keeps incrementing regardless, so a * repo re-enabling `"close"` (or removing an `"off"` override, which now also resolves to `"close"`) can * immediately treat a historical off-period cycle as "repeated" on the very next legitimate conversion. */ - reviewEvasionProtection?: "off" | "close" | undefined; + reviewEvasionProtection: "off" | "close"; /** Draft-PR close policy (#draft-pr-close-policy): distinct from {@link reviewEvasionProtection} above -- * that family only enforces AFTER a review has already run against the PR's current head, or on the 2nd+ * ready↔draft conversion. `"close"` enforces on ANY draft, including the very first one opened @@ -1498,7 +1498,7 @@ export type RepositorySettings = { * reviewEvasionProtection's own #6443 migration -- `db/repositories.ts`'s `getRepositorySettings`/ * `upsertRepositorySettings` always resolve the hardcoded `"off"` default now; only `.loopover.yml`'s * `settings.draftPrClosePolicy` can override it. See `queue/review-evasion.ts`'s `maybeCloseDraftPr`. */ - draftPrClosePolicy?: "off" | "close" | undefined; + draftPrClosePolicy: "off" | "close"; /** One-shot synchronize-amendment close policy (#synchronize-close-policy): distinct from {@link * reviewEvasionProtection} and {@link draftPrClosePolicy} above -- those families enforce on closing/ * converting-to-draft, or on draft usage; this one enforces on the contributor's OWN PR receiving an @@ -1520,7 +1520,7 @@ export type RepositorySettings = { * label/comment conventions, no need for separate config). Config-as-code only -- no DB column; set via * `.loopover.yml settings.synchronizeClosePolicy`. See `queue/review-evasion.ts`'s * `maybeCloseSynchronizeAmendment`. */ - synchronizeClosePolicy?: "off" | "close" | undefined; + synchronizeClosePolicy: "off" | "close"; /** Merge-train FIFO gate (#selfhost-merge-train): without this, a PR merges the instant its OWN gate * clears, with zero awareness of an older sibling PR still open in the same repo -- proven live to cause * out-of-order merges and the conflicts that follow. `"off"` (the default) is unchanged behavior. @@ -1541,7 +1541,7 @@ export type RepositorySettings = { * markup, scoped to the repo's configured labels/paths (`whenLabels`/`whenPaths`, OR-matched). Off by * default (`enabled: false`) -- opt in per repo, mirroring every other anti-abuse mechanism's shape. See * `review/screenshot-table-gate.ts` for the normalizer and the pure evaluator. */ - screenshotTableGate?: ScreenshotTableGateConfig | undefined; + screenshotTableGate: ScreenshotTableGateConfig; createdAt?: string | null | undefined; updatedAt?: string | null | undefined; }; diff --git a/test/unit/check-manifest-drift-script.test.ts b/test/unit/check-manifest-drift-script.test.ts index 3c163d011b..5939966f52 100644 --- a/test/unit/check-manifest-drift-script.test.ts +++ b/test/unit/check-manifest-drift-script.test.ts @@ -5,7 +5,7 @@ import { checkManifestDrift } from "../../scripts/check-manifest-drift"; import { LOOPOVER_REPO_FOCUS_MANIFEST_YAML } from "../../src/config/loopover-repo-focus-manifest"; // The script imports src/config/loopover-repo-focus-manifest.ts (a .ts module), so -- like -// check-schema-drift.ts, check-migrations.ts, and check-openapi-settings-parity.ts -- it must run via +// check-schema-drift.ts and check-migrations.ts -- it must run via // `tsx`, the same binary package.json's manifest:drift-check uses, rather than plain `node`. const TSX_BIN = join(process.cwd(), "node_modules", ".bin", "tsx"); diff --git a/test/unit/check-schema-drift-script.test.ts b/test/unit/check-schema-drift-script.test.ts index 50cc841675..10fee2c04b 100644 --- a/test/unit/check-schema-drift-script.test.ts +++ b/test/unit/check-schema-drift-script.test.ts @@ -15,8 +15,8 @@ import { } from "../../scripts/check-schema-drift"; import * as realSchema from "../../src/db/schema"; -// #2565: the script imports src/db/schema.ts (a .ts module), so -- like check-migrations.ts and -// check-openapi-settings-parity.ts -- it must run via `tsx`, the same binary package.json's +// #2565: the script imports src/db/schema.ts (a .ts module), so -- like check-migrations.ts -- it must +// run via `tsx`, the same binary package.json's // db:schema-drift:check uses, rather than plain `node`. const TSX_BIN = join(process.cwd(), "node_modules", ".bin", "tsx"); diff --git a/test/unit/ci-openapi-settings-parity.test.ts b/test/unit/ci-openapi-settings-parity.test.ts deleted file mode 100644 index 61d7f4a172..0000000000 --- a/test/unit/ci-openapi-settings-parity.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { diffFieldSets, extractRepoSettingsPreviewFieldNames, extractRepositorySettingsFieldNames, SETTINGS_PREVIEW_PATH, TYPES_PATH } from "../../scripts/check-openapi-settings-parity"; -import { RepoSettingsPreviewSchema, RepositorySettingsSchema } from "../../src/openapi/schemas"; - -// #2556: RepositorySettingsSchema (hand-authored Zod) can silently drift from the RepositorySettings TS -// type -- this is the structural-diff guard closing that gap. ui:openapi:check only verified the generated -// spec matched the Zod schema, never that the schema matched the type the API actually serializes. -describe("OpenAPI settings-parity check (#2556)", () => { - it("extracts every top-level field name from a RepositorySettings-shaped type block", () => { - const source = [ - "export type RepositorySettings = {", - " repoFullName: string;", - " /** a doc comment with a trailing colon: like this */", - " qualityGateMinScore?: number | null | undefined;", - " aiReviewProvider?: \"anthropic\" | \"openai\" | null | undefined;", - "};", - "", - "export type SomethingElse = { notAField: string };", - ].join("\n"); - const fields = extractRepositorySettingsFieldNames(source); - expect(fields).toEqual(new Set(["repoFullName", "qualityGateMinScore", "aiReviewProvider"])); - }); - - it("throws when the type start marker is missing", () => { - expect(() => extractRepositorySettingsFieldNames("export type Unrelated = { a: string };")).toThrow(/Could not find/); - }); - - it("throws when the closing brace is missing", () => { - expect(() => extractRepositorySettingsFieldNames("export type RepositorySettings = {\n repoFullName: string;")).toThrow(/closing/); - }); - - it("diffFieldSets reports fields missing from the schema and fields extra in the schema", () => { - const typeFields = new Set(["a", "b", "c"]); - const schemaFields = new Set(["a", "c", "d"]); - expect(diffFieldSets(typeFields, schemaFields)).toEqual({ - missingFromSchema: ["b"], - extraInSchema: ["d"], - }); - }); - - it("diffFieldSets reports no differences for identical sets", () => { - const fields = new Set(["a", "b"]); - expect(diffFieldSets(fields, fields)).toEqual({ missingFromSchema: [], extraInSchema: [] }); - }); - - it("the real RepositorySettings type and RepositorySettingsSchema are in parity (regression guard)", () => { - const typeFields = extractRepositorySettingsFieldNames(readFileSync(TYPES_PATH, "utf8")); - const schemaFields = new Set(Object.keys(RepositorySettingsSchema.shape)); - expect(diffFieldSets(typeFields, schemaFields)).toEqual({ missingFromSchema: [], extraInSchema: [] }); - }); - it("rejects contributor open caps above the enforcement sample budget", () => { - expect(() => RepositorySettingsSchema.partial().parse({ contributorOpenPrCap: 101 })).toThrow(); - expect(() => RepositorySettingsSchema.partial().parse({ contributorOpenIssueCap: 101 })).toThrow(); - expect(RepositorySettingsSchema.partial().parse({ contributorOpenPrCap: 100, contributorOpenIssueCap: 100 })).toMatchObject({ contributorOpenPrCap: 100, contributorOpenIssueCap: 100 }); - }); -}); - -// #7011: the header comment names RepoSettingsPreviewSchema too, but main() only guarded RepositorySettings. -// This block covers the added second check -- RepoSettingsPreviewSchema.settings against buildRepoSettingsPreview's -// return shape (the RepoSettingsPreview.settings type), mirroring the RepositorySettings coverage above. -describe("OpenAPI settings-preview parity check (#7011)", () => { - it("extracts only the direct field names of the nested settings block, skipping nested and sibling fields", () => { - const source = [ - "export type RepoSettingsPreview = {", - " repoFullName: string;", - " settings: {", - " publicSurface: RepositorySettings[\"publicSurface\"];", - " qualityGateMinScore?: number | null | undefined;", - " commandAuthorization: {", - " defaultAllowed: CommandAuthorizationRole[];", - " commandOverrides: Array<{ command: string; allowedRoles: CommandAuthorizationRole[] }>;", - " };", - " };", - " commandAuthorizationPreview: {", - " commandName: string;", - " };", - "};", - ].join("\n"); - const fields = extractRepoSettingsPreviewFieldNames(source); - expect(fields).toEqual(new Set(["publicSurface", "qualityGateMinScore", "commandAuthorization"])); - }); - - it("throws when the RepoSettingsPreview type start marker is missing", () => { - expect(() => extractRepoSettingsPreviewFieldNames("export type Unrelated = { settings: { a: string } };")).toThrow(/Could not find/); - }); - - it("throws when the settings block is missing from the type", () => { - expect(() => extractRepoSettingsPreviewFieldNames("export type RepoSettingsPreview = {\n repoFullName: string;\n};")).toThrow(/settings/); - }); - - it("throws when the settings block is never closed", () => { - expect(() => extractRepoSettingsPreviewFieldNames("export type RepoSettingsPreview = {\n settings: {\n publicSurface: string;")).toThrow(/closing/); - }); - - it("the real RepoSettingsPreview type and RepoSettingsPreviewSchema.settings are in parity (regression guard)", () => { - const previewFields = extractRepoSettingsPreviewFieldNames(readFileSync(SETTINGS_PREVIEW_PATH, "utf8")); - const schemaFields = new Set(Object.keys(RepoSettingsPreviewSchema.shape.settings.shape)); - expect(diffFieldSets(previewFields, schemaFields)).toEqual({ missingFromSchema: [], extraInSchema: [] }); - }); -}); diff --git a/test/unit/docs-selfhost-troubleshooting-metric-names.test.ts b/test/unit/docs-selfhost-troubleshooting-metric-names.test.ts index ba45ab8d01..e63aa92bba 100644 --- a/test/unit/docs-selfhost-troubleshooting-metric-names.test.ts +++ b/test/unit/docs-selfhost-troubleshooting-metric-names.test.ts @@ -4,7 +4,8 @@ import { describe, expect, it } from "vitest"; // Drift guard (#1943 gate review finding): the self-hosting troubleshooting runbooks reference exact // Prometheus metric names and alert names. If a metric is ever renamed/removed in src/, or an alert is // renamed/removed in prometheus/rules/alerts.yml, this test fails instead of the docs silently going stale -// — mirrors the same source-of-truth-diff approach as scripts/check-openapi-settings-parity.ts (#2556). +// — the same source-of-truth-diff approach the retired scripts/check-openapi-settings-parity.ts took for +// the OpenAPI settings schemas (#2556) before #9531 replaced it with a compile-time assertion. const DOC_PATH = "apps/loopover-ui/content/docs/self-hosting-troubleshooting.mdx"; const doc = readFileSync(DOC_PATH, "utf8"); diff --git a/test/unit/effective-config-summary.test.ts b/test/unit/effective-config-summary.test.ts index c6e0c99ad4..e337d4485e 100644 --- a/test/unit/effective-config-summary.test.ts +++ b/test/unit/effective-config-summary.test.ts @@ -36,8 +36,12 @@ describe("summarizeEffectiveConfig", () => { }); it("renders the blacklist label across configured / default / disabled cases", () => { + // #9531: blacklistLabel is required on RepositorySettings now (both DB read paths always populate it), so + // "absent" is expressed by omitting the key rather than passing an explicit undefined -- which is what the + // summarizer's `?? "slop"` fallback actually guards against for a settings object built elsewhere. + const { blacklistLabel: _absent, ...withoutBlacklistLabel } = base(); expect(summarizeEffectiveConfig(base({ blacklistLabel: "spam" }), "live")).toContain("Blacklist label: `spam`"); - expect(summarizeEffectiveConfig(base({ blacklistLabel: undefined }), "live")).toContain("Blacklist label: `slop`"); // default + expect(summarizeEffectiveConfig(withoutBlacklistLabel as RepositorySettings, "live")).toContain("Blacklist label: `slop`"); // default expect(summarizeEffectiveConfig(base({ blacklistLabel: null }), "live")).toContain("Blacklist label: `(disabled)`"); }); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index f17e03c124..5aab97263c 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -6299,3 +6299,24 @@ describe("kill-switch flags are ratchets, not overridable settings (#9049)", () expect(resolveEffectiveSettings({ agentDryRun: false } as RepositorySettings, manifestWith({})).agentDryRun).toBe(false); }); }); + +// #9531: `manifest.settings` is a `Partial<...>`, so an explicitly-`undefined` key is indistinguishable from +// an absent one to the type system -- but NOT to the spread that layers the manifest over the DB row, which +// happily overwrote a resolved value with `undefined`. "Not overridden" is what an absent value means at +// every other layer of this resolver, so the spread now skips undefined-valued keys entirely. +describe("an explicitly-undefined manifest setting does not punch a hole in the DB value (#9531)", () => { + const manifestWithSettings = (settings: FocusManifest["settings"]): FocusManifest => { + const parsed = parseFocusManifest({}); + return { ...parsed, settings: { ...parsed.settings, ...settings } }; + }; + + it("keeps the DB value when the manifest carries the key with an undefined value", () => { + const effective = resolveEffectiveSettings({ typeLabelsEnabled: true } as RepositorySettings, manifestWithSettings({ typeLabelsEnabled: undefined })); + expect(effective.typeLabelsEnabled).toBe(true); + }); + + it("still lets a DEFINED manifest value win, including a falsy one", () => { + expect(resolveEffectiveSettings({ typeLabelsEnabled: true } as RepositorySettings, manifestWithSettings({ typeLabelsEnabled: false })).typeLabelsEnabled).toBe(false); + expect(resolveEffectiveSettings({ typeLabelsEnabled: false } as RepositorySettings, manifestWithSettings({ typeLabelsEnabled: true })).typeLabelsEnabled).toBe(true); + }); +}); diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts index af6cda62a5..ca2872c63c 100644 --- a/test/unit/maintainer-activation.test.ts +++ b/test/unit/maintainer-activation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { buildMaintainerActivationPreview } from "../../src/services/maintainer-activation"; import type { PullRequestRecord, RepositoryRecord, RepositorySettings } from "../../src/types"; @@ -21,6 +22,9 @@ const repo: RepositoryRecord = { function settings(overrides: Partial = {}): RepositorySettings { return { + // #9531: the built-in defaults fill the thirteen config-as-code fields this literal used to + // silently omit; every explicit value below still overrides them. + ...defaultRepositorySettings("fixture/fixture"), repoFullName: repo.fullName, commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", diff --git a/test/unit/openapi-settings-schema-parity.test.ts b/test/unit/openapi-settings-schema-parity.test.ts new file mode 100644 index 0000000000..6cdd475abb --- /dev/null +++ b/test/unit/openapi-settings-schema-parity.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import type { z } from "zod"; +import { defaultRepositorySettings } from "../../src/db/repositories"; +import { RepoSettingsPreviewSchema, RepositorySettingsSchema } from "../../src/openapi/schemas"; +import type { RepoSettingsPreview } from "../../src/signals/settings-preview"; +import type { RepositorySettings } from "../../src/types"; + +// #9531: RepositorySettingsSchema/RepoSettingsPreviewSchema (src/openapi/schemas.ts) are hand-authored Zod +// schemas, and ui:openapi:check only verifies the generated openapi.json matches THEM -- never that they +// match the TS types the handlers actually serialize. scripts/check-openapi-settings-parity.ts (#2556/#7011) +// closed that gap by regex-diffing the two KEY SETS out of the raw source text; the assertions below replace +// it and subsume it, because the compiler compares what a key-set diff structurally cannot: each field's +// optionality, nullability, and value type, in BOTH directions. Retiring the script also retires its own +// blind spots -- it could only ever see top-level names, and it re-parsed src/types.ts by brace-and-indent +// heuristics that any reformatting would have quietly broken. +// +// Three published-spec defects it had been unable to see, all found the moment these assertions compiled: +// the dead `autonomy` levels (#4620), `contributorBlacklist.githubId` (#9125), and `moderationRules`' +// missing `copycat` member -- see each field's own comment in src/openapi/schemas.ts. + +/** Exact type equality (not mutual assignability): the deferred-conditional identity trick, which compares + * the two types structurally rather than checking each is assignable to the other -- the latter treats + * `{ a?: T }` and `{ a: T | undefined }` as interchangeable in one direction, which is exactly the drift + * class an OpenAPI schema is most likely to have. */ +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +/** The keys on which two object types disagree. Each key is compared through `Pick` so its OPTIONALITY is + * part of the comparison, and a key present in only one of the two types compares `{}` against `{ k: V }` + * -- so a missing field and a mistyped field are both caught, by the same mechanism. + * + * This exists instead of a bare `Equal` assertion so a failure NAMES the drifted field the way the + * retired script's error message did: {@link AssertNoSchemaDrift} constrains its parameter to `never`, so + * tsc reports the offending key as the type that "does not satisfy the constraint". */ +type DriftedKeys = { + [K in keyof A | keyof B]-?: Equal, Pick> extends true ? never : K; +}[keyof A | keyof B]; + +/** A drifted field turns each instantiation below into a `tsc` error naming that field. Fix + * src/openapi/schemas.ts (or the type, when the route genuinely omits the field), then `npm run ui:openapi`. */ +type AssertNoSchemaDrift<_Drifted extends never> = true; + +/** GET /v1/repos/:owner/:repo/settings serializes a RepositorySettings verbatim. */ +type RepositorySettingsParity = AssertNoSchemaDrift, RepositorySettings>>; +/** GET /v1/repos/:owner/:repo/settings-preview serializes buildRepoSettingsPreview's RepoSettingsPreview. */ +type RepoSettingsPreviewParity = AssertNoSchemaDrift, RepoSettingsPreview>>; + +// The two aliases are checked when tsc INSTANTIATES them, which only happens where they are referenced -- +// hence this anchor, rather than two dangling type declarations a later cleanup would read as dead. +const schemaParity: [RepositorySettingsParity, RepoSettingsPreviewParity] = [true, true]; + +describe("RepositorySettingsSchema parity with the RepositorySettings type (#9531)", () => { + it("compiles the schema-vs-type drift assertions", () => { + expect(schemaParity).toEqual([true, true]); + }); + + // The assertions above are erased at runtime, so this pins the other half of the contract: that the schema + // ACCEPTS a real payload. A field the schema requires but no read path actually populates would compile + // fine (the type says required) and still reject every generated client -- only parsing catches that. + it("accepts the built-in defaults every no-row settings read returns", () => { + const parsed = RepositorySettingsSchema.safeParse(defaultRepositorySettings("JSONbored/loopover")); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.success).toBe(true); + }); + + it("rejects a payload missing a config-as-code field both read paths always populate", () => { + const { screenshotTableGate: _omitted, ...withoutScreenshotTableGate } = defaultRepositorySettings("JSONbored/loopover"); + expect(RepositorySettingsSchema.safeParse(withoutScreenshotTableGate).success).toBe(false); + }); + + // #9125's immutable-id field: returned by the API since it landed, absent from the published schema until + // the parity assertion above flagged it. + it("accepts a contributor-blacklist entry carrying the immutable githubId (#9125)", () => { + const parsed = RepositorySettingsSchema.safeParse({ + ...defaultRepositorySettings("JSONbored/loopover"), + contributorBlacklist: [{ login: "banned-user", githubId: 4242, reason: "slop", evidence: ["https://github.com/JSONbored/loopover/pull/1"], addedAt: "2026-07-28T00:00:00.000Z" }], + }); + expect(parsed.error?.issues ?? []).toEqual([]); + }); + + // #4620 removed "suggest"/"propose" from AutonomyLevel -- both were behaviorally identical to "observe" + // from day one -- but the published schema kept advertising them for every action class. + it("accepts every live autonomy level and rejects the two #4620 removed", () => { + const settings = defaultRepositorySettings("JSONbored/loopover"); + expect(RepositorySettingsSchema.safeParse({ ...settings, autonomy: { review: "observe", merge: "auto_with_approval", close: "auto" } }).success).toBe(true); + expect(RepositorySettingsSchema.safeParse({ ...settings, autonomy: { review: "suggest" } }).success).toBe(false); + expect(RepositorySettingsSchema.safeParse({ ...settings, autonomy: { review: "propose" } }).success).toBe(false); + }); + + // #1969 folded copycat containment into the same generic moderation-violation ledger as the other four + // rules, making "copycat" a real ModerationRuleType this route can return. + it("accepts every moderation rule the type declares, copycat included (#1969)", () => { + const settings = defaultRepositorySettings("JSONbored/loopover"); + const moderationRules: RepositorySettings["moderationRules"] = ["contributor_cap", "blacklist", "review_nag", "review_evasion", "copycat"]; + expect(RepositorySettingsSchema.safeParse({ ...settings, moderationRules }).success).toBe(true); + }); + + it("rejects contributor open caps above the enforcement sample budget", () => { + expect(() => RepositorySettingsSchema.partial().parse({ contributorOpenPrCap: 101 })).toThrow(); + expect(() => RepositorySettingsSchema.partial().parse({ contributorOpenIssueCap: 101 })).toThrow(); + expect(RepositorySettingsSchema.partial().parse({ contributorOpenPrCap: 100, contributorOpenIssueCap: 100 })).toMatchObject({ contributorOpenPrCap: 100, contributorOpenIssueCap: 100 }); + }); +}); + +describe("defaultRepositorySettings (#9531)", () => { + it("carries the caller's repo name and the built-in config-as-code defaults", () => { + const settings = defaultRepositorySettings("acme/widgets"); + expect(settings.repoFullName).toBe("acme/widgets"); + expect(settings.blacklistLabel).toBe("slop"); + expect(settings.autonomy).toEqual({}); + expect(settings.contributorBlacklist).toEqual([]); + expect(settings.synchronizeClosePolicy).toBe("off"); + expect(settings.linkedIssueHardRules.pointBearingLabels).toEqual([]); + expect(settings.screenshotTableGate.whenLabels).toEqual([]); + }); + + it("returns an independent object per call, so a mutated default never leaks into the next read", () => { + const first = defaultRepositorySettings("acme/widgets"); + first.linkedIssueHardRules.pointBearingLabels.push("points:3"); + expect(defaultRepositorySettings("acme/widgets").linkedIssueHardRules.pointBearingLabels).toEqual([]); + }); +}); diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts index 4e3c8103b7..577b6dfc5e 100644 --- a/test/unit/policy-sanitizer.test.ts +++ b/test/unit/policy-sanitizer.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { __controlPanelRolesInternals, buildControlPanelRoleSummary } from "../../src/services/control-panel-roles"; import { buildCollisionReport, @@ -54,6 +55,9 @@ function configFor(overrides: Partial = {}): RegistryRepoCon function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettings { return { + // #9531: the built-in defaults fill the thirteen config-as-code fields this literal used to + // silently omit; every explicit value below still overrides them. + ...defaultRepositorySettings("fixture/fixture"), repoFullName, commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", diff --git a/test/unit/queue-lifecycle-guards.test.ts b/test/unit/queue-lifecycle-guards.test.ts index acb36baf08..e096b763cb 100644 --- a/test/unit/queue-lifecycle-guards.test.ts +++ b/test/unit/queue-lifecycle-guards.test.ts @@ -6,6 +6,7 @@ import { } from "../../src/review/review-memory-wire"; import { } from "../../src/github/comments"; import * as repositoriesModule from "../../src/db/repositories"; import * as repositorySettingsModule from "../../src/settings/repository-settings"; +import type { RepositorySettings } from "../../src/types"; import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics"; import { } from "../../src/selfhost/queue-common"; import { @@ -2509,7 +2510,11 @@ describe("review-evasion protection (#review-evasion-protection)", () => { await setupEvasionRepo(env); await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); - vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionProtection: undefined }); + // #9531: reviewEvasionProtection is required on RepositorySettings now (both DB read paths always + // populate it), so an UNSET setting is a missing key, not an explicit undefined -- which is what the + // "#4011 default is close" fallback in review-evasion.ts actually resolves. + const { reviewEvasionProtection: _unset, ...settingsWithoutProtection } = baseSettings; + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue(settingsWithoutProtection as RepositorySettings); await processJob(env, { type: "github-webhook", deliveryId: "self-close-protection-unset", eventName: "pull_request", payload: closedPayload("contributor") }); @@ -2964,7 +2969,11 @@ describe("review-evasion protection (#review-evasion-protection)", () => { await setupEvasionRepo(env); await repositoriesModule.startActiveReviewTracking(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", deliveryId: "review-start-1" }); const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); - vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionProtection: undefined }); + // #9531: reviewEvasionProtection is required on RepositorySettings now (both DB read paths always + // populate it), so an UNSET setting is a missing key, not an explicit undefined -- which is what the + // "#4011 default is close" fallback in review-evasion.ts actually resolves. + const { reviewEvasionProtection: _unset, ...settingsWithoutProtection } = baseSettings; + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue(settingsWithoutProtection as RepositorySettings); await processJob(env, { type: "github-webhook", deliveryId: "draft-evasion-protection-unset", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); @@ -3183,7 +3192,11 @@ describe("review-evasion protection (#review-evasion-protection)", () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "loopover-orb" }); await setupEvasionRepo(env); const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory"); - vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue({ ...baseSettings, reviewEvasionProtection: undefined }); + // #9531: reviewEvasionProtection is required on RepositorySettings now (both DB read paths always + // populate it), so an UNSET setting is a missing key, not an explicit undefined -- which is what the + // "#4011 default is close" fallback in review-evasion.ts actually resolves. + const { reviewEvasionProtection: _unset, ...settingsWithoutProtection } = baseSettings; + vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockResolvedValue(settingsWithoutProtection as RepositorySettings); await processJob(env, { type: "github-webhook", deliveryId: "draft-cycle-unset-1", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); // first conversion never closes diff --git a/test/unit/registration-readiness.test.ts b/test/unit/registration-readiness.test.ts index edef6dd721..d707c98fe4 100644 --- a/test/unit/registration-readiness.test.ts +++ b/test/unit/registration-readiness.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { buildCollisionReport, buildConfigQuality, @@ -35,6 +36,9 @@ function configFor(overrides: Partial = {}): RegistryRepoCon function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettings { return { + // #9531: the built-in defaults fill the thirteen config-as-code fields this literal used to + // silently omit; every explicit value below still overrides them. + ...defaultRepositorySettings("fixture/fixture"), repoFullName, commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", diff --git a/test/unit/repo-policy-readiness.test.ts b/test/unit/repo-policy-readiness.test.ts index ab342fa472..5dece32335 100644 --- a/test/unit/repo-policy-readiness.test.ts +++ b/test/unit/repo-policy-readiness.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { isFocusManifestPublicSafe, parseFocusManifest } from "../../src/signals/focus-manifest"; import { buildRepoPolicyReadiness, @@ -13,6 +14,9 @@ const FORBIDDEN_PUBLIC_LANGUAGE = function settings(overrides: Partial = {}): RepositorySettings { return { + // #9531: the built-in defaults fill the thirteen config-as-code fields this literal used to + // silently omit; every explicit value below still overrides them. + ...defaultRepositorySettings("fixture/fixture"), repoFullName: "owner/repo", commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", diff --git a/test/unit/repository-settings-enforcement.test.ts b/test/unit/repository-settings-enforcement.test.ts index c306da409b..eb7cefe44e 100644 --- a/test/unit/repository-settings-enforcement.test.ts +++ b/test/unit/repository-settings-enforcement.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { gateCheckPolicy } from "../../src/queue/processors"; import { evaluateGateCheck } from "../../src/rules/advisory"; import { runAiReviewForAdvisory } from "../../src/queue/processors"; @@ -9,6 +10,9 @@ import type { Advisory, PullRequestRecord, RepositoryRecord, RepositorySettings function settings(over: Partial = {}): RepositorySettings { return { + // #9531: the built-in defaults fill the thirteen config-as-code fields this literal used to + // silently omit; every explicit value below still overrides them. + ...defaultRepositorySettings("fixture/fixture"), repoFullName: "owner/repo", commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index aaca6de720..a51f732bd4 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { buildCollisionReport, buildConfigQuality, @@ -48,6 +49,9 @@ function configFor(overrides: Partial = {}): RegistryRepoCon function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettings { return { + // #9531: the built-in defaults fill the thirteen config-as-code fields this literal used to + // silently omit; every explicit value below still overrides them. + ...defaultRepositorySettings("fixture/fixture"), repoFullName, commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", diff --git a/test/unit/settings-preview.test.ts b/test/unit/settings-preview.test.ts index aca0d3e5bc..60466b1147 100644 --- a/test/unit/settings-preview.test.ts +++ b/test/unit/settings-preview.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { buildRepoSettingsPreview, buildSampleCheckRunReadiness, decidePublicSurface, type InstallationHealthSummary } from "../../src/signals/settings-preview"; import { REQUIRED_INSTALLATION_PERMISSIONS } from "../../src/github/backfill"; import { RepoSettingsPreviewSchema } from "../../src/openapi/schemas"; @@ -33,6 +34,9 @@ const pullRequests: PullRequestRecord[] = []; function settings(overrides: Partial = {}): RepositorySettings { return { + // #9531: the built-in defaults fill the thirteen config-as-code fields this literal used to + // silently omit; every explicit value below still overrides them. + ...defaultRepositorySettings("fixture/fixture"), repoFullName: repo.fullName, commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", diff --git a/test/unit/signals-edge-cases.test.ts b/test/unit/signals-edge-cases.test.ts index 52f420d644..1eaff71edc 100644 --- a/test/unit/signals-edge-cases.test.ts +++ b/test/unit/signals-edge-cases.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { buildBountyAdvisory, buildCollisionReport, @@ -2375,6 +2376,9 @@ function pr(repoFullName: string, number: number, title: string, overrides: Part function repoSettings(repoFullName: string): RepositorySettings { return { + // #9531: the built-in defaults fill the thirteen config-as-code fields this literal used to + // silently omit; every explicit value below still overrides them. + ...defaultRepositorySettings("fixture/fixture"), repoFullName, commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index 51e61bb91f..71e76aed3f 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { buildCollisionEdges, buildCollisionReport, @@ -2098,6 +2099,7 @@ describe("v2 signal builders", () => { collisions: buildCollisionReport(repo.fullName, [], []), preflight: buildPreflightResult({ repoFullName: repo.fullName, title: "Docs", body: "No linked issue", changedFiles: ["README.md"], tests: ["manual"] }, repo, [], []), settings: { + ...defaultRepositorySettings("fixture/fixture"), repoFullName: repo.fullName, commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index fdf696a3f1..0495b0109a 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { buildBountyAdvisory, buildBurdenForecast, @@ -525,6 +526,7 @@ describe("world-class backend signals", () => { }; const detection = { ...detectGittensorContributor("oktofeesh1", currentPr, [currentPr, priorPr], []), source: "official_gittensor_api" as const }; const settings = { + ...defaultRepositorySettings("fixture/fixture"), repoFullName: repo.fullName, commentMode: "detected_contributors_only" as const, publicAudienceMode: "gittensor_only" as const, @@ -575,6 +577,7 @@ describe("world-class backend signals", () => { }; const detection = { ...detectGittensorContributor("oktofeesh1", currentPr, [currentPr], []), source: "official_gittensor_api" as const }; const settings: RepositorySettings = { + ...defaultRepositorySettings("fixture/fixture"), repoFullName: repo.fullName, commentMode: "detected_contributors_only", publicAudienceMode: "gittensor_only", @@ -659,6 +662,7 @@ describe("world-class backend signals", () => { }; const detection = detectGittensorContributor("oktofeesh1", currentPr, [currentPr], []); const settings: RepositorySettings = { + ...defaultRepositorySettings("fixture/fixture"), repoFullName: repo.fullName, commentMode: "detected_contributors_only", publicAudienceMode: "gittensor_only", @@ -776,6 +780,7 @@ describe("world-class backend signals", () => { it("keeps contributor detection and comment modes conservative", () => { const currentPr = pullRequests[0]!; const settings: RepositorySettings = { + ...defaultRepositorySettings("fixture/fixture"), repoFullName: repo.fullName, commentMode: "off", publicAudienceMode: "gittensor_only", diff --git a/test/unit/unified-comment-parity.test.ts b/test/unit/unified-comment-parity.test.ts index 8185262870..7890866e8f 100644 --- a/test/unit/unified-comment-parity.test.ts +++ b/test/unit/unified-comment-parity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultRepositorySettings } from "../../src/db/repositories"; import { buildCollisionReport, buildContributorProfile, @@ -46,6 +47,7 @@ const pullRequests: PullRequestRecord[] = [ ]; const settings: RepositorySettings = { + ...defaultRepositorySettings("fixture/fixture"), repoFullName: repo.fullName, commentMode: "detected_contributors_only", publicAudienceMode: "gittensor_only",