@davidvornholt/standards 0.10.2 → 0.11.1
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 +9 -2
- package/package.json +38 -1
- package/src/cli.ts +98 -87
- package/src/github-api.ts +2 -0
- package/src/github-commands.ts +2 -0
- package/src/github-label-identity.ts +9 -0
- package/src/github-labels.ts +123 -0
- package/src/github-live-drift.ts +29 -0
- package/src/github-paginate.ts +49 -0
- package/src/github-settings-merge.ts +167 -0
- package/src/github-settings-parse.ts +82 -7
- package/src/github-settings.ts +5 -123
- package/src/managed-files.ts +64 -0
- package/src/poller-approval.ts +80 -0
- package/src/poller-claim.ts +154 -0
- package/src/poller-codex.ts +93 -0
- package/src/poller-commands.ts +78 -0
- package/src/poller-config.ts +188 -0
- package/src/poller-fix-outcome.ts +36 -0
- package/src/poller-fix-output.ts +135 -0
- package/src/poller-fix-publication.ts +187 -0
- package/src/poller-fix-run.ts +175 -0
- package/src/poller-fix-validation.ts +64 -0
- package/src/poller-github-publication.ts +23 -0
- package/src/poller-github-pulls.ts +189 -0
- package/src/poller-github-write.ts +87 -0
- package/src/poller-github.ts +190 -0
- package/src/poller-job-shared.ts +132 -0
- package/src/poller-outcome.ts +136 -0
- package/src/poller-output-integrity.ts +107 -0
- package/src/poller-prompts.ts +70 -0
- package/src/poller-protected-paths.ts +51 -0
- package/src/poller-protocol.ts +137 -0
- package/src/poller-review-artifacts.ts +119 -0
- package/src/poller-review-execution.ts +108 -0
- package/src/poller-review-output.ts +189 -0
- package/src/poller-review-publication.ts +153 -0
- package/src/poller-review-run.ts +160 -0
- package/src/poller-review-state.ts +126 -0
- package/src/poller-review-validation.ts +66 -0
- package/src/poller-schedule.ts +121 -0
- package/src/poller-tick.ts +144 -0
- package/src/poller-trust.ts +96 -0
- package/src/poller-units.ts +82 -0
- package/src/poller-workspace.ts +199 -0
package/src/github-live-drift.ts
CHANGED
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
apiError,
|
|
7
7
|
fetchLiveRulesets,
|
|
8
8
|
fetchMergeSettingsViaGraphql,
|
|
9
|
+
HTTP_FORBIDDEN,
|
|
9
10
|
HTTP_OK,
|
|
11
|
+
HTTP_UNAUTHORIZED,
|
|
10
12
|
request,
|
|
11
13
|
resolveGithubRepo,
|
|
12
14
|
resolveToken,
|
|
@@ -17,8 +19,25 @@ import {
|
|
|
17
19
|
unverifiableProblem,
|
|
18
20
|
} from './github-command-shared';
|
|
19
21
|
import { diffRepositorySettings, diffRulesets } from './github-diff';
|
|
22
|
+
import { diffLabels, fetchLiveLabels } from './github-labels';
|
|
23
|
+
import { GithubListResponseError } from './github-paginate';
|
|
20
24
|
import { type GithubSettings, isRecord } from './github-settings-parse';
|
|
21
25
|
|
|
26
|
+
const LABEL_VISIBILITY_PROBLEM =
|
|
27
|
+
'declared labels not visible to this token, so the gate cannot verify them. In CI, use a valid ci.github_settings_read_token from secrets/ci.yaml with read-only "Issues" access (or "Pull requests" read); locally use a token with one of those permissions';
|
|
28
|
+
const PERMISSION_DENIAL_MESSAGES: ReadonlySet<string> = new Set([
|
|
29
|
+
'Resource not accessible by integration',
|
|
30
|
+
'Resource not accessible by personal access token',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const isLabelPermissionDenial = (error: unknown): boolean =>
|
|
34
|
+
error instanceof GithubListResponseError &&
|
|
35
|
+
(error.status === HTTP_UNAUTHORIZED ||
|
|
36
|
+
(error.status === HTTP_FORBIDDEN &&
|
|
37
|
+
isRecord(error.responseBody) &&
|
|
38
|
+
typeof error.responseBody.message === 'string' &&
|
|
39
|
+
PERMISSION_DENIAL_MESSAGES.has(error.responseBody.message)));
|
|
40
|
+
|
|
22
41
|
const repositoryDrift = async (
|
|
23
42
|
token: string | null,
|
|
24
43
|
repo: string,
|
|
@@ -87,11 +106,21 @@ export const collectLiveDrift = async (
|
|
|
87
106
|
}
|
|
88
107
|
const token = resolveToken();
|
|
89
108
|
try {
|
|
109
|
+
// No declared labels means nothing to verify — skipping the fetch is
|
|
110
|
+
// exact, not lenient.
|
|
111
|
+
const labelDrift =
|
|
112
|
+
declared.labels.length === 0
|
|
113
|
+
? []
|
|
114
|
+
: diffLabels(declared.labels, await fetchLiveLabels(token, repo));
|
|
90
115
|
return [
|
|
91
116
|
...(await repositoryDrift(token, repo, declared)),
|
|
92
117
|
...(await rulesetDrift(token, repo, declared)),
|
|
118
|
+
...labelDrift,
|
|
93
119
|
];
|
|
94
120
|
} catch (error) {
|
|
121
|
+
if (isLabelPermissionDenial(error)) {
|
|
122
|
+
return [LABEL_VISIBILITY_PROBLEM];
|
|
123
|
+
}
|
|
95
124
|
return [
|
|
96
125
|
`GitHub API unreachable: ${error instanceof Error ? error.message : String(error)}`,
|
|
97
126
|
];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Exhaustive pagination for GitHub list endpoints. Single-page reads are a
|
|
2
|
+
// correctness hazard everywhere the caller needs "the latest" or "all" of
|
|
3
|
+
// something: a truncated timeline mis-attributes label approvals, truncated
|
|
4
|
+
// comments break answer detection, and truncated label lists read as drift.
|
|
5
|
+
// Overflow past the page cap fails loudly instead of silently truncating.
|
|
6
|
+
|
|
7
|
+
import { type ApiResponse, apiError, HTTP_OK, request } from './github-api';
|
|
8
|
+
|
|
9
|
+
const PAGE_SIZE = 100;
|
|
10
|
+
const MAX_PAGES = 30;
|
|
11
|
+
|
|
12
|
+
export class GithubListResponseError extends Error {
|
|
13
|
+
readonly responseBody: unknown;
|
|
14
|
+
readonly status: number;
|
|
15
|
+
|
|
16
|
+
constructor(context: string, response: ApiResponse) {
|
|
17
|
+
super(apiError(context, response));
|
|
18
|
+
this.name = 'GithubListResponseError';
|
|
19
|
+
this.responseBody = response.body;
|
|
20
|
+
this.status = response.status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const listAllPages = async (
|
|
25
|
+
token: string | null,
|
|
26
|
+
path: string,
|
|
27
|
+
context: string,
|
|
28
|
+
): Promise<ReadonlyArray<unknown>> => {
|
|
29
|
+
const items: Array<unknown> = [];
|
|
30
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
31
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
32
|
+
// biome-ignore lint/performance/noAwaitInLoops: pages are sequential by definition; the next request needs to know the previous page was full.
|
|
33
|
+
const response = await request(
|
|
34
|
+
token,
|
|
35
|
+
'GET',
|
|
36
|
+
`${path}${separator}per_page=${PAGE_SIZE}&page=${page}`,
|
|
37
|
+
);
|
|
38
|
+
if (response.status !== HTTP_OK || !Array.isArray(response.body)) {
|
|
39
|
+
throw new GithubListResponseError(context, response);
|
|
40
|
+
}
|
|
41
|
+
items.push(...response.body);
|
|
42
|
+
if (response.body.length < PAGE_SIZE) {
|
|
43
|
+
return items;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw new Error(
|
|
47
|
+
`${context}: more than ${MAX_PAGES * PAGE_SIZE} items; refusing to act on a truncated list`,
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Merge logic for the canonical settings file and its repo-owned extension.
|
|
2
|
+
// The seam may only add. Overriding a canonical repository key or redefining a
|
|
3
|
+
// canonical ruleset or label could weaken the canonical floor; GitHub layers
|
|
4
|
+
// multiple rulesets strictest-wins, so adding is always safe. The one
|
|
5
|
+
// subtractive declaration is the ruleset-enforcement opt-out, which skips the
|
|
6
|
+
// ruleset gate entirely rather than weakening any single rule.
|
|
7
|
+
|
|
8
|
+
import { labelIdentity } from './github-label-identity';
|
|
9
|
+
import {
|
|
10
|
+
ENFORCEMENT_OPT_OUT,
|
|
11
|
+
type GithubSettings,
|
|
12
|
+
isLabelDeclaration,
|
|
13
|
+
isNamedRuleset,
|
|
14
|
+
type ParsedGithubSettings,
|
|
15
|
+
} from './github-settings-parse';
|
|
16
|
+
|
|
17
|
+
const completeSettings = (
|
|
18
|
+
parsed: ParsedGithubSettings | null,
|
|
19
|
+
): GithubSettings | null => {
|
|
20
|
+
if (
|
|
21
|
+
parsed === null ||
|
|
22
|
+
parsed.repository === null ||
|
|
23
|
+
parsed.rulesets === null ||
|
|
24
|
+
parsed.labels === null ||
|
|
25
|
+
parsed.rulesetEnforcement === null
|
|
26
|
+
) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const rulesets = parsed.rulesets.filter(isNamedRuleset);
|
|
30
|
+
const labels = parsed.labels.filter(isLabelDeclaration);
|
|
31
|
+
if (
|
|
32
|
+
rulesets.length !== parsed.rulesets.length ||
|
|
33
|
+
labels.length !== parsed.labels.length
|
|
34
|
+
) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
repository: parsed.repository,
|
|
39
|
+
rulesets,
|
|
40
|
+
labels,
|
|
41
|
+
rulesetEnforcement: parsed.rulesetEnforcement,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const enforcementMergeProblems = (
|
|
46
|
+
local: ParsedGithubSettings | null,
|
|
47
|
+
): ReadonlyArray<string> => {
|
|
48
|
+
if (
|
|
49
|
+
local?.rulesetEnforcement !== ENFORCEMENT_OPT_OUT ||
|
|
50
|
+
local.rulesets === null ||
|
|
51
|
+
local.rulesets.length === 0
|
|
52
|
+
) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return [
|
|
56
|
+
`.github/settings.local.json declares additional rulesets while "rulesetEnforcement" is "${ENFORCEMENT_OPT_OUT}"; remove the rulesets or the opt-out`,
|
|
57
|
+
];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const repositoryMergeProblems = (
|
|
61
|
+
canonical: ParsedGithubSettings | null,
|
|
62
|
+
local: ParsedGithubSettings | null,
|
|
63
|
+
): ReadonlyArray<string> => {
|
|
64
|
+
if (
|
|
65
|
+
canonical === null ||
|
|
66
|
+
canonical.repository === null ||
|
|
67
|
+
local === null ||
|
|
68
|
+
local.repository === null
|
|
69
|
+
) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const canonicalRepository = canonical.repository;
|
|
73
|
+
return Object.keys(local.repository)
|
|
74
|
+
.filter((key) => key in canonicalRepository)
|
|
75
|
+
.map(
|
|
76
|
+
(key) =>
|
|
77
|
+
`.github/settings.local.json repository."${key}" would override a canonical value; canonical settings are read-only`,
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const rulesetMergeProblems = (
|
|
82
|
+
canonical: ParsedGithubSettings | null,
|
|
83
|
+
local: ParsedGithubSettings | null,
|
|
84
|
+
): ReadonlyArray<string> => {
|
|
85
|
+
if (
|
|
86
|
+
canonical === null ||
|
|
87
|
+
canonical.rulesets === null ||
|
|
88
|
+
local === null ||
|
|
89
|
+
local.rulesets === null
|
|
90
|
+
) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const canonicalNames = new Set(
|
|
94
|
+
canonical.rulesets.filter(isNamedRuleset).map((ruleset) => ruleset.name),
|
|
95
|
+
);
|
|
96
|
+
return local.rulesets
|
|
97
|
+
.filter(isNamedRuleset)
|
|
98
|
+
.filter((ruleset) => canonicalNames.has(ruleset.name))
|
|
99
|
+
.map(
|
|
100
|
+
(ruleset) =>
|
|
101
|
+
`.github/settings.local.json ruleset "${ruleset.name}" collides with a canonical ruleset; add a separately named ruleset to tighten further`,
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const labelMergeProblems = (
|
|
106
|
+
canonical: ParsedGithubSettings | null,
|
|
107
|
+
local: ParsedGithubSettings | null,
|
|
108
|
+
): ReadonlyArray<string> => {
|
|
109
|
+
if (
|
|
110
|
+
canonical === null ||
|
|
111
|
+
canonical.labels === null ||
|
|
112
|
+
local === null ||
|
|
113
|
+
local.labels === null
|
|
114
|
+
) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const canonicalNames = new Set(
|
|
118
|
+
canonical.labels
|
|
119
|
+
.filter(isLabelDeclaration)
|
|
120
|
+
.map((label) => labelIdentity(label.name)),
|
|
121
|
+
);
|
|
122
|
+
return local.labels
|
|
123
|
+
.filter(isLabelDeclaration)
|
|
124
|
+
.filter((label) => canonicalNames.has(labelIdentity(label.name)))
|
|
125
|
+
.map(
|
|
126
|
+
(label) =>
|
|
127
|
+
`.github/settings.local.json label "${label.name}" collides with a canonical label; canonical labels are read-only`,
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export type MergedSettings = {
|
|
132
|
+
readonly merged: GithubSettings | null;
|
|
133
|
+
readonly problems: ReadonlyArray<string>;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const mergeSettings = (
|
|
137
|
+
canonical: ParsedGithubSettings | null,
|
|
138
|
+
local: ParsedGithubSettings | null,
|
|
139
|
+
): MergedSettings => {
|
|
140
|
+
const problems = [
|
|
141
|
+
...enforcementMergeProblems(local),
|
|
142
|
+
...repositoryMergeProblems(canonical, local),
|
|
143
|
+
...rulesetMergeProblems(canonical, local),
|
|
144
|
+
...labelMergeProblems(canonical, local),
|
|
145
|
+
];
|
|
146
|
+
const completeCanonical = completeSettings(canonical);
|
|
147
|
+
const completeLocal = completeSettings(local);
|
|
148
|
+
if (
|
|
149
|
+
problems.length > 0 ||
|
|
150
|
+
completeCanonical === null ||
|
|
151
|
+
completeLocal === null
|
|
152
|
+
) {
|
|
153
|
+
return { merged: null, problems };
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
merged: {
|
|
157
|
+
repository: {
|
|
158
|
+
...completeCanonical.repository,
|
|
159
|
+
...completeLocal.repository,
|
|
160
|
+
},
|
|
161
|
+
rulesets: [...completeCanonical.rulesets, ...completeLocal.rulesets],
|
|
162
|
+
labels: [...completeCanonical.labels, ...completeLocal.labels],
|
|
163
|
+
rulesetEnforcement: completeLocal.rulesetEnforcement,
|
|
164
|
+
},
|
|
165
|
+
problems: [],
|
|
166
|
+
};
|
|
167
|
+
};
|
|
@@ -4,21 +4,34 @@
|
|
|
4
4
|
// github-diff.ts, and the API interaction in github-api.ts. Like cli.ts, this
|
|
5
5
|
// module is zero-dependency so `bunx` can execute the published package.
|
|
6
6
|
|
|
7
|
+
import { labelIdentity } from './github-label-identity';
|
|
8
|
+
|
|
7
9
|
// GitHub only enforces rulesets on private repositories on paid plans. The
|
|
8
10
|
// seam can declare that fact so the gate skips rulesets loudly instead of
|
|
9
11
|
// failing closed forever (personal accounts) or trusting rulesets the API
|
|
10
12
|
// reports as active but a free-plan organization silently does not enforce.
|
|
11
13
|
export type RulesetEnforcement = 'enforced' | 'unavailable-on-plan';
|
|
12
14
|
|
|
15
|
+
// Declared issue labels are an additive floor like rulesets: declared labels
|
|
16
|
+
// must exist with the declared color and description; extra live labels are
|
|
17
|
+
// not drift. The fix-poller protocol labels ride this seam to every consumer.
|
|
18
|
+
export type LabelDeclaration = {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly color: string;
|
|
21
|
+
readonly description: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
13
24
|
export type GithubSettings = {
|
|
14
25
|
readonly repository: Readonly<Record<string, unknown>>;
|
|
15
26
|
readonly rulesets: ReadonlyArray<Readonly<Record<string, unknown>>>;
|
|
27
|
+
readonly labels: ReadonlyArray<LabelDeclaration>;
|
|
16
28
|
readonly rulesetEnforcement: RulesetEnforcement;
|
|
17
29
|
};
|
|
18
30
|
|
|
19
31
|
export type ParsedGithubSettings = {
|
|
20
32
|
readonly repository: Readonly<Record<string, unknown>> | null;
|
|
21
33
|
readonly rulesets: ReadonlyArray<unknown> | null;
|
|
34
|
+
readonly labels: ReadonlyArray<unknown> | null;
|
|
22
35
|
readonly rulesetEnforcement: RulesetEnforcement | null;
|
|
23
36
|
};
|
|
24
37
|
|
|
@@ -33,9 +46,21 @@ export const isNamedRuleset = (
|
|
|
33
46
|
): value is Readonly<Record<string, unknown>> & { readonly name: string } =>
|
|
34
47
|
isRecord(value) && typeof value.name === 'string' && value.name.length > 0;
|
|
35
48
|
|
|
49
|
+
const LABEL_COLOR = /^[0-9a-f]{6}$/u;
|
|
50
|
+
const LABEL_DECLARATION_KEY_COUNT = 3;
|
|
51
|
+
|
|
52
|
+
export const isLabelDeclaration = (value: unknown): value is LabelDeclaration =>
|
|
53
|
+
isRecord(value) &&
|
|
54
|
+
Object.keys(value).length === LABEL_DECLARATION_KEY_COUNT &&
|
|
55
|
+
isNonEmptyString(value.name) &&
|
|
56
|
+
typeof value.color === 'string' &&
|
|
57
|
+
LABEL_COLOR.test(value.color) &&
|
|
58
|
+
isNonEmptyString(value.description);
|
|
59
|
+
|
|
36
60
|
export const SETTINGS_KEYS: ReadonlySet<string> = new Set([
|
|
37
61
|
'repository',
|
|
38
62
|
'rulesets',
|
|
63
|
+
'labels',
|
|
39
64
|
]);
|
|
40
65
|
export const LOCAL_SETTINGS_KEYS: ReadonlySet<string> = new Set([
|
|
41
66
|
...SETTINGS_KEYS,
|
|
@@ -78,6 +103,51 @@ const rulesetListProblems = (
|
|
|
78
103
|
return problems;
|
|
79
104
|
};
|
|
80
105
|
|
|
106
|
+
const labelListProblems = (
|
|
107
|
+
labels: ReadonlyArray<unknown>,
|
|
108
|
+
label: string,
|
|
109
|
+
): ReadonlyArray<string> => {
|
|
110
|
+
const problems: Array<string> = [];
|
|
111
|
+
const names = new Set<string>();
|
|
112
|
+
for (const [index, declaration] of labels.entries()) {
|
|
113
|
+
if (!isLabelDeclaration(declaration)) {
|
|
114
|
+
problems.push(
|
|
115
|
+
`${label} labels[${index}] must be {"name","color","description"} with a non-empty name, a 6-digit lowercase hex color, and a non-empty description`,
|
|
116
|
+
);
|
|
117
|
+
} else if (names.has(labelIdentity(declaration.name))) {
|
|
118
|
+
problems.push(
|
|
119
|
+
`${label} declares label "${declaration.name}" more than once`,
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
names.add(labelIdentity(declaration.name));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return problems;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type ListField = {
|
|
129
|
+
readonly label: string;
|
|
130
|
+
readonly field: string;
|
|
131
|
+
readonly listProblems: (
|
|
132
|
+
list: ReadonlyArray<unknown>,
|
|
133
|
+
label: string,
|
|
134
|
+
) => ReadonlyArray<string>;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const parseDeclarationList = (
|
|
138
|
+
raw: unknown,
|
|
139
|
+
options: ListField,
|
|
140
|
+
problems: Array<string>,
|
|
141
|
+
): ReadonlyArray<unknown> | null => {
|
|
142
|
+
const list = raw ?? [];
|
|
143
|
+
if (!Array.isArray(list)) {
|
|
144
|
+
problems.push(`${options.label} "${options.field}" must be an array`);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
problems.push(...options.listProblems(list, options.label));
|
|
148
|
+
return list;
|
|
149
|
+
};
|
|
150
|
+
|
|
81
151
|
export const parseSettings = (
|
|
82
152
|
raw: unknown,
|
|
83
153
|
label: string,
|
|
@@ -96,12 +166,16 @@ export const parseSettings = (
|
|
|
96
166
|
if (!isRecord(repository)) {
|
|
97
167
|
problems.push(`${label} "repository" must be an object`);
|
|
98
168
|
}
|
|
99
|
-
const rulesets =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
169
|
+
const rulesets = parseDeclarationList(
|
|
170
|
+
raw.rulesets,
|
|
171
|
+
{ label, field: 'rulesets', listProblems: rulesetListProblems },
|
|
172
|
+
problems,
|
|
173
|
+
);
|
|
174
|
+
const labels = parseDeclarationList(
|
|
175
|
+
raw.labels,
|
|
176
|
+
{ label, field: 'labels', listProblems: labelListProblems },
|
|
177
|
+
problems,
|
|
178
|
+
);
|
|
105
179
|
// Only the opt-out value is accepted: enforcement is the default, and an
|
|
106
180
|
// explicit "enforced" would be a second way to spell the same state.
|
|
107
181
|
if (
|
|
@@ -115,7 +189,8 @@ export const parseSettings = (
|
|
|
115
189
|
return {
|
|
116
190
|
settings: {
|
|
117
191
|
repository: isRecord(repository) ? repository : null,
|
|
118
|
-
rulesets
|
|
192
|
+
rulesets,
|
|
193
|
+
labels,
|
|
119
194
|
rulesetEnforcement: parseRulesetEnforcement(raw.rulesetEnforcement),
|
|
120
195
|
},
|
|
121
196
|
problems,
|
package/src/github-settings.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Load and merge canonical GitHub settings with the repo-owned extension.
|
|
2
|
+
// Parsing lives in github-settings-parse.ts, merge policy in
|
|
3
|
+
// github-settings-merge.ts, drift in github-diff.ts, and API work in
|
|
4
|
+
// github-api.ts; this stays dependency-free.
|
|
3
5
|
|
|
6
|
+
import { mergeSettings } from './github-settings-merge';
|
|
4
7
|
import {
|
|
5
|
-
ENFORCEMENT_OPT_OUT,
|
|
6
8
|
type GithubSettings,
|
|
7
|
-
isNamedRuleset,
|
|
8
9
|
LOCAL_SETTINGS_KEYS,
|
|
9
|
-
type ParsedGithubSettings,
|
|
10
10
|
parseSettings,
|
|
11
11
|
SETTINGS_KEYS,
|
|
12
12
|
} from './github-settings-parse';
|
|
@@ -16,124 +16,6 @@ export type LoadedGithubSettings = {
|
|
|
16
16
|
readonly problems: ReadonlyArray<string>;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
const completeSettings = (
|
|
20
|
-
parsed: ParsedGithubSettings | null,
|
|
21
|
-
): GithubSettings | null => {
|
|
22
|
-
if (
|
|
23
|
-
parsed === null ||
|
|
24
|
-
parsed.repository === null ||
|
|
25
|
-
parsed.rulesets === null ||
|
|
26
|
-
parsed.rulesetEnforcement === null
|
|
27
|
-
) {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
const rulesets = parsed.rulesets.filter(isNamedRuleset);
|
|
31
|
-
if (rulesets.length !== parsed.rulesets.length) {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
return {
|
|
35
|
-
repository: parsed.repository,
|
|
36
|
-
rulesets,
|
|
37
|
-
rulesetEnforcement: parsed.rulesetEnforcement,
|
|
38
|
-
};
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const enforcementMergeProblems = (
|
|
42
|
-
local: ParsedGithubSettings | null,
|
|
43
|
-
): ReadonlyArray<string> => {
|
|
44
|
-
if (
|
|
45
|
-
local?.rulesetEnforcement !== ENFORCEMENT_OPT_OUT ||
|
|
46
|
-
local.rulesets === null ||
|
|
47
|
-
local.rulesets.length === 0
|
|
48
|
-
) {
|
|
49
|
-
return [];
|
|
50
|
-
}
|
|
51
|
-
return [
|
|
52
|
-
`.github/settings.local.json declares additional rulesets while "rulesetEnforcement" is "${ENFORCEMENT_OPT_OUT}"; remove the rulesets or the opt-out`,
|
|
53
|
-
];
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const repositoryMergeProblems = (
|
|
57
|
-
canonical: ParsedGithubSettings | null,
|
|
58
|
-
local: ParsedGithubSettings | null,
|
|
59
|
-
): ReadonlyArray<string> => {
|
|
60
|
-
if (
|
|
61
|
-
canonical === null ||
|
|
62
|
-
canonical.repository === null ||
|
|
63
|
-
local === null ||
|
|
64
|
-
local.repository === null
|
|
65
|
-
) {
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
68
|
-
const canonicalRepository = canonical.repository;
|
|
69
|
-
return Object.keys(local.repository)
|
|
70
|
-
.filter((key) => key in canonicalRepository)
|
|
71
|
-
.map(
|
|
72
|
-
(key) =>
|
|
73
|
-
`.github/settings.local.json repository."${key}" would override a canonical value; canonical settings are read-only`,
|
|
74
|
-
);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const rulesetMergeProblems = (
|
|
78
|
-
canonical: ParsedGithubSettings | null,
|
|
79
|
-
local: ParsedGithubSettings | null,
|
|
80
|
-
): ReadonlyArray<string> => {
|
|
81
|
-
if (
|
|
82
|
-
canonical === null ||
|
|
83
|
-
canonical.rulesets === null ||
|
|
84
|
-
local === null ||
|
|
85
|
-
local.rulesets === null
|
|
86
|
-
) {
|
|
87
|
-
return [];
|
|
88
|
-
}
|
|
89
|
-
const canonicalNames = new Set(
|
|
90
|
-
canonical.rulesets.filter(isNamedRuleset).map((ruleset) => ruleset.name),
|
|
91
|
-
);
|
|
92
|
-
return local.rulesets
|
|
93
|
-
.filter(isNamedRuleset)
|
|
94
|
-
.filter((ruleset) => canonicalNames.has(ruleset.name))
|
|
95
|
-
.map(
|
|
96
|
-
(ruleset) =>
|
|
97
|
-
`.github/settings.local.json ruleset "${ruleset.name}" collides with a canonical ruleset; add a separately named ruleset to tighten further`,
|
|
98
|
-
);
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
// The seam may only add. Overriding a canonical repository key or redefining a
|
|
102
|
-
// canonical ruleset could weaken the canonical floor; GitHub layers multiple
|
|
103
|
-
// rulesets strictest-wins, so adding a ruleset is always safe. The one
|
|
104
|
-
// subtractive declaration is the ruleset-enforcement opt-out, which skips the
|
|
105
|
-
// ruleset gate entirely rather than weakening any single rule.
|
|
106
|
-
const mergeSettings = (
|
|
107
|
-
canonical: ParsedGithubSettings | null,
|
|
108
|
-
local: ParsedGithubSettings | null,
|
|
109
|
-
) => {
|
|
110
|
-
const problems = [
|
|
111
|
-
...enforcementMergeProblems(local),
|
|
112
|
-
...repositoryMergeProblems(canonical, local),
|
|
113
|
-
...rulesetMergeProblems(canonical, local),
|
|
114
|
-
];
|
|
115
|
-
const completeCanonical = completeSettings(canonical);
|
|
116
|
-
const completeLocal = completeSettings(local);
|
|
117
|
-
if (
|
|
118
|
-
problems.length > 0 ||
|
|
119
|
-
completeCanonical === null ||
|
|
120
|
-
completeLocal === null
|
|
121
|
-
) {
|
|
122
|
-
return { merged: null, problems };
|
|
123
|
-
}
|
|
124
|
-
return {
|
|
125
|
-
merged: {
|
|
126
|
-
repository: {
|
|
127
|
-
...completeCanonical.repository,
|
|
128
|
-
...completeLocal.repository,
|
|
129
|
-
},
|
|
130
|
-
rulesets: [...completeCanonical.rulesets, ...completeLocal.rulesets],
|
|
131
|
-
rulesetEnforcement: completeLocal.rulesetEnforcement,
|
|
132
|
-
},
|
|
133
|
-
problems: [],
|
|
134
|
-
};
|
|
135
|
-
};
|
|
136
|
-
|
|
137
19
|
type JsonParse = {
|
|
138
20
|
readonly value: unknown;
|
|
139
21
|
readonly problem: string | null;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Never mirrored, even under a managed directory path: build output, VCS
|
|
5
|
+
// metadata, and installed dependencies would otherwise pollute the lock when
|
|
6
|
+
// syncing from a working tree that has them.
|
|
7
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
8
|
+
'node_modules',
|
|
9
|
+
'.git',
|
|
10
|
+
'.turbo',
|
|
11
|
+
'dist',
|
|
12
|
+
'.next',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const directiveToken = Buffer.from(['biome', 'ignore'].join('-'));
|
|
16
|
+
|
|
17
|
+
const toPosix = (path: string): string => path.split(sep).join('/');
|
|
18
|
+
|
|
19
|
+
const walkManagedFiles = async (
|
|
20
|
+
path: string,
|
|
21
|
+
repositoryRoot: string,
|
|
22
|
+
files: Map<string, string>,
|
|
23
|
+
): Promise<void> => {
|
|
24
|
+
const info = await stat(path).catch(() => null);
|
|
25
|
+
if (info === null) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (info.isDirectory()) {
|
|
29
|
+
const entries = await readdir(path);
|
|
30
|
+
await Promise.all(
|
|
31
|
+
entries
|
|
32
|
+
.filter((entry) => !IGNORED_DIRECTORIES.has(entry))
|
|
33
|
+
.map((entry) =>
|
|
34
|
+
walkManagedFiles(join(path, entry), repositoryRoot, files),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
files.set(toPosix(relative(repositoryRoot, path)), path);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const listManagedFiles = async (
|
|
43
|
+
repositoryRoot: string,
|
|
44
|
+
paths: ReadonlyArray<string>,
|
|
45
|
+
): Promise<ReadonlyMap<string, string>> => {
|
|
46
|
+
const files = new Map<string, string>();
|
|
47
|
+
await Promise.all(
|
|
48
|
+
paths.map((path) =>
|
|
49
|
+
walkManagedFiles(join(repositoryRoot, path), repositoryRoot, files),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
return files;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const findManagedFilesContainingBiomeDirectiveToken = async (
|
|
56
|
+
files: ReadonlyMap<string, string>,
|
|
57
|
+
): Promise<ReadonlyArray<string>> => {
|
|
58
|
+
const matches = await Promise.all(
|
|
59
|
+
[...files].map(async ([path, absolutePath]) =>
|
|
60
|
+
(await readFile(absolutePath)).includes(directiveToken) ? path : null,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
return matches.filter((path): path is string => path !== null).sort();
|
|
64
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { hasLabel } from './github-label-identity';
|
|
3
|
+
import {
|
|
4
|
+
collaboratorRole,
|
|
5
|
+
getIssue,
|
|
6
|
+
type IssueItem,
|
|
7
|
+
lastLabelEvent,
|
|
8
|
+
} from './poller-github';
|
|
9
|
+
import { isTrustedRole } from './poller-protocol';
|
|
10
|
+
|
|
11
|
+
export type ApprovalBinding = {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly repo: string;
|
|
14
|
+
readonly issueNumber: number;
|
|
15
|
+
readonly eventId: number;
|
|
16
|
+
readonly label: string;
|
|
17
|
+
readonly actorLogin: string;
|
|
18
|
+
readonly approvedAt: string;
|
|
19
|
+
readonly target: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ApprovalContext = {
|
|
23
|
+
readonly token: string | null;
|
|
24
|
+
readonly repo: string;
|
|
25
|
+
readonly issueNumber: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const stableDigest = (value: unknown): string =>
|
|
29
|
+
createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
30
|
+
|
|
31
|
+
export const issueRevision = (issue: IssueItem): string =>
|
|
32
|
+
`issue:${stableDigest({ title: issue.title, body: issue.body })}`;
|
|
33
|
+
|
|
34
|
+
export const prRevision = (
|
|
35
|
+
baseRef: string,
|
|
36
|
+
baseSha: string,
|
|
37
|
+
headSha: string,
|
|
38
|
+
): string => `pr:${stableDigest({ baseRef, baseSha, headSha })}`;
|
|
39
|
+
|
|
40
|
+
export const readApprovalBinding = async (
|
|
41
|
+
context: ApprovalContext,
|
|
42
|
+
label: string,
|
|
43
|
+
target: string,
|
|
44
|
+
): Promise<ApprovalBinding | string> => {
|
|
45
|
+
const issue = await getIssue(
|
|
46
|
+
context.token,
|
|
47
|
+
context.repo,
|
|
48
|
+
context.issueNumber,
|
|
49
|
+
);
|
|
50
|
+
if (!hasLabel(issue.labels, label)) {
|
|
51
|
+
return `"${label}" is not currently present on ${context.repo}#${context.issueNumber}`;
|
|
52
|
+
}
|
|
53
|
+
const event = await lastLabelEvent(
|
|
54
|
+
context.token,
|
|
55
|
+
context.repo,
|
|
56
|
+
context.issueNumber,
|
|
57
|
+
label,
|
|
58
|
+
);
|
|
59
|
+
if (event === null) {
|
|
60
|
+
return `no "${label}" label event found on ${context.repo}#${context.issueNumber}`;
|
|
61
|
+
}
|
|
62
|
+
const role = await collaboratorRole(
|
|
63
|
+
context.token,
|
|
64
|
+
context.repo,
|
|
65
|
+
event.actorLogin,
|
|
66
|
+
);
|
|
67
|
+
if (!isTrustedRole(role)) {
|
|
68
|
+
return `"${label}" on ${context.repo}#${context.issueNumber} was applied by ${event.actorLogin} (role: ${role}); only admin or maintain roles may approve automation`;
|
|
69
|
+
}
|
|
70
|
+
const fields = {
|
|
71
|
+
repo: context.repo,
|
|
72
|
+
issueNumber: context.issueNumber,
|
|
73
|
+
eventId: event.id,
|
|
74
|
+
label,
|
|
75
|
+
actorLogin: event.actorLogin,
|
|
76
|
+
approvedAt: event.createdAt,
|
|
77
|
+
target,
|
|
78
|
+
};
|
|
79
|
+
return { id: stableDigest(fields), ...fields };
|
|
80
|
+
};
|