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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- The `limits.api` defaults (each pool's `plan`, `writer-pause`, `breaker`, `read-floor`, github-token's `limit-cap`, content-creation's `per-minute` and `per-hour`) moved out of `pr-pipeline.schema.json` into [`.github/actions/log-api-budget/rate-limits.json`](https://github.com/melodic-software/claude-code-plugins/blob/main/.github/actions/log-api-budget/rate-limits.json), their only copy, with the same values. The schema states no default for them now. The merge path floor (`merge-holds.mjs` `HOLD_PATH_FLOOR`, `babysit_risk.HIGH_FLOOR`, ci-status's `hold_patterns` and both review lanes' `hold-paths`) holds that file, so a change to a rate-limit default waits for a person, while a schema change does not.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: patch
---

### Changed

- `babysit_gh.py` reads its `limits.api` defaults (`writer-pause`, `read-floor`, and github-token's `limit-cap`, which replaces the `GITHUB_TOKEN_LIMIT_CAP` constant) from `.github/actions/log-api-budget/rate-limits.json` instead of the pipeline schema, with the same values. A missing or invalid file still refuses every call, and a default that is not a number now does too.
- `babysit_risk.HIGH_FLOOR` also holds `.github/actions/log-api-budget/rate-limits.json`, the rate-limit defaults `rate-guard.mjs` reads, so a change to one waits for a person.
2 changes: 1 addition & 1 deletion .github/actions/log-api-budget/api-budget.bash
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
# answer is a 2xx; under
# API_BUDGET_READ_FLOOR of the planned budget
# left, warns once (unset: no floor; ci-status
# sets it to the schema's read-floor default);
# sets it to the read-floor default);
# the plan is the header's limit, at most
# API_BUDGET_LIMIT_CAP (unset: no cap; ci-status
# sets github-token's limit-cap default)
Expand Down
6 changes: 3 additions & 3 deletions .github/actions/log-api-budget/log-api-budget.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -657,9 +657,9 @@ test("ci-status sets the shell limit cap to github-token's limit-cap default", (
assert.equal(Number(cap[1]), apiLimits({ resolvedPath: "" })["github-token"]["limit-cap"]);
});

// ci-status has no checkout of the schema, so its wrapper's floor is set in
// the job env, pinned here to the schema default the Node guard reads.
test("ci-status sets the shell read floor to the schema's read-floor default", () => {
// ci-status has no checkout of rate-limits.json, so its wrapper's floor is set in
// the job env, pinned here to the rate-limits.json default the Node guard reads.
test("ci-status sets the shell read floor to the read-floor default", () => {
const floor = /\n {6}API_BUDGET_READ_FLOOR: "?([0-9.]+)"?\n/.exec(CI);
assert.ok(floor, "ci-status sets API_BUDGET_READ_FLOOR");
assert.equal(Number(floor[1]), apiLimits({ resolvedPath: "" })["github-token"]["read-floor"]);
Expand Down
56 changes: 36 additions & 20 deletions .github/actions/log-api-budget/rate-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
// the header's remaining and GitHub's refusals, never the plan.
//
// The margins are limits.api in pr-pipeline.yaml, read from resolve-config's
// output (RESOLVED_CONFIG), over the defaults in pr-pipeline.schema.json,
// which this module reads at load: the schema holds the only copy.
// output (RESOLVED_CONFIG), over the defaults in rate-limits.json beside this
// module: that file holds the only copy, and every hold list floors it, so a
// change to a default waits for a person.
// rate-guard.vectors.json pins classify's decisions for every port.

import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
Expand Down Expand Up @@ -46,24 +47,39 @@ const MAX_RESET = 999_999_999_999;

export const API_POOLS = Object.freeze(["github-token", "lanes-app", "graphql", "owner-token", "content-creation"]);

const SCHEMA = new URL("../../../docs/conventions/pr-pipeline/pr-pipeline.schema.json", import.meta.url);
const DEFAULTS = new URL("./rate-limits.json", import.meta.url);

const defaultsOf = (properties) =>
Object.fromEntries(
Object.entries(properties)
.filter(([, property]) => "default" in property)
.map(([key, property]) => [key, property.default]),
);
// Each field's check: the shares, then the counts. per-minute and per-hour
// belong to content-creation alone.
const SHARE = (value) => typeof value === "number" && value >= 0 && value <= 1;
const COUNT = (value) => Number.isInteger(value) && value >= 1;
const FIELDS = { plan: (value) => SHARE(value) && value > 0, "writer-pause": SHARE, breaker: SHARE, "read-floor": SHARE, "limit-cap": COUNT };
const CONTENT_FIELDS = { ...FIELDS, "per-minute": COUNT, "per-hour": COUNT };
const REQUIRED = ["plan", "writer-pause", "breaker", "read-floor"];

// rate-limits.json, checked: every pool in API_POOLS and no other, each with
// the four shares, and every field in its range. Throws on anything else.
function readDefaults(path) {
const defaults = JSON.parse(readFileSync(path, "utf8"));
const pools = Object.keys(defaults ?? {});
if (Array.isArray(defaults) || pools.length !== API_POOLS.length || !API_POOLS.every((name) => pools.includes(name))) {
throw new Error(`${path}: the pools must be ${API_POOLS.join(", ")}`);
}
for (const name of API_POOLS) {
const fields = name === "content-creation" ? CONTENT_FIELDS : FIELDS;
const entry = defaults[name];
const bad = [...REQUIRED.filter((field) => !(field in (entry ?? {}))), ...Object.entries(entry ?? {}).filter(([field, value]) => !fields[field]?.(value)).map(([field]) => field)];
if (typeof entry !== "object" || entry === null || Array.isArray(entry) || bad.length) {
throw new Error(`${path}: ${name} has a missing or invalid ${bad.join(", ") || "entry"}`);
}
}
return defaults;
}

// limits.api with every unset field filled from the schema's defaults: the
// pool's $defs entry, then the entry's own default (github-token's
// limit-cap). Throws when the schema cannot be read.
export function apiLimits({ resolvedPath = process.env.RESOLVED_CONFIG, schemaPath = SCHEMA } = {}) {
const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
const defs = schema.$defs;
const entries = schema.properties.limits.properties.api.properties;
const pool = defaultsOf(defs["api-pool"].properties);
const content = { ...pool, ...defaultsOf(defs["api-content-pool"].properties) };
// limits.api with every unset field filled from rate-limits.json. Throws when
// that file cannot be read or fails readDefaults' check.
export function apiLimits({ resolvedPath = process.env.RESOLVED_CONFIG, defaultsPath = DEFAULTS } = {}) {
const defaults = readDefaults(defaultsPath);
let set = {};
if (resolvedPath) {
try {
Expand All @@ -73,7 +89,7 @@ export function apiLimits({ resolvedPath = process.env.RESOLVED_CONFIG, schemaPa
}
}
return Object.fromEntries(
API_POOLS.map((name) => [name, { ...(name === "content-creation" ? content : pool), ...entries[name]?.default, ...(set[name] ?? {}) }]),
API_POOLS.map((name) => [name, { ...defaults[name], ...(set[name] ?? {}) }]),
);
}

Expand Down Expand Up @@ -311,7 +327,7 @@ export function createRateLimitLatch({
config = limits();
} catch {
config = null;
log("::warning::rate guard: limits.api and its schema defaults could not be read; the read floor is not checked");
log("::warning::rate guard: limits.api and its defaults (rate-limits.json) could not be read; the read floor is not checked");
}
}
const value = config?.[entryOf(pool, token)]?.[key];
Expand Down
76 changes: 61 additions & 15 deletions .github/actions/log-api-budget/rate-guard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,69 @@ test("classify reads a Headers object as it reads a plain one", () => {
assert.deepEqual(classify({ ...vector.input, headers }, { now: NOW, readFloor: READ_FLOOR }), vector.expect);
});

test("defaults come from the schema: a changed schema default changes the module's", () =>
const DEFAULTS_PATH = new URL("./rate-limits.json", import.meta.url);

test("defaults come from rate-limits.json: a changed default changes the module's", () =>
withDir((dir) => {
const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8"));
const live = apiLimits({ resolvedPath: "", schemaPath: SCHEMA_PATH });
schema.$defs["api-pool"].properties["read-floor"].default = 0.25;
schema.$defs["api-content-pool"].properties["per-minute"].default = 7;
const path = join(dir, "schema.json");
writeFileSync(path, JSON.stringify(schema));
const changed = apiLimits({ resolvedPath: "", schemaPath: path });
for (const name of ["github-token", "lanes-app", "graphql", "owner-token", "content-creation"]) {
assert.equal(changed[name]["read-floor"], 0.25, name);
assert.notEqual(live[name]["read-floor"], 0.25, name);
}
const defaults = JSON.parse(readFileSync(DEFAULTS_PATH, "utf8"));
const live = apiLimits({ resolvedPath: "", defaultsPath: DEFAULTS_PATH });
defaults["lanes-app"]["read-floor"] = 0.25;
defaults["content-creation"]["per-minute"] = 7;
const path = join(dir, "rate-limits.json");
writeFileSync(path, JSON.stringify(defaults));
const changed = apiLimits({ resolvedPath: "", defaultsPath: path });
assert.equal(changed["lanes-app"]["read-floor"], 0.25);
assert.notEqual(live["lanes-app"]["read-floor"], 0.25);
assert.equal(changed["content-creation"]["per-minute"], 7);
assert.equal(changed["github-token"]["per-minute"], undefined);
assert.deepEqual(changed["github-token"], live["github-token"]);
}));

test("a missing or invalid rate-limits.json throws, so the latch warns that the floor is unchecked", () =>
withDir((dir) => {
const valid = JSON.parse(readFileSync(DEFAULTS_PATH, "utf8"));
const edits = {
"not JSON": "{",
"not an object": "[]",
"a pool missing": JSON.stringify({ ...valid, graphql: undefined }),
"an unknown pool": JSON.stringify({ ...valid, search: valid.graphql }),
"a share missing": JSON.stringify({ ...valid, "lanes-app": { ...valid["lanes-app"], "read-floor": undefined } }),
"a share past 1": JSON.stringify({ ...valid, "lanes-app": { ...valid["lanes-app"], "read-floor": 1.5 } }),
"a share as text": JSON.stringify({ ...valid, "lanes-app": { ...valid["lanes-app"], breaker: "0.2" } }),
"a plan of 0": JSON.stringify({ ...valid, graphql: { ...valid.graphql, plan: 0 } }),
"a fractional limit-cap": JSON.stringify({ ...valid, "github-token": { ...valid["github-token"], "limit-cap": 10.5 } }),
"a limit-cap of 0": JSON.stringify({ ...valid, "github-token": { ...valid["github-token"], "limit-cap": 0 } }),
"per-minute off content-creation": JSON.stringify({ ...valid, "owner-token": { ...valid["owner-token"], "per-minute": 80 } }),
"a per-hour of 0": JSON.stringify({ ...valid, "content-creation": { ...valid["content-creation"], "per-hour": 0 } }),
"an unknown field": JSON.stringify({ ...valid, graphql: { ...valid.graphql, floor: 0.1 } }),
};
assert.throws(() => apiLimits({ resolvedPath: "", defaultsPath: join(dir, "missing.json") }), "missing");
for (const [name, text] of Object.entries(edits)) {
const path = join(dir, "rate-limits.json");
writeFileSync(path, text);
assert.throws(() => apiLimits({ resolvedPath: "", defaultsPath: path }), name);
}
}));

test("the schema states no limits.api default: rate-limits.json holds the only copy, for the schema's pools and fields", () => {
const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8"));
const defaults = JSON.parse(readFileSync(DEFAULTS_PATH, "utf8"));
const entries = schema.properties.limits.properties.api.properties;
const pools = [schema.$defs["api-pool"], schema.$defs["api-content-pool"]];
for (const [name, entry] of Object.entries(entries)) {
assert.equal("default" in entry, false, name);
}
for (const pool of pools) {
for (const [field, property] of Object.entries(pool.properties)) {
assert.equal("default" in property, false, field);
}
}
assert.deepEqual(Object.keys(defaults).sort(), Object.keys(entries).sort());
for (const [name, fields] of Object.entries(defaults)) {
const allowed = Object.keys(schema.$defs[name === "content-creation" ? "api-content-pool" : "api-pool"].properties);
assert.deepEqual(Object.keys(fields).filter((field) => !allowed.includes(field)), [], name);
}
});

// github-token plans to a limit-cap of 1,000, the figure GitHub documents for
// GITHUB_TOKEN per repository per hour
// (https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#primary-rate-limit-for-github_token-in-github-actions).
Expand All @@ -87,7 +133,7 @@ test("a resolved limit-cap caps another pool, and another github-token field kee
assert.equal(limits["lanes-app"]["limit-cap"], 4000);
}));

test("a resolved config's limits.api overrides the schema default for that field only", () =>
test("a resolved config's limits.api overrides the rate-limits.json default for that field only", () =>
withDir((dir) => {
const path = join(dir, "resolved.json");
writeFileSync(path, JSON.stringify({ limits: { api: { "lanes-app": { "read-floor": 0.3 } } } }));
Expand Down Expand Up @@ -229,7 +275,7 @@ test("graphql 200 with RATE_LIMITED latches graphql", async () => {
assert.equal(gh.sent.length, 1);
});

test("without a readable schema the latch still stops, and says the floor is unchecked", async () => {
test("without readable rate-limit defaults the latch still stops, and says the floor is unchecked", async () => {
const lines = [];
const latch = createRateLimitLatch({
now: () => NOW,
Expand Down
7 changes: 7 additions & 0 deletions .github/actions/log-api-budget/rate-limits.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"github-token": { "plan": 0.7, "writer-pause": 0.4, "breaker": 0.2, "read-floor": 0.1, "limit-cap": 1000 },
"lanes-app": { "plan": 0.7, "writer-pause": 0.4, "breaker": 0.2, "read-floor": 0.1 },
"graphql": { "plan": 0.7, "writer-pause": 0.4, "breaker": 0.2, "read-floor": 0.1 },
"owner-token": { "plan": 0.7, "writer-pause": 0.4, "breaker": 0.2, "read-floor": 0.1 },
"content-creation": { "plan": 0.7, "writer-pause": 0.4, "breaker": 0.2, "read-floor": 0.1, "per-minute": 80, "per-hour": 500 }
}
2 changes: 1 addition & 1 deletion .github/actions/resolve-config/resolve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,7 @@ const limitsOf = (limits = {}) => ({
"fix-docs-per-run": limits["fix-docs-per-run"] ?? 2,
"admission-factor": limits["admission-factor"] ?? 0.8,
"admission-min-per-hour": limits["admission-min-per-hour"] ?? 1,
// As set: rate-guard.mjs fills it from the schema's defaults.
// As set: rate-guard.mjs fills it from rate-limits.json beside it.
api: limits.api ?? {},
});

Expand Down
1 change: 1 addition & 0 deletions .github/scripts/pr-merge/enable-auto-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ test("a floor path no configured glob names is commented on and labeled human-ga
".github/actions/check-trusted-trigger/check-trusted-trigger.mjs",
".github/actions/check-kill-switch/check-kill-switch.mjs",
".github/actions/log-api-budget/rate-guard.mjs",
".github/actions/log-api-budget/rate-limits.json",
".github/CODEOWNERS",
"vendor/.gitmodules",
".github/scripts/pr-merge/merge-holds.mjs",
Expand Down
1 change: 1 addition & 0 deletions .github/scripts/pr-merge/merge-holds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export const HOLD_PATH_FLOOR = Object.freeze([
".github/actions/check-kill-switch/**",
".github/actions/request-lane-token/**",
".github/actions/log-api-budget/rate-guard.mjs",
".github/actions/log-api-budget/rate-limits.json",
".github/scripts/pr-merge/merge-holds.mjs",
".github/scripts/pr-merge/enable-auto-merge.mjs",
".github/scripts/pr-merge/superseded.mjs",
Expand Down
1 change: 1 addition & 0 deletions .github/scripts/pr-merge/merge-holds.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ const FLOOR_FILES = [
".github/actions/check-trusted-trigger/check-trusted-trigger.mjs",
".github/actions/check-kill-switch/check-kill-switch.mjs",
".github/actions/log-api-budget/rate-guard.mjs",
".github/actions/log-api-budget/rate-limits.json",
".github/CODEOWNERS",
"plugins/foo/codeowners",
".gitmodules",
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/intake-label-needs-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ jobs:
/.github/actions/log-api-budget/
/.github/standards/trusted-actors/
/.node-version
/docs/conventions/pr-pipeline/pr-pipeline.schema.json
sparse-checkout-cone-mode: false

- name: Set up Node
Expand Down
Loading
Loading