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
10 changes: 5 additions & 5 deletions .github/scripts/gen-action-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
import { SECTIONS } from "../../src/sections/registry.js";
import { agree } from "../../src/text.js";
import { countWord } from "./lib/count-word.js";
import { escapeRe, type GeneratedRegion, regenerateRegions } from "./lib/generated-regions.js";
import { type GeneratedRegion, regenerateRegions } from "./lib/generated-regions.js";

const ROOT = join(import.meta.dir, "..", "..");

Expand Down Expand Up @@ -338,7 +338,7 @@ function blockShape(lines: string): RegExp {
}

function tableShape(header: string, cells: string): RegExp {
return blockShape(String.raw`${escapeRe(header)}\n(?:\| ${cells} \|\n)*`);
return blockShape(String.raw`${RegExp.escape(header)}\n(?:\| ${cells} \|\n)*`);
}

/** A JSON string literal as JSON.stringify() emits it: its own escapes only, bare quotes never. */
Expand Down Expand Up @@ -398,7 +398,7 @@ export const GENERATED_REGIONS: Readonly<Record<string, readonly GeneratedRegion
{
name: "policy-count-sentence",
placement: { kind: "under-heading", heading: "# The undeclared policy" },
body: blockShape(String.raw`[A-Z][a-z-]*${escapeRe(COUNT_SENTENCE_LEAD)}[^\n]+\.\n`),
body: blockShape(String.raw`[A-Z][a-z-]*${RegExp.escape(COUNT_SENTENCE_LEAD)}[^\n]+\.\n`),
render: block(() => renderPolicyCountSentence(knobbedSections())),
},
{
Expand All @@ -415,7 +415,7 @@ export const GENERATED_REGIONS: Readonly<Record<string, readonly GeneratedRegion
{
name: "permissions-grant-sentence",
placement: { kind: "under-heading", heading: "## What to grant" },
body: blockShape(String.raw`${escapeRe(GRANT_SENTENCE_LEAD)}[^\n]+\.\n`),
body: blockShape(String.raw`${RegExp.escape(GRANT_SENTENCE_LEAD)}[^\n]+\.\n`),
render: block(() => renderGrantSentence(SECTIONS)),
},
{
Expand All @@ -433,7 +433,7 @@ export const GENERATED_REGIONS: Readonly<Record<string, readonly GeneratedRegion
heading: "## Checking settings changes on pull requests",
},
body: blockShape(
String.raw`${escapeRe(NO_GATED_READS)}\n|${escapeRe(GATED_READS_LEAD_IN)}\n\n(?:${GATED_READ_BULLET})+`,
String.raw`${RegExp.escape(NO_GATED_READS)}\n|${RegExp.escape(GATED_READS_LEAD_IN)}\n\n(?:${GATED_READ_BULLET})+`,
),
render: block(() => renderCheckModeGatedReads(SECTIONS)),
},
Expand Down
25 changes: 10 additions & 15 deletions .github/scripts/gen-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,7 @@ import type { UndeclaredPolicy } from "../../src/types.js";
import { readArchitecture, renderArchitectureMermaid } from "./arch-lint.js";
import { COVERAGE_DATA, type CoverageData } from "./coverage-data.js";
import { ENDPOINT_ANCHORS, type EndpointAnchors } from "./endpoint-docs.js";
import {
escapeRe,
type GeneratedRegion,
regenerateRegions,
regionBounds,
} from "./lib/generated-regions.js";
import { type GeneratedRegion, regenerateRegions, regionBounds } from "./lib/generated-regions.js";

const ROOT = join(import.meta.dir, "..", "..");
export const COVERAGE_PATH = "docs/reference/coverage.md";
Expand Down Expand Up @@ -428,7 +423,7 @@ function sectionsTableRegion(name: string, heading: string): GeneratedRegion {
name,
placement: { kind: "under-heading", heading },
body: new RegExp(
String.raw`^\n(?:${escapeRe(TABLE_HEADER)}\n(?:\| \x60[a-z_]+\x60 \| [^\n]* \|\n)*)?$`,
String.raw`^\n(?:${RegExp.escape(TABLE_HEADER)}\n(?:\| \x60[a-z_]+\x60 \| [^\n]* \|\n)*)?$`,
),
render: () => `\n${renderSectionsTable(SECTIONS, DOCS)}\n`,
};
Expand All @@ -439,7 +434,7 @@ function outputsListRegion(name: string, heading: string): GeneratedRegion {
name,
placement: { kind: "under-heading", heading },
body: new RegExp(
String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${escapeRe(RESULT_TAIL)})?$`,
String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${RegExp.escape(RESULT_TAIL)})?$`,
),
render: () => renderOutputsList(RUN_RESULTS),
};
Expand All @@ -450,7 +445,7 @@ function patUrlRegion(name: string): GeneratedRegion {
return {
name,
placement: { kind: "tail" },
body: new RegExp(String.raw`^\n(?:\[${escapeRe(PAT_FORM_LABEL)}\]: \S+\n)?$`),
body: new RegExp(String.raw`^\n(?:\[${RegExp.escape(PAT_FORM_LABEL)}\]: \S+\n)?$`),
render: () => `\n[${PAT_FORM_LABEL}]: ${patFormUrl()}\n`,
};
}
Expand Down Expand Up @@ -505,10 +500,10 @@ const nonBlank = (excluded: string): string =>
String.raw`[ \t]*[^${excluded}\s][^${excluded}\r\n]*`;
const PROSE_LINE = `${nonBlank("")}\n`;
const CELL = nonBlank("|");
const KEY_CELL = String.raw`\[\x60[a-z_]+\x60\]\(${escapeRe(SECTIONS_PAGE)}\)(?: \(\x60${nonBlank("|\x60")}\x60\))?`;
const KEY_CELL = String.raw`\[\x60[a-z_]+\x60\]\(${RegExp.escape(SECTIONS_PAGE)}\)(?: \(\x60${nonBlank("|\x60")}\x60\))?`;
const SUPPORTED_ROWS = String.raw`(?:\| ${CELL} \| ${KEY_CELL} \| ${CELL} \|\n)+`;
const GAP_ROWS = String.raw`(?:\| ${CELL} \| ${CELL} \| ${CELL} \|\n)+`;
const GAPS_BODY = String.raw`(?:${PROSE_LINE}\n${escapeRe(GAPS_HEADER)}\n|${escapeRe(GAPS_HEADER)}\n${GAP_ROWS})`;
const GAPS_BODY = String.raw`(?:${PROSE_LINE}\n${RegExp.escape(GAPS_HEADER)}\n|${RegExp.escape(GAPS_HEADER)}\n${GAP_ROWS})`;
const BULLETS = `(?:- ${PROSE_LINE})+`;
const NOTE_GROUPS = String.raw`(?:\*\*${nonBlank("*")}\*\* \(\x60[a-z_]+\x60\)\n\n${BULLETS}\n)+`;

Expand All @@ -519,10 +514,10 @@ const COVERAGE_REGIONS: readonly GeneratedRegion[] = [
name: "coverage",
placement: { kind: "tail" },
body: new RegExp(
String.raw`^\n(?:(?:${PROSE_LINE}\n)+${escapeRe(SUPPORTED_HEADING)}\n\n${escapeRe(SUPPORTED_HEADER)}\n` +
String.raw`${SUPPORTED_ROWS}\n${escapeRe(NOTES_HEADING)}\n\n${NOTE_GROUPS}` +
String.raw`${escapeRe(GAPS_HEADING)}\n\n${GAPS_BODY}\n${escapeRe(NO_API_HEADING)}\n\n` +
String.raw`${PROSE_LINE}\n${BULLETS}\n${escapeRe(OUT_OF_SCOPE_HEADING)}\n\n${BULLETS})?$`,
String.raw`^\n(?:(?:${PROSE_LINE}\n)+${RegExp.escape(SUPPORTED_HEADING)}\n\n${RegExp.escape(SUPPORTED_HEADER)}\n` +
String.raw`${SUPPORTED_ROWS}\n${RegExp.escape(NOTES_HEADING)}\n\n${NOTE_GROUPS}` +
String.raw`${RegExp.escape(GAPS_HEADING)}\n\n${GAPS_BODY}\n${RegExp.escape(NO_API_HEADING)}\n\n` +
String.raw`${PROSE_LINE}\n${BULLETS}\n${RegExp.escape(OUT_OF_SCOPE_HEADING)}\n\n${BULLETS})?$`,
),
render: () => `\n${renderCoverage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS)}\n`,
},
Expand Down
5 changes: 0 additions & 5 deletions .github/scripts/lib/generated-regions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@ import { Parser } from "yaml";
/** Which comment syntax a file's markers use: a complete `<!-- -->` comment, or a whole-line YAML `#` comment. */
export type MarkerSyntax = "html" | "yaml";

/** `text` as a regex source matching itself literally; body shapes splice renderer constants through it. */
export function escapeRe(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/** Character offsets of one marker: `[start, end)`. */
export type MarkerSpan = readonly [start: number, end: number];

Expand Down
12 changes: 6 additions & 6 deletions src/discovery/central.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
*/

import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { extname, join, parse } from "node:path";
import { err, ok, type Result } from "neverthrow";
import { type SlugKey, slugKey } from "../github/slug.js";
import type { CentralFileProblem, ProblemOf } from "../problem.js";
import { type CentralTarget, SLUG_RE } from "./targets.js";

const YAML_EXT = /\.ya?ml$/;
const YAML_EXTENSIONS = new Set([".yml", ".yaml"]);

export function resolveCentralTargets(
reposDir: string,
Expand Down Expand Up @@ -52,13 +52,13 @@ export function resolveCentralTargets(
);
continue;
}
if (!YAML_EXT.test(inner)) {
if (!YAML_EXTENSIONS.has(extname(inner))) {
warnings.push(
`ignoring ${innerPath}: not a .yml/.yaml file, so it defines no target repository`,
);
continue;
}
addTarget(`${owner}/${inner.replace(YAML_EXT, "")}`, innerPath);
addTarget(`${owner}/${parse(inner).name}`, innerPath);
}
};

Expand All @@ -71,7 +71,7 @@ export function resolveCentralTargets(
scanOwnerDir(entryPath, entry);
continue;
}
if (!YAML_EXT.test(entry)) {
if (!YAML_EXTENSIONS.has(extname(entry))) {
warnings.push(
`ignoring ${entryPath}: not a .yml/.yaml file, so it defines no target repository`,
);
Expand All @@ -81,7 +81,7 @@ export function resolveCentralTargets(
ownerlessFiles.push(entryPath);
continue;
}
addTarget(`${adminOwner}/${entry.replace(YAML_EXT, "")}`, entryPath);
addTarget(`${adminOwner}/${parse(entry).name}`, entryPath);
}
if (ownerlessFiles.length > 0) {
errors.push({ kind: "ownerless", files: ownerlessFiles });
Expand Down
24 changes: 5 additions & 19 deletions src/engine/validate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** Shape validation against each section's loose zod shape; the parsed output, not the input, is what the engine applies. */

import { err, ok, type Result } from "neverthrow";
import { z } from "zod";
import { nonPlainKind } from "../plain-data.js";
import type { ProblemOf } from "../problem.js";
import { LIST_SECTIONS, type ListSection, SECTION_KEYS, type SettingsFile } from "../schema.js";
Expand All @@ -10,6 +11,7 @@ import {
type DeclaredSecretValue,
} from "../sections/contract/module.js";
import { listLayering, sectionModule, sectionShape } from "../sections/registry.js";
import { valueAt } from "../sections/shared/list-section.js";
import { agree, countNoun } from "../text.js";
import { type SettingsSource, validateSecretRef } from "./secret-refs.js";

Expand All @@ -19,18 +21,6 @@ function isListSection(key: string): key is ListSection {
return LIST_KEYS.has(key);
}

/** The value at an issue's path, so a null the author wrote can be told from a type the author got wrong. */
function valueAt(root: unknown, path: readonly PropertyKey[]): unknown {
let node: unknown = root;
for (const step of path) {
if (typeof node !== "object" || node === null) {
return undefined;
}
node = (node as Record<PropertyKey, unknown>)[step];
}
return node;
}

/** zod's issue fields this module reads; `legal` is this action's own, set where a shape refuses null itself. */
interface NullIssue {
code: string;
Expand Down Expand Up @@ -230,18 +220,14 @@ export function validateSectionShapes(
if (!parsed.success) {
const issues = parsed.error.issues;
for (const issue of issues.slice(0, 5)) {
const path = issue.path
.map((p) => (typeof p === "number" ? `[${p}]` : `.${String(p)}`))
.join("");
const path = z.core.toDotPath([key, ...issue.path]);
// A null the shape refused is the author saying "empty" where GitHub has no empty state: name the values that
// exist. A shape's own diagnostic (a custom issue) already names the fix, unless it supplies the legal values itself.
if (valueAt(declared, issue.path) === null && rewritesForNull(issue as NullIssue)) {
problems.push(
`${key}${path} has no empty state; write ${legalValues(issue as NullIssue)}`,
);
problems.push(`${path} has no empty state; write ${legalValues(issue as NullIssue)}`);
continue;
}
problems.push(`${key}${path}: ${issue.message}`);
problems.push(`${path}: ${issue.message}`);
}
if (issues.length > 5) {
// A silently truncated list costs one fix-and-rerun cycle per hidden offender.
Expand Down
5 changes: 1 addition & 4 deletions src/sections/contract/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,7 @@ export function expand(
);
}
if (query && Object.keys(query).length > 0) {
const qs = Object.entries(query)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
return `${path}?${qs}`;
return `${path}?${new URLSearchParams(query)}`;
}
return path;
}
Expand Down
7 changes: 2 additions & 5 deletions src/sections/contract/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { err, ok, type Result } from "neverthrow";
import type { z } from "zod";
import { z } from "zod";
import { countNoun } from "../../text.js";
import { endpointMethod, endpointPath } from "./endpoints.js";
import type { SectionFailure } from "./errors.js";
Expand Down Expand Up @@ -85,10 +85,7 @@ export function parseLive<T>(
}
const issues = parsed.error.issues;
const shown = issues.slice(0, 3).map((issue) => {
const path = issue.path
.map((part) => (typeof part === "number" ? `[${part}]` : `.${String(part)}`))
.join("");
return `${path.replace(/^\./, "") || "(body)"}: ${issue.message}`;
return `${z.core.toDotPath(issue.path) || "(body)"}: ${issue.message}`;
});
const more =
issues.length > 3 ? `; and ${countNoun(issues.length - 3, "more issue", "more issues")}` : "";
Expand Down
6 changes: 1 addition & 5 deletions src/sections/contract/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,7 @@ export function samePermission(
}
const resources = new Set(a.repo);
const others = new Set(b.repo);
return (
a.org === b.org &&
resources.size === others.size &&
[...resources].every((resource) => others.has(resource))
);
return a.org === b.org && resources.size === others.size && resources.isSubsetOf(others);
}

/** Human-facing label for each PAT resource, as shown in the token UI. */
Expand Down
2 changes: 1 addition & 1 deletion src/sections/custom_properties/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function sameValue(a: WireValue, b: WireValue): boolean {
if (Array.isArray(a) && Array.isArray(b)) {
const setA = new Set(a);
const setB = new Set(b);
return setA.size === setB.size && [...setA].every((element) => setB.has(element));
return setA.size === setB.size && setA.isSubsetOf(setB);
}
return a === b;
}
Expand Down
19 changes: 9 additions & 10 deletions src/sections/environments/branch-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { err, ok, Result, safeTry } from "neverthrow";
import { z } from "zod";
import { subsetDiff } from "../../engine/diff.js";
import type { UndeclaredPolicy } from "../../types.js";
import { type SectionFailure, sectionFailure } from "../contract/errors.js";
import type { SectionFailure } from "../contract/errors.js";
import { liveByIdentity, liveIdentity } from "../contract/live.js";
import {
type DeclaredIssue,
Expand All @@ -18,7 +18,11 @@ import {
undeclaredNote,
} from "../contract/module.js";
import { hasDrift, plainData, type Read } from "../contract/plan.js";
import type { EnvironmentRestOp, EnvironmentsRestContext } from "./endpoints.js";
import {
type EnvironmentRestOp,
type EnvironmentsRestContext,
unreconcilable,
} from "./endpoints.js";
import type { LiveEnvironmentBody } from "./index.js";
import type { NestedPlan } from "./nested.js";
import type { DeploymentBranchPolicyConfig } from "./schema.js";
Expand Down Expand Up @@ -46,23 +50,18 @@ function livePolicyType(policy: LiveBranchPolicy): string {
return typeof policy.type === "string" ? policy.type : "branch";
}

function unreconcilable(envName: string, what: string): SectionFailure {
return sectionFailure(
"live-shape",
`environments: the deployment branch-policy list for environment "${envName}" returned a policy without ${what}, so it cannot be reconciled. Check the "api-version" input against the GitHub REST docs for this endpoint`,
);
}
const POLICY = { list: "deployment branch-policy", entry: "policy" };

function livePolicyId(policy: LiveBranchPolicy, envName: string): Result<string, SectionFailure> {
if (policy.id === undefined) {
return err(unreconcilable(envName, "an id"));
return err(unreconcilable(POLICY, envName, "an id"));
}
return ok(String(policy.id));
}

function livePolicyName(policy: LiveBranchPolicy, envName: string): Result<string, SectionFailure> {
if (typeof policy.name !== "string") {
return err(unreconcilable(envName, "a name"));
return err(unreconcilable(POLICY, envName, "a name"));
}
return ok(policy.name);
}
Expand Down
13 changes: 13 additions & 0 deletions src/sections/environments/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { EndpointDecl } from "../contract/endpoints.js";
import { type SectionFailure, sectionFailure } from "../contract/errors.js";
import type { PlanContext, PlannedOp } from "../contract/plan.js";

const BRANCH_POLICIES_DENIAL_HINT =
Expand Down Expand Up @@ -133,3 +134,15 @@ export const ENDPOINTS = {
export type EnvironmentsRestContext = PlanContext<typeof ENDPOINTS>;

export type EnvironmentRestOp = PlannedOp<typeof ENDPOINTS>;

/** A live entry missing the field its reconcile keys on has no identity to match; `noun` names the list and one entry. */
export function unreconcilable(
noun: { list: string; entry: string },
envName: string,
what: string,
): SectionFailure {
return sectionFailure(
"live-shape",
`environments: the ${noun.list} list for environment "${envName}" returned a ${noun.entry} without ${what}, so it cannot be reconciled. Check the "api-version" input against the GitHub REST docs for this endpoint`,
);
}
13 changes: 4 additions & 9 deletions src/sections/environments/protection-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
undeclaredNote,
} from "../contract/module.js";
import type { Read } from "../contract/plan.js";
import type { EnvironmentsRestContext } from "./endpoints.js";
import { type EnvironmentsRestContext, unreconcilable } from "./endpoints.js";
import type { NestedPlan } from "./nested.js";
import type { DeploymentProtectionRuleConfig } from "./schema.js";

Expand All @@ -32,25 +32,20 @@ const LiveProtectionRule = z.looseObject({
});
type LiveProtectionRule = z.infer<typeof LiveProtectionRule>;

function unreconcilable(envName: string, what: string): SectionFailure {
return sectionFailure(
"live-shape",
`environments: the deployment protection rule list for environment "${envName}" returned a rule without ${what}, so it cannot be reconciled. Check the "api-version" input against the GitHub REST docs for this endpoint`,
);
}
const RULE = { list: "deployment protection rule", entry: "rule" };

function liveRuleSlug(rule: LiveProtectionRule, envName: string): Result<string, SectionFailure> {
const slug = rule.app?.slug;
if (typeof slug !== "string") {
return err(unreconcilable(envName, "an app slug"));
return err(unreconcilable(RULE, envName, "an app slug"));
}
return ok(slug);
}

function liveRuleId(rule: LiveProtectionRule, envName: string): Result<string, SectionFailure> {
// A null or string id would serialize into the DELETE path (".../deployment_protection_rules/null").
if (typeof rule.id !== "number") {
return err(unreconcilable(envName, "a numeric id"));
return err(unreconcilable(RULE, envName, "a numeric id"));
}
return ok(String(rule.id));
}
Expand Down
Loading
Loading