@davidvornholt/standards 0.10.2 → 0.11.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 +6 -1
- package/package.json +37 -1
- package/src/cli.ts +68 -11
- package/src/github-commands.ts +2 -0
- package/src/github-label-identity.ts +9 -0
- package/src/github-labels.ts +123 -0
- package/src/github-live-drift.ts +8 -0
- package/src/github-paginate.ts +37 -0
- package/src/github-settings-merge.ts +167 -0
- package/src/github-settings-parse.ts +82 -7
- package/src/github-settings.ts +5 -123
- package/src/poller-approval.ts +80 -0
- package/src/poller-claim.ts +154 -0
- package/src/poller-codex.ts +93 -0
- package/src/poller-commands.ts +78 -0
- package/src/poller-config.ts +188 -0
- package/src/poller-fix-outcome.ts +36 -0
- package/src/poller-fix-output.ts +135 -0
- package/src/poller-fix-publication.ts +187 -0
- package/src/poller-fix-run.ts +175 -0
- package/src/poller-fix-validation.ts +64 -0
- package/src/poller-github-publication.ts +23 -0
- package/src/poller-github-pulls.ts +189 -0
- package/src/poller-github-write.ts +87 -0
- package/src/poller-github.ts +190 -0
- package/src/poller-job-shared.ts +132 -0
- package/src/poller-outcome.ts +136 -0
- package/src/poller-output-integrity.ts +107 -0
- package/src/poller-prompts.ts +70 -0
- package/src/poller-protected-paths.ts +51 -0
- package/src/poller-protocol.ts +137 -0
- package/src/poller-review-artifacts.ts +119 -0
- package/src/poller-review-execution.ts +108 -0
- package/src/poller-review-output.ts +189 -0
- package/src/poller-review-publication.ts +153 -0
- package/src/poller-review-run.ts +160 -0
- package/src/poller-review-state.ts +126 -0
- package/src/poller-review-validation.ts +66 -0
- package/src/poller-schedule.ts +121 -0
- package/src/poller-tick.ts +144 -0
- package/src/poller-trust.ts +96 -0
- package/src/poller-units.ts +82 -0
- package/src/poller-workspace.ts +199 -0
package/README.md
CHANGED
|
@@ -12,14 +12,18 @@ standards dependabot --check
|
|
|
12
12
|
standards dependabot --write
|
|
13
13
|
standards github --check
|
|
14
14
|
standards github --apply
|
|
15
|
+
standards poller --config <path>
|
|
16
|
+
standards poller --print-units --config <path>
|
|
15
17
|
```
|
|
16
18
|
|
|
17
19
|
`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.
|
|
18
20
|
|
|
19
|
-
`github --check` compares the live GitHub repository (merge settings and
|
|
21
|
+
`github --check` compares the live GitHub repository (merge settings, rulesets, and declared labels) against the merged declaration in `.github/settings.json` + `.github/settings.local.json`; `github --apply` converges that declared state. Undeclared live labels remain untouched, while undeclared repository-owned rulesets are removed. `check` runs the same comparison whenever `.github/settings.json` is present, and fails closed: declared state the token cannot see fails the gate with a message naming the token fix instead of passing unverified. The canonical workflow sets `STANDARDS_SKIP_GITHUB_CHECK=true` only in its unprivileged quality step because its isolated `github-settings` job runs the live comparison with the admin-read token; this is a workflow-internal integration seam, not a general way to disable the local gate. The CLI prints an explicit diagnostic whenever that exact value skips either `check`'s live phase or `github --check`; `github --apply` is never skipped. `--apply` re-diffs the PATCH response, so a setting GitHub accepts with HTTP 200 but silently ignores (plan-unavailable features) is a reported failure, not a false success. A repository whose GitHub plan cannot enforce rulesets can declare that in the seam (see `rulesetEnforcement` below); both commands then skip rulesets and plan-gated repository settings, converge the rest, and print an unprotected-branch notice on every run.
|
|
20
22
|
|
|
21
23
|
`structure` enforces the canonical monorepo structure contract: workspace scripts (`check-types`, `lint`, `lint:fix`, `test`), root gate scripts, filtered Turbo convenience aliases, internal `0.0.0`/`workspace:*` versioning, package `exports`, shared tsconfig inheritance, and browser a11y wiring. Only a workspace containing an explicit `*.a11y.ts` suite must provide a `test:a11y` script and direct `@axe-core/playwright` and `@playwright/test` dependencies. `check` includes `structure`, so these rules gate every consumer PR.
|
|
22
24
|
|
|
25
|
+
`poller` runs one tick of the fix poller: host-level automation that turns maintainer-approved GitHub issues into verified draft PRs and maintainer-approved draft PRs into completed review-fix cycles, using headless Codex runs. All workflow state lives in GitHub labels, comments, and draft PRs, so ticks are stateless, re-runnable, and safe to schedule from any host. A tick claims work by label (`fix-in-progress` / `review-in-progress`), releases claims stale for longer than `staleClaimHours`, and fails loudly — a red tick means jobs stalled, never silence. Trust is verified per event, not per configuration: the approval labels (`approved-for-fix`, `approved-for-review`) only count when the applying user holds the `admin` or `maintain` role, bind to the exact approved issue revision or PR head, and are revalidated before publication; only admin/maintain comments are treated as answers to agent questions. The poller removes its direct GitHub token variables before launching Codex, but the approved run shares the host service identity and can read its other ambient credentials and logged-in tool state; that accepted visibility must not be treated as credential isolation. The run works in a throwaway git worktree, hands results back through a structured outcome file, and the poller verifies effects (commits exist, no protected paths touched) before pushing, opening the draft PR, or posting the review — the required CI checks on the resulting PR remain the quality gate. Host mutation is declarative-only: `--print-units` emits a correctly escaped systemd service and timer for the polling host's infrastructure repository to adapt and deploy. That repository owns the service identity, writable HOME, PATH, GitHub token environment, lingering, and deployment. The service's tick budget (`TimeoutStartSec`) is derived from `maxJobsPerTick` × `runTimeoutMinutes` plus overhead, so re-render and redeploy the units after changing either value.
|
|
26
|
+
|
|
23
27
|
`structure --profile source` validates the standards source repository itself, which is deliberately not a consumer. The profile pins its intentional exceptions so they cannot drift silently: exact, ordered root gate scripts that run this CLI from the local checkout (`structure --profile source`, `dependabot --check` or `dependabot --write`, `github --check`, and the Turbo gate) instead of a recursive `standards check`, and a non-private published bin-only CLI workspace that carries a stable release SemVer and exactly maps the `standards` bin to `src/cli.ts` without `exports`. Every other consumer rule still applies.
|
|
24
28
|
|
|
25
29
|
## Configuration
|
|
@@ -27,6 +31,7 @@ standards github --apply
|
|
|
27
31
|
- **`.github/dependabot.local.yml`** (optional) — repo-owned Dependabot extension composed with the canonical `.github/dependabot.base.yml` into the generated `.github/dependabot.yml`. May define `updates` and top-level `registries`; a block matching a canonical normalized target may only add `ignore` and/or `registries` entries. Other matching-block policy is rejected deliberately. A missing or comments-only file means no additions. CLI 0.10.1 requires the selected content ref to contain the canonical base and rejects older refs before mutation.
|
|
28
32
|
- **`sync-standards.local.json`** (optional) — consumer-owned sync policy at the repo root, validated by `doctor`/`check` and every `init`/`sync` even when an explicit ref or local source makes its `"ref"` irrelevant. All fields optional; a missing file means the defaults. `"ref"` is a non-empty single-line string that pins a tag, branch, or full commit sha to sync from instead of `main` (an explicit `--ref` overrides it; a local-path `--from` source is used as-is and ignores only the validated pin). `"autoSync": false` is read by the standards-sync workflow, not the CLI, and skips the weekly scheduled run. Version 0.7.0 removes the legacy `STANDARDS_AUTO_SYNC` and `STANDARDS_SYNC_REF` variable behavior; consumers must upgrade the package and lockfile before adopting a policy file.
|
|
29
33
|
- **`.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.
|
|
34
|
+
- **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (unique `owner/repo` strings — one poller watches any number of repositories), `"model"` and `"reasoningEffort"` (passed to `codex exec`; the model choice is deliberate and has no default). Optional: `"maxJobsPerTick"` (default 1 — the concurrency and rate-limit guard; subscription-authenticated Codex runs share the interactive rate window), `"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`, `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.
|
|
30
35
|
- **`GH_TOKEN` / `GITHUB_TOKEN`** (optional) — GitHub API token for the `github` command and the GitHub portion of `check`. When neither is set, the token from the local `gh` CLI is used; with no token at all, reads still work on public repositories. `--apply` needs an admin token. GitHub's REST API reveals repo merge settings only to write-capable viewers — `check` retries REST-hidden keys over GraphQL, which serves them to read-only tokens — and ruleset `bypass_actors` requires repository Administration read; a token that still cannot see declared state fails the check rather than passing unverified. In CI, where the workflow token can never hold that permission, the canonical workflow decrypts `ci.github_settings_read_token` from the SOPS-encrypted `secrets/ci.yaml` — a fine-grained PAT with read-only "Administration" access to the repository.
|
|
31
36
|
|
|
32
37
|
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.11.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -29,9 +29,45 @@
|
|
|
29
29
|
"src/github-command-shared.ts",
|
|
30
30
|
"src/github-commands.ts",
|
|
31
31
|
"src/github-diff.ts",
|
|
32
|
+
"src/github-labels.ts",
|
|
33
|
+
"src/github-label-identity.ts",
|
|
32
34
|
"src/github-live-drift.ts",
|
|
35
|
+
"src/github-paginate.ts",
|
|
36
|
+
"src/github-settings-merge.ts",
|
|
33
37
|
"src/github-settings-parse.ts",
|
|
34
38
|
"src/github-settings.ts",
|
|
39
|
+
"src/poller-approval.ts",
|
|
40
|
+
"src/poller-claim.ts",
|
|
41
|
+
"src/poller-codex.ts",
|
|
42
|
+
"src/poller-commands.ts",
|
|
43
|
+
"src/poller-config.ts",
|
|
44
|
+
"src/poller-fix-outcome.ts",
|
|
45
|
+
"src/poller-fix-output.ts",
|
|
46
|
+
"src/poller-fix-publication.ts",
|
|
47
|
+
"src/poller-fix-run.ts",
|
|
48
|
+
"src/poller-fix-validation.ts",
|
|
49
|
+
"src/poller-github-pulls.ts",
|
|
50
|
+
"src/poller-github-publication.ts",
|
|
51
|
+
"src/poller-github-write.ts",
|
|
52
|
+
"src/poller-github.ts",
|
|
53
|
+
"src/poller-job-shared.ts",
|
|
54
|
+
"src/poller-outcome.ts",
|
|
55
|
+
"src/poller-output-integrity.ts",
|
|
56
|
+
"src/poller-prompts.ts",
|
|
57
|
+
"src/poller-protected-paths.ts",
|
|
58
|
+
"src/poller-protocol.ts",
|
|
59
|
+
"src/poller-review-publication.ts",
|
|
60
|
+
"src/poller-review-output.ts",
|
|
61
|
+
"src/poller-review-artifacts.ts",
|
|
62
|
+
"src/poller-review-execution.ts",
|
|
63
|
+
"src/poller-review-state.ts",
|
|
64
|
+
"src/poller-review-validation.ts",
|
|
65
|
+
"src/poller-schedule.ts",
|
|
66
|
+
"src/poller-review-run.ts",
|
|
67
|
+
"src/poller-tick.ts",
|
|
68
|
+
"src/poller-trust.ts",
|
|
69
|
+
"src/poller-units.ts",
|
|
70
|
+
"src/poller-workspace.ts",
|
|
35
71
|
"src/structure-check.ts",
|
|
36
72
|
"src/structure-profile.ts",
|
|
37
73
|
"src/structure-script.ts",
|
package/src/cli.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { CANONICAL_SETTINGS_FILE, LOCAL_SETTINGS_FILE } from './github-api';
|
|
|
44
44
|
import { runGithubApply, runGithubCheck } from './github-commands';
|
|
45
45
|
import { loadGithubSettings } from './github-settings';
|
|
46
46
|
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
47
|
+
import { runPollerCommand } from './poller-commands';
|
|
47
48
|
import { collectStructureProblems } from './structure-check';
|
|
48
49
|
import type { StructureProfile } from './structure-profile';
|
|
49
50
|
import { hasSafeCommand } from './structure-script';
|
|
@@ -92,6 +93,7 @@ type Command =
|
|
|
92
93
|
| 'github'
|
|
93
94
|
| 'help'
|
|
94
95
|
| 'init'
|
|
96
|
+
| 'poller'
|
|
95
97
|
| 'structure'
|
|
96
98
|
| 'sync';
|
|
97
99
|
|
|
@@ -104,6 +106,8 @@ type CliOptions = {
|
|
|
104
106
|
readonly apply: boolean;
|
|
105
107
|
readonly write: boolean;
|
|
106
108
|
readonly profile: StructureProfile;
|
|
109
|
+
readonly config: string | undefined;
|
|
110
|
+
readonly printUnits: boolean;
|
|
107
111
|
};
|
|
108
112
|
|
|
109
113
|
const sha256 = (buf: Buffer): string =>
|
|
@@ -779,17 +783,20 @@ Commands:
|
|
|
779
783
|
structure Validate monorepo structure rules only
|
|
780
784
|
dependabot Verify (--check) or regenerate (--write) the composed .github/dependabot.yml
|
|
781
785
|
github Compare (--check) or converge (--apply) live GitHub settings
|
|
786
|
+
poller Run one fix-poller tick over the configured repositories (host automation)
|
|
782
787
|
help Show this help
|
|
783
788
|
|
|
784
789
|
Options:
|
|
785
|
-
--dir <path>
|
|
786
|
-
--profile <p>
|
|
787
|
-
--from <src>
|
|
788
|
-
--ref <ref>
|
|
789
|
-
--dry-run
|
|
790
|
-
--check
|
|
791
|
-
--apply
|
|
792
|
-
--write
|
|
790
|
+
--dir <path> Consumer directory to operate on (default: current directory)
|
|
791
|
+
--profile <p> With structure: validate as a "consumer" (default) or as the standards "source" repository itself
|
|
792
|
+
--from <src> Upstream override for init/sync (GitHub repo or local path)
|
|
793
|
+
--ref <ref> Upstream tag, branch, or full commit sha for init/sync (remote Git/GitHub sources only; default: main)
|
|
794
|
+
--dry-run Preview a sync without writing anything
|
|
795
|
+
--check With github/dependabot: compare against the declared sources (default)
|
|
796
|
+
--apply With github: converge the live repository (needs admin auth)
|
|
797
|
+
--write With dependabot: regenerate the composed .github/dependabot.yml
|
|
798
|
+
--config <path> With poller: the host-level poller config file (required)
|
|
799
|
+
--print-units With poller: print declarative systemd unit content without touching the host`;
|
|
793
800
|
|
|
794
801
|
const commandFromArg = (arg: string): Command => {
|
|
795
802
|
if (
|
|
@@ -799,6 +806,7 @@ const commandFromArg = (arg: string): Command => {
|
|
|
799
806
|
arg === 'github' ||
|
|
800
807
|
arg === 'help' ||
|
|
801
808
|
arg === 'init' ||
|
|
809
|
+
arg === 'poller' ||
|
|
802
810
|
arg === 'structure' ||
|
|
803
811
|
arg === 'sync'
|
|
804
812
|
) {
|
|
@@ -841,12 +849,24 @@ type ParsedFlags = {
|
|
|
841
849
|
readonly write: boolean;
|
|
842
850
|
readonly ref: string | undefined;
|
|
843
851
|
readonly profile: StructureProfile | undefined;
|
|
852
|
+
readonly config: string | undefined;
|
|
853
|
+
readonly printUnits: boolean;
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
const assertPollerOptionUsage = (flags: ParsedFlags): void => {
|
|
857
|
+
if (flags.config !== undefined && flags.command !== 'poller') {
|
|
858
|
+
throw new Error('--config is only valid with the poller command');
|
|
859
|
+
}
|
|
860
|
+
if (flags.printUnits && flags.command !== 'poller') {
|
|
861
|
+
throw new Error('--print-units is only valid with the poller command');
|
|
862
|
+
}
|
|
844
863
|
};
|
|
845
864
|
|
|
846
865
|
// Every option is only meaningful with specific commands; reject the rest so a
|
|
847
866
|
// typo fails loudly instead of silently doing the default thing.
|
|
848
867
|
const assertOptionUsage = (flags: ParsedFlags): void => {
|
|
849
868
|
const { command, checkFlag, apply, write, ref, profile } = flags;
|
|
869
|
+
assertPollerOptionUsage(flags);
|
|
850
870
|
if (checkFlag && command !== 'github' && command !== 'dependabot') {
|
|
851
871
|
throw new Error(
|
|
852
872
|
'--check is only valid with the github and dependabot commands',
|
|
@@ -882,6 +902,8 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
882
902
|
let apply = false;
|
|
883
903
|
let write = false;
|
|
884
904
|
let profile: StructureProfile | undefined;
|
|
905
|
+
let config: string | undefined;
|
|
906
|
+
let printUnits = false;
|
|
885
907
|
|
|
886
908
|
for (let index = 0; index < argv.length; index += 1) {
|
|
887
909
|
const arg = argv[index];
|
|
@@ -889,6 +911,13 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
889
911
|
case '--apply':
|
|
890
912
|
apply = true;
|
|
891
913
|
break;
|
|
914
|
+
case '--config':
|
|
915
|
+
config = nextOptionValue(argv, index);
|
|
916
|
+
index += 1;
|
|
917
|
+
break;
|
|
918
|
+
case '--print-units':
|
|
919
|
+
printUnits = true;
|
|
920
|
+
break;
|
|
892
921
|
case '--check':
|
|
893
922
|
checkFlag = true;
|
|
894
923
|
break;
|
|
@@ -923,7 +952,16 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
923
952
|
}
|
|
924
953
|
}
|
|
925
954
|
|
|
926
|
-
assertOptionUsage({
|
|
955
|
+
assertOptionUsage({
|
|
956
|
+
command,
|
|
957
|
+
checkFlag,
|
|
958
|
+
apply,
|
|
959
|
+
write,
|
|
960
|
+
ref,
|
|
961
|
+
profile,
|
|
962
|
+
config,
|
|
963
|
+
printUnits,
|
|
964
|
+
});
|
|
927
965
|
|
|
928
966
|
return {
|
|
929
967
|
command,
|
|
@@ -934,6 +972,8 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
934
972
|
apply,
|
|
935
973
|
write,
|
|
936
974
|
profile: profile ?? 'consumer',
|
|
975
|
+
config,
|
|
976
|
+
printUnits,
|
|
937
977
|
};
|
|
938
978
|
};
|
|
939
979
|
|
|
@@ -1135,8 +1175,18 @@ const runGateCommand = (
|
|
|
1135
1175
|
};
|
|
1136
1176
|
|
|
1137
1177
|
const main = async (): Promise<void> => {
|
|
1138
|
-
const {
|
|
1139
|
-
|
|
1178
|
+
const {
|
|
1179
|
+
command,
|
|
1180
|
+
consumer,
|
|
1181
|
+
dryRun,
|
|
1182
|
+
from,
|
|
1183
|
+
ref,
|
|
1184
|
+
apply,
|
|
1185
|
+
write,
|
|
1186
|
+
profile,
|
|
1187
|
+
config,
|
|
1188
|
+
printUnits,
|
|
1189
|
+
} = parseArgs(process.argv.slice(2));
|
|
1140
1190
|
|
|
1141
1191
|
if (command === undefined) {
|
|
1142
1192
|
console.error('standards: a command is required\n');
|
|
@@ -1165,6 +1215,13 @@ const main = async (): Promise<void> => {
|
|
|
1165
1215
|
return;
|
|
1166
1216
|
}
|
|
1167
1217
|
|
|
1218
|
+
if (command === 'poller') {
|
|
1219
|
+
if (!(await runPollerCommand({ configPath: config, printUnits }))) {
|
|
1220
|
+
process.exitCode = 1;
|
|
1221
|
+
}
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1168
1225
|
if (!(await runGateCommand(command, consumer, apply, profile))) {
|
|
1169
1226
|
process.exitCode = 1;
|
|
1170
1227
|
}
|
package/src/github-commands.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
enforceableRepositorySettings,
|
|
21
21
|
optOutEligibilityProblem,
|
|
22
22
|
} from './github-command-shared';
|
|
23
|
+
import { applyLabels } from './github-labels';
|
|
23
24
|
import { collectLiveDrift } from './github-live-drift';
|
|
24
25
|
import { isRecord } from './github-settings-parse';
|
|
25
26
|
|
|
@@ -108,6 +109,7 @@ export const runGithubApply = async (consumer: string): Promise<boolean> => {
|
|
|
108
109
|
if (!rulesetsSkipped) {
|
|
109
110
|
actions.push(...(await applyRulesets(token, repo, declared.merged)));
|
|
110
111
|
}
|
|
112
|
+
actions.push(...(await applyLabels(token, repo, declared.merged.labels)));
|
|
111
113
|
for (const action of actions) {
|
|
112
114
|
console.log(` ${action}`);
|
|
113
115
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const labelIdentity = (name: string): string => name.toLowerCase();
|
|
2
|
+
|
|
3
|
+
export const hasLabel = (
|
|
4
|
+
labels: ReadonlyArray<string>,
|
|
5
|
+
expected: string,
|
|
6
|
+
): boolean => {
|
|
7
|
+
const identity = labelIdentity(expected);
|
|
8
|
+
return labels.some((label) => labelIdentity(label) === identity);
|
|
9
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Live issue-label reading, drift, and convergence. Labels are an additive
|
|
2
|
+
// floor: every declared label must exist with the declared color and
|
|
3
|
+
// description; undeclared live labels (GitHub defaults, ad-hoc triage labels)
|
|
4
|
+
// are ignored. Any token that can read the repository can verify labels, so
|
|
5
|
+
// unlike merge settings there are no unverifiable states.
|
|
6
|
+
|
|
7
|
+
import { apiError, HTTP_CREATED, HTTP_OK, request } from './github-api';
|
|
8
|
+
import { labelIdentity } from './github-label-identity';
|
|
9
|
+
import { listAllPages } from './github-paginate';
|
|
10
|
+
import { isRecord, type LabelDeclaration } from './github-settings-parse';
|
|
11
|
+
|
|
12
|
+
type LiveLabel = {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly color: string;
|
|
15
|
+
readonly description: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const fetchLiveLabels = async (
|
|
19
|
+
token: string | null,
|
|
20
|
+
repo: string,
|
|
21
|
+
): Promise<ReadonlyArray<LiveLabel>> => {
|
|
22
|
+
const items = await listAllPages(
|
|
23
|
+
token,
|
|
24
|
+
`/repos/${repo}/labels`,
|
|
25
|
+
`reading labels for ${repo}`,
|
|
26
|
+
);
|
|
27
|
+
return items.filter(isRecord).map((label) => ({
|
|
28
|
+
name: typeof label.name === 'string' ? label.name : '',
|
|
29
|
+
color: typeof label.color === 'string' ? label.color : '',
|
|
30
|
+
description: typeof label.description === 'string' ? label.description : '',
|
|
31
|
+
}));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const labelDrift = (
|
|
35
|
+
declared: LabelDeclaration,
|
|
36
|
+
live: LiveLabel | undefined,
|
|
37
|
+
): string | null => {
|
|
38
|
+
if (live === undefined) {
|
|
39
|
+
return `label "${declared.name}" is declared but missing on GitHub`;
|
|
40
|
+
}
|
|
41
|
+
// GitHub reports colors in varying case; declarations are lowercase.
|
|
42
|
+
const liveColor = live.color.toLowerCase();
|
|
43
|
+
if (liveColor !== declared.color) {
|
|
44
|
+
return `label "${declared.name}" has color "${liveColor}" on GitHub, declared "${declared.color}"`;
|
|
45
|
+
}
|
|
46
|
+
if (live.description !== declared.description) {
|
|
47
|
+
return `label "${declared.name}" has a different description on GitHub than declared`;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const diffLabels = (
|
|
53
|
+
declared: ReadonlyArray<LabelDeclaration>,
|
|
54
|
+
live: ReadonlyArray<LiveLabel>,
|
|
55
|
+
): ReadonlyArray<string> => {
|
|
56
|
+
const liveByName = new Map(
|
|
57
|
+
live.map((label) => [labelIdentity(label.name), label]),
|
|
58
|
+
);
|
|
59
|
+
return declared.flatMap((label) => {
|
|
60
|
+
const drift = labelDrift(label, liveByName.get(labelIdentity(label.name)));
|
|
61
|
+
return drift === null ? [] : [drift];
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const convergeLabel = async (
|
|
66
|
+
token: string,
|
|
67
|
+
repo: string,
|
|
68
|
+
declared: LabelDeclaration,
|
|
69
|
+
live: LiveLabel | undefined,
|
|
70
|
+
): Promise<string | null> => {
|
|
71
|
+
if (labelDrift(declared, live) === null) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (live === undefined) {
|
|
75
|
+
const created = await request(token, 'POST', `/repos/${repo}/labels`, {
|
|
76
|
+
name: declared.name,
|
|
77
|
+
color: declared.color,
|
|
78
|
+
description: declared.description,
|
|
79
|
+
});
|
|
80
|
+
if (created.status !== HTTP_CREATED) {
|
|
81
|
+
throw new Error(apiError(`creating label "${declared.name}"`, created));
|
|
82
|
+
}
|
|
83
|
+
return `created label "${declared.name}"`;
|
|
84
|
+
}
|
|
85
|
+
const updated = await request(
|
|
86
|
+
token,
|
|
87
|
+
'PATCH',
|
|
88
|
+
`/repos/${repo}/labels/${encodeURIComponent(live.name)}`,
|
|
89
|
+
{ color: declared.color, description: declared.description },
|
|
90
|
+
);
|
|
91
|
+
if (updated.status !== HTTP_OK) {
|
|
92
|
+
throw new Error(apiError(`updating label "${declared.name}"`, updated));
|
|
93
|
+
}
|
|
94
|
+
return `updated label "${declared.name}"`;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const applyLabels = async (
|
|
98
|
+
token: string,
|
|
99
|
+
repo: string,
|
|
100
|
+
declared: ReadonlyArray<LabelDeclaration>,
|
|
101
|
+
): Promise<ReadonlyArray<string>> => {
|
|
102
|
+
if (declared.length === 0) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const live = await fetchLiveLabels(token, repo);
|
|
106
|
+
const liveByName = new Map(
|
|
107
|
+
live.map((label) => [labelIdentity(label.name), label]),
|
|
108
|
+
);
|
|
109
|
+
const actions: Array<string> = [];
|
|
110
|
+
for (const label of declared) {
|
|
111
|
+
// biome-ignore lint/performance/noAwaitInLoops: GitHub advises against concurrent write requests (secondary rate limits); mutations run sequentially on purpose.
|
|
112
|
+
const action = await convergeLabel(
|
|
113
|
+
token,
|
|
114
|
+
repo,
|
|
115
|
+
label,
|
|
116
|
+
liveByName.get(labelIdentity(label.name)),
|
|
117
|
+
);
|
|
118
|
+
if (action !== null) {
|
|
119
|
+
actions.push(action);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return actions;
|
|
123
|
+
};
|
package/src/github-live-drift.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
unverifiableProblem,
|
|
18
18
|
} from './github-command-shared';
|
|
19
19
|
import { diffRepositorySettings, diffRulesets } from './github-diff';
|
|
20
|
+
import { diffLabels, fetchLiveLabels } from './github-labels';
|
|
20
21
|
import { type GithubSettings, isRecord } from './github-settings-parse';
|
|
21
22
|
|
|
22
23
|
const repositoryDrift = async (
|
|
@@ -87,9 +88,16 @@ export const collectLiveDrift = async (
|
|
|
87
88
|
}
|
|
88
89
|
const token = resolveToken();
|
|
89
90
|
try {
|
|
91
|
+
// No declared labels means nothing to verify — skipping the fetch is
|
|
92
|
+
// exact, not lenient.
|
|
93
|
+
const labelDrift =
|
|
94
|
+
declared.labels.length === 0
|
|
95
|
+
? []
|
|
96
|
+
: diffLabels(declared.labels, await fetchLiveLabels(token, repo));
|
|
90
97
|
return [
|
|
91
98
|
...(await repositoryDrift(token, repo, declared)),
|
|
92
99
|
...(await rulesetDrift(token, repo, declared)),
|
|
100
|
+
...labelDrift,
|
|
93
101
|
];
|
|
94
102
|
} catch (error) {
|
|
95
103
|
return [
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Exhaustive pagination for GitHub list endpoints. Single-page reads are a
|
|
2
|
+
// correctness hazard everywhere the caller needs "the latest" or "all" of
|
|
3
|
+
// something: a truncated timeline mis-attributes label approvals, truncated
|
|
4
|
+
// comments break answer detection, and truncated label lists read as drift.
|
|
5
|
+
// Overflow past the page cap fails loudly instead of silently truncating.
|
|
6
|
+
|
|
7
|
+
import { apiError, HTTP_OK, request } from './github-api';
|
|
8
|
+
|
|
9
|
+
const PAGE_SIZE = 100;
|
|
10
|
+
const MAX_PAGES = 30;
|
|
11
|
+
|
|
12
|
+
export const listAllPages = async (
|
|
13
|
+
token: string | null,
|
|
14
|
+
path: string,
|
|
15
|
+
context: string,
|
|
16
|
+
): Promise<ReadonlyArray<unknown>> => {
|
|
17
|
+
const items: Array<unknown> = [];
|
|
18
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
19
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
20
|
+
// biome-ignore lint/performance/noAwaitInLoops: pages are sequential by definition; the next request needs to know the previous page was full.
|
|
21
|
+
const response = await request(
|
|
22
|
+
token,
|
|
23
|
+
'GET',
|
|
24
|
+
`${path}${separator}per_page=${PAGE_SIZE}&page=${page}`,
|
|
25
|
+
);
|
|
26
|
+
if (response.status !== HTTP_OK || !Array.isArray(response.body)) {
|
|
27
|
+
throw new Error(apiError(context, response));
|
|
28
|
+
}
|
|
29
|
+
items.push(...response.body);
|
|
30
|
+
if (response.body.length < PAGE_SIZE) {
|
|
31
|
+
return items;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error(
|
|
35
|
+
`${context}: more than ${MAX_PAGES * PAGE_SIZE} items; refusing to act on a truncated list`,
|
|
36
|
+
);
|
|
37
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Merge logic for the canonical settings file and its repo-owned extension.
|
|
2
|
+
// The seam may only add. Overriding a canonical repository key or redefining a
|
|
3
|
+
// canonical ruleset or label could weaken the canonical floor; GitHub layers
|
|
4
|
+
// multiple rulesets strictest-wins, so adding is always safe. The one
|
|
5
|
+
// subtractive declaration is the ruleset-enforcement opt-out, which skips the
|
|
6
|
+
// ruleset gate entirely rather than weakening any single rule.
|
|
7
|
+
|
|
8
|
+
import { labelIdentity } from './github-label-identity';
|
|
9
|
+
import {
|
|
10
|
+
ENFORCEMENT_OPT_OUT,
|
|
11
|
+
type GithubSettings,
|
|
12
|
+
isLabelDeclaration,
|
|
13
|
+
isNamedRuleset,
|
|
14
|
+
type ParsedGithubSettings,
|
|
15
|
+
} from './github-settings-parse';
|
|
16
|
+
|
|
17
|
+
const completeSettings = (
|
|
18
|
+
parsed: ParsedGithubSettings | null,
|
|
19
|
+
): GithubSettings | null => {
|
|
20
|
+
if (
|
|
21
|
+
parsed === null ||
|
|
22
|
+
parsed.repository === null ||
|
|
23
|
+
parsed.rulesets === null ||
|
|
24
|
+
parsed.labels === null ||
|
|
25
|
+
parsed.rulesetEnforcement === null
|
|
26
|
+
) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const rulesets = parsed.rulesets.filter(isNamedRuleset);
|
|
30
|
+
const labels = parsed.labels.filter(isLabelDeclaration);
|
|
31
|
+
if (
|
|
32
|
+
rulesets.length !== parsed.rulesets.length ||
|
|
33
|
+
labels.length !== parsed.labels.length
|
|
34
|
+
) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
repository: parsed.repository,
|
|
39
|
+
rulesets,
|
|
40
|
+
labels,
|
|
41
|
+
rulesetEnforcement: parsed.rulesetEnforcement,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const enforcementMergeProblems = (
|
|
46
|
+
local: ParsedGithubSettings | null,
|
|
47
|
+
): ReadonlyArray<string> => {
|
|
48
|
+
if (
|
|
49
|
+
local?.rulesetEnforcement !== ENFORCEMENT_OPT_OUT ||
|
|
50
|
+
local.rulesets === null ||
|
|
51
|
+
local.rulesets.length === 0
|
|
52
|
+
) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return [
|
|
56
|
+
`.github/settings.local.json declares additional rulesets while "rulesetEnforcement" is "${ENFORCEMENT_OPT_OUT}"; remove the rulesets or the opt-out`,
|
|
57
|
+
];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const repositoryMergeProblems = (
|
|
61
|
+
canonical: ParsedGithubSettings | null,
|
|
62
|
+
local: ParsedGithubSettings | null,
|
|
63
|
+
): ReadonlyArray<string> => {
|
|
64
|
+
if (
|
|
65
|
+
canonical === null ||
|
|
66
|
+
canonical.repository === null ||
|
|
67
|
+
local === null ||
|
|
68
|
+
local.repository === null
|
|
69
|
+
) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const canonicalRepository = canonical.repository;
|
|
73
|
+
return Object.keys(local.repository)
|
|
74
|
+
.filter((key) => key in canonicalRepository)
|
|
75
|
+
.map(
|
|
76
|
+
(key) =>
|
|
77
|
+
`.github/settings.local.json repository."${key}" would override a canonical value; canonical settings are read-only`,
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const rulesetMergeProblems = (
|
|
82
|
+
canonical: ParsedGithubSettings | null,
|
|
83
|
+
local: ParsedGithubSettings | null,
|
|
84
|
+
): ReadonlyArray<string> => {
|
|
85
|
+
if (
|
|
86
|
+
canonical === null ||
|
|
87
|
+
canonical.rulesets === null ||
|
|
88
|
+
local === null ||
|
|
89
|
+
local.rulesets === null
|
|
90
|
+
) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const canonicalNames = new Set(
|
|
94
|
+
canonical.rulesets.filter(isNamedRuleset).map((ruleset) => ruleset.name),
|
|
95
|
+
);
|
|
96
|
+
return local.rulesets
|
|
97
|
+
.filter(isNamedRuleset)
|
|
98
|
+
.filter((ruleset) => canonicalNames.has(ruleset.name))
|
|
99
|
+
.map(
|
|
100
|
+
(ruleset) =>
|
|
101
|
+
`.github/settings.local.json ruleset "${ruleset.name}" collides with a canonical ruleset; add a separately named ruleset to tighten further`,
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const labelMergeProblems = (
|
|
106
|
+
canonical: ParsedGithubSettings | null,
|
|
107
|
+
local: ParsedGithubSettings | null,
|
|
108
|
+
): ReadonlyArray<string> => {
|
|
109
|
+
if (
|
|
110
|
+
canonical === null ||
|
|
111
|
+
canonical.labels === null ||
|
|
112
|
+
local === null ||
|
|
113
|
+
local.labels === null
|
|
114
|
+
) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const canonicalNames = new Set(
|
|
118
|
+
canonical.labels
|
|
119
|
+
.filter(isLabelDeclaration)
|
|
120
|
+
.map((label) => labelIdentity(label.name)),
|
|
121
|
+
);
|
|
122
|
+
return local.labels
|
|
123
|
+
.filter(isLabelDeclaration)
|
|
124
|
+
.filter((label) => canonicalNames.has(labelIdentity(label.name)))
|
|
125
|
+
.map(
|
|
126
|
+
(label) =>
|
|
127
|
+
`.github/settings.local.json label "${label.name}" collides with a canonical label; canonical labels are read-only`,
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export type MergedSettings = {
|
|
132
|
+
readonly merged: GithubSettings | null;
|
|
133
|
+
readonly problems: ReadonlyArray<string>;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const mergeSettings = (
|
|
137
|
+
canonical: ParsedGithubSettings | null,
|
|
138
|
+
local: ParsedGithubSettings | null,
|
|
139
|
+
): MergedSettings => {
|
|
140
|
+
const problems = [
|
|
141
|
+
...enforcementMergeProblems(local),
|
|
142
|
+
...repositoryMergeProblems(canonical, local),
|
|
143
|
+
...rulesetMergeProblems(canonical, local),
|
|
144
|
+
...labelMergeProblems(canonical, local),
|
|
145
|
+
];
|
|
146
|
+
const completeCanonical = completeSettings(canonical);
|
|
147
|
+
const completeLocal = completeSettings(local);
|
|
148
|
+
if (
|
|
149
|
+
problems.length > 0 ||
|
|
150
|
+
completeCanonical === null ||
|
|
151
|
+
completeLocal === null
|
|
152
|
+
) {
|
|
153
|
+
return { merged: null, problems };
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
merged: {
|
|
157
|
+
repository: {
|
|
158
|
+
...completeCanonical.repository,
|
|
159
|
+
...completeLocal.repository,
|
|
160
|
+
},
|
|
161
|
+
rulesets: [...completeCanonical.rulesets, ...completeLocal.rulesets],
|
|
162
|
+
labels: [...completeCanonical.labels, ...completeLocal.labels],
|
|
163
|
+
rulesetEnforcement: completeLocal.rulesetEnforcement,
|
|
164
|
+
},
|
|
165
|
+
problems: [],
|
|
166
|
+
};
|
|
167
|
+
};
|