@davidvornholt/standards 0.1.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 +10 -4
- package/package.json +17 -3
- package/src/cli.ts +301 -38
- 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,8 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/davidvornholt/standards.git",
|
|
9
|
+
"directory": "packages/standards-cli"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/davidvornholt/standards#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/davidvornholt/standards/issues"
|
|
14
|
+
},
|
|
6
15
|
"type": "module",
|
|
7
16
|
"bin": {
|
|
8
17
|
"standards": "src/cli.ts"
|
|
@@ -10,7 +19,12 @@
|
|
|
10
19
|
"files": [
|
|
11
20
|
"LICENSE",
|
|
12
21
|
"README.md",
|
|
13
|
-
"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"
|
|
14
28
|
],
|
|
15
29
|
"scripts": {
|
|
16
30
|
"lint": "biome check --error-on-warnings .",
|
|
@@ -19,7 +33,7 @@
|
|
|
19
33
|
"test": "bun test"
|
|
20
34
|
},
|
|
21
35
|
"devDependencies": {
|
|
22
|
-
"@davidvornholt/typescript-config": "
|
|
36
|
+
"@davidvornholt/typescript-config": "0.0.0",
|
|
23
37
|
"@types/bun": "^1.3.14",
|
|
24
38
|
"typescript": "7.0.2"
|
|
25
39
|
},
|
package/src/cli.ts
CHANGED
|
@@ -32,6 +32,11 @@ 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';
|
|
38
|
+
|
|
39
|
+
const { YAML: BunYaml } = await import('bun');
|
|
35
40
|
|
|
36
41
|
const DEFAULT_UPSTREAM = 'github:davidvornholt/standards';
|
|
37
42
|
|
|
@@ -69,13 +74,14 @@ type Source = {
|
|
|
69
74
|
readonly cleanup: () => void;
|
|
70
75
|
};
|
|
71
76
|
|
|
72
|
-
type Command = 'check' | 'doctor' | 'init' | 'sync';
|
|
77
|
+
type Command = 'check' | 'doctor' | 'github' | 'init' | 'sync';
|
|
73
78
|
|
|
74
79
|
type CliOptions = {
|
|
75
80
|
readonly command: Command;
|
|
76
81
|
readonly consumer: string;
|
|
77
82
|
readonly dryRun: boolean;
|
|
78
83
|
readonly from: string | undefined;
|
|
84
|
+
readonly apply: boolean;
|
|
79
85
|
};
|
|
80
86
|
|
|
81
87
|
const sha256 = (buf: Buffer): string =>
|
|
@@ -477,6 +483,179 @@ const hasStandardsImport = (justfile: string): boolean =>
|
|
|
477
483
|
);
|
|
478
484
|
});
|
|
479
485
|
|
|
486
|
+
const DEPENDABOT_BASELINE_ECOSYSTEMS = ['bun', 'github-actions'] as const;
|
|
487
|
+
const DEPENDABOT_SCHEDULE_INTERVALS = new Set([
|
|
488
|
+
'daily',
|
|
489
|
+
'weekly',
|
|
490
|
+
'monthly',
|
|
491
|
+
'quarterly',
|
|
492
|
+
'semiannually',
|
|
493
|
+
'yearly',
|
|
494
|
+
'cron',
|
|
495
|
+
]);
|
|
496
|
+
|
|
497
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
498
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
499
|
+
|
|
500
|
+
const isNonEmptyString = (value: unknown): value is string =>
|
|
501
|
+
typeof value === 'string' && value.length > 0;
|
|
502
|
+
|
|
503
|
+
type DependabotUpdateInspection = {
|
|
504
|
+
readonly problems: ReadonlyArray<string>;
|
|
505
|
+
readonly rootEcosystem: string | null;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const inspectDependabotSchedule = (
|
|
509
|
+
schedule: unknown,
|
|
510
|
+
label: string,
|
|
511
|
+
): ReadonlyArray<string> => {
|
|
512
|
+
if (!(isRecord(schedule) && isNonEmptyString(schedule.interval))) {
|
|
513
|
+
return [`${label} must define schedule.interval`];
|
|
514
|
+
}
|
|
515
|
+
if (!DEPENDABOT_SCHEDULE_INTERVALS.has(schedule.interval)) {
|
|
516
|
+
return [`${label} has an unsupported schedule.interval`];
|
|
517
|
+
}
|
|
518
|
+
if (schedule.interval === 'cron' && !isNonEmptyString(schedule.cronjob)) {
|
|
519
|
+
return [`${label} must define schedule.cronjob for a cron interval`];
|
|
520
|
+
}
|
|
521
|
+
return [];
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
type DependabotGroupInspection = {
|
|
525
|
+
readonly problems: ReadonlyArray<string>;
|
|
526
|
+
readonly scheduledGroups: ReadonlySet<string>;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const inspectDependabotGroups = (
|
|
530
|
+
groups: unknown,
|
|
531
|
+
): DependabotGroupInspection => {
|
|
532
|
+
if (groups === undefined) {
|
|
533
|
+
return { problems: [], scheduledGroups: new Set() };
|
|
534
|
+
}
|
|
535
|
+
if (!isRecord(groups)) {
|
|
536
|
+
return {
|
|
537
|
+
problems: [
|
|
538
|
+
'.github/dependabot.yml multi-ecosystem-groups must be a mapping',
|
|
539
|
+
],
|
|
540
|
+
scheduledGroups: new Set(),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const problems: Array<string> = [];
|
|
545
|
+
const scheduledGroups = new Set<string>();
|
|
546
|
+
for (const [name, group] of Object.entries(groups)) {
|
|
547
|
+
const label = `.github/dependabot.yml multi-ecosystem-groups.${name}`;
|
|
548
|
+
const groupProblems = isRecord(group)
|
|
549
|
+
? inspectDependabotSchedule(group.schedule, label)
|
|
550
|
+
: [`${label} must be a mapping`];
|
|
551
|
+
problems.push(...groupProblems);
|
|
552
|
+
if (groupProblems.length === 0) {
|
|
553
|
+
scheduledGroups.add(name);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return { problems, scheduledGroups };
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const inspectDependabotUpdate = (
|
|
560
|
+
update: unknown,
|
|
561
|
+
index: number,
|
|
562
|
+
scheduledGroups: ReadonlySet<string>,
|
|
563
|
+
): DependabotUpdateInspection => {
|
|
564
|
+
const label = `.github/dependabot.yml updates[${index}]`;
|
|
565
|
+
if (!isRecord(update)) {
|
|
566
|
+
return { problems: [`${label} must be a mapping`], rootEcosystem: null };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const {
|
|
570
|
+
directory,
|
|
571
|
+
directories,
|
|
572
|
+
schedule,
|
|
573
|
+
'multi-ecosystem-group': multiEcosystemGroup,
|
|
574
|
+
'package-ecosystem': ecosystem,
|
|
575
|
+
} = update;
|
|
576
|
+
const problems: Array<string> = [];
|
|
577
|
+
if (!isNonEmptyString(ecosystem)) {
|
|
578
|
+
problems.push(`${label} must define package-ecosystem`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const hasDirectory = isNonEmptyString(directory);
|
|
582
|
+
const hasDirectories =
|
|
583
|
+
Array.isArray(directories) &&
|
|
584
|
+
directories.length > 0 &&
|
|
585
|
+
directories.every(isNonEmptyString);
|
|
586
|
+
if (hasDirectory === hasDirectories) {
|
|
587
|
+
problems.push(
|
|
588
|
+
`${label} must define exactly one of directory or directories`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (schedule === undefined && isNonEmptyString(multiEcosystemGroup)) {
|
|
593
|
+
if (!scheduledGroups.has(multiEcosystemGroup)) {
|
|
594
|
+
problems.push(
|
|
595
|
+
`${label} must reference a scheduled multi-ecosystem group`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
problems.push(...inspectDependabotSchedule(schedule, label));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const targetsRoot =
|
|
603
|
+
directory === '/' ||
|
|
604
|
+
(Array.isArray(directories) && directories.includes('/'));
|
|
605
|
+
return {
|
|
606
|
+
problems,
|
|
607
|
+
rootEcosystem:
|
|
608
|
+
isNonEmptyString(ecosystem) && targetsRoot ? ecosystem : null,
|
|
609
|
+
};
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const inspectDependabot = (raw: string): ReadonlyArray<string> => {
|
|
613
|
+
const problems: Array<string> = [];
|
|
614
|
+
let config: unknown;
|
|
615
|
+
try {
|
|
616
|
+
config = BunYaml.parse(raw);
|
|
617
|
+
} catch {
|
|
618
|
+
return ['.github/dependabot.yml must contain valid YAML'];
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (!isRecord(config)) {
|
|
622
|
+
return ['.github/dependabot.yml must contain a YAML mapping'];
|
|
623
|
+
}
|
|
624
|
+
if (config.version !== 2) {
|
|
625
|
+
problems.push('.github/dependabot.yml must use version: 2');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const { updates, 'multi-ecosystem-groups': multiEcosystemGroups } = config;
|
|
629
|
+
if (!Array.isArray(updates)) {
|
|
630
|
+
problems.push('.github/dependabot.yml must define an updates list');
|
|
631
|
+
return problems;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const groupInspection = inspectDependabotGroups(multiEcosystemGroups);
|
|
635
|
+
problems.push(...groupInspection.problems);
|
|
636
|
+
const rootEcosystems = new Set<string>();
|
|
637
|
+
for (const [index, update] of updates.entries()) {
|
|
638
|
+
const inspection = inspectDependabotUpdate(
|
|
639
|
+
update,
|
|
640
|
+
index,
|
|
641
|
+
groupInspection.scheduledGroups,
|
|
642
|
+
);
|
|
643
|
+
problems.push(...inspection.problems);
|
|
644
|
+
if (inspection.rootEcosystem !== null) {
|
|
645
|
+
rootEcosystems.add(inspection.rootEcosystem);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
for (const ecosystem of DEPENDABOT_BASELINE_ECOSYSTEMS) {
|
|
650
|
+
if (!rootEcosystems.has(ecosystem)) {
|
|
651
|
+
problems.push(
|
|
652
|
+
`.github/dependabot.yml must include a root-directory ${ecosystem} ecosystem`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return problems;
|
|
657
|
+
};
|
|
658
|
+
|
|
480
659
|
const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
|
|
481
660
|
const problems: Array<string> = [];
|
|
482
661
|
const packageJson = JSON.parse(packageRaw) as Record<string, unknown>;
|
|
@@ -514,6 +693,15 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
514
693
|
problems.push('AGENTS.local.md must exist for project-specific guidance');
|
|
515
694
|
}
|
|
516
695
|
|
|
696
|
+
const dependabot = await readTextIfPresent(
|
|
697
|
+
join(consumer, '.github/dependabot.yml'),
|
|
698
|
+
);
|
|
699
|
+
if (dependabot === null) {
|
|
700
|
+
problems.push('.github/dependabot.yml must exist');
|
|
701
|
+
} else {
|
|
702
|
+
problems.push(...inspectDependabot(dependabot));
|
|
703
|
+
}
|
|
704
|
+
|
|
517
705
|
const packagePath = join(consumer, 'package.json');
|
|
518
706
|
const packageRaw = await readTextIfPresent(packagePath);
|
|
519
707
|
if (packageRaw === null) {
|
|
@@ -522,6 +710,20 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
522
710
|
problems.push(...inspectPackageJson(packageRaw));
|
|
523
711
|
}
|
|
524
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
|
+
|
|
525
727
|
if (problems.length > 0) {
|
|
526
728
|
console.error(
|
|
527
729
|
`standards doctor: ${problems.length} integration problem(s):`,
|
|
@@ -534,7 +736,13 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
534
736
|
};
|
|
535
737
|
|
|
536
738
|
const commandFromArg = (arg: string): Command => {
|
|
537
|
-
if (
|
|
739
|
+
if (
|
|
740
|
+
arg === 'check' ||
|
|
741
|
+
arg === 'doctor' ||
|
|
742
|
+
arg === 'github' ||
|
|
743
|
+
arg === 'init' ||
|
|
744
|
+
arg === 'sync'
|
|
745
|
+
) {
|
|
538
746
|
return arg;
|
|
539
747
|
}
|
|
540
748
|
throw new Error(
|
|
@@ -565,12 +773,17 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
565
773
|
let consumer = process.cwd();
|
|
566
774
|
let dryRun = false;
|
|
567
775
|
let from: string | undefined;
|
|
776
|
+
let checkFlag = false;
|
|
777
|
+
let apply = false;
|
|
568
778
|
|
|
569
779
|
for (let index = 0; index < argv.length; index += 1) {
|
|
570
780
|
const arg = argv[index];
|
|
571
781
|
switch (arg) {
|
|
782
|
+
case '--apply':
|
|
783
|
+
apply = true;
|
|
784
|
+
break;
|
|
572
785
|
case '--check':
|
|
573
|
-
|
|
786
|
+
checkFlag = true;
|
|
574
787
|
break;
|
|
575
788
|
case '--dir':
|
|
576
789
|
consumer = nextOptionValue(argv, index);
|
|
@@ -588,21 +801,100 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
588
801
|
}
|
|
589
802
|
}
|
|
590
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
|
+
|
|
591
816
|
return {
|
|
592
817
|
command: command ?? 'sync',
|
|
593
818
|
consumer: resolve(consumer),
|
|
594
819
|
dryRun,
|
|
595
820
|
from,
|
|
821
|
+
apply,
|
|
596
822
|
};
|
|
597
823
|
};
|
|
598
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
|
+
|
|
599
881
|
const main = async (): Promise<void> => {
|
|
600
|
-
const { command, consumer, dryRun, from } = parseArgs(
|
|
882
|
+
const { command, consumer, dryRun, from, apply } = parseArgs(
|
|
883
|
+
process.argv.slice(2),
|
|
884
|
+
);
|
|
601
885
|
|
|
602
886
|
if (command === 'check') {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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) {
|
|
606
898
|
process.exitCode = 1;
|
|
607
899
|
}
|
|
608
900
|
return;
|
|
@@ -616,41 +908,12 @@ const main = async (): Promise<void> => {
|
|
|
616
908
|
}
|
|
617
909
|
|
|
618
910
|
if (command === 'init') {
|
|
619
|
-
|
|
620
|
-
// would silently overwrite local canonical edits and orphan files that
|
|
621
|
-
// upstream deleted (they leave the lock and no future sync removes them).
|
|
622
|
-
if (existsSync(join(consumer, 'sync-standards.lock'))) {
|
|
623
|
-
console.error(
|
|
624
|
-
'standards: already initialized (sync-standards.lock exists). Use `just sync-standards` to update.',
|
|
625
|
-
);
|
|
626
|
-
process.exitCode = 1;
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
const source = resolveSource(from ?? DEFAULT_UPSTREAM);
|
|
630
|
-
try {
|
|
631
|
-
const manifest = await loadManifest(
|
|
632
|
-
join(source.dir, 'sync-standards.json'),
|
|
633
|
-
);
|
|
634
|
-
await runInit(manifest, source, consumer);
|
|
635
|
-
} finally {
|
|
636
|
-
source.cleanup();
|
|
637
|
-
}
|
|
911
|
+
await runInitCommand(consumer, from);
|
|
638
912
|
return;
|
|
639
913
|
}
|
|
640
914
|
|
|
641
915
|
if (command === 'sync') {
|
|
642
|
-
|
|
643
|
-
join(consumer, 'sync-standards.json'),
|
|
644
|
-
);
|
|
645
|
-
const source = resolveSource(from ?? consumerManifest.upstream);
|
|
646
|
-
try {
|
|
647
|
-
const manifest = await loadManifest(
|
|
648
|
-
join(source.dir, 'sync-standards.json'),
|
|
649
|
-
);
|
|
650
|
-
await runSync(manifest, source, consumer, dryRun);
|
|
651
|
-
} finally {
|
|
652
|
-
source.cleanup();
|
|
653
|
-
}
|
|
916
|
+
await runSyncCommand(consumer, from, dryRun);
|
|
654
917
|
}
|
|
655
918
|
};
|
|
656
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
|
+
};
|