@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/README.md +3 -3
- package/package.json +11 -1
- package/src/cli.ts +26 -11
- package/src/creds-login-github-manifest.ts +14 -7
- package/src/dependabot-directory-character-class.ts +139 -0
- package/src/dependabot-directory-normalize.ts +33 -0
- package/src/dependabot-directory-overlap.ts +139 -0
- package/src/dependabot-update.ts +32 -4
- package/src/github-api.ts +0 -80
- package/src/github-apply.ts +3 -2
- package/src/github-bypass-actors.ts +56 -0
- package/src/github-command-shared.ts +9 -2
- package/src/github-diff.ts +2 -109
- package/src/github-graphql.ts +198 -0
- package/src/github-live-drift.ts +57 -6
- package/src/github-ruleset-api.ts +57 -0
- package/src/github-ruleset-diff.ts +181 -0
- package/src/github-ruleset-visibility.ts +100 -0
- package/src/github-settings-parse.ts +12 -10
- package/src/github-settings.ts +3 -2
- package/src/poller-claim-reconciliation.ts +160 -0
- package/src/poller-claim.ts +92 -89
- package/src/poller-comment-reconciliation.ts +29 -0
- package/src/poller-fix-run.ts +13 -10
- package/src/poller-queue-marker.ts +10 -9
- package/src/poller-review-execution.ts +11 -9
- package/src/poller-review-run.ts +17 -21
- package/src/poller-schedule.ts +5 -0
- package/src/poller-status.ts +34 -19
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
|
-
};
|
package/src/github-apply.ts
CHANGED
|
@@ -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
|
|
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('; ')}.
|
|
51
|
+
`${scope} not visible to this token, so the gate cannot verify: ${items.join('; ')}. ${advice}`,
|
|
45
52
|
];
|
package/src/github-diff.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/github-live-drift.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
...
|
|
95
|
-
...
|
|
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
|
+
};
|