@davidvornholt/standards 0.10.1 → 0.11.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 +6 -1
- package/package.json +37 -1
- package/src/cli.ts +85 -13
- 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 +8 -0
- package/src/github-paginate.ts +37 -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/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
|
@@ -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,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
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { hasLabel } from './github-label-identity';
|
|
3
|
+
import { type ApprovalBinding, readApprovalBinding } from './poller-approval';
|
|
4
|
+
import {
|
|
5
|
+
collaboratorRole,
|
|
6
|
+
getIssue,
|
|
7
|
+
type IssueComment,
|
|
8
|
+
lastLabelEvent,
|
|
9
|
+
listIssueComments,
|
|
10
|
+
} from './poller-github';
|
|
11
|
+
import { createComment } from './poller-github-write';
|
|
12
|
+
import { CLAIM_MARKER, isTrustedRole } from './poller-protocol';
|
|
13
|
+
|
|
14
|
+
export type ClaimBinding = {
|
|
15
|
+
readonly approval: ApprovalBinding;
|
|
16
|
+
readonly claimLabel: string;
|
|
17
|
+
readonly claimEpoch: string;
|
|
18
|
+
readonly markerId: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type ClaimMarker = Omit<ClaimBinding, 'markerId'> & {
|
|
22
|
+
readonly nonce: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type ClaimContext = {
|
|
26
|
+
readonly token: string | null;
|
|
27
|
+
readonly repo: string;
|
|
28
|
+
readonly issueNumber: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const markerBody = (marker: ClaimMarker): string =>
|
|
32
|
+
`${CLAIM_MARKER}\n${JSON.stringify(marker)}`;
|
|
33
|
+
|
|
34
|
+
const parseMarker = (comment: IssueComment): ClaimMarker | null => {
|
|
35
|
+
if (!comment.body.startsWith(`${CLAIM_MARKER}\n`)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(comment.body.slice(CLAIM_MARKER.length + 1)) as {
|
|
40
|
+
readonly approval?: ApprovalBinding;
|
|
41
|
+
readonly claimLabel?: unknown;
|
|
42
|
+
readonly claimEpoch?: unknown;
|
|
43
|
+
readonly nonce?: unknown;
|
|
44
|
+
};
|
|
45
|
+
if (
|
|
46
|
+
typeof raw.claimLabel !== 'string' ||
|
|
47
|
+
typeof raw.claimEpoch !== 'string' ||
|
|
48
|
+
typeof raw.nonce !== 'string' ||
|
|
49
|
+
raw.approval === undefined ||
|
|
50
|
+
typeof raw.approval.id !== 'string'
|
|
51
|
+
) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
approval: raw.approval,
|
|
56
|
+
claimLabel: raw.claimLabel,
|
|
57
|
+
claimEpoch: raw.claimEpoch,
|
|
58
|
+
nonce: raw.nonce,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const winningMarkerId = async (
|
|
66
|
+
context: ClaimContext,
|
|
67
|
+
binding: Omit<ClaimBinding, 'markerId'>,
|
|
68
|
+
): Promise<number | null> => {
|
|
69
|
+
const comments = await listIssueComments(
|
|
70
|
+
context.token,
|
|
71
|
+
context.repo,
|
|
72
|
+
context.issueNumber,
|
|
73
|
+
);
|
|
74
|
+
let winner: number | null = null;
|
|
75
|
+
for (const comment of comments) {
|
|
76
|
+
const marker = parseMarker(comment);
|
|
77
|
+
const differs =
|
|
78
|
+
marker?.approval.id !== binding.approval.id ||
|
|
79
|
+
marker.claimLabel !== binding.claimLabel ||
|
|
80
|
+
marker.claimEpoch !== binding.claimEpoch;
|
|
81
|
+
if (!differs) {
|
|
82
|
+
// biome-ignore lint/performance/noAwaitInLoops: claim authors must be checked against their current role; the usually-one-item list is deliberately fail-closed.
|
|
83
|
+
const role = await collaboratorRole(
|
|
84
|
+
context.token,
|
|
85
|
+
context.repo,
|
|
86
|
+
comment.authorLogin,
|
|
87
|
+
);
|
|
88
|
+
if (isTrustedRole(role) && (winner === null || comment.id < winner)) {
|
|
89
|
+
winner = comment.id;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return winner;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const acquireClaim = async (
|
|
97
|
+
context: ClaimContext,
|
|
98
|
+
approval: ApprovalBinding,
|
|
99
|
+
claimLabel: string,
|
|
100
|
+
): Promise<ClaimBinding | null> => {
|
|
101
|
+
const event = await lastLabelEvent(
|
|
102
|
+
context.token,
|
|
103
|
+
context.repo,
|
|
104
|
+
context.issueNumber,
|
|
105
|
+
claimLabel,
|
|
106
|
+
);
|
|
107
|
+
if (event === null) {
|
|
108
|
+
throw new Error(`no "${claimLabel}" claim event found`);
|
|
109
|
+
}
|
|
110
|
+
const provisional = {
|
|
111
|
+
approval,
|
|
112
|
+
claimLabel,
|
|
113
|
+
claimEpoch: String(event.id),
|
|
114
|
+
};
|
|
115
|
+
const nonce = randomUUID();
|
|
116
|
+
const markerId = await createComment(
|
|
117
|
+
context.token,
|
|
118
|
+
context.repo,
|
|
119
|
+
context.issueNumber,
|
|
120
|
+
markerBody({ ...provisional, nonce }),
|
|
121
|
+
);
|
|
122
|
+
const winner = await winningMarkerId(context, provisional);
|
|
123
|
+
return winner === markerId ? { ...provisional, markerId } : null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const validateClaim = async (
|
|
127
|
+
context: ClaimContext,
|
|
128
|
+
claim: ClaimBinding,
|
|
129
|
+
currentTarget: string,
|
|
130
|
+
): Promise<string | null> => {
|
|
131
|
+
const current = await readApprovalBinding(
|
|
132
|
+
context,
|
|
133
|
+
claim.approval.label,
|
|
134
|
+
currentTarget,
|
|
135
|
+
);
|
|
136
|
+
if (typeof current === 'string') {
|
|
137
|
+
return current;
|
|
138
|
+
}
|
|
139
|
+
if (current.id !== claim.approval.id) {
|
|
140
|
+
return 'approval no longer matches the exact approved revision/head';
|
|
141
|
+
}
|
|
142
|
+
const issue = await getIssue(
|
|
143
|
+
context.token,
|
|
144
|
+
context.repo,
|
|
145
|
+
context.issueNumber,
|
|
146
|
+
);
|
|
147
|
+
if (!hasLabel(issue.labels, claim.claimLabel)) {
|
|
148
|
+
return `"${claim.claimLabel}" is no longer present`;
|
|
149
|
+
}
|
|
150
|
+
const winner = await winningMarkerId(context, claim);
|
|
151
|
+
return winner === claim.markerId
|
|
152
|
+
? null
|
|
153
|
+
: 'claim ownership changed or could not be proven';
|
|
154
|
+
};
|