@davidvornholt/standards 0.2.0 → 0.3.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 CHANGED
@@ -1,14 +1,20 @@
1
1
  # @davidvornholt/standards
2
2
 
3
- CLI for bootstrapping, synchronizing, and validating repositories that consume
4
- [davidvornholt/standards](https://github.com/davidvornholt/standards).
3
+ CLI for bootstrapping, synchronizing, and validating repositories that consume [davidvornholt/standards](https://github.com/davidvornholt/standards).
5
4
 
6
5
  ```sh
7
6
  bunx @davidvornholt/standards init
8
7
  standards sync
9
8
  standards check
10
9
  standards doctor
10
+ standards github --check
11
+ standards github --apply
11
12
  ```
12
13
 
13
- See the standards repository README for the ownership model and adoption
14
- workflow.
14
+ `github --check` compares the live GitHub repository (merge settings and rulesets) against the merged declaration in `.github/settings.json` + `.github/settings.local.json`; `github --apply` converges the live repository to exactly the declared state. `check` runs the same comparison whenever `.github/settings.json` is present.
15
+
16
+ ## Configuration
17
+
18
+ - **`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` and private-repo reads need an authenticated token, and repo merge settings are only visible (and thus verifiable) to admin tokens — non-admin runs report them as unverifiable.
19
+
20
+ 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.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,12 @@
19
19
  "files": [
20
20
  "LICENSE",
21
21
  "README.md",
22
- "src/cli.ts"
22
+ "src/cli.ts",
23
+ "src/github-api.ts",
24
+ "src/github-apply.ts",
25
+ "src/github-commands.ts",
26
+ "src/github-diff.ts",
27
+ "src/github-settings.ts"
23
28
  ],
24
29
  "scripts": {
25
30
  "lint": "biome check --error-on-warnings .",
package/src/cli.ts CHANGED
@@ -32,6 +32,9 @@ import {
32
32
  sep,
33
33
  } from 'node:path';
34
34
  import process from 'node:process';
35
+ import { CANONICAL_SETTINGS_FILE, LOCAL_SETTINGS_FILE } from './github-api';
36
+ import { runGithubApply, runGithubCheck } from './github-commands';
37
+ import { loadGithubSettings } from './github-settings';
35
38
 
36
39
  const { YAML: BunYaml } = await import('bun');
37
40
 
@@ -71,13 +74,14 @@ type Source = {
71
74
  readonly cleanup: () => void;
72
75
  };
73
76
 
74
- type Command = 'check' | 'doctor' | 'init' | 'sync';
77
+ type Command = 'check' | 'doctor' | 'github' | 'init' | 'sync';
75
78
 
76
79
  type CliOptions = {
77
80
  readonly command: Command;
78
81
  readonly consumer: string;
79
82
  readonly dryRun: boolean;
80
83
  readonly from: string | undefined;
84
+ readonly apply: boolean;
81
85
  };
82
86
 
83
87
  const sha256 = (buf: Buffer): string =>
@@ -706,6 +710,20 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
706
710
  problems.push(...inspectPackageJson(packageRaw));
707
711
  }
708
712
 
713
+ // The GitHub settings seam only exists once the canonical declaration has
714
+ // been synced in; before that there is nothing to extend.
715
+ const canonicalSettings = await readTextIfPresent(
716
+ join(consumer, CANONICAL_SETTINGS_FILE),
717
+ );
718
+ if (canonicalSettings !== null) {
719
+ const localSettings = await readTextIfPresent(
720
+ join(consumer, LOCAL_SETTINGS_FILE),
721
+ );
722
+ problems.push(
723
+ ...loadGithubSettings(canonicalSettings, localSettings).problems,
724
+ );
725
+ }
726
+
709
727
  if (problems.length > 0) {
710
728
  console.error(
711
729
  `standards doctor: ${problems.length} integration problem(s):`,
@@ -718,7 +736,13 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
718
736
  };
719
737
 
720
738
  const commandFromArg = (arg: string): Command => {
721
- if (arg === 'check' || arg === 'doctor' || arg === 'init' || arg === 'sync') {
739
+ if (
740
+ arg === 'check' ||
741
+ arg === 'doctor' ||
742
+ arg === 'github' ||
743
+ arg === 'init' ||
744
+ arg === 'sync'
745
+ ) {
722
746
  return arg;
723
747
  }
724
748
  throw new Error(
@@ -749,12 +773,17 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
749
773
  let consumer = process.cwd();
750
774
  let dryRun = false;
751
775
  let from: string | undefined;
776
+ let checkFlag = false;
777
+ let apply = false;
752
778
 
753
779
  for (let index = 0; index < argv.length; index += 1) {
754
780
  const arg = argv[index];
755
781
  switch (arg) {
782
+ case '--apply':
783
+ apply = true;
784
+ break;
756
785
  case '--check':
757
- command = setCommand(command, 'check');
786
+ checkFlag = true;
758
787
  break;
759
788
  case '--dir':
760
789
  consumer = nextOptionValue(argv, index);
@@ -772,21 +801,100 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
772
801
  }
773
802
  }
774
803
 
804
+ // `--check` doubles as the legacy spelling of the check command and as the
805
+ // explicit (default) mode of `github`.
806
+ if (checkFlag && command !== 'github') {
807
+ command = setCommand(command, 'check');
808
+ }
809
+ if (apply && command !== 'github') {
810
+ throw new Error('--apply is only valid with the github command');
811
+ }
812
+ if (apply && checkFlag) {
813
+ throw new Error('github accepts exactly one of --check or --apply');
814
+ }
815
+
775
816
  return {
776
817
  command: command ?? 'sync',
777
818
  consumer: resolve(consumer),
778
819
  dryRun,
779
820
  from,
821
+ apply,
780
822
  };
781
823
  };
782
824
 
825
+ const runCheckCommand = async (consumer: string): Promise<boolean> => {
826
+ const driftIsClean = await runCheck(consumer);
827
+ const integrationIsValid = await runDoctor(consumer);
828
+ // The GitHub gate activates with the synced declaration and then fails
829
+ // closed: once .github/settings.json exists, an unreachable API or an
830
+ // unreadable origin is a failure, not a skip.
831
+ const githubIsConverged = existsSync(join(consumer, CANONICAL_SETTINGS_FILE))
832
+ ? await runGithubCheck(consumer)
833
+ : true;
834
+ return driftIsClean && integrationIsValid && githubIsConverged;
835
+ };
836
+
837
+ const runInitCommand = async (
838
+ consumer: string,
839
+ from: string | undefined,
840
+ ): Promise<void> => {
841
+ // Refuse before cloning upstream: re-initializing skips the lock, so it
842
+ // would silently overwrite local canonical edits and orphan files that
843
+ // upstream deleted (they leave the lock and no future sync removes them).
844
+ if (existsSync(join(consumer, 'sync-standards.lock'))) {
845
+ console.error(
846
+ 'standards: already initialized (sync-standards.lock exists). Use `just sync-standards` to update.',
847
+ );
848
+ process.exitCode = 1;
849
+ return;
850
+ }
851
+ const source = resolveSource(from ?? DEFAULT_UPSTREAM);
852
+ try {
853
+ const manifest = await loadManifest(
854
+ join(source.dir, 'sync-standards.json'),
855
+ );
856
+ await runInit(manifest, source, consumer);
857
+ } finally {
858
+ source.cleanup();
859
+ }
860
+ };
861
+
862
+ const runSyncCommand = async (
863
+ consumer: string,
864
+ from: string | undefined,
865
+ dryRun: boolean,
866
+ ): Promise<void> => {
867
+ const consumerManifest = await loadManifest(
868
+ join(consumer, 'sync-standards.json'),
869
+ );
870
+ const source = resolveSource(from ?? consumerManifest.upstream);
871
+ try {
872
+ const manifest = await loadManifest(
873
+ join(source.dir, 'sync-standards.json'),
874
+ );
875
+ await runSync(manifest, source, consumer, dryRun);
876
+ } finally {
877
+ source.cleanup();
878
+ }
879
+ };
880
+
783
881
  const main = async (): Promise<void> => {
784
- const { command, consumer, dryRun, from } = parseArgs(process.argv.slice(2));
882
+ const { command, consumer, dryRun, from, apply } = parseArgs(
883
+ process.argv.slice(2),
884
+ );
785
885
 
786
886
  if (command === 'check') {
787
- const driftIsClean = await runCheck(consumer);
788
- const integrationIsValid = await runDoctor(consumer);
789
- if (!(driftIsClean && integrationIsValid)) {
887
+ if (!(await runCheckCommand(consumer))) {
888
+ process.exitCode = 1;
889
+ }
890
+ return;
891
+ }
892
+
893
+ if (command === 'github') {
894
+ const converged = apply
895
+ ? await runGithubApply(consumer)
896
+ : await runGithubCheck(consumer);
897
+ if (!converged) {
790
898
  process.exitCode = 1;
791
899
  }
792
900
  return;
@@ -800,41 +908,12 @@ const main = async (): Promise<void> => {
800
908
  }
801
909
 
802
910
  if (command === 'init') {
803
- // Refuse before cloning upstream: re-initializing skips the lock, so it
804
- // would silently overwrite local canonical edits and orphan files that
805
- // upstream deleted (they leave the lock and no future sync removes them).
806
- if (existsSync(join(consumer, 'sync-standards.lock'))) {
807
- console.error(
808
- 'standards: already initialized (sync-standards.lock exists). Use `just sync-standards` to update.',
809
- );
810
- process.exitCode = 1;
811
- return;
812
- }
813
- const source = resolveSource(from ?? DEFAULT_UPSTREAM);
814
- try {
815
- const manifest = await loadManifest(
816
- join(source.dir, 'sync-standards.json'),
817
- );
818
- await runInit(manifest, source, consumer);
819
- } finally {
820
- source.cleanup();
821
- }
911
+ await runInitCommand(consumer, from);
822
912
  return;
823
913
  }
824
914
 
825
915
  if (command === 'sync') {
826
- const consumerManifest = await loadManifest(
827
- join(consumer, 'sync-standards.json'),
828
- );
829
- const source = resolveSource(from ?? consumerManifest.upstream);
830
- try {
831
- const manifest = await loadManifest(
832
- join(source.dir, 'sync-standards.json'),
833
- );
834
- await runSync(manifest, source, consumer, dryRun);
835
- } finally {
836
- source.cleanup();
837
- }
916
+ await runSyncCommand(consumer, from, dryRun);
838
917
  }
839
918
  };
840
919
 
@@ -0,0 +1,144 @@
1
+ // GitHub API plumbing for declarative repository settings: token and repo
2
+ // resolution, a minimal fetch wrapper, and shared loaders. The check/apply
3
+ // commands live in github-commands.ts.
4
+
5
+ import { execFileSync } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import process from 'node:process';
10
+ import {
11
+ isRecord,
12
+ type LoadedGithubSettings,
13
+ loadGithubSettings,
14
+ } from './github-settings';
15
+
16
+ const API_ROOT = 'https://api.github.com';
17
+
18
+ const GITHUB_REMOTE_PATTERN =
19
+ /github\.com[/:](?<repo>[^/]+\/[^/]+?)(?:\.git)?$/u;
20
+
21
+ export const HTTP_OK = 200;
22
+ export const HTTP_CREATED = 201;
23
+ export const HTTP_NO_CONTENT = 204;
24
+
25
+ export const CANONICAL_SETTINGS_FILE = '.github/settings.json';
26
+ export const LOCAL_SETTINGS_FILE = '.github/settings.local.json';
27
+
28
+ const quietExec = (
29
+ file: string,
30
+ args: ReadonlyArray<string>,
31
+ ): string | null => {
32
+ try {
33
+ const out = execFileSync(file, [...args], {
34
+ encoding: 'utf8',
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ }).trim();
37
+ return out.length > 0 ? out : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ };
42
+
43
+ // GH_TOKEN or GITHUB_TOKEN, else the local gh CLI. Null is still usable for
44
+ // reads on public repositories.
45
+ export const resolveToken = (): string | null => {
46
+ const fromEnv = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
47
+ if (fromEnv !== undefined && fromEnv.length > 0) {
48
+ return fromEnv;
49
+ }
50
+ return quietExec('gh', ['auth', 'token']);
51
+ };
52
+
53
+ export const resolveGithubRepo = (consumer: string): string | null => {
54
+ const url = quietExec('git', ['-C', consumer, 'remote', 'get-url', 'origin']);
55
+ return url?.match(GITHUB_REMOTE_PATTERN)?.groups?.repo ?? null;
56
+ };
57
+
58
+ export type ApiResponse = { readonly status: number; readonly body: unknown };
59
+
60
+ export const request = async (
61
+ token: string | null,
62
+ method: string,
63
+ path: string,
64
+ body?: unknown,
65
+ ): Promise<ApiResponse> => {
66
+ const response = await fetch(`${API_ROOT}${path}`, {
67
+ method,
68
+ headers: {
69
+ accept: 'application/vnd.github+json',
70
+ 'x-github-api-version': '2022-11-28',
71
+ ...(token === null ? {} : { authorization: `Bearer ${token}` }),
72
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
73
+ },
74
+ body: body === undefined ? undefined : JSON.stringify(body),
75
+ });
76
+ const text = await response.text();
77
+ let parsed: unknown = null;
78
+ try {
79
+ parsed = text.length === 0 ? null : (JSON.parse(text) as unknown);
80
+ } catch {
81
+ parsed = text;
82
+ }
83
+ return { status: response.status, body: parsed };
84
+ };
85
+
86
+ export const apiError = (context: string, response: ApiResponse): string => {
87
+ const message =
88
+ isRecord(response.body) && typeof response.body.message === 'string'
89
+ ? response.body.message
90
+ : 'unexpected response';
91
+ return `${context}: HTTP ${response.status} ${message}`;
92
+ };
93
+
94
+ export const loadDeclared = async (
95
+ consumer: string,
96
+ ): Promise<LoadedGithubSettings> => {
97
+ const canonicalPath = join(consumer, CANONICAL_SETTINGS_FILE);
98
+ if (!existsSync(canonicalPath)) {
99
+ return {
100
+ merged: null,
101
+ problems: [
102
+ `${CANONICAL_SETTINGS_FILE} not found; run \`just sync-standards\` first`,
103
+ ],
104
+ };
105
+ }
106
+ const localPath = join(consumer, LOCAL_SETTINGS_FILE);
107
+ return loadGithubSettings(
108
+ await readFile(canonicalPath, 'utf8'),
109
+ existsSync(localPath) ? await readFile(localPath, 'utf8') : null,
110
+ );
111
+ };
112
+
113
+ export type LiveRulesets = {
114
+ readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
115
+ readonly problem: string | null;
116
+ };
117
+
118
+ // Only repository-sourced rulesets are managed; org-level rulesets a consumer
119
+ // inherits are outside this declaration's authority.
120
+ export const fetchLiveRulesets = async (
121
+ token: string | null,
122
+ repo: string,
123
+ ): Promise<LiveRulesets> => {
124
+ const list = await request(token, 'GET', `/repos/${repo}/rulesets`);
125
+ if (list.status !== HTTP_OK || !Array.isArray(list.body)) {
126
+ return { rulesets: null, problem: apiError('listing rulesets', list) };
127
+ }
128
+ const repoOwned = list.body
129
+ .filter(isRecord)
130
+ .filter((ruleset) => ruleset.source_type === 'Repository');
131
+ const detailed = await Promise.all(
132
+ repoOwned.map((ruleset) =>
133
+ request(token, 'GET', `/repos/${repo}/rulesets/${ruleset.id}`),
134
+ ),
135
+ );
136
+ const failed = detailed.find((response) => response.status !== HTTP_OK);
137
+ if (failed !== undefined) {
138
+ return { rulesets: null, problem: apiError('reading a ruleset', failed) };
139
+ }
140
+ return {
141
+ rulesets: detailed.map((response) => response.body).filter(isRecord),
142
+ problem: null,
143
+ };
144
+ };
@@ -0,0 +1,99 @@
1
+ // Ruleset reconciliation for `standards github --apply`: create, update, and
2
+ // delete live rulesets so they converge on exactly the declared set. Mutations
3
+ // throw on the first API failure so a partial apply is reported, not hidden.
4
+
5
+ import {
6
+ apiError,
7
+ fetchLiveRulesets,
8
+ HTTP_CREATED,
9
+ HTTP_NO_CONTENT,
10
+ HTTP_OK,
11
+ request,
12
+ } from './github-api';
13
+ import { diffRuleset } from './github-diff';
14
+ import type { GithubSettings } from './github-settings';
15
+
16
+ const reconcileRuleset = async (
17
+ token: string,
18
+ repo: string,
19
+ ruleset: Readonly<Record<string, unknown>>,
20
+ liveRuleset: Readonly<Record<string, unknown>> | undefined,
21
+ ): Promise<string | null> => {
22
+ const name = String(ruleset.name);
23
+ if (liveRuleset === undefined) {
24
+ const created = await request(
25
+ token,
26
+ 'POST',
27
+ `/repos/${repo}/rulesets`,
28
+ ruleset,
29
+ );
30
+ if (created.status !== HTTP_CREATED) {
31
+ throw new Error(apiError(`creating ruleset "${name}"`, created));
32
+ }
33
+ return `created ruleset "${name}"`;
34
+ }
35
+ const diff = diffRuleset(ruleset, liveRuleset);
36
+ if (diff.drifted.length === 0 && diff.unverifiable.length === 0) {
37
+ return null;
38
+ }
39
+ const updated = await request(
40
+ token,
41
+ 'PUT',
42
+ `/repos/${repo}/rulesets/${liveRuleset.id}`,
43
+ ruleset,
44
+ );
45
+ if (updated.status !== HTTP_OK) {
46
+ throw new Error(apiError(`updating ruleset "${name}"`, updated));
47
+ }
48
+ return `updated ruleset "${name}"`;
49
+ };
50
+
51
+ const deleteRuleset = async (
52
+ token: string,
53
+ repo: string,
54
+ name: string,
55
+ liveRuleset: Readonly<Record<string, unknown>>,
56
+ ): Promise<string> => {
57
+ const deleted = await request(
58
+ token,
59
+ 'DELETE',
60
+ `/repos/${repo}/rulesets/${liveRuleset.id}`,
61
+ );
62
+ if (deleted.status !== HTTP_NO_CONTENT) {
63
+ throw new Error(apiError(`deleting ruleset "${name}"`, deleted));
64
+ }
65
+ return `deleted undeclared ruleset "${name}"`;
66
+ };
67
+
68
+ export const applyRulesets = async (
69
+ token: string,
70
+ repo: string,
71
+ declared: GithubSettings,
72
+ ): Promise<ReadonlyArray<string>> => {
73
+ const live = await fetchLiveRulesets(token, repo);
74
+ if (live.rulesets === null) {
75
+ throw new Error(live.problem ?? 'unable to read rulesets');
76
+ }
77
+ const liveByName = new Map(live.rulesets.map((r) => [String(r.name), r]));
78
+ const declaredNames = new Set(declared.rulesets.map((r) => String(r.name)));
79
+ const actions: Array<string> = [];
80
+ for (const ruleset of declared.rulesets) {
81
+ // biome-ignore lint/performance/noAwaitInLoops: GitHub advises against concurrent write requests (secondary rate limits); mutations run sequentially on purpose.
82
+ const action = await reconcileRuleset(
83
+ token,
84
+ repo,
85
+ ruleset,
86
+ liveByName.get(String(ruleset.name)),
87
+ );
88
+ if (action !== null) {
89
+ actions.push(action);
90
+ }
91
+ }
92
+ for (const [name, liveRuleset] of liveByName) {
93
+ if (!declaredNames.has(name)) {
94
+ // biome-ignore lint/performance/noAwaitInLoops: GitHub advises against concurrent write requests (secondary rate limits); mutations run sequentially on purpose.
95
+ actions.push(await deleteRuleset(token, repo, name, liveRuleset));
96
+ }
97
+ }
98
+ return actions;
99
+ };
@@ -0,0 +1,149 @@
1
+ // `standards github --check` verifies the live GitHub repository against the
2
+ // declared settings and fails closed on any drift or API error. `standards
3
+ // github --apply` converges the live repository; it needs an admin token, so
4
+ // it runs locally rather than in CI.
5
+
6
+ import {
7
+ apiError,
8
+ fetchLiveRulesets,
9
+ HTTP_OK,
10
+ loadDeclared,
11
+ request,
12
+ resolveGithubRepo,
13
+ resolveToken,
14
+ } from './github-api';
15
+ import { applyRulesets } from './github-apply';
16
+ import { diffRepositorySettings, diffRulesets } from './github-diff';
17
+ import { type GithubSettings, isRecord } from './github-settings';
18
+
19
+ const reportProblems = (problems: ReadonlyArray<string>): void => {
20
+ console.error(
21
+ `standards github: ${problems.length} problem(s) with declared GitHub settings:`,
22
+ );
23
+ console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
24
+ };
25
+
26
+ const collectLiveDrift = async (
27
+ consumer: string,
28
+ declared: GithubSettings,
29
+ ): Promise<ReadonlyArray<string>> => {
30
+ const repo = resolveGithubRepo(consumer);
31
+ if (repo === null) {
32
+ return ['cannot determine the GitHub repository from the origin remote'];
33
+ }
34
+ const token = resolveToken();
35
+ const problems: Array<string> = [];
36
+ try {
37
+ const repoResponse = await request(token, 'GET', `/repos/${repo}`);
38
+ if (repoResponse.status !== HTTP_OK || !isRecord(repoResponse.body)) {
39
+ problems.push(apiError(`reading repository ${repo}`, repoResponse));
40
+ } else {
41
+ const diff = diffRepositorySettings(
42
+ declared.repository,
43
+ repoResponse.body,
44
+ );
45
+ problems.push(...diff.drifted);
46
+ if (diff.unverifiable.length > 0) {
47
+ console.log(
48
+ `standards github: repository setting(s) not visible to this token, verify with admin auth: ${diff.unverifiable.join(', ')}`,
49
+ );
50
+ }
51
+ }
52
+ const live = await fetchLiveRulesets(token, repo);
53
+ if (live.rulesets === null) {
54
+ problems.push(live.problem ?? 'unable to read rulesets');
55
+ } else {
56
+ const rulesetDiff = diffRulesets(declared.rulesets, live.rulesets);
57
+ problems.push(...rulesetDiff.drifted);
58
+ if (rulesetDiff.unverifiable.length > 0) {
59
+ console.log(
60
+ `standards github: ruleset field(s) not visible to this token, verify with admin auth: ${rulesetDiff.unverifiable.join('; ')}`,
61
+ );
62
+ }
63
+ }
64
+ } catch (error) {
65
+ problems.push(
66
+ `GitHub API unreachable: ${error instanceof Error ? error.message : String(error)}`,
67
+ );
68
+ }
69
+ return problems;
70
+ };
71
+
72
+ export const runGithubCheck = async (consumer: string): Promise<boolean> => {
73
+ const declared = await loadDeclared(consumer);
74
+ const problems = [...declared.problems];
75
+ if (declared.merged !== null) {
76
+ problems.push(...(await collectLiveDrift(consumer, declared.merged)));
77
+ }
78
+ if (problems.length > 0) {
79
+ reportProblems(problems);
80
+ console.error(
81
+ 'Converge with `just sync-standards github --apply` (admin auth), or fix the declaration.',
82
+ );
83
+ return false;
84
+ }
85
+ console.log(
86
+ 'standards github: live GitHub settings match the declared configuration',
87
+ );
88
+ return true;
89
+ };
90
+
91
+ export const runGithubApply = async (consumer: string): Promise<boolean> => {
92
+ const declared = await loadDeclared(consumer);
93
+ if (declared.merged === null || declared.problems.length > 0) {
94
+ reportProblems(declared.problems);
95
+ return false;
96
+ }
97
+ const repo = resolveGithubRepo(consumer);
98
+ if (repo === null) {
99
+ console.error(
100
+ 'standards github: cannot determine the GitHub repository from the origin remote',
101
+ );
102
+ return false;
103
+ }
104
+ const token = resolveToken();
105
+ if (token === null) {
106
+ console.error(
107
+ 'standards github: apply needs an admin token; authenticate the gh CLI or set GH_TOKEN',
108
+ );
109
+ return false;
110
+ }
111
+ try {
112
+ const actions: Array<string> = [];
113
+ const repoResponse = await request(token, 'GET', `/repos/${repo}`);
114
+ if (repoResponse.status !== HTTP_OK || !isRecord(repoResponse.body)) {
115
+ throw new Error(apiError(`reading repository ${repo}`, repoResponse));
116
+ }
117
+ const diff = diffRepositorySettings(
118
+ declared.merged.repository,
119
+ repoResponse.body,
120
+ );
121
+ if (diff.drifted.length > 0 || diff.unverifiable.length > 0) {
122
+ const patched = await request(
123
+ token,
124
+ 'PATCH',
125
+ `/repos/${repo}`,
126
+ declared.merged.repository,
127
+ );
128
+ if (patched.status !== HTTP_OK) {
129
+ throw new Error(apiError('updating repository settings', patched));
130
+ }
131
+ actions.push('updated repository merge settings');
132
+ }
133
+ actions.push(...(await applyRulesets(token, repo, declared.merged)));
134
+ for (const action of actions) {
135
+ console.log(` ${action}`);
136
+ }
137
+ console.log(
138
+ actions.length === 0
139
+ ? 'standards github: already converged; no changes'
140
+ : `standards github: apply complete for ${repo}`,
141
+ );
142
+ return true;
143
+ } catch (error) {
144
+ console.error(
145
+ `standards github: ${error instanceof Error ? error.message : String(error)}`,
146
+ );
147
+ return false;
148
+ }
149
+ };
@@ -0,0 +1,172 @@
1
+ // Drift comparison between declared GitHub settings (github-settings.ts) and
2
+ // the live state returned by the GitHub API. Pure logic; no network.
3
+
4
+ import { isRecord } from './github-settings';
5
+
6
+ export type SettingsDiff = {
7
+ readonly drifted: ReadonlyArray<string>;
8
+ readonly unverifiable: ReadonlyArray<string>;
9
+ };
10
+
11
+ // Declared values must match live ones; keys GitHub adds to live objects are
12
+ // ignored so new API defaults do not read as drift. Arrays must have the same
13
+ // length, with each declared element matching a distinct live element — so an
14
+ // added bypass actor or required check is drift even when the declared list is
15
+ // a subset of the live one.
16
+ export const subsetMatches = (declared: unknown, live: unknown): boolean => {
17
+ if (Array.isArray(declared)) {
18
+ if (!Array.isArray(live) || declared.length !== live.length) {
19
+ return false;
20
+ }
21
+ const remaining = [...(live as ReadonlyArray<unknown>)];
22
+ return declared.every((value) => {
23
+ const index = remaining.findIndex((candidate) =>
24
+ subsetMatches(value, candidate),
25
+ );
26
+ if (index === -1) {
27
+ return false;
28
+ }
29
+ remaining.splice(index, 1);
30
+ return true;
31
+ });
32
+ }
33
+ if (isRecord(declared)) {
34
+ return (
35
+ isRecord(live) &&
36
+ Object.entries(declared).every(([key, value]) =>
37
+ subsetMatches(value, live[key]),
38
+ )
39
+ );
40
+ }
41
+ return declared === live;
42
+ };
43
+
44
+ const RULESET_COMPARED_KEYS = [
45
+ 'target',
46
+ 'enforcement',
47
+ 'conditions',
48
+ 'bypass_actors',
49
+ ] as const;
50
+
51
+ const diffRules = (
52
+ name: string,
53
+ declared: Readonly<Record<string, unknown>>,
54
+ live: Readonly<Record<string, unknown>>,
55
+ ): ReadonlyArray<string> => {
56
+ const drifted: Array<string> = [];
57
+ const declaredRules = Array.isArray(declared.rules)
58
+ ? declared.rules.filter(isRecord)
59
+ : [];
60
+ const liveRules = Array.isArray(live.rules)
61
+ ? live.rules.filter(isRecord)
62
+ : [];
63
+ const liveByType = new Map(
64
+ liveRules.map((rule) => [String(rule.type), rule]),
65
+ );
66
+ const declaredTypes = new Set(declaredRules.map((rule) => String(rule.type)));
67
+ for (const rule of declaredRules) {
68
+ const type = String(rule.type);
69
+ const liveRule = liveByType.get(type);
70
+ const declaredWithoutType = Object.fromEntries(
71
+ Object.entries(rule).filter(([key]) => key !== 'type'),
72
+ );
73
+ if (liveRule === undefined) {
74
+ drifted.push(`ruleset "${name}": missing rule "${type}"`);
75
+ } else if (!subsetMatches(declaredWithoutType, liveRule)) {
76
+ drifted.push(
77
+ `ruleset "${name}": rule "${type}" differs from the declared configuration`,
78
+ );
79
+ }
80
+ }
81
+ for (const type of liveByType.keys()) {
82
+ if (!declaredTypes.has(type)) {
83
+ drifted.push(`ruleset "${name}": has undeclared extra rule "${type}"`);
84
+ }
85
+ }
86
+ return drifted;
87
+ };
88
+
89
+ // Some ruleset fields — bypass_actors in particular — are only included in
90
+ // API responses for admin viewers. A declared key that is absent on the live
91
+ // side is unverifiable for this token, not drift: the same policy as
92
+ // repository merge settings, so a non-admin CI token does not fail the gate
93
+ // on state it cannot see.
94
+ export const diffRuleset = (
95
+ declared: Readonly<Record<string, unknown>>,
96
+ live: Readonly<Record<string, unknown>>,
97
+ ): SettingsDiff => {
98
+ const name = String(declared.name);
99
+ const drifted: Array<string> = [];
100
+ const unverifiable: Array<string> = [];
101
+ for (const key of RULESET_COMPARED_KEYS) {
102
+ if (declared[key] !== undefined) {
103
+ if (live[key] === undefined) {
104
+ unverifiable.push(`ruleset "${name}": ${key}`);
105
+ } else if (!subsetMatches(declared[key], live[key])) {
106
+ drifted.push(
107
+ `ruleset "${name}": ${key} differs from the declared configuration`,
108
+ );
109
+ }
110
+ }
111
+ }
112
+ drifted.push(...diffRules(name, declared, live));
113
+ return { drifted, unverifiable };
114
+ };
115
+
116
+ // Live rulesets must be exactly the declared set: additions, removals, and
117
+ // in-place edits are all drift.
118
+ export const diffRulesets = (
119
+ declared: ReadonlyArray<Readonly<Record<string, unknown>>>,
120
+ live: ReadonlyArray<Readonly<Record<string, unknown>>>,
121
+ ): SettingsDiff => {
122
+ const drifted: Array<string> = [];
123
+ const unverifiable: Array<string> = [];
124
+ const liveByName = new Map(
125
+ live.map((ruleset) => [String(ruleset.name), ruleset]),
126
+ );
127
+ const declaredNames = new Set(
128
+ declared.map((ruleset) => String(ruleset.name)),
129
+ );
130
+ for (const ruleset of declared) {
131
+ const liveRuleset = liveByName.get(String(ruleset.name));
132
+ if (liveRuleset === undefined) {
133
+ drifted.push(
134
+ `ruleset "${ruleset.name}" is declared but missing on GitHub`,
135
+ );
136
+ } else {
137
+ const diff = diffRuleset(ruleset, liveRuleset);
138
+ drifted.push(...diff.drifted);
139
+ unverifiable.push(...diff.unverifiable);
140
+ }
141
+ }
142
+ for (const name of liveByName.keys()) {
143
+ if (!declaredNames.has(name)) {
144
+ drifted.push(
145
+ `ruleset "${name}" exists on GitHub but is not declared; declare it in .github/settings.local.json or delete it`,
146
+ );
147
+ }
148
+ }
149
+ return { drifted, unverifiable };
150
+ };
151
+
152
+ // Repo merge settings are only visible to admin tokens; report invisible keys
153
+ // as unverifiable instead of drifted so a non-admin CI token does not fail the
154
+ // gate, while still surfacing that only a local admin check gives full
155
+ // coverage.
156
+ export const diffRepositorySettings = (
157
+ declared: Readonly<Record<string, unknown>>,
158
+ live: Readonly<Record<string, unknown>>,
159
+ ): SettingsDiff => {
160
+ const drifted: Array<string> = [];
161
+ const unverifiable: Array<string> = [];
162
+ for (const [key, value] of Object.entries(declared)) {
163
+ if (live[key] === undefined) {
164
+ unverifiable.push(key);
165
+ } else if (!subsetMatches(value, live[key])) {
166
+ drifted.push(
167
+ `repository setting "${key}" is ${JSON.stringify(live[key])} on GitHub, declared ${JSON.stringify(value)}`,
168
+ );
169
+ }
170
+ }
171
+ return { drifted, unverifiable };
172
+ };
@@ -0,0 +1,174 @@
1
+ // Declarative GitHub repository settings: parsing and seam merging for the
2
+ // canonical `.github/settings.json` and the repo-owned
3
+ // `.github/settings.local.json` extension. Pure logic only; drift comparison
4
+ // lives in github-diff.ts and the API interaction in github-api.ts. Like
5
+ // cli.ts, this module is zero-dependency so `bunx` can execute the published
6
+ // package.
7
+
8
+ export type GithubSettings = {
9
+ readonly repository: Readonly<Record<string, unknown>>;
10
+ readonly rulesets: ReadonlyArray<Readonly<Record<string, unknown>>>;
11
+ };
12
+
13
+ export type LoadedGithubSettings = {
14
+ readonly merged: GithubSettings | null;
15
+ readonly problems: ReadonlyArray<string>;
16
+ };
17
+
18
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
19
+ typeof value === 'object' && value !== null && !Array.isArray(value);
20
+
21
+ const SETTINGS_KEYS = new Set(['repository', 'rulesets']);
22
+
23
+ type ParseResult = {
24
+ readonly settings: GithubSettings | null;
25
+ readonly problems: ReadonlyArray<string>;
26
+ };
27
+
28
+ const rulesetListProblems = (
29
+ rulesets: ReadonlyArray<unknown>,
30
+ label: string,
31
+ ): ReadonlyArray<string> => {
32
+ const problems: Array<string> = [];
33
+ const names = new Set<string>();
34
+ for (const [index, ruleset] of rulesets.entries()) {
35
+ if (
36
+ !(
37
+ isRecord(ruleset) &&
38
+ typeof ruleset.name === 'string' &&
39
+ ruleset.name.length > 0
40
+ )
41
+ ) {
42
+ problems.push(
43
+ `${label} rulesets[${index}] must be an object with a non-empty "name"`,
44
+ );
45
+ } else if (names.has(ruleset.name)) {
46
+ problems.push(
47
+ `${label} declares ruleset "${ruleset.name}" more than once`,
48
+ );
49
+ } else {
50
+ names.add(ruleset.name);
51
+ }
52
+ }
53
+ return problems;
54
+ };
55
+
56
+ const parseSettings = (raw: unknown, label: string): ParseResult => {
57
+ if (!isRecord(raw)) {
58
+ return { settings: null, problems: [`${label} must be a JSON object`] };
59
+ }
60
+ const problems: Array<string> = [];
61
+ for (const key of Object.keys(raw)) {
62
+ if (!SETTINGS_KEYS.has(key)) {
63
+ problems.push(`${label} has unknown key "${key}"`);
64
+ }
65
+ }
66
+ const repository = raw.repository ?? {};
67
+ if (!isRecord(repository)) {
68
+ problems.push(`${label} "repository" must be an object`);
69
+ }
70
+ const rulesets = raw.rulesets ?? [];
71
+ if (Array.isArray(rulesets)) {
72
+ problems.push(...rulesetListProblems(rulesets, label));
73
+ } else {
74
+ problems.push(`${label} "rulesets" must be an array`);
75
+ }
76
+ if (problems.length > 0) {
77
+ return { settings: null, problems };
78
+ }
79
+ return {
80
+ settings: {
81
+ repository: repository as Record<string, unknown>,
82
+ rulesets: rulesets as ReadonlyArray<Record<string, unknown>>,
83
+ },
84
+ problems: [],
85
+ };
86
+ };
87
+
88
+ type MergeResult = {
89
+ readonly merged: GithubSettings | null;
90
+ readonly problems: ReadonlyArray<string>;
91
+ };
92
+
93
+ // The seam may only add. Overriding a canonical repository key or redefining a
94
+ // canonical ruleset could weaken the canonical floor; GitHub layers multiple
95
+ // rulesets strictest-wins, so adding a ruleset is always safe.
96
+ const mergeSettings = (
97
+ canonical: GithubSettings,
98
+ local: GithubSettings,
99
+ ): MergeResult => {
100
+ const problems: Array<string> = [];
101
+ for (const key of Object.keys(local.repository)) {
102
+ if (key in canonical.repository) {
103
+ problems.push(
104
+ `.github/settings.local.json repository."${key}" would override a canonical value; canonical settings are read-only`,
105
+ );
106
+ }
107
+ }
108
+ const canonicalNames = new Set(canonical.rulesets.map((r) => r.name));
109
+ for (const ruleset of local.rulesets) {
110
+ if (canonicalNames.has(ruleset.name)) {
111
+ problems.push(
112
+ `.github/settings.local.json ruleset "${ruleset.name}" collides with a canonical ruleset; add a separately named ruleset to tighten further`,
113
+ );
114
+ }
115
+ }
116
+ if (problems.length > 0) {
117
+ return { merged: null, problems };
118
+ }
119
+ return {
120
+ merged: {
121
+ repository: { ...canonical.repository, ...local.repository },
122
+ rulesets: [...canonical.rulesets, ...local.rulesets],
123
+ },
124
+ problems: [],
125
+ };
126
+ };
127
+
128
+ type JsonParse = {
129
+ readonly value: unknown;
130
+ readonly problem: string | null;
131
+ };
132
+
133
+ const parseJson = (raw: string, label: string): JsonParse => {
134
+ try {
135
+ return { value: JSON.parse(raw) as unknown, problem: null };
136
+ } catch {
137
+ return { value: null, problem: `${label} must contain valid JSON` };
138
+ }
139
+ };
140
+
141
+ // Parse both files and merge them, gathering every problem before failing.
142
+ export const loadGithubSettings = (
143
+ canonicalRaw: string,
144
+ localRaw: string | null,
145
+ ): LoadedGithubSettings => {
146
+ const problems: Array<string> = [];
147
+ const canonicalJson = parseJson(canonicalRaw, '.github/settings.json');
148
+ if (canonicalJson.problem !== null) {
149
+ problems.push(canonicalJson.problem);
150
+ }
151
+ if (localRaw === null) {
152
+ problems.push(
153
+ '.github/settings.local.json must exist; seed it with {"repository":{},"rulesets":[]}',
154
+ );
155
+ }
156
+ const localJson =
157
+ localRaw === null
158
+ ? null
159
+ : parseJson(localRaw, '.github/settings.local.json');
160
+ if (localJson !== null && localJson.problem !== null) {
161
+ problems.push(localJson.problem);
162
+ }
163
+ if (problems.length > 0) {
164
+ return { merged: null, problems };
165
+ }
166
+ const canonical = parseSettings(canonicalJson.value, '.github/settings.json');
167
+ const local = parseSettings(localJson?.value, '.github/settings.local.json');
168
+ problems.push(...canonical.problems, ...local.problems);
169
+ if (canonical.settings === null || local.settings === null) {
170
+ return { merged: null, problems };
171
+ }
172
+ const merged = mergeSettings(canonical.settings, local.settings);
173
+ return { merged: merged.merged, problems: [...problems, ...merged.problems] };
174
+ };