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: 3 additions & 1 deletion docs/reference/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ The side-by-side comparison with the Probot Settings app lives in the migration

- Covers build_type, source, cname, https_enforced, and any PUT field GitHub adds; pages: null disables the site.
- POST /repos/{owner}/{repo}/pages (create accepts only build_type/source) then PUT for the rest; existing sites get straight PUT passthrough; pages: null issues DELETE, mirroring branches' protection: null.
- The create requires build_type or source, so a declaration with neither, against a repository without a site, sends no write: check reports drift naming the fix (declare build_type or source), and apply reports the same line as a notice and changes nothing.
- In a mode: render fold, pages: null is written as the section's value even over a lower layer's pages declaration, so the rendered file turns the site off (the layering guide owns that rule); a multi-repo defaults-file never merges into a target's file.
- Snapshot reads the live site back on the declared keys; a repository without Pages is omitted rather than declared null.

Expand Down Expand Up @@ -446,10 +447,11 @@ The side-by-side comparison with the Probot Settings app lives in the migration
- In a mode: render fold, interaction_limits: null is written as the section's value even over a lower layer's declaration, mirroring pages: null, so the rendered file clears the limit.
- A 409 means an organization- or user-level limit overrides the repository's; writes surface that as a note, not a failure, while check mode still reports a mismatched declaration as drift - the org is the place to change it.
- Snapshot reads back the repository-level limit (an inherited one is left out with a note, and the expiry duration is not readable), the cap when the feature exists and is enabled, and the bypass list when it has members.
- A read-only Administration grant reads the limit and is denied the cap and the bypass list, which GitHub gates at write: under on-missing-permission: warn the snapshot leaves each of the two keys out with a note naming the grant and still writes the limit, and under fail the section fails.

**Pull request creation cap** (`interaction_limits`)

- `pull_request_creation_cap` routes to GET/PATCH /repos/{owner}/{repo}/interaction-limits/pulls/creation-cap: persistent desired state with no self-expiry (enabled plus max_open_pull_requests 1-1000, read back verbatim), so check diffs it exactly and apply PATCHes only on divergence - no re-arm.
- `pull_request_creation_cap` routes to GET/PATCH /repos/{owner}/{repo}/interaction-limits/pulls/creation-cap: persistent desired state with no self-expiry (enabled, max_open_pull_requests 1-1000, and include_drafts, read back verbatim), so check diffs it exactly and apply PATCHes only on divergence - no re-arm.
- GitHub gates the cap GET at write, so a read-only Administration grant cannot check this key.
- Where the cap is unavailable the endpoints answer 405, which apply surfaces as a note (like the 409) and check as honest drift.

Expand Down
12 changes: 10 additions & 2 deletions docs/sections/interaction_limits.docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ coverage:
Snapshot reads back the repository-level limit (an inherited one is left out with a note, and
the expiry duration is not readable), the cap when the feature exists and is enabled, and the
bypass list when it has members.
- >-
A read-only Administration grant reads the limit and is denied the cap and the bypass list,
which GitHub gates at write: under on-missing-permission: warn the snapshot leaves each of
the two keys out with a note naming the grant and still writes the limit, and under fail the
section fails.
- area: >-
[Pull request creation
cap](https://docs.github.com/en/rest/interactions/repos#update-pull-request-creation-cap-for-a-repository)
Expand All @@ -45,8 +50,8 @@ coverage:
- >-
`pull_request_creation_cap` routes to GET/PATCH
/repos/{owner}/{repo}/interaction-limits/pulls/creation-cap: persistent desired state with no
self-expiry (enabled plus max_open_pull_requests 1-1000, read back verbatim), so check diffs it
exactly and apply PATCHes only on divergence - no re-arm.
self-expiry (enabled, max_open_pull_requests 1-1000, and include_drafts, read back verbatim),
so check diffs it exactly and apply PATCHes only on divergence - no re-arm.
- >-
GitHub gates the cap GET at write, so a read-only Administration grant cannot check this key.
- >-
Expand Down Expand Up @@ -101,6 +106,9 @@ schema:
InteractionLimitsConfig.pull_request_creation_cap.max_open_pull_requests: >-
The maximum number of open pull requests one user may have, a whole number from 1 to 1000
(GitHub's range); anything else fails at parse time.
InteractionLimitsConfig.pull_request_creation_cap.include_drafts: >-
Whether draft pull requests count toward the cap. GitHub documents a boolean; the value is sent
as written, its type unchecked when the file is parsed.
InteractionLimitsConfig.pull_request_creation_bypass: >-
User logins exempt from the pull request creation cap, routed to GET/PUT/DELETE
/repos/{r}/interaction-limits/pulls/bypass-list and reconciled: apply removes the undeclared
Expand Down
8 changes: 7 additions & 1 deletion docs/sections/pages.docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ coverage:
POST /repos/{owner}/{repo}/pages (create accepts only build_type/source) then PUT for the
rest; existing sites get straight PUT passthrough; pages: null issues DELETE, mirroring
branches' protection: null.
- >-
The create requires build_type or source, so a declaration with neither, against a repository
without a site, sends no write: check reports drift naming the fix (declare build_type or
source), and apply reports the same line as a notice and changes nothing.
- >-
In a mode: render fold, pages: null is written as the section's value even over a lower
layer's pages declaration, so the rendered file turns the site off (the layering guide owns
Expand Down Expand Up @@ -53,7 +57,9 @@ coverage:
drift with no note.
schema:
SettingsFile.pages: GitHub Pages configuration; null disables Pages on the repository.
PagesConfig: "GitHub Pages site configuration; use `pages: null` to disable the site."
PagesConfig: >-
GitHub Pages site configuration; use `pages: null` to disable the site. A repository without a
site gets one only from a declared build_type or source.
PagesConfig.build_type: '"legacy" (branch) or "workflow" (GitHub Actions).'
PagesConfig.source: The update PUT requires both branch and path when source is sent.
PagesConfig.source.branch: The branch Pages publishes from.
Expand Down
89 changes: 60 additions & 29 deletions src/sections/interaction_limits/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ok, safeTry } from "neverthrow";
import { z } from "zod";
import { phantomKeys, phantomNote, subsetDiff } from "../../engine/diff.js";
import { agree } from "../../text.js";
import { requirePlainMapping } from "../contract/declared.js";
import { type PlainTyped, requirePlainMapping } from "../contract/declared.js";
import { cannotVerifyNote } from "../contract/drift.js";
import type { EndpointDecl } from "../contract/endpoints.js";
import type { SectionModule } from "../contract/module.js";
Expand All @@ -23,13 +23,15 @@ import {
type PlannedOp,
type Read,
type SectionPlan,
type SnapshotContext,
} from "../contract/plan.js";
import { leftOutOfSnapshot, projectOntoSchema } from "../shared/snapshot-helpers.js";
import { leftOutOfSnapshot, projectOntoSchema, readOrNote } from "../shared/snapshot-helpers.js";
import { BYPASS_MAX, InteractionLimitsConfig } from "./schema.js";

const permission: SectionPermission = { repo: ["administration"] };

type DeclaredInteractionLimits = NonNullable<InteractionLimitsConfig>;
/** The validated declaration: the cap's unchecked include_drafts reads as the plain data the engine's walk proved it. */
type DeclaredInteractionLimits = PlainTyped<NonNullable<InteractionLimitsConfig>>;

const ORG_OVERRIDE = "an organization- or user-level interaction limit overrides this repository's";

Expand Down Expand Up @@ -68,7 +70,7 @@ const ENDPOINTS = {
route: "PATCH /repos/{owner}/{repo}/interaction-limits/pulls/creation-cap",
statuses: { 200: "pull request creation cap updated", 405: CAP_UNAVAILABLE },
hints: {
422: "enabled must be a boolean and max_open_pull_requests a whole number from 1 to 1000; see the pull request creation cap endpoint documentation",
422: "enabled and include_drafts must be booleans and max_open_pull_requests a whole number from 1 to 1000; see the pull request creation cap endpoint documentation",
},
},
// The bypass routes document no 405 (only the cap pair does), so on a repository without the cap
Expand Down Expand Up @@ -357,6 +359,58 @@ function planBypassList(
});
}

/**
* The cap and its bypass list as a snapshot declares them; a disabled cap is GitHub's default. Each is its
* own write-gated read, so a token that reads the limit can be denied either.
*
* cap answers 405 -> cap and bypass both omitted, noted
* cap or bypass read denied -> that key left out and noted under warn; the section fails under fail
* cap disabled, or nobody on the bypass -> omitted
*/
function snapshotPullRequestKeys(
ctx: SnapshotContext<typeof ENDPOINTS>,
notes: string[],
): Read<Record<string, unknown>> {
return safeTry(async function* () {
const keys: Record<string, unknown> = {};
const cap = yield* await readOrNote(
ctx,
notes,
"interaction_limits.pull_request_creation_cap",
() =>
ctx.read.capGet.tryCall(LiveCreationCap, {
describe: "reading the pull request creation cap",
}),
);
if ("value" in cap) {
if ("error" in cap.value) {
notes.push(
`interaction_limits: ${CAP_UNAVAILABLE} (405), so pull_request_creation_cap and pull_request_creation_bypass are omitted`,
);
return ok(keys);
}
// Parsed at the port: a body off the shape (a null, a quoted flag) fails the section instead of reading as "no cap".
if (cap.value.data.enabled) {
keys.pull_request_creation_cap = projectOntoSchema(
InteractionLimitsConfig.unwrap().shape.pull_request_creation_cap,
cap.value.data,
);
}
}

const bypass = yield* await readOrNote(
ctx,
notes,
"interaction_limits.pull_request_creation_bypass",
() => liveBypassLogins(ctx),
);
if ("value" in bypass && bypass.value.length > 0) {
keys.pull_request_creation_bypass = bypass.value;
}
return ok(keys);
});
}

export const interactionLimitsSection = {
key: "interaction_limits",
undeclaredDefault: "untouched",
Expand Down Expand Up @@ -388,11 +442,7 @@ export const interactionLimitsSection = {
},
/**
* Only what the repository itself owns reads back; an inherited limit is the org's or user's
* setting, and a disabled cap is GitHub's default.
*
* limit inherited (org or user origin) -> noted, not declared
* cap answers 405 -> cap and bypass both omitted, noted
* cap disabled, or nobody on the bypass -> omitted
* setting, noted and not declared.
*/
async snapshot(ctx) {
return safeTry(async function* () {
Expand All @@ -412,26 +462,7 @@ export const interactionLimitsSection = {
),
);
}
const cap = yield* ctx.read.capGet.tryCall(LiveCreationCap, {
describe: "reading the pull request creation cap",
});
if ("error" in cap) {
notes.push(
`interaction_limits: ${CAP_UNAVAILABLE} (405), so pull_request_creation_cap and pull_request_creation_bypass are omitted`,
);
} else {
// Parsed at the port: a body off the shape (a null, a quoted flag) fails the section instead of reading as "no cap".
if (cap.data.enabled) {
value.pull_request_creation_cap = projectOntoSchema(
InteractionLimitsConfig.unwrap().shape.pull_request_creation_cap,
cap.data,
);
}
const bypass = yield* liveBypassLogins(ctx);
if (bypass.length > 0) {
value.pull_request_creation_bypass = bypass;
}
}
Object.assign(value, yield* snapshotPullRequestKeys(ctx, notes));
if (Object.keys(value).length === 0) {
return ok({ value: undefined, notes });
}
Expand Down
3 changes: 3 additions & 0 deletions src/sections/interaction_limits/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const InteractionLimits = z
.min(CAP_MIN, CAP_RULE)
.max(CAP_MAX, CAP_RULE)
.optional(),
// Named so a snapshot keeps it (the projection reads back the named keys alone); its value is as
// unchecked as any key the open mapping passes through, which is how it has always parsed.
include_drafts: z.unknown().optional(),
}).optional(),
pull_request_creation_bypass: z.array(z.string()).optional(),
},
Expand Down
52 changes: 38 additions & 14 deletions src/sections/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,22 @@

/**
* GitHub documents every field but build_type and source as update-only, so enabling a site is
* create-then-update. A Pick over the wire payload, so a renamed config field breaks this split at
* compile time instead of silently rerouting through the wrong endpoint.
* create-then-update, and its create requires one of the two, so the type holds no empty body and
* createBody() is the only mint. Picks over the wire payload, so a renamed config field breaks this
* split at compile time instead of silently rerouting through the wrong endpoint.
*/
type PagesCreateBody = Pick<PagesWirePayload, "build_type" | "source">;
type PagesCreateBody =
| (Required<Pick<PagesWirePayload, "build_type">> & Pick<PagesWirePayload, "source">)
| (Required<Pick<PagesWirePayload, "source">> & Pick<PagesWirePayload, "build_type">);

/** The create body a declaration fills; undefined when it declares neither field GitHub enables a site from. */
function createBody(payload: PagesWirePayload): PagesCreateBody | undefined {
const { build_type: buildType, source } = payload;
if (buildType !== undefined) {
return source === undefined ? { build_type: buildType } : { build_type: buildType, source };
}
return source === undefined ? undefined : { source };
}

// A mapping only: PagesConfig accepts null as the declared "Pages off", so a null 200 would otherwise read back as a declaration that DISABLES the site.
const LiveSite = z.looseObject({});
Expand All @@ -56,15 +68,18 @@

type PagesPlan = SectionPlan<PlannedOp<typeof ENDPOINTS>>;

// A 404 is ambiguous: no Pages site, or a fine-grained token without the Pages permission. A plan that

Check warning on line 71 in src/sections/pages/index.ts

View workflow job for this annotation

GitHub Actions / platform / standard-checks

src/sections/pages/index.ts:71: one-line-comment
// creates stays loud either way (the POST would fail); a plan with no write must say so itself.
const CONCEALED_SITE =
"A fine-grained token missing the Pages permission gets the same answer; if this repo does have a Pages site, grant the token Pages read and write";

/** `pages: null` disables a live site; with no site the note says what else the 404 may mean. */
function disablePlan(missing: boolean): PagesPlan {
if (missing) {
// A 404 is ambiguous: no Pages site, or a fine-grained token without the Pages permission.
// The non-null path stays loud either way (the POST would fail); this no-op path must say so.
return {
ops: [],
notes: [
"pages: declared null and GitHub reports no Pages site, so there is nothing to disable. A fine-grained token missing the Pages permission gets the same answer; if this repo does have a Pages site, grant the token Pages read and write",
`pages: declared null and GitHub reports no Pages site, so there is nothing to disable. ${CONCEALED_SITE}`,
],
drift: [],
};
Expand Down Expand Up @@ -116,16 +131,25 @@
return plan;
}

/** Enabling a site is the create POST, which takes build_type and source alone, then the update PUT for whatever else is declared. */
/**
* Enabling a site is the create POST, which takes build_type and source alone, then the update PUT for
* whatever else is declared. With neither declared there is no create GitHub accepts, and the update
* answers 404 without a site, so the plan is drift no operation fixes.
*/
function enablePlan(payload: PagesWirePayload): PagesPlan {
const plan: PagesPlan = { ops: [], notes: [], drift: [] };
const create: PagesCreateBody = {};
if (payload.build_type !== undefined) {
create.build_type = payload.build_type;
}
if (payload.source !== undefined) {
create.source = payload.source;
const create = createBody(payload);
if (create === undefined) {
return {
ops: [],
notes: [`pages: GitHub reports no Pages site. ${CONCEALED_SITE}`],
drift: [
"pages: declared in the settings file but GitHub Pages is not enabled on the repo, and GitHub enables a site only from build_type or source, " +
"so apply changes nothing - declare build_type or source to enable the site, or remove the pages section",
],
};
}

const plan: PagesPlan = { ops: [], notes: [], drift: [] };
plan.ops.push({
role: "create",
payload: create,
Expand Down
Loading
Loading