From 77abcd5cb51a3e6044c8eb0e21de44d50bfa3f3d Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:23:32 -0700 Subject: [PATCH] fix(openapi): add 11 missing RepositorySettings fields + a structural-drift CI check RepositorySettingsSchema and RepoSettingsPreviewSchema (hand-authored Zod schemas in src/openapi/schemas.ts) were missing 11 real fields the runtime RepositorySettings TS type has and GET /v1/repos/:owner/:repo/settings actually serializes: sizeGateMode, gateDryRun, premergeContentRecheck, aiReviewMode, aiReviewByok, aiReviewProvider, aiReviewModel, aiReviewAllAuthors, aiReviewCloseConfidence, closeOwnerAuthors, badgeEnabled. ui:openapi:check only verifies the generated openapi.json matches the Zod schema -- it never verifies the Zod schema matches the actual TS interface, so this silently breaks generated API clients (including @jsonbored/gittensory-mcp) with no CI signal. Added all 11 fields to RepositorySettingsSchema, plus the 6 the RepoSettingsPreview.settings sub-type separately carries (badgeEnabled + 5 aiReview* fields) to RepoSettingsPreviewSchema. Added scripts/check-openapi-settings-parity.mjs: parses the top-level field names out of the RepositorySettings type block in src/types.ts and diffs them against RepositorySettingsSchema.shape's keys, failing with the exact field names on any asymmetric difference. Wired into test:ci as ui:openapi:settings-parity, right after the existing ui:openapi:check step (same push/ui/uiContract gating), and documented in the contributing skill's reference table. Regenerated apps/gittensory-ui/public/openapi.json. Closes #2556 --- .../contributing-to-gittensory/reference.md | 1 + .github/workflows/ci.yml | 6 ++ apps/gittensory-ui/public/openapi.json | 86 +++++++++++++++++++ package.json | 3 +- scripts/check-openapi-settings-parity.d.mts | 5 ++ scripts/check-openapi-settings-parity.mjs | 58 +++++++++++++ src/openapi/schemas.ts | 17 ++++ test/unit/ci-openapi-settings-parity.test.ts | 52 +++++++++++ 8 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 scripts/check-openapi-settings-parity.d.mts create mode 100644 scripts/check-openapi-settings-parity.mjs create mode 100644 test/unit/ci-openapi-settings-parity.test.ts diff --git a/.claude/skills/contributing-to-gittensory/reference.md b/.claude/skills/contributing-to-gittensory/reference.md index f9d2cd89ae..e7f9dc0d34 100644 --- a/.claude/skills/contributing-to-gittensory/reference.md +++ b/.claude/skills/contributing-to-gittensory/reference.md @@ -33,6 +33,7 @@ path filter matched; on push to `main`, everything runs. | mcp → build | MCP pkg build | `npm run build:mcp` | MCP package build error | | mcp → pack | tarball hygiene | `npm run test:mcp-pack` | unexpected/forbidden file or stale README in the npm tarball | | ui → openapi drift | spec check | `npm run ui:openapi:check` | committed `openapi.json` is stale (run `npm run ui:openapi`) | +| ui → openapi settings-parity | schema/type structural diff | `npm run ui:openapi:settings-parity` | `RepositorySettingsSchema` (src/openapi/schemas.ts) is missing a field the `RepositorySettings` type has | | ui → version audit | MCP version copy | `npm run ui:version-audit` | stale MCP version strings / non-`@latest` install copy (hits npm registry) | | ui → lint | `eslint .` (UI) | `npm run ui:lint` | ESLint **incl. Prettier formatting** + design-token rules | | ui → typecheck | `tsc --noEmit` (UI) | `npm run ui:typecheck` | UI type error | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28259d39a0..587285f426 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -344,6 +344,12 @@ 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/gittensory-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/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index a2eaa0de13..d56ea4c3ef 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8631,6 +8631,57 @@ }, "contributorCapLabel": { "type": "string" + }, + "sizeGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "gateDryRun": { + "type": "boolean" + }, + "premergeContentRecheck": { + "type": "boolean" + }, + "aiReviewMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "aiReviewByok": { + "type": "boolean" + }, + "aiReviewProvider": { + "type": "string", + "nullable": true, + "enum": [ + "anthropic", + "openai", + null + ] + }, + "aiReviewModel": { + "type": "string", + "nullable": true + }, + "aiReviewAllAuthors": { + "type": "boolean" + }, + "aiReviewCloseConfidence": { + "type": "number", + "nullable": true + }, + "closeOwnerAuthors": { + "type": "boolean" + }, + "badgeEnabled": { + "type": "boolean" } }, "required": [ @@ -8651,6 +8702,10 @@ "selfAuthoredLinkedIssueGateMode", "firstTimeContributorGrace", "slopAiAdvisory", + "aiReviewMode", + "aiReviewByok", + "aiReviewAllAuthors", + "closeOwnerAuthors", "autoLabelEnabled", "gittensorLabel", "blacklistLabel", @@ -9260,6 +9315,31 @@ }, "blacklistLabel": { "type": "string" + }, + "badgeEnabled": { + "type": "boolean" + }, + "aiReviewMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "aiReviewByok": { + "type": "boolean" + }, + "aiReviewProvider": { + "type": "string", + "nullable": true + }, + "aiReviewModel": { + "type": "string", + "nullable": true + }, + "aiReviewAllAuthors": { + "type": "boolean" } }, "required": [ @@ -9285,6 +9365,12 @@ "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", + "badgeEnabled", + "aiReviewMode", + "aiReviewByok", + "aiReviewProvider", + "aiReviewModel", + "aiReviewAllAuthors", "commandAuthorization" ] }, diff --git a/package.json b/package.json index 3d0775e85d..20a442b438 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "ui:test": "npm --workspace @jsonbored/gittensory-ui 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.mjs", "ui:version-audit": "node scripts/check-ui-mcp-version-copy.mjs", "ui:deploy": "npm run ui:build && npm run ui:deploy:built", "ui:deploy:built": "wrangler deploy --config apps/gittensory-ui/dist/server/wrangler.json", @@ -63,7 +64,7 @@ "test:smoke:observability": "node scripts/smoke-observability-traces.mjs", "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node scripts/smoke-ui-browser.mjs", - "test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run cf-typegen:check && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && 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 db:migrations:check && npm run cf-typegen:check && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && 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 && npm run changelog:check:mcp", "test:watch": "vitest", diff --git a/scripts/check-openapi-settings-parity.d.mts b/scripts/check-openapi-settings-parity.d.mts new file mode 100644 index 0000000000..daef8b00c2 --- /dev/null +++ b/scripts/check-openapi-settings-parity.d.mts @@ -0,0 +1,5 @@ +export const TYPES_PATH: string; + +export function extractRepositorySettingsFieldNames(source: string): Set; + +export function diffFieldSets(typeFields: Set, schemaFields: Set): { missingFromSchema: string[]; extraInSchema: string[] }; diff --git a/scripts/check-openapi-settings-parity.mjs b/scripts/check-openapi-settings-parity.mjs new file mode 100644 index 0000000000..9e4d1d189a --- /dev/null +++ b/scripts/check-openapi-settings-parity.mjs @@ -0,0 +1,58 @@ +#!/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 @jsonbored/gittensory-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 } from "../src/openapi/schemas.ts"; + +export const TYPES_PATH = "src/types.ts"; +const TYPE_START = "export type RepositorySettings = {"; + +/** 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) { + 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: diff two field-name sets, returning the sorted asymmetric differences. */ +export function diffFieldSets(typeFields, schemaFields) { + return { + missingFromSchema: [...typeFields].filter((field) => !schemaFields.has(field)).sort(), + extraInSchema: [...schemaFields].filter((field) => !typeFields.has(field)).sort(), + }; +} + +function main() { + const typeFields = extractRepositorySettingsFieldNames(readFileSync(TYPES_PATH, "utf8")); + const schemaFields = new Set(Object.keys(RepositorySettingsSchema.shape)); + const { missingFromSchema, extraInSchema } = diffFieldSets(typeFields, schemaFields); + + if (missingFromSchema.length > 0 || extraInSchema.length > 0) { + if (missingFromSchema.length > 0) { + console.error(`RepositorySettingsSchema (src/openapi/schemas.ts) is missing field(s) present on the RepositorySettings type: ${missingFromSchema.join(", ")}`); + } + if (extraInSchema.length > 0) { + console.error(`RepositorySettingsSchema (src/openapi/schemas.ts) declares field(s) not present on the RepositorySettings type: ${extraInSchema.join(", ")}`); + } + console.error("Update src/openapi/schemas.ts, then run: npm run ui:openapi"); + process.exit(1); + } + + console.log(`RepositorySettingsSchema matches the RepositorySettings type (${typeFields.size} fields).`); +} + +// 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/src/openapi/schemas.ts b/src/openapi/schemas.ts index f421cd928e..877fccd559 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -595,12 +595,22 @@ export const RepositorySettingsSchema = z qualityGateMode: z.enum(["off", "advisory", "block"]), qualityGateMinScore: z.number().nullable().optional(), slopGateMode: z.enum(["off", "advisory", "block"]), + sizeGateMode: z.enum(["off", "advisory", "block"]).optional(), + gateDryRun: z.boolean().optional(), + premergeContentRecheck: z.boolean().optional(), mergeReadinessGateMode: z.enum(["off", "advisory", "block"]), manifestPolicyGateMode: z.enum(["off", "advisory", "block"]), selfAuthoredLinkedIssueGateMode: z.enum(["off", "advisory", "block"]), firstTimeContributorGrace: z.boolean(), slopGateMinScore: z.number().nullable().optional(), slopAiAdvisory: z.boolean(), + aiReviewMode: z.enum(["off", "advisory", "block"]), + aiReviewByok: z.boolean(), + aiReviewProvider: z.enum(["anthropic", "openai"]).nullable().optional(), + aiReviewModel: z.string().nullable().optional(), + aiReviewAllAuthors: z.boolean(), + aiReviewCloseConfidence: z.number().nullable().optional(), + closeOwnerAuthors: z.boolean(), autoLabelEnabled: z.boolean(), gittensorLabel: z.string(), blacklistLabel: z.string(), @@ -610,6 +620,7 @@ export const RepositorySettingsSchema = z requireLinkedIssue: z.boolean(), backfillEnabled: z.boolean(), privateTrustEnabled: z.boolean(), + badgeEnabled: z.boolean().optional(), commandAuthorization: z.object({ default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])), commands: z.record(z.string(), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"]))), @@ -672,6 +683,12 @@ export const RepoSettingsPreviewSchema = z createMissingLabel: z.boolean(), includeMaintainerAuthors: z.boolean(), requireLinkedIssue: z.boolean(), + badgeEnabled: z.boolean(), + aiReviewMode: z.enum(["off", "advisory", "block"]), + aiReviewByok: z.boolean(), + aiReviewProvider: z.string().nullable(), + aiReviewModel: z.string().nullable(), + aiReviewAllAuthors: z.boolean(), commandAuthorization: z.object({ defaultAllowed: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])), commandOverrides: z.array( diff --git a/test/unit/ci-openapi-settings-parity.test.ts b/test/unit/ci-openapi-settings-parity.test.ts new file mode 100644 index 0000000000..314ecab314 --- /dev/null +++ b/test/unit/ci-openapi-settings-parity.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { diffFieldSets, extractRepositorySettingsFieldNames, TYPES_PATH } from "../../scripts/check-openapi-settings-parity.mjs"; +import { 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: [] }); + }); +});