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
4 changes: 4 additions & 0 deletions scripts/check-openapi-settings-parity.d.mts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export const TYPES_PATH: string;

export const SETTINGS_PREVIEW_PATH: string;

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

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

export function diffFieldSets(typeFields: Set<string>, schemaFields: Set<string>): { missingFromSchema: string[]; extraInSchema: string[] };
73 changes: 64 additions & 9 deletions scripts/check-openapi-settings-parity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,20 @@
// 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";
import { RepositorySettingsSchema, RepoSettingsPreviewSchema } from "../src/openapi/schemas.ts";

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. */
Expand All @@ -27,6 +36,33 @@ export function extractRepositorySettingsFieldNames(source) {
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) {
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, schemaFields) {
return {
Expand All @@ -36,22 +72,41 @@ export function diffFieldSets(typeFields, schemaFields) {
}

function main() {
let failed = false;

const typeFields = extractRepositorySettingsFieldNames(readFileSync(TYPES_PATH, "utf8"));
const schemaFields = new Set(Object.keys(RepositorySettingsSchema.shape));
const { missingFromSchema, extraInSchema } = diffFieldSets(typeFields, schemaFields);
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).`);
}

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(", ")}`);
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 (extraInSchema.length > 0) {
console.error(`RepositorySettingsSchema (src/openapi/schemas.ts) declares field(s) not present on the RepositorySettings type: ${extraInSchema.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");
process.exit(1);
failed = true;
} else {
console.log(`RepoSettingsPreviewSchema.settings matches buildRepoSettingsPreview's return shape (${previewTypeFields.size} fields).`);
}

console.log(`RepositorySettingsSchema matches the RepositorySettings type (${typeFields.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.
Expand Down
4 changes: 4 additions & 0 deletions src/signals/settings-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ export type RepoSettingsPreview = {
checkRunDetailLevel: RepositorySettings["checkRunDetailLevel"];
regateSweepOrderMode: RepositorySettings["regateSweepOrderMode"];
reviewCheckMode: RepositorySettings["reviewCheckMode"];
autoProjectMilestoneMatch: RepositorySettings["autoProjectMilestoneMatch"];
autoProjectMilestoneMatchBackend: RepositorySettings["autoProjectMilestoneMatchBackend"];
gatePack: RepositorySettings["gatePack"];
linkedIssueGateMode: RepositorySettings["linkedIssueGateMode"];
duplicatePrGateMode: RepositorySettings["duplicatePrGateMode"];
Expand Down Expand Up @@ -368,6 +370,8 @@ export function buildRepoSettingsPreview(args: {
checkRunDetailLevel: settings.checkRunDetailLevel,
regateSweepOrderMode: settings.regateSweepOrderMode,
reviewCheckMode: settings.reviewCheckMode,
autoProjectMilestoneMatch: settings.autoProjectMilestoneMatch,
autoProjectMilestoneMatchBackend: settings.autoProjectMilestoneMatchBackend,
gatePack: settings.gatePack,
linkedIssueGateMode: settings.linkedIssueGateMode,
duplicatePrGateMode: settings.duplicatePrGateMode,
Expand Down
48 changes: 46 additions & 2 deletions test/unit/ci-openapi-settings-parity.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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";
import { diffFieldSets, extractRepoSettingsPreviewFieldNames, extractRepositorySettingsFieldNames, SETTINGS_PREVIEW_PATH, TYPES_PATH } from "../../scripts/check-openapi-settings-parity.mjs";
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
Expand Down Expand Up @@ -55,3 +55,47 @@ describe("OpenAPI settings-parity check (#2556)", () => {
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: [] });
});
});