@davidvornholt/standards 0.2.0 → 0.4.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 +10 -4
- package/package.json +7 -2
- package/src/cli.ts +152 -41
- package/src/github-api.ts +144 -0
- package/src/github-apply.ts +99 -0
- package/src/github-commands.ts +149 -0
- package/src/github-diff.ts +172 -0
- package/src/github-settings.ts +174 -0
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
|
-
|
|
14
|
-
|
|
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.
|
|
3
|
+
"version": "0.4.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' | 'help' | 'init' | 'sync';
|
|
75
78
|
|
|
76
79
|
type CliOptions = {
|
|
77
|
-
readonly command: Command;
|
|
80
|
+
readonly command: Command | undefined;
|
|
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 =>
|
|
@@ -457,7 +461,7 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
457
461
|
);
|
|
458
462
|
console.error(problems.join('\n'));
|
|
459
463
|
console.error(
|
|
460
|
-
'These files are read-only. Restore them with `
|
|
464
|
+
'These files are read-only. Restore them with `bun standards sync`, or move your change upstream.',
|
|
461
465
|
);
|
|
462
466
|
return false;
|
|
463
467
|
}
|
|
@@ -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):`,
|
|
@@ -717,8 +735,32 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
717
735
|
return true;
|
|
718
736
|
};
|
|
719
737
|
|
|
738
|
+
const USAGE = `Usage: standards <command> [options]
|
|
739
|
+
|
|
740
|
+
Commands:
|
|
741
|
+
init Bootstrap a consumer repo: seed repo-owned files, mirror canonical files, write the lock
|
|
742
|
+
sync Mirror canonical files from upstream and rewrite the lock
|
|
743
|
+
check Verify canonical files, extension seams, and GitHub settings
|
|
744
|
+
doctor Validate extension seams only
|
|
745
|
+
github Compare (--check) or converge (--apply) live GitHub settings
|
|
746
|
+
help Show this help
|
|
747
|
+
|
|
748
|
+
Options:
|
|
749
|
+
--dir <path> Consumer directory to operate on (default: current directory)
|
|
750
|
+
--from <src> Upstream override for init/sync (GitHub repo or local path)
|
|
751
|
+
--dry-run Preview a sync without writing anything
|
|
752
|
+
--check With github: compare live settings to the declaration (default)
|
|
753
|
+
--apply With github: converge the live repository (needs admin auth)`;
|
|
754
|
+
|
|
720
755
|
const commandFromArg = (arg: string): Command => {
|
|
721
|
-
if (
|
|
756
|
+
if (
|
|
757
|
+
arg === 'check' ||
|
|
758
|
+
arg === 'doctor' ||
|
|
759
|
+
arg === 'github' ||
|
|
760
|
+
arg === 'help' ||
|
|
761
|
+
arg === 'init' ||
|
|
762
|
+
arg === 'sync'
|
|
763
|
+
) {
|
|
722
764
|
return arg;
|
|
723
765
|
}
|
|
724
766
|
throw new Error(
|
|
@@ -749,12 +791,17 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
749
791
|
let consumer = process.cwd();
|
|
750
792
|
let dryRun = false;
|
|
751
793
|
let from: string | undefined;
|
|
794
|
+
let checkFlag = false;
|
|
795
|
+
let apply = false;
|
|
752
796
|
|
|
753
797
|
for (let index = 0; index < argv.length; index += 1) {
|
|
754
798
|
const arg = argv[index];
|
|
755
799
|
switch (arg) {
|
|
800
|
+
case '--apply':
|
|
801
|
+
apply = true;
|
|
802
|
+
break;
|
|
756
803
|
case '--check':
|
|
757
|
-
|
|
804
|
+
checkFlag = true;
|
|
758
805
|
break;
|
|
759
806
|
case '--dir':
|
|
760
807
|
consumer = nextOptionValue(argv, index);
|
|
@@ -767,26 +814,119 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
767
814
|
from = nextOptionValue(argv, index);
|
|
768
815
|
index += 1;
|
|
769
816
|
break;
|
|
817
|
+
case '--help':
|
|
818
|
+
case '-h':
|
|
819
|
+
command = setCommand(command, 'help');
|
|
820
|
+
break;
|
|
770
821
|
default:
|
|
771
822
|
command = setCommand(command, commandFromArg(arg));
|
|
772
823
|
}
|
|
773
824
|
}
|
|
774
825
|
|
|
826
|
+
if (checkFlag && command !== 'github') {
|
|
827
|
+
throw new Error('--check is only valid with the github command');
|
|
828
|
+
}
|
|
829
|
+
if (apply && command !== 'github') {
|
|
830
|
+
throw new Error('--apply is only valid with the github command');
|
|
831
|
+
}
|
|
832
|
+
if (apply && checkFlag) {
|
|
833
|
+
throw new Error('github accepts exactly one of --check or --apply');
|
|
834
|
+
}
|
|
835
|
+
|
|
775
836
|
return {
|
|
776
|
-
command
|
|
837
|
+
command,
|
|
777
838
|
consumer: resolve(consumer),
|
|
778
839
|
dryRun,
|
|
779
840
|
from,
|
|
841
|
+
apply,
|
|
780
842
|
};
|
|
781
843
|
};
|
|
782
844
|
|
|
845
|
+
const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
846
|
+
const driftIsClean = await runCheck(consumer);
|
|
847
|
+
const integrationIsValid = await runDoctor(consumer);
|
|
848
|
+
// The GitHub gate activates with the synced declaration and then fails
|
|
849
|
+
// closed: once .github/settings.json exists, an unreachable API or an
|
|
850
|
+
// unreadable origin is a failure, not a skip.
|
|
851
|
+
const githubIsConverged = existsSync(join(consumer, CANONICAL_SETTINGS_FILE))
|
|
852
|
+
? await runGithubCheck(consumer)
|
|
853
|
+
: true;
|
|
854
|
+
return driftIsClean && integrationIsValid && githubIsConverged;
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const runInitCommand = async (
|
|
858
|
+
consumer: string,
|
|
859
|
+
from: string | undefined,
|
|
860
|
+
): Promise<void> => {
|
|
861
|
+
// Refuse before cloning upstream: re-initializing skips the lock, so it
|
|
862
|
+
// would silently overwrite local canonical edits and orphan files that
|
|
863
|
+
// upstream deleted (they leave the lock and no future sync removes them).
|
|
864
|
+
if (existsSync(join(consumer, 'sync-standards.lock'))) {
|
|
865
|
+
console.error(
|
|
866
|
+
'standards: already initialized (sync-standards.lock exists). Use `bun standards sync` to update.',
|
|
867
|
+
);
|
|
868
|
+
process.exitCode = 1;
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const source = resolveSource(from ?? DEFAULT_UPSTREAM);
|
|
872
|
+
try {
|
|
873
|
+
const manifest = await loadManifest(
|
|
874
|
+
join(source.dir, 'sync-standards.json'),
|
|
875
|
+
);
|
|
876
|
+
await runInit(manifest, source, consumer);
|
|
877
|
+
} finally {
|
|
878
|
+
source.cleanup();
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const runSyncCommand = async (
|
|
883
|
+
consumer: string,
|
|
884
|
+
from: string | undefined,
|
|
885
|
+
dryRun: boolean,
|
|
886
|
+
): Promise<void> => {
|
|
887
|
+
const consumerManifest = await loadManifest(
|
|
888
|
+
join(consumer, 'sync-standards.json'),
|
|
889
|
+
);
|
|
890
|
+
const source = resolveSource(from ?? consumerManifest.upstream);
|
|
891
|
+
try {
|
|
892
|
+
const manifest = await loadManifest(
|
|
893
|
+
join(source.dir, 'sync-standards.json'),
|
|
894
|
+
);
|
|
895
|
+
await runSync(manifest, source, consumer, dryRun);
|
|
896
|
+
} finally {
|
|
897
|
+
source.cleanup();
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
|
|
783
901
|
const main = async (): Promise<void> => {
|
|
784
|
-
const { command, consumer, dryRun, from } = parseArgs(
|
|
902
|
+
const { command, consumer, dryRun, from, apply } = parseArgs(
|
|
903
|
+
process.argv.slice(2),
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
if (command === undefined) {
|
|
907
|
+
console.error('standards: a command is required\n');
|
|
908
|
+
console.error(USAGE);
|
|
909
|
+
process.exitCode = 1;
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (command === 'help') {
|
|
914
|
+
console.log(USAGE);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
785
917
|
|
|
786
918
|
if (command === 'check') {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
919
|
+
if (!(await runCheckCommand(consumer))) {
|
|
920
|
+
process.exitCode = 1;
|
|
921
|
+
}
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (command === 'github') {
|
|
926
|
+
const converged = apply
|
|
927
|
+
? await runGithubApply(consumer)
|
|
928
|
+
: await runGithubCheck(consumer);
|
|
929
|
+
if (!converged) {
|
|
790
930
|
process.exitCode = 1;
|
|
791
931
|
}
|
|
792
932
|
return;
|
|
@@ -800,41 +940,12 @@ const main = async (): Promise<void> => {
|
|
|
800
940
|
}
|
|
801
941
|
|
|
802
942
|
if (command === 'init') {
|
|
803
|
-
|
|
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
|
-
}
|
|
943
|
+
await runInitCommand(consumer, from);
|
|
822
944
|
return;
|
|
823
945
|
}
|
|
824
946
|
|
|
825
947
|
if (command === 'sync') {
|
|
826
|
-
|
|
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
|
-
}
|
|
948
|
+
await runSyncCommand(consumer, from, dryRun);
|
|
838
949
|
}
|
|
839
950
|
};
|
|
840
951
|
|
|
@@ -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 \`bun standards sync\` 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 `bun 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
|
+
};
|