@davidvornholt/standards 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/github-api.ts CHANGED
@@ -112,83 +112,3 @@ export const loadDeclared = async (
112
112
  existsSync(localPath) ? await readFile(localPath, 'utf8') : null,
113
113
  );
114
114
  };
115
-
116
- const GRAPHQL_MERGE_FIELDS: ReadonlyMap<string, string> = new Map([
117
- ['allow_auto_merge', 'autoMergeAllowed'],
118
- ['allow_merge_commit', 'mergeCommitAllowed'],
119
- ['allow_rebase_merge', 'rebaseMergeAllowed'],
120
- ['allow_squash_merge', 'squashMergeAllowed'],
121
- ['delete_branch_on_merge', 'deleteBranchOnMerge'],
122
- ['merge_commit_message', 'mergeCommitMessage'],
123
- ['merge_commit_title', 'mergeCommitTitle'],
124
- ['squash_merge_commit_message', 'squashMergeCommitMessage'],
125
- ['squash_merge_commit_title', 'squashMergeCommitTitle'],
126
- ]);
127
-
128
- // REST omits repository merge settings for read-only viewers (they surface
129
- // only with write access — community discussion #153258), but GraphQL serves
130
- // the same values, with identical enum spellings, to any token that can read
131
- // the repository. This keeps a read-only PAT sufficient for the check.
132
- // Returns only the keys GraphQL answered; the rest stay unverifiable.
133
- export const fetchMergeSettingsViaGraphql = async (
134
- token: string | null,
135
- repo: string,
136
- keys: ReadonlyArray<string>,
137
- ): Promise<Readonly<Record<string, unknown>>> => {
138
- const mapped = keys.filter((key) => GRAPHQL_MERGE_FIELDS.has(key));
139
- const [owner, name] = repo.split('/');
140
- if (mapped.length === 0 || owner === undefined || name === undefined) {
141
- return {};
142
- }
143
- const fields = mapped.map((key) => GRAPHQL_MERGE_FIELDS.get(key)).join(' ');
144
- const response = await request(token, 'POST', '/graphql', {
145
- query: `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { ${fields} } }`,
146
- });
147
- if (response.status !== HTTP_OK || !isRecord(response.body)) {
148
- return {};
149
- }
150
- const data = isRecord(response.body.data) ? response.body.data : null;
151
- const repository =
152
- data !== null && isRecord(data.repository) ? data.repository : null;
153
- if (repository === null) {
154
- return {};
155
- }
156
- return Object.fromEntries(
157
- mapped
158
- .map((key) => [key, repository[GRAPHQL_MERGE_FIELDS.get(key) ?? '']])
159
- .filter(([, value]) => value !== undefined && value !== null),
160
- );
161
- };
162
-
163
- export type LiveRulesets = {
164
- readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
165
- readonly problem: string | null;
166
- };
167
-
168
- // Only repository-sourced rulesets are managed; org-level rulesets a consumer
169
- // inherits are outside this declaration's authority.
170
- export const fetchLiveRulesets = async (
171
- token: string | null,
172
- repo: string,
173
- ): Promise<LiveRulesets> => {
174
- const list = await request(token, 'GET', `/repos/${repo}/rulesets`);
175
- if (list.status !== HTTP_OK || !Array.isArray(list.body)) {
176
- return { rulesets: null, problem: apiError('listing rulesets', list) };
177
- }
178
- const repoOwned = list.body
179
- .filter(isRecord)
180
- .filter((ruleset) => ruleset.source_type === 'Repository');
181
- const detailed = await Promise.all(
182
- repoOwned.map((ruleset) =>
183
- request(token, 'GET', `/repos/${repo}/rulesets/${ruleset.id}`),
184
- ),
185
- );
186
- const failed = detailed.find((response) => response.status !== HTTP_OK);
187
- if (failed !== undefined) {
188
- return { rulesets: null, problem: apiError('reading a ruleset', failed) };
189
- }
190
- return {
191
- rulesets: detailed.map((response) => response.body).filter(isRecord),
192
- problem: null,
193
- };
194
- };
@@ -4,13 +4,14 @@
4
4
 
5
5
  import {
6
6
  apiError,
7
- fetchLiveRulesets,
8
7
  HTTP_CREATED,
9
8
  HTTP_NO_CONTENT,
10
9
  HTTP_OK,
11
10
  request,
12
11
  } from './github-api';
13
- import { diffRepositorySettings, diffRuleset } from './github-diff';
12
+ import { diffRepositorySettings } from './github-diff';
13
+ import { fetchLiveRulesets } from './github-ruleset-api';
14
+ import { diffRuleset } from './github-ruleset-diff';
14
15
  import { type GithubSettings, isRecord } from './github-settings-parse';
15
16
 
16
17
  // GitHub answers a PATCH containing a plan-unavailable setting with HTTP 200
@@ -0,0 +1,56 @@
1
+ // Reconciles ruleset bypass-actor lists REST hides, using the counts GraphQL
2
+ // answers for tokens that cannot read the actors themselves. Pure logic; the
3
+ // GraphQL call lives in github-graphql.ts.
4
+
5
+ import { BYPASS_ACTORS_KEY } from './github-ruleset-diff';
6
+
7
+ // Stand-ins for bypass actors GraphQL counted but would not name. They exist
8
+ // only to carry the count into the ordinary length comparison, and are attached
9
+ // exactly when the count and the declared length disagree or both are zero — so
10
+ // their content is never observed: subsetMatches short-circuits at the length
11
+ // check, two empty lists compare no element, and a declared ruleset with no
12
+ // bypass_actors array never reaches that comparison. Nothing else looks inside
13
+ // them; bypassActorCountDetail reads only `.length`, and github-live-drift.ts
14
+ // is the sole importer, so a stand-in cannot reach a PATCH body. The guarantee
15
+ // is not local: it also relies on declared ruleset names being unique, which
16
+ // github-settings-parse.ts and github-settings-merge.ts enforce.
17
+ const unreadableBypassActors = (count: number): ReadonlyArray<unknown> =>
18
+ Array.from({ length: count }, (_unused, index) => ({
19
+ unreadableBypassActor: index,
20
+ }));
21
+
22
+ // A count of zero pins the live list to the empty list exactly. A count that
23
+ // disagrees with the declared length is drift no identity could explain, so
24
+ // that many unreadable actors stand in and the ordinary comparison reports it.
25
+ // A matching non-zero count proves nothing about *which* actors bypass, so the
26
+ // field stays hidden and the gate fails closed on it. A list REST already
27
+ // answered is authoritative and never replaced.
28
+ //
29
+ // The counts are joined by ruleset id, the one identifier both APIs agree on:
30
+ // names are not unique on the live side, so joining on them could pin one
31
+ // ruleset's list from another ruleset's count. A live ruleset carrying no
32
+ // numeric id gets no count and stays hidden.
33
+ export const resolveHiddenBypassActors = (
34
+ declared: ReadonlyArray<Readonly<Record<string, unknown>>>,
35
+ live: ReadonlyArray<Readonly<Record<string, unknown>>>,
36
+ counts: ReadonlyMap<number, number>,
37
+ ): ReadonlyArray<Readonly<Record<string, unknown>>> => {
38
+ const declaredCounts = new Map(
39
+ declared
40
+ .filter((ruleset) => Array.isArray(ruleset[BYPASS_ACTORS_KEY]))
41
+ .map((ruleset) => [
42
+ String(ruleset.name),
43
+ (ruleset[BYPASS_ACTORS_KEY] as ReadonlyArray<unknown>).length,
44
+ ]),
45
+ );
46
+ return live.map((ruleset) => {
47
+ const count =
48
+ typeof ruleset.id === 'number' ? counts.get(ruleset.id) : undefined;
49
+ if (ruleset[BYPASS_ACTORS_KEY] !== undefined || count === undefined) {
50
+ return ruleset;
51
+ }
52
+ return count > 0 && count === declaredCounts.get(String(ruleset.name))
53
+ ? ruleset
54
+ : { ...ruleset, [BYPASS_ACTORS_KEY]: unreadableBypassActors(count) };
55
+ });
56
+ };
@@ -32,14 +32,21 @@ export const optOutEligibilityProblem = (
32
32
  ? `.github/settings.local.json "rulesetEnforcement" may only be declared for a private repository; ${repo} is public`
33
33
  : null;
34
34
 
35
+ export const ADMIN_VISIBILITY_ADVICE =
36
+ 'Use a token with read access to repository administration (in CI: ci.github_settings_read_token in secrets/ci.yaml), or verify locally with admin auth';
37
+
35
38
  // Declared state the token cannot see is a gate failure, not a pass with a
36
- // log line: a gate that cannot perform its comparison fails closed.
39
+ // log line: a gate that cannot perform its comparison fails closed. The advice
40
+ // is the caller's, because not every invisible field has the same remedy —
41
+ // ruleset bypass actors in particular are unreachable over REST for an
42
+ // installation token at any permission level.
37
43
  export const unverifiableProblem = (
38
44
  scope: string,
39
45
  items: ReadonlyArray<string>,
46
+ advice: string,
40
47
  ): ReadonlyArray<string> =>
41
48
  items.length === 0
42
49
  ? []
43
50
  : [
44
- `${scope} not visible to this token, so the gate cannot verify: ${items.join('; ')}. Use a token with read access to repository administration (in CI: ci.github_settings_read_token in secrets/ci.yaml), or verify locally with admin auth`,
51
+ `${scope} not visible to this token, so the gate cannot verify: ${items.join('; ')}. ${advice}`,
45
52
  ];
@@ -1,5 +1,6 @@
1
1
  // Drift comparison between declared GitHub settings (github-settings.ts) and
2
- // the live state returned by the GitHub API. Pure logic; no network.
2
+ // the live state returned by the GitHub API. Pure logic; no network. Ruleset
3
+ // comparison lives in github-ruleset-diff.ts.
3
4
 
4
5
  import { isRecord } from './github-settings-parse';
5
6
 
@@ -41,114 +42,6 @@ export const subsetMatches = (declared: unknown, live: unknown): boolean => {
41
42
  return declared === live;
42
43
  };
43
44
 
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 callers can fail with a targeted
93
- // missing-visibility message instead of a bogus value mismatch.
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
45
  // Repo merge settings are only visible to admin tokens; report invisible keys
153
46
  // as unverifiable instead of drifted so callers fail the gate with a
154
47
  // missing-visibility message that names the token fix, not a bogus value
@@ -0,0 +1,198 @@
1
+ // GraphQL fallbacks for repository state REST hides from tokens that can
2
+ // legitimately read it. Each returns only what GraphQL actually answered, so a
3
+ // remaining gap stays unverifiable instead of becoming a false pass.
4
+
5
+ import { apiError, HTTP_OK, request } from './github-api';
6
+ import { isRecord } from './github-settings-parse';
7
+
8
+ // The `repository` node GraphQL answered with, or why there is no answer.
9
+ type GraphqlAnswer = {
10
+ readonly repository: Readonly<Record<string, unknown>> | null;
11
+ readonly failure: string | null;
12
+ };
13
+
14
+ const errorSummary = (errors: ReadonlyArray<unknown>): string =>
15
+ errors
16
+ .map((error) =>
17
+ isRecord(error) && typeof error.message === 'string'
18
+ ? error.message
19
+ : JSON.stringify(error),
20
+ )
21
+ .join('; ');
22
+
23
+ // GraphQL reports a permission failure as HTTP 200 with a partial `data` and a
24
+ // non-empty `errors` array, so a body carrying errors is no answer at all: a
25
+ // suppressed field that survives as a plausible value — `0` for a count — must
26
+ // never be read as a verified one.
27
+ const queryRepository = async (
28
+ token: string | null,
29
+ repo: string,
30
+ selection: string,
31
+ ): Promise<GraphqlAnswer> => {
32
+ const [owner, name] = repo.split('/');
33
+ if (owner === undefined || name === undefined) {
34
+ return {
35
+ repository: null,
36
+ failure: `"${repo}" is not an owner/name repository`,
37
+ };
38
+ }
39
+ const response = await request(token, 'POST', '/graphql', {
40
+ query: `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { ${selection} } }`,
41
+ });
42
+ if (response.status !== HTTP_OK || !isRecord(response.body)) {
43
+ return {
44
+ repository: null,
45
+ failure: apiError('querying the GraphQL API', response),
46
+ };
47
+ }
48
+ const { errors } = response.body;
49
+ if (Array.isArray(errors) && errors.length > 0) {
50
+ return {
51
+ repository: null,
52
+ failure: `HTTP ${response.status} ${errorSummary(errors)}`,
53
+ };
54
+ }
55
+ const data = isRecord(response.body.data) ? response.body.data : null;
56
+ if (data === null || !isRecord(data.repository)) {
57
+ return {
58
+ repository: null,
59
+ failure: `HTTP ${response.status} the response carried no repository`,
60
+ };
61
+ }
62
+ return { repository: data.repository, failure: null };
63
+ };
64
+
65
+ const GRAPHQL_MERGE_FIELDS: ReadonlyMap<string, string> = new Map([
66
+ ['allow_auto_merge', 'autoMergeAllowed'],
67
+ ['allow_merge_commit', 'mergeCommitAllowed'],
68
+ ['allow_rebase_merge', 'rebaseMergeAllowed'],
69
+ ['allow_squash_merge', 'squashMergeAllowed'],
70
+ ['delete_branch_on_merge', 'deleteBranchOnMerge'],
71
+ ['merge_commit_message', 'mergeCommitMessage'],
72
+ ['merge_commit_title', 'mergeCommitTitle'],
73
+ ['squash_merge_commit_message', 'squashMergeCommitMessage'],
74
+ ['squash_merge_commit_title', 'squashMergeCommitTitle'],
75
+ ]);
76
+
77
+ // REST omits repository merge settings for read-only viewers (they surface
78
+ // only with write access — community discussion #153258), but GraphQL serves
79
+ // the same values, with identical enum spellings, to any token that can read
80
+ // the repository. This keeps a read-only token sufficient for the check.
81
+ // Returns only the keys GraphQL answered; the rest stay unverifiable.
82
+ export const fetchMergeSettingsViaGraphql = async (
83
+ token: string | null,
84
+ repo: string,
85
+ keys: ReadonlyArray<string>,
86
+ ): Promise<Readonly<Record<string, unknown>>> => {
87
+ const mapped = keys.filter((key) => GRAPHQL_MERGE_FIELDS.has(key));
88
+ if (mapped.length === 0) {
89
+ return {};
90
+ }
91
+ const { repository } = await queryRepository(
92
+ token,
93
+ repo,
94
+ mapped.map((key) => GRAPHQL_MERGE_FIELDS.get(key)).join(' '),
95
+ );
96
+ if (repository === null) {
97
+ return {};
98
+ }
99
+ return Object.fromEntries(
100
+ mapped
101
+ .map((key) => [key, repository[GRAPHQL_MERGE_FIELDS.get(key) ?? '']])
102
+ .filter(([, value]) => value !== undefined && value !== null),
103
+ );
104
+ };
105
+
106
+ // One page is every ruleset any consumer declares. Nothing rests on that:
107
+ // counts are only ever consulted for rulesets the REST loader listed, and a
108
+ // ruleset GraphQL did not answer for stays unverifiable.
109
+ const GRAPHQL_RULESET_PAGE = 100;
110
+
111
+ // Asking for one actor makes the count self-corroborating at no extra cost:
112
+ // `totalCount === 0` must yield an empty node list and `totalCount >= 1`
113
+ // exactly one node.
114
+ const BYPASS_ACTOR_PROBE = 1;
115
+
116
+ // A repository-owned ruleset node paired with its bypass-actor count, keyed by
117
+ // the id REST reports for the same ruleset, or null for anything else.
118
+ // Org-inherited rulesets are outside this declaration's authority, the same
119
+ // filter the REST loader applies. A node list that disagrees with its own
120
+ // `totalCount` means the connection is being filtered rather than merely
121
+ // redacted, so it supplies no count; a null *element* is the documented
122
+ // identity-withholding shape, so only the length is checked.
123
+ const countedRuleset = (node: unknown): readonly [number, number] | null => {
124
+ if (!isRecord(node) || typeof node.databaseId !== 'number') {
125
+ return null;
126
+ }
127
+ if (!isRecord(node.source) || node.source.__typename !== 'Repository') {
128
+ return null;
129
+ }
130
+ const actors = isRecord(node.bypassActors) ? node.bypassActors : null;
131
+ if (
132
+ actors === null ||
133
+ typeof actors.totalCount !== 'number' ||
134
+ !Array.isArray(actors.nodes)
135
+ ) {
136
+ return null;
137
+ }
138
+ const total = actors.totalCount;
139
+ return total >= 0 &&
140
+ actors.nodes.length === Math.min(total, BYPASS_ACTOR_PROBE)
141
+ ? [node.databaseId, total]
142
+ : null;
143
+ };
144
+
145
+ // Nothing promises an id appears once in the node list, and a repeated key
146
+ // would silently take the last node's count. Dropping the id entirely degrades
147
+ // it to unverifiable instead.
148
+ const withoutRepeatedIds = (
149
+ entries: ReadonlyArray<readonly [number, number]>,
150
+ ): ReadonlyMap<number, number> => {
151
+ const seen = new Set<number>();
152
+ const repeated = new Set<number>();
153
+ for (const [id] of entries) {
154
+ if (seen.has(id)) {
155
+ repeated.add(id);
156
+ }
157
+ seen.add(id);
158
+ }
159
+ return new Map(entries.filter(([id]) => !repeated.has(id)));
160
+ };
161
+
162
+ export type BypassActorCounts = {
163
+ // Keyed by ruleset id, never by name: two live rulesets may share a name, and
164
+ // joining on it would let a harmless ruleset's count verify a bypassed
165
+ // ruleset's list.
166
+ readonly counts: ReadonlyMap<number, number>;
167
+ readonly failure: string | null;
168
+ };
169
+
170
+ // REST omits ruleset `bypass_actors` for viewers without repository
171
+ // Administration access, and omits it from GitHub App installation tokens
172
+ // whatever the App's permissions — so a brokered CI token can never read the
173
+ // list there. GraphQL answers `bypassActors` on the same repository-owned
174
+ // rulesets and serves an exact `totalCount` to those tokens, withholding only
175
+ // the actor identities. Returns the count per ruleset id; an id GraphQL did
176
+ // not answer for stays absent, so the caller keeps it unverifiable.
177
+ export const fetchBypassActorCountsViaGraphql = async (
178
+ token: string | null,
179
+ repo: string,
180
+ ): Promise<BypassActorCounts> => {
181
+ const answer = await queryRepository(
182
+ token,
183
+ repo,
184
+ `rulesets(first: ${GRAPHQL_RULESET_PAGE}, includeParents: false) { nodes { databaseId source { __typename } bypassActors(first: ${BYPASS_ACTOR_PROBE}) { totalCount nodes { __typename } } } }`,
185
+ );
186
+ const rulesets =
187
+ answer.repository !== null && isRecord(answer.repository.rulesets)
188
+ ? answer.repository.rulesets
189
+ : null;
190
+ const nodes =
191
+ rulesets !== null && Array.isArray(rulesets.nodes) ? rulesets.nodes : [];
192
+ return {
193
+ counts: withoutRepeatedIds(
194
+ nodes.map(countedRuleset).filter((entry) => entry !== null),
195
+ ),
196
+ failure: answer.failure,
197
+ };
198
+ };
@@ -4,8 +4,6 @@
4
4
 
5
5
  import {
6
6
  apiError,
7
- fetchLiveRulesets,
8
- fetchMergeSettingsViaGraphql,
9
7
  HTTP_FORBIDDEN,
10
8
  HTTP_OK,
11
9
  HTTP_UNAUTHORIZED,
@@ -13,14 +11,27 @@ import {
13
11
  resolveGithubRepo,
14
12
  resolveToken,
15
13
  } from './github-api';
14
+ import { resolveHiddenBypassActors } from './github-bypass-actors';
16
15
  import {
16
+ ADMIN_VISIBILITY_ADVICE,
17
17
  enforceableRepositorySettings,
18
18
  optOutEligibilityProblem,
19
19
  unverifiableProblem,
20
20
  } from './github-command-shared';
21
- import { diffRepositorySettings, diffRulesets } from './github-diff';
21
+ import { diffRepositorySettings } from './github-diff';
22
+ import {
23
+ fetchBypassActorCountsViaGraphql,
24
+ fetchMergeSettingsViaGraphql,
25
+ } from './github-graphql';
22
26
  import { diffLabels, fetchLiveLabels } from './github-labels';
23
27
  import { GithubListResponseError } from './github-paginate';
28
+ import { fetchLiveRulesets } from './github-ruleset-api';
29
+ import { diffRulesets } from './github-ruleset-diff';
30
+ import {
31
+ GRAPHQL_NOT_CONSULTED,
32
+ isHiddenBypassActors,
33
+ rulesetVisibilityProblems,
34
+ } from './github-ruleset-visibility';
24
35
  import { type GithubSettings, isRecord } from './github-settings-parse';
25
36
 
26
37
  const LABEL_VISIBILITY_PROBLEM =
@@ -73,10 +84,29 @@ const repositoryDrift = async (
73
84
  });
74
85
  return [
75
86
  ...rediff.drifted,
76
- ...unverifiableProblem('repository setting(s)', rediff.unverifiable),
87
+ ...unverifiableProblem(
88
+ 'repository setting(s)',
89
+ rediff.unverifiable,
90
+ ADMIN_VISIBILITY_ADVICE,
91
+ ),
77
92
  ];
78
93
  };
79
94
 
95
+ // The live rulesets GraphQL answered a bypass-actor count for, named as the
96
+ // REST response names them so the advice can say whether a hidden list was
97
+ // counted-and-matched or never answered for at all.
98
+ const countedRulesetNames = (
99
+ live: ReadonlyArray<Readonly<Record<string, unknown>>>,
100
+ counts: ReadonlyMap<number, number>,
101
+ ): ReadonlySet<string> =>
102
+ new Set(
103
+ live
104
+ .filter(
105
+ (ruleset) => typeof ruleset.id === 'number' && counts.has(ruleset.id),
106
+ )
107
+ .map((ruleset) => String(ruleset.name)),
108
+ );
109
+
80
110
  const rulesetDrift = async (
81
111
  token: string | null,
82
112
  repo: string,
@@ -90,9 +120,30 @@ const rulesetDrift = async (
90
120
  return [live.problem ?? 'unable to read rulesets'];
91
121
  }
92
122
  const diff = diffRulesets(declared.rulesets, live.rulesets);
123
+ if (!diff.unverifiable.some(isHiddenBypassActors)) {
124
+ return [
125
+ ...diff.drifted,
126
+ ...rulesetVisibilityProblems(diff.unverifiable, GRAPHQL_NOT_CONSULTED),
127
+ ];
128
+ }
129
+ // REST hides bypass actors from every non-admin token and from installation
130
+ // tokens outright; retry the hidden lists as GraphQL counts before failing
131
+ // the gate.
132
+ const answered = await fetchBypassActorCountsViaGraphql(token, repo);
133
+ const rediff = diffRulesets(
134
+ declared.rulesets,
135
+ resolveHiddenBypassActors(
136
+ declared.rulesets,
137
+ live.rulesets,
138
+ answered.counts,
139
+ ),
140
+ );
93
141
  return [
94
- ...diff.drifted,
95
- ...unverifiableProblem('ruleset field(s)', diff.unverifiable),
142
+ ...rediff.drifted,
143
+ ...rulesetVisibilityProblems(rediff.unverifiable, {
144
+ countedNames: countedRulesetNames(live.rulesets, answered.counts),
145
+ failure: answered.failure,
146
+ }),
96
147
  ];
97
148
  };
98
149
 
@@ -0,0 +1,57 @@
1
+ // Loading the live repository rulesets. This lives outside github-api.ts
2
+ // because the exhaustive pagination it needs (github-paginate.ts) is itself
3
+ // built on github-api.ts, and calling it from there would be a mutual import.
4
+
5
+ import { apiError, HTTP_OK, request } from './github-api';
6
+ import { listAllPages } from './github-paginate';
7
+ import { isRecord } from './github-settings-parse';
8
+
9
+ export type LiveRulesets = {
10
+ readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
11
+ readonly problem: string | null;
12
+ };
13
+
14
+ // Only repository-sourced rulesets are managed; org-level rulesets a consumer
15
+ // inherits are outside this declaration's authority. The list really
16
+ // paginates — GitHub serves 30 per page by default and `includes_parents`
17
+ // defaults to true — so a single-page read would hide an undeclared ruleset
18
+ // that happens to land on page two, and the "not declared" branch would never
19
+ // fire for it.
20
+ //
21
+ // listAllPages throws, but the drift collector tells a ruleset read failure
22
+ // apart from a label read failure by the returned problem string, not by the
23
+ // exception, so the throw is converted here rather than propagated.
24
+ export const fetchLiveRulesets = async (
25
+ token: string | null,
26
+ repo: string,
27
+ ): Promise<LiveRulesets> => {
28
+ let summaries: ReadonlyArray<unknown>;
29
+ try {
30
+ summaries = await listAllPages(
31
+ token,
32
+ `/repos/${repo}/rulesets`,
33
+ 'listing rulesets',
34
+ );
35
+ } catch (error) {
36
+ return {
37
+ rulesets: null,
38
+ problem: error instanceof Error ? error.message : String(error),
39
+ };
40
+ }
41
+ const repoOwned = summaries
42
+ .filter(isRecord)
43
+ .filter((ruleset) => ruleset.source_type === 'Repository');
44
+ const detailed = await Promise.all(
45
+ repoOwned.map((ruleset) =>
46
+ request(token, 'GET', `/repos/${repo}/rulesets/${ruleset.id}`),
47
+ ),
48
+ );
49
+ const failed = detailed.find((response) => response.status !== HTTP_OK);
50
+ if (failed !== undefined) {
51
+ return { rulesets: null, problem: apiError('reading a ruleset', failed) };
52
+ }
53
+ return {
54
+ rulesets: detailed.map((response) => response.body).filter(isRecord),
55
+ problem: null,
56
+ };
57
+ };