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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/contributing-to-gittensory/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -8651,6 +8702,10 @@
"selfAuthoredLinkedIssueGateMode",
"firstTimeContributorGrace",
"slopAiAdvisory",
"aiReviewMode",
"aiReviewByok",
"aiReviewAllAuthors",
"closeOwnerAuthors",
"autoLabelEnabled",
"gittensorLabel",
"blacklistLabel",
Expand Down Expand Up @@ -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": [
Expand All @@ -9285,6 +9365,12 @@
"createMissingLabel",
"includeMaintainerAuthors",
"requireLinkedIssue",
"badgeEnabled",
"aiReviewMode",
"aiReviewByok",
"aiReviewProvider",
"aiReviewModel",
"aiReviewAllAuthors",
"commandAuthorization"
]
},
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions scripts/check-openapi-settings-parity.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const TYPES_PATH: string;

export function extractRepositorySettingsFieldNames(source: string): Set<string>;

export function diffFieldSets(typeFields: Set<string>, schemaFields: Set<string>): { missingFromSchema: string[]; extraInSchema: string[] };
58 changes: 58 additions & 0 deletions scripts/check-openapi-settings-parity.mjs
Original file line number Diff line number Diff line change
@@ -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();
17 changes: 17 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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"]))),
Expand Down Expand Up @@ -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(
Expand Down
52 changes: 52 additions & 0 deletions test/unit/ci-openapi-settings-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] });
});
});
Loading