@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.
Files changed (45) hide show
  1. package/README.md +9 -2
  2. package/package.json +38 -1
  3. package/src/cli.ts +98 -87
  4. package/src/github-api.ts +2 -0
  5. package/src/github-commands.ts +2 -0
  6. package/src/github-label-identity.ts +9 -0
  7. package/src/github-labels.ts +123 -0
  8. package/src/github-live-drift.ts +29 -0
  9. package/src/github-paginate.ts +49 -0
  10. package/src/github-settings-merge.ts +167 -0
  11. package/src/github-settings-parse.ts +82 -7
  12. package/src/github-settings.ts +5 -123
  13. package/src/managed-files.ts +64 -0
  14. package/src/poller-approval.ts +80 -0
  15. package/src/poller-claim.ts +154 -0
  16. package/src/poller-codex.ts +93 -0
  17. package/src/poller-commands.ts +78 -0
  18. package/src/poller-config.ts +188 -0
  19. package/src/poller-fix-outcome.ts +36 -0
  20. package/src/poller-fix-output.ts +135 -0
  21. package/src/poller-fix-publication.ts +187 -0
  22. package/src/poller-fix-run.ts +175 -0
  23. package/src/poller-fix-validation.ts +64 -0
  24. package/src/poller-github-publication.ts +23 -0
  25. package/src/poller-github-pulls.ts +189 -0
  26. package/src/poller-github-write.ts +87 -0
  27. package/src/poller-github.ts +190 -0
  28. package/src/poller-job-shared.ts +132 -0
  29. package/src/poller-outcome.ts +136 -0
  30. package/src/poller-output-integrity.ts +107 -0
  31. package/src/poller-prompts.ts +70 -0
  32. package/src/poller-protected-paths.ts +51 -0
  33. package/src/poller-protocol.ts +137 -0
  34. package/src/poller-review-artifacts.ts +119 -0
  35. package/src/poller-review-execution.ts +108 -0
  36. package/src/poller-review-output.ts +189 -0
  37. package/src/poller-review-publication.ts +153 -0
  38. package/src/poller-review-run.ts +160 -0
  39. package/src/poller-review-state.ts +126 -0
  40. package/src/poller-review-validation.ts +66 -0
  41. package/src/poller-schedule.ts +121 -0
  42. package/src/poller-tick.ts +144 -0
  43. package/src/poller-trust.ts +96 -0
  44. package/src/poller-units.ts +82 -0
  45. package/src/poller-workspace.ts +199 -0
@@ -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 = raw.rulesets ?? [];
100
- if (Array.isArray(rulesets)) {
101
- problems.push(...rulesetListProblems(rulesets, label));
102
- } else {
103
- problems.push(`${label} "rulesets" must be an array`);
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: Array.isArray(rulesets) ? rulesets : null,
192
+ rulesets,
193
+ labels,
119
194
  rulesetEnforcement: parseRulesetEnforcement(raw.rulesetEnforcement),
120
195
  },
121
196
  problems,
@@ -1,12 +1,12 @@
1
- // Merge canonical GitHub settings with the repo-owned extension. Parsing,
2
- // drift, and API work live in named modules; this stays dependency-free.
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
+ };