@davidvornholt/standards 0.17.3 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +9 -1
- package/src/cli.ts +26 -11
- package/src/creds-login-github-manifest.ts +14 -7
- package/src/dependabot-directory-character-class.ts +139 -0
- package/src/dependabot-directory-normalize.ts +33 -0
- package/src/dependabot-directory-overlap.ts +139 -0
- package/src/dependabot-update.ts +32 -4
- package/src/github-api.ts +0 -80
- package/src/github-apply.ts +3 -2
- package/src/github-bypass-actors.ts +56 -0
- package/src/github-command-shared.ts +9 -2
- package/src/github-diff.ts +2 -109
- package/src/github-graphql.ts +198 -0
- package/src/github-live-drift.ts +57 -6
- package/src/github-ruleset-api.ts +57 -0
- package/src/github-ruleset-diff.ts +181 -0
- package/src/github-ruleset-visibility.ts +100 -0
- package/src/github-settings-parse.ts +12 -10
- package/src/github-settings.ts +3 -2
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ standards poller --config <path>
|
|
|
25
25
|
standards poller --print-units --config <path>
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
`dependabot --write` regenerates the composed `.github/dependabot.yml` from the canonical `.github/dependabot.base.yml` and the optional repo-owned `.github/dependabot.local.yml`; `init` and `sync` do the same automatically. `dependabot --check` verifies the generated file still matches its sources, and `doctor`/`check` include the same verification. The overlay is deliberately lean and additive only: new update blocks for repo-specific ecosystems, top-level private registry definitions, and `ignore` or `registries` entries appended by repeating a canonical block's normalized target. Matching blocks cannot add labels, groups, cooldowns, pull-request limits, or other policy.
|
|
28
|
+
`dependabot --write` regenerates the composed `.github/dependabot.yml` from the canonical `.github/dependabot.base.yml` and the optional repo-owned `.github/dependabot.local.yml`; `init` and `sync` do the same automatically. `dependabot --check` verifies the generated file still matches its sources, and `doctor`/`check` include the same verification. The overlay is deliberately lean and additive only: new update blocks for repo-specific ecosystems, top-level private registry definitions, and `ignore` or `registries` entries appended by repeating a canonical block's normalized target. Matching blocks cannot add labels, groups, cooldowns, pull-request limits, or other policy. Update blocks for the same ecosystem and target branch must not select intersecting directories after Dependabot's path normalization. A plural `directories` target is expanded as a glob when it contains `*`, `?`, or both `[` and `]`: `*` and bare `**` stay within one path segment, `**/` matches zero or more directory segments, `?` matches one non-separator character, and bracket expressions match character sets or ranges. Singular `directory` values are always literal.
|
|
29
29
|
|
|
30
30
|
`dev-env` decrypts the SOPS-encrypted `secrets/dev.yaml` — keyed by workspace group and a one-segment kebab-case workspace name (`apps.<name>`, `packages.<name>`), each mapping portable environment variable names to string values — and transactionally writes every declared workspace's `.env.local` with owner-only permissions and a do-not-edit header. Its Bun-specific dotenv codec exactly round-trips every value it accepts without adding helper environment variables; a value the codec cannot encode exactly is gathered as a validation problem instead of being changed silently. Validation gathers all document, containment, destination-type, workspace, and gitignore problems before writing anything. Generation then stages every output in its destination directory and rolls the whole set back if staging or commit fails, preserving prior contents, modes, and absent files; symlink destinations, duplicate resolved destinations, changed parent-directory identities, and symlinked workspace paths are rejected without following the link. Cleanup trouble after a completed commit is reported loudly as a warning rather than falsely reporting generation failure. Parent identities are checked immediately before each path operation; an unavoidable swap in the syscall-sized interval after a check remains outside the non-hostile maintainer threat model. The canonical `justfile` exposes the command as `just dev-env-generate`, and `just dev-refresh` chains a `just secrets edit dev` session into regeneration.
|
|
31
31
|
|
|
@@ -48,6 +48,6 @@ standards poller --print-units --config <path>
|
|
|
48
48
|
- **`.github/settings.local.json` `"rulesetEnforcement": "unavailable-on-plan"`** (optional) — declares that the repository's GitHub plan cannot enforce rulesets (private repositories on GitHub Free, both personal accounts and organizations). `github --check` and `--apply` then skip rulesets instead of comparing them, because a comparison cannot be trusted there: personal accounts answer ruleset reads with HTTP 403, and free-plan organizations report declared rulesets as active while silently not enforcing them. Plan-gated repository settings that only function alongside branch protection (`allow_auto_merge`) are skipped for the same reason — GitHub accepts a PATCH for them with HTTP 200 and silently keeps the old value. The remaining repository merge settings are still checked and converged, and both commands print an unprotected-branch notice on every run. The only accepted value is `"unavailable-on-plan"` (enforcement is the default; omit the key on paid plans), and combining the opt-out with additional local rulesets is rejected. After upgrading the plan, remove the declaration and run `bun standards github --apply` to restore enforcement.
|
|
49
49
|
- **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (1–12 unique `owner/repo` strings), `"model"` and `"reasoningEffort"` (passed to `codex exec`; the model choice is deliberate and has no default). The 12-repository maximum and five-minute acknowledgement cooldown keep supported mixed fix/review queues and persisted review-plan recovery below the 5,000-request/hour GitHub API budget with at least 1,400 requests/hour reserved for pagination, active jobs, writes, and other token users; the compound regression bound covers four acknowledged fixes plus four acknowledged reviews with persisted plans in every repository at no more than 3,312 requests/hour. Existing configs above the repository limit must be reduced or split across pollers with independent GitHub API budgets before upgrading. Optional: `"maxJobsPerTick"` (default 1 — the maximum number of new Codex runs the sequential worker may start in one tick), `"staleClaimHours"` (default 6; must exceed `runTimeoutMinutes` so a sweep can never release the claim of a job that is still running), `"runTimeoutMinutes"` (default 240 — the timeout catches wedged agents, not honest multi-hour review runs), `"cacheDir"` (default `~/.cache/standards-poller`; holds bare mirrors and job worktrees; relative paths resolve against the config file), `"extraCodexArgs"` (extra `codex exec` arguments, e.g. enabling the collaboration feature for subagent fan-out). Unknown keys are rejected. The poller needs `git`, `gh`, `codex` (authenticated — its rotating auth state must live in a writable location), and a GitHub token per the bullet below; the token needs issues, pull-requests, and contents write on the watched repos, and nothing else.
|
|
50
50
|
- **Broker store** (created by `creds login`) — machine-global root credentials at `$XDG_CONFIG_HOME/standards/broker.yaml` (default `~/.config/standards/broker.yaml`; override with `STANDARDS_BROKER_FILE`), holding the GitHub App identity (id, slug, client id, private key) and one bootstrap token per Cloudflare account. The file is plaintext with `0600` permissions — deliberately the same local-account trust boundary as the plaintext personal age identity at `~/.config/sops/age/keys.txt`, which unlocks the same secrets; SOPS-encrypting the store to that adjacent identity would not improve custody. Writes use an owner-only temporary file and atomic replacement, and simultaneous provider logins merge rather than overwrite each other; `creds status` warns when the final mode is broader. The store lives outside every repository, is never synced or backed up, and losing it is cheap: re-run the two login commands and revoke the old App key and bootstrap token. On an additional machine, do not create a second App: generate an additional private key from the existing App's settings page (GitHub Apps support several active keys; key generation has no API) and add it to that machine's store, so retiring a machine revokes one key, not the setup.
|
|
51
|
-
- **`GH_TOKEN` / `GITHUB_TOKEN`** (optional generally; poller automation requires this or a logged-in `gh`) — GitHub API token for the `github` command and the GitHub portion of `check`; poller runs pass environment tokens through to Codex, while a local poller can share the service identity's authenticated `gh` state. When neither is available, other commands can still read public repositories anonymously, but poller writes and `github --apply` fail. GitHub's REST API reveals repo merge settings only to write-capable viewers
|
|
51
|
+
- **`GH_TOKEN` / `GITHUB_TOKEN`** (optional generally; poller automation requires this or a logged-in `gh`) — GitHub API token for the `github` command and the GitHub portion of `check`; poller runs pass environment tokens through to Codex, while a local poller can share the service identity's authenticated `gh` state. When neither is available, other commands can still read public repositories anonymously, but poller writes and `github --apply` fail. GitHub's REST API reveals repo merge settings only to write-capable viewers, and `check` retries REST-hidden keys over GraphQL, which serves them to read-only tokens. REST serves ruleset `bypass_actors` only to a user-scoped token holding repository Administration read (a fine-grained PAT with that permission, such as CI's settings token, or the local `gh` CLI), and never to a GitHub App installation token at any permission level, so `check` retries the hidden lists over GraphQL, which withholds the actor identities but answers an exact bypass-actor count (established for a GitHub App installation token and for a non-admin classic-scope viewer, not for every identity class): a declared-empty list verifies exactly, a count that disagrees with the declared length is drift, and a matching non-zero count leaves *which* actors bypass unconfirmed, so the gate fails closed on that ruleset. An installation-token run therefore leaves a declared non-empty list failing until the check is re-run locally with admin `gh` auth; broadening the settings PAT beyond read-only Administration is not the fix. Private-repository labels require Issues read (or Pull requests read); a token that still cannot see declared state fails the check rather than passing unverified. In CI, the canonical workflow decrypts `ci.github_settings_read_token` from the SOPS-encrypted `secrets/ci.yaml`: a repository-scoped fine-grained PAT with read-only "Administration" and "Issues" access.
|
|
52
52
|
|
|
53
53
|
See the standards repository README for the ownership model and adoption workflow.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
"src/creds-store-lock.ts",
|
|
55
55
|
"src/dependabot-compose-input.ts",
|
|
56
56
|
"src/dependabot-compose.ts",
|
|
57
|
+
"src/dependabot-directory-character-class.ts",
|
|
58
|
+
"src/dependabot-directory-normalize.ts",
|
|
59
|
+
"src/dependabot-directory-overlap.ts",
|
|
57
60
|
"src/dependabot-inspect.ts",
|
|
58
61
|
"src/dependabot-update.ts",
|
|
59
62
|
"src/dev-env-document.ts",
|
|
@@ -66,13 +69,18 @@
|
|
|
66
69
|
"src/dev-env.ts",
|
|
67
70
|
"src/github-api.ts",
|
|
68
71
|
"src/github-apply.ts",
|
|
72
|
+
"src/github-bypass-actors.ts",
|
|
69
73
|
"src/github-command-shared.ts",
|
|
70
74
|
"src/github-commands.ts",
|
|
71
75
|
"src/github-diff.ts",
|
|
76
|
+
"src/github-graphql.ts",
|
|
72
77
|
"src/github-labels.ts",
|
|
73
78
|
"src/github-label-identity.ts",
|
|
74
79
|
"src/github-live-drift.ts",
|
|
75
80
|
"src/github-paginate.ts",
|
|
81
|
+
"src/github-ruleset-api.ts",
|
|
82
|
+
"src/github-ruleset-diff.ts",
|
|
83
|
+
"src/github-ruleset-visibility.ts",
|
|
76
84
|
"src/github-settings-merge.ts",
|
|
77
85
|
"src/github-settings-parse.ts",
|
|
78
86
|
"src/github-settings.ts",
|
package/src/cli.ts
CHANGED
|
@@ -328,13 +328,6 @@ const reportMirror = (result: MirrorResult, dryRun: boolean): void => {
|
|
|
328
328
|
` would overwrite ${result.tampered.length} locally-modified canonical file(s): ${result.tampered.join(', ')}`,
|
|
329
329
|
);
|
|
330
330
|
}
|
|
331
|
-
const changes =
|
|
332
|
-
result.created.length + result.updated.length + result.deleted.length;
|
|
333
|
-
console.log(
|
|
334
|
-
changes === 0
|
|
335
|
-
? 'dry run: already in sync; no changes'
|
|
336
|
-
: `dry run: ${result.created.length} to create, ${result.updated.length} to update, ${result.deleted.length} to delete`,
|
|
337
|
-
);
|
|
338
331
|
return;
|
|
339
332
|
}
|
|
340
333
|
for (const rel of result.deleted) {
|
|
@@ -347,6 +340,22 @@ const reportMirror = (result: MirrorResult, dryRun: boolean): void => {
|
|
|
347
340
|
}
|
|
348
341
|
};
|
|
349
342
|
|
|
343
|
+
const reportDryRunSummary = (
|
|
344
|
+
result: MirrorResult,
|
|
345
|
+
generated: boolean,
|
|
346
|
+
): void => {
|
|
347
|
+
const changes =
|
|
348
|
+
result.created.length +
|
|
349
|
+
result.updated.length +
|
|
350
|
+
result.deleted.length +
|
|
351
|
+
Number(generated);
|
|
352
|
+
console.log(
|
|
353
|
+
changes === 0
|
|
354
|
+
? 'dry run: already in sync; no changes'
|
|
355
|
+
: `dry run: ${result.created.length} to create, ${result.updated.length} to update, ${result.deleted.length} to delete, ${Number(generated)} to generate`,
|
|
356
|
+
);
|
|
357
|
+
};
|
|
358
|
+
|
|
350
359
|
const seedTargets = (
|
|
351
360
|
srcDir: string,
|
|
352
361
|
seedDir: string,
|
|
@@ -422,8 +431,13 @@ const runSync = async (
|
|
|
422
431
|
dryRun,
|
|
423
432
|
});
|
|
424
433
|
reportMirror(result, dryRun);
|
|
425
|
-
await applyProspectiveDependabot(
|
|
434
|
+
const generated = await applyProspectiveDependabot(
|
|
435
|
+
consumer,
|
|
436
|
+
prospectiveDependabot,
|
|
437
|
+
dryRun,
|
|
438
|
+
);
|
|
426
439
|
if (dryRun) {
|
|
440
|
+
reportDryRunSummary(result, generated);
|
|
427
441
|
return;
|
|
428
442
|
}
|
|
429
443
|
await writeLock(consumer, {
|
|
@@ -612,16 +626,17 @@ const applyProspectiveDependabot = async (
|
|
|
612
626
|
consumer: string,
|
|
613
627
|
prospective: ProspectiveDependabot,
|
|
614
628
|
dryRun: boolean,
|
|
615
|
-
): Promise<
|
|
629
|
+
): Promise<boolean> => {
|
|
616
630
|
if (prospective.current === prospective.composed) {
|
|
617
|
-
return;
|
|
631
|
+
return false;
|
|
618
632
|
}
|
|
619
633
|
if (dryRun) {
|
|
620
634
|
console.log(` would generate ${DEPENDABOT_FILE}`);
|
|
621
|
-
return;
|
|
635
|
+
return true;
|
|
622
636
|
}
|
|
623
637
|
await writeComposedDependabot(consumer, prospective.composed);
|
|
624
638
|
console.log(` generated ${DEPENDABOT_FILE}`);
|
|
639
|
+
return true;
|
|
625
640
|
};
|
|
626
641
|
|
|
627
642
|
const runDependabotCheck = async (consumer: string): Promise<boolean> => {
|
|
@@ -10,13 +10,20 @@ import { isRecord } from './github-settings-parse';
|
|
|
10
10
|
|
|
11
11
|
export const MANIFEST_STATE_BYTES = 32;
|
|
12
12
|
|
|
13
|
-
// Root-credential ceiling: everything the broker may ever delegate.
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
// Root-credential ceiling: everything the broker may ever delegate. The
|
|
14
|
+
// `permission-*` inputs a workflow passes when minting are a request, not a
|
|
15
|
+
// constraint — anything holding the durable private key can request the whole
|
|
16
|
+
// ceiling — so this, not those inputs, is the blast radius of the App identity
|
|
17
|
+
// sitting in every consumer's secrets/ci.yaml. It therefore lists only grants a
|
|
18
|
+
// workflow actually uses today; widening it is a deliberate edit here plus a
|
|
19
|
+
// settings-page change on the existing App, not a default to grow into.
|
|
20
|
+
const DEFAULT_PERMISSIONS = {
|
|
21
|
+
actions: 'read',
|
|
22
|
+
contents: 'write',
|
|
23
|
+
issues: 'write',
|
|
24
|
+
metadata: 'read',
|
|
25
|
+
pull_requests: 'write',
|
|
26
|
+
} as const;
|
|
20
27
|
|
|
21
28
|
export const buildAppManifest = (
|
|
22
29
|
name: string,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export type CharacterMatcher =
|
|
2
|
+
| { readonly kind: 'literal'; readonly value: number }
|
|
3
|
+
| { readonly kind: 'non-slash' }
|
|
4
|
+
| {
|
|
5
|
+
readonly kind: 'class';
|
|
6
|
+
readonly ranges: ReadonlyArray<readonly [number, number]>;
|
|
7
|
+
readonly negated: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type CharacterRange = readonly [number, number];
|
|
11
|
+
|
|
12
|
+
type ClassValue = {
|
|
13
|
+
readonly escaped: boolean;
|
|
14
|
+
readonly value: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const BEFORE_SLASH = '/'.codePointAt(0) ?? 0;
|
|
18
|
+
const AFTER_SLASH = BEFORE_SLASH + 1;
|
|
19
|
+
const HYPHEN = '-'.codePointAt(0) ?? 0;
|
|
20
|
+
const MAXIMUM_CODE_POINT = 0x10_ff_ff;
|
|
21
|
+
|
|
22
|
+
const NON_SLASH_RANGES: ReadonlyArray<CharacterRange> = [
|
|
23
|
+
[0, BEFORE_SLASH - 1],
|
|
24
|
+
[AFTER_SLASH, MAXIMUM_CODE_POINT],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const intersectRanges = (
|
|
28
|
+
left: ReadonlyArray<CharacterRange>,
|
|
29
|
+
right: ReadonlyArray<CharacterRange>,
|
|
30
|
+
): ReadonlyArray<CharacterRange> => {
|
|
31
|
+
const intersection: Array<CharacterRange> = [];
|
|
32
|
+
for (const [leftStart, leftEnd] of left) {
|
|
33
|
+
for (const [rightStart, rightEnd] of right) {
|
|
34
|
+
const start = Math.max(leftStart, rightStart);
|
|
35
|
+
const end = Math.min(leftEnd, rightEnd);
|
|
36
|
+
if (start <= end) {
|
|
37
|
+
intersection.push([start, end]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return intersection;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const subtractRanges = (
|
|
45
|
+
ranges: ReadonlyArray<CharacterRange>,
|
|
46
|
+
excluded: ReadonlyArray<CharacterRange>,
|
|
47
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Range subtraction keeps the finite character-class intersection exact.
|
|
48
|
+
): ReadonlyArray<CharacterRange> => {
|
|
49
|
+
let remaining = [...ranges];
|
|
50
|
+
for (const [excludedStart, excludedEnd] of excluded) {
|
|
51
|
+
const next: Array<CharacterRange> = [];
|
|
52
|
+
for (const [start, end] of remaining) {
|
|
53
|
+
if (excludedEnd < start || excludedStart > end) {
|
|
54
|
+
next.push([start, end]);
|
|
55
|
+
} else {
|
|
56
|
+
if (start < excludedStart) {
|
|
57
|
+
next.push([start, excludedStart - 1]);
|
|
58
|
+
}
|
|
59
|
+
if (excludedEnd < end) {
|
|
60
|
+
next.push([excludedEnd + 1, end]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
remaining = next;
|
|
65
|
+
}
|
|
66
|
+
return remaining;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const matcherRanges = (
|
|
70
|
+
matcher: CharacterMatcher,
|
|
71
|
+
): ReadonlyArray<CharacterRange> => {
|
|
72
|
+
if (matcher.kind === 'literal') {
|
|
73
|
+
return [[matcher.value, matcher.value]];
|
|
74
|
+
}
|
|
75
|
+
if (matcher.kind === 'non-slash') {
|
|
76
|
+
return NON_SLASH_RANGES;
|
|
77
|
+
}
|
|
78
|
+
return matcher.negated
|
|
79
|
+
? subtractRanges(NON_SLASH_RANGES, matcher.ranges)
|
|
80
|
+
: intersectRanges(NON_SLASH_RANGES, matcher.ranges);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const matchersIntersect = (
|
|
84
|
+
left: CharacterMatcher,
|
|
85
|
+
right: CharacterMatcher,
|
|
86
|
+
): boolean =>
|
|
87
|
+
intersectRanges(matcherRanges(left), matcherRanges(right)).length > 0;
|
|
88
|
+
|
|
89
|
+
export const parseCharacterClass = (
|
|
90
|
+
characters: ReadonlyArray<string>,
|
|
91
|
+
start: number,
|
|
92
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Ruby character classes require escape, negation, and range state in one parser.
|
|
93
|
+
): { readonly end: number; readonly matcher: CharacterMatcher } | null => {
|
|
94
|
+
let cursor = start + 1;
|
|
95
|
+
const negated = characters[cursor] === '!' || characters[cursor] === '^';
|
|
96
|
+
if (negated) {
|
|
97
|
+
cursor += 1;
|
|
98
|
+
}
|
|
99
|
+
const values: Array<ClassValue> = [];
|
|
100
|
+
for (
|
|
101
|
+
;
|
|
102
|
+
cursor < characters.length && characters[cursor] !== ']';
|
|
103
|
+
cursor += 1
|
|
104
|
+
) {
|
|
105
|
+
let escaped = false;
|
|
106
|
+
if (characters[cursor] === '\\' && characters[cursor + 1] !== undefined) {
|
|
107
|
+
cursor += 1;
|
|
108
|
+
escaped = true;
|
|
109
|
+
}
|
|
110
|
+
values.push({
|
|
111
|
+
escaped,
|
|
112
|
+
value: characters[cursor]?.codePointAt(0) ?? 0,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (characters[cursor] !== ']') {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const ranges: Array<CharacterRange> = [];
|
|
119
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
120
|
+
const startValue = values[index]?.value ?? 0;
|
|
121
|
+
const separator = values[index + 1];
|
|
122
|
+
const endValue = values[index + 2]?.value ?? 0;
|
|
123
|
+
if (
|
|
124
|
+
index + 2 < values.length &&
|
|
125
|
+
separator?.value === HYPHEN &&
|
|
126
|
+
!separator.escaped
|
|
127
|
+
) {
|
|
128
|
+
if (startValue <= endValue) {
|
|
129
|
+
ranges.push([startValue, endValue]);
|
|
130
|
+
} else {
|
|
131
|
+
ranges.push([startValue, startValue], [endValue, endValue]);
|
|
132
|
+
}
|
|
133
|
+
index += 2;
|
|
134
|
+
} else {
|
|
135
|
+
ranges.push([startValue, startValue]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { end: cursor, matcher: { kind: 'class', ranges, negated } };
|
|
139
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const applySegment = (
|
|
2
|
+
segments: Array<string>,
|
|
3
|
+
segment: string,
|
|
4
|
+
absolute: boolean,
|
|
5
|
+
): void => {
|
|
6
|
+
if (segment !== '' && segment !== '.') {
|
|
7
|
+
if (segment !== '..') {
|
|
8
|
+
segments.push(segment);
|
|
9
|
+
} else if (segments.at(-1) !== undefined && segments.at(-1) !== '..') {
|
|
10
|
+
segments.pop();
|
|
11
|
+
} else if (!absolute) {
|
|
12
|
+
segments.push(segment);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const normalizeDependabotDirectory = (directory: string): string => {
|
|
18
|
+
const absolute = directory.startsWith('/');
|
|
19
|
+
const segments: Array<string> = [];
|
|
20
|
+
for (const segment of directory.split('/')) {
|
|
21
|
+
applySegment(segments, segment, absolute);
|
|
22
|
+
}
|
|
23
|
+
let normalized = segments.join('/');
|
|
24
|
+
if (normalized === '') {
|
|
25
|
+
normalized = absolute ? '/' : '.';
|
|
26
|
+
}
|
|
27
|
+
return normalized.startsWith('/') ? normalized : `/${normalized}`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const isDependabotGlob = (directory: string): boolean =>
|
|
31
|
+
directory.includes('*') ||
|
|
32
|
+
directory.includes('?') ||
|
|
33
|
+
(directory.includes('[') && directory.includes(']'));
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CharacterMatcher,
|
|
3
|
+
matchersIntersect,
|
|
4
|
+
parseCharacterClass,
|
|
5
|
+
} from './dependabot-directory-character-class';
|
|
6
|
+
|
|
7
|
+
type Edge = {
|
|
8
|
+
readonly from: number;
|
|
9
|
+
readonly to: number;
|
|
10
|
+
readonly matcher: CharacterMatcher | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Automaton = {
|
|
14
|
+
readonly accept: number;
|
|
15
|
+
readonly edges: ReadonlyArray<Edge>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const compileDirectory = (
|
|
19
|
+
directory: string,
|
|
20
|
+
supportsGlobs: boolean,
|
|
21
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Ruby glob tokens have distinct NFA transitions that are clearest in one ordered parser.
|
|
22
|
+
): Automaton => {
|
|
23
|
+
const characters = [...directory];
|
|
24
|
+
const edges: Array<Edge> = [];
|
|
25
|
+
let current = 0;
|
|
26
|
+
let states = 1;
|
|
27
|
+
const append = (matcher: CharacterMatcher): void => {
|
|
28
|
+
const next = states;
|
|
29
|
+
states += 1;
|
|
30
|
+
edges.push({ from: current, to: next, matcher });
|
|
31
|
+
current = next;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < characters.length; index += 1) {
|
|
35
|
+
const character = characters[index];
|
|
36
|
+
if (!supportsGlobs) {
|
|
37
|
+
append({ kind: 'literal', value: character?.codePointAt(0) ?? 0 });
|
|
38
|
+
} else if (character === '\\' && characters[index + 1] !== undefined) {
|
|
39
|
+
index += 1;
|
|
40
|
+
append({
|
|
41
|
+
kind: 'literal',
|
|
42
|
+
value: characters[index]?.codePointAt(0) ?? 0,
|
|
43
|
+
});
|
|
44
|
+
} else if (
|
|
45
|
+
character === '*' &&
|
|
46
|
+
characters[index + 1] === '*' &&
|
|
47
|
+
characters[index + 2] === '/' &&
|
|
48
|
+
(index === 0 || characters[index - 1] === '/')
|
|
49
|
+
) {
|
|
50
|
+
const next = states;
|
|
51
|
+
const insideDirectory = states + 1;
|
|
52
|
+
states += 2;
|
|
53
|
+
edges.push(
|
|
54
|
+
{ from: current, to: next, matcher: null },
|
|
55
|
+
{ from: current, to: insideDirectory, matcher: { kind: 'non-slash' } },
|
|
56
|
+
{
|
|
57
|
+
from: insideDirectory,
|
|
58
|
+
to: insideDirectory,
|
|
59
|
+
matcher: { kind: 'non-slash' },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
from: insideDirectory,
|
|
63
|
+
to: current,
|
|
64
|
+
matcher: { kind: 'literal', value: '/'.codePointAt(0) ?? 0 },
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
current = next;
|
|
68
|
+
index += 2;
|
|
69
|
+
} else if (character === '*') {
|
|
70
|
+
while (characters[index + 1] === '*') {
|
|
71
|
+
index += 1;
|
|
72
|
+
}
|
|
73
|
+
const next = states;
|
|
74
|
+
states += 1;
|
|
75
|
+
edges.push(
|
|
76
|
+
{ from: current, to: next, matcher: null },
|
|
77
|
+
{ from: current, to: current, matcher: { kind: 'non-slash' } },
|
|
78
|
+
);
|
|
79
|
+
current = next;
|
|
80
|
+
} else if (character === '?') {
|
|
81
|
+
append({ kind: 'non-slash' });
|
|
82
|
+
} else if (character === '[') {
|
|
83
|
+
const parsed = parseCharacterClass(characters, index);
|
|
84
|
+
if (parsed === null) {
|
|
85
|
+
append({ kind: 'literal', value: '['.codePointAt(0) ?? 0 });
|
|
86
|
+
} else {
|
|
87
|
+
append(parsed.matcher);
|
|
88
|
+
index = parsed.end;
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
append({ kind: 'literal', value: character?.codePointAt(0) ?? 0 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { accept: current, edges };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const directoriesOverlap = (
|
|
98
|
+
leftDirectory: string,
|
|
99
|
+
leftSupportsGlobs: boolean,
|
|
100
|
+
rightDirectory: string,
|
|
101
|
+
rightSupportsGlobs: boolean,
|
|
102
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The product walk must process both automata's epsilon and consuming transitions together.
|
|
103
|
+
): boolean => {
|
|
104
|
+
const left = compileDirectory(leftDirectory, leftSupportsGlobs);
|
|
105
|
+
const right = compileDirectory(rightDirectory, rightSupportsGlobs);
|
|
106
|
+
const pending: Array<readonly [number, number]> = [[0, 0]];
|
|
107
|
+
const visited = new Set<string>();
|
|
108
|
+
for (const [leftState, rightState] of pending) {
|
|
109
|
+
const key = `${leftState}:${rightState}`;
|
|
110
|
+
if (!visited.has(key)) {
|
|
111
|
+
visited.add(key);
|
|
112
|
+
if (leftState === left.accept && rightState === right.accept) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
const leftEdges = left.edges.filter((edge) => edge.from === leftState);
|
|
116
|
+
const rightEdges = right.edges.filter((edge) => edge.from === rightState);
|
|
117
|
+
pending.push(
|
|
118
|
+
...leftEdges
|
|
119
|
+
.filter((edge) => edge.matcher === null)
|
|
120
|
+
.map((edge) => [edge.to, rightState] as const),
|
|
121
|
+
...rightEdges
|
|
122
|
+
.filter((edge) => edge.matcher === null)
|
|
123
|
+
.map((edge) => [leftState, edge.to] as const),
|
|
124
|
+
);
|
|
125
|
+
for (const leftEdge of leftEdges) {
|
|
126
|
+
for (const rightEdge of rightEdges) {
|
|
127
|
+
if (
|
|
128
|
+
leftEdge.matcher !== null &&
|
|
129
|
+
rightEdge.matcher !== null &&
|
|
130
|
+
matchersIntersect(leftEdge.matcher, rightEdge.matcher)
|
|
131
|
+
) {
|
|
132
|
+
pending.push([leftEdge.to, rightEdge.to]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
};
|
package/src/dependabot-update.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isDependabotGlob,
|
|
3
|
+
normalizeDependabotDirectory,
|
|
4
|
+
} from './dependabot-directory-normalize';
|
|
5
|
+
import { directoriesOverlap } from './dependabot-directory-overlap';
|
|
1
6
|
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
2
7
|
|
|
3
8
|
export type UpdateBlock = Record<string, unknown>;
|
|
@@ -6,6 +11,8 @@ export type UpdateTarget = {
|
|
|
6
11
|
readonly ecosystem: string;
|
|
7
12
|
readonly targetBranch: string | null;
|
|
8
13
|
readonly directories: ReadonlyArray<string>;
|
|
14
|
+
readonly normalizedDirectories: ReadonlyArray<string>;
|
|
15
|
+
readonly globDirectories: ReadonlySet<string>;
|
|
9
16
|
};
|
|
10
17
|
|
|
11
18
|
const listOfNonEmptyStrings = (
|
|
@@ -72,6 +79,7 @@ export const updateTarget = (
|
|
|
72
79
|
block: UpdateBlock,
|
|
73
80
|
label: string,
|
|
74
81
|
problems: Array<string>,
|
|
82
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Target validation deliberately gathers every independent configuration problem.
|
|
75
83
|
): UpdateTarget | null => {
|
|
76
84
|
const ecosystem = block['package-ecosystem'];
|
|
77
85
|
if (!isNonEmptyString(ecosystem)) {
|
|
@@ -103,6 +111,9 @@ export const updateTarget = (
|
|
|
103
111
|
? [directory as string]
|
|
104
112
|
: (directories as ReadonlyArray<string>);
|
|
105
113
|
const normalized = [...new Set(rawDirectories)].sort();
|
|
114
|
+
const normalizedDirectories = [
|
|
115
|
+
...new Set(normalized.map(normalizeDependabotDirectory)),
|
|
116
|
+
].sort();
|
|
106
117
|
if (
|
|
107
118
|
!hasDirectory &&
|
|
108
119
|
Array.isArray(directories) &&
|
|
@@ -120,6 +131,10 @@ export const updateTarget = (
|
|
|
120
131
|
ecosystem,
|
|
121
132
|
targetBranch: targetBranch ?? null,
|
|
122
133
|
directories: normalized,
|
|
134
|
+
normalizedDirectories,
|
|
135
|
+
globDirectories: new Set(
|
|
136
|
+
hasDirectories ? normalizedDirectories.filter(isDependabotGlob) : [],
|
|
137
|
+
),
|
|
123
138
|
};
|
|
124
139
|
};
|
|
125
140
|
|
|
@@ -135,9 +150,13 @@ export const sameUpdateTarget = (
|
|
|
135
150
|
right: UpdateTarget,
|
|
136
151
|
): boolean =>
|
|
137
152
|
sameUpdateScope(left, right) &&
|
|
138
|
-
left.
|
|
139
|
-
left.
|
|
140
|
-
(directory, index) => directory === right.
|
|
153
|
+
left.normalizedDirectories.length === right.normalizedDirectories.length &&
|
|
154
|
+
left.normalizedDirectories.every(
|
|
155
|
+
(directory, index) => directory === right.normalizedDirectories[index],
|
|
156
|
+
) &&
|
|
157
|
+
left.globDirectories.size === right.globDirectories.size &&
|
|
158
|
+
[...left.globDirectories].every((directory) =>
|
|
159
|
+
right.globDirectories.has(directory),
|
|
141
160
|
);
|
|
142
161
|
|
|
143
162
|
export const overlapsUpdateTarget = (
|
|
@@ -145,7 +164,16 @@ export const overlapsUpdateTarget = (
|
|
|
145
164
|
right: UpdateTarget,
|
|
146
165
|
): boolean =>
|
|
147
166
|
sameUpdateScope(left, right) &&
|
|
148
|
-
left.
|
|
167
|
+
left.normalizedDirectories.some((leftDirectory) =>
|
|
168
|
+
right.normalizedDirectories.some((rightDirectory) =>
|
|
169
|
+
directoriesOverlap(
|
|
170
|
+
leftDirectory,
|
|
171
|
+
left.globDirectories.has(leftDirectory),
|
|
172
|
+
rightDirectory,
|
|
173
|
+
right.globDirectories.has(rightDirectory),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
149
177
|
|
|
150
178
|
export const updateTargetDescription = (target: UpdateTarget): string => {
|
|
151
179
|
const branch =
|
package/src/github-api.ts
CHANGED
|
@@ -112,83 +112,3 @@ export const loadDeclared = async (
|
|
|
112
112
|
existsSync(localPath) ? await readFile(localPath, 'utf8') : null,
|
|
113
113
|
);
|
|
114
114
|
};
|
|
115
|
-
|
|
116
|
-
const GRAPHQL_MERGE_FIELDS: ReadonlyMap<string, string> = new Map([
|
|
117
|
-
['allow_auto_merge', 'autoMergeAllowed'],
|
|
118
|
-
['allow_merge_commit', 'mergeCommitAllowed'],
|
|
119
|
-
['allow_rebase_merge', 'rebaseMergeAllowed'],
|
|
120
|
-
['allow_squash_merge', 'squashMergeAllowed'],
|
|
121
|
-
['delete_branch_on_merge', 'deleteBranchOnMerge'],
|
|
122
|
-
['merge_commit_message', 'mergeCommitMessage'],
|
|
123
|
-
['merge_commit_title', 'mergeCommitTitle'],
|
|
124
|
-
['squash_merge_commit_message', 'squashMergeCommitMessage'],
|
|
125
|
-
['squash_merge_commit_title', 'squashMergeCommitTitle'],
|
|
126
|
-
]);
|
|
127
|
-
|
|
128
|
-
// REST omits repository merge settings for read-only viewers (they surface
|
|
129
|
-
// only with write access — community discussion #153258), but GraphQL serves
|
|
130
|
-
// the same values, with identical enum spellings, to any token that can read
|
|
131
|
-
// the repository. This keeps a read-only PAT sufficient for the check.
|
|
132
|
-
// Returns only the keys GraphQL answered; the rest stay unverifiable.
|
|
133
|
-
export const fetchMergeSettingsViaGraphql = async (
|
|
134
|
-
token: string | null,
|
|
135
|
-
repo: string,
|
|
136
|
-
keys: ReadonlyArray<string>,
|
|
137
|
-
): Promise<Readonly<Record<string, unknown>>> => {
|
|
138
|
-
const mapped = keys.filter((key) => GRAPHQL_MERGE_FIELDS.has(key));
|
|
139
|
-
const [owner, name] = repo.split('/');
|
|
140
|
-
if (mapped.length === 0 || owner === undefined || name === undefined) {
|
|
141
|
-
return {};
|
|
142
|
-
}
|
|
143
|
-
const fields = mapped.map((key) => GRAPHQL_MERGE_FIELDS.get(key)).join(' ');
|
|
144
|
-
const response = await request(token, 'POST', '/graphql', {
|
|
145
|
-
query: `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { ${fields} } }`,
|
|
146
|
-
});
|
|
147
|
-
if (response.status !== HTTP_OK || !isRecord(response.body)) {
|
|
148
|
-
return {};
|
|
149
|
-
}
|
|
150
|
-
const data = isRecord(response.body.data) ? response.body.data : null;
|
|
151
|
-
const repository =
|
|
152
|
-
data !== null && isRecord(data.repository) ? data.repository : null;
|
|
153
|
-
if (repository === null) {
|
|
154
|
-
return {};
|
|
155
|
-
}
|
|
156
|
-
return Object.fromEntries(
|
|
157
|
-
mapped
|
|
158
|
-
.map((key) => [key, repository[GRAPHQL_MERGE_FIELDS.get(key) ?? '']])
|
|
159
|
-
.filter(([, value]) => value !== undefined && value !== null),
|
|
160
|
-
);
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
export type LiveRulesets = {
|
|
164
|
-
readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
|
|
165
|
-
readonly problem: string | null;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
// Only repository-sourced rulesets are managed; org-level rulesets a consumer
|
|
169
|
-
// inherits are outside this declaration's authority.
|
|
170
|
-
export const fetchLiveRulesets = async (
|
|
171
|
-
token: string | null,
|
|
172
|
-
repo: string,
|
|
173
|
-
): Promise<LiveRulesets> => {
|
|
174
|
-
const list = await request(token, 'GET', `/repos/${repo}/rulesets`);
|
|
175
|
-
if (list.status !== HTTP_OK || !Array.isArray(list.body)) {
|
|
176
|
-
return { rulesets: null, problem: apiError('listing rulesets', list) };
|
|
177
|
-
}
|
|
178
|
-
const repoOwned = list.body
|
|
179
|
-
.filter(isRecord)
|
|
180
|
-
.filter((ruleset) => ruleset.source_type === 'Repository');
|
|
181
|
-
const detailed = await Promise.all(
|
|
182
|
-
repoOwned.map((ruleset) =>
|
|
183
|
-
request(token, 'GET', `/repos/${repo}/rulesets/${ruleset.id}`),
|
|
184
|
-
),
|
|
185
|
-
);
|
|
186
|
-
const failed = detailed.find((response) => response.status !== HTTP_OK);
|
|
187
|
-
if (failed !== undefined) {
|
|
188
|
-
return { rulesets: null, problem: apiError('reading a ruleset', failed) };
|
|
189
|
-
}
|
|
190
|
-
return {
|
|
191
|
-
rulesets: detailed.map((response) => response.body).filter(isRecord),
|
|
192
|
-
problem: null,
|
|
193
|
-
};
|
|
194
|
-
};
|
package/src/github-apply.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
apiError,
|
|
7
|
-
fetchLiveRulesets,
|
|
8
7
|
HTTP_CREATED,
|
|
9
8
|
HTTP_NO_CONTENT,
|
|
10
9
|
HTTP_OK,
|
|
11
10
|
request,
|
|
12
11
|
} from './github-api';
|
|
13
|
-
import { diffRepositorySettings
|
|
12
|
+
import { diffRepositorySettings } from './github-diff';
|
|
13
|
+
import { fetchLiveRulesets } from './github-ruleset-api';
|
|
14
|
+
import { diffRuleset } from './github-ruleset-diff';
|
|
14
15
|
import { type GithubSettings, isRecord } from './github-settings-parse';
|
|
15
16
|
|
|
16
17
|
// GitHub answers a PATCH containing a plan-unavailable setting with HTTP 200
|