@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.
Files changed (43) hide show
  1. package/README.md +6 -1
  2. package/package.json +37 -1
  3. package/src/cli.ts +85 -13
  4. package/src/github-commands.ts +2 -0
  5. package/src/github-label-identity.ts +9 -0
  6. package/src/github-labels.ts +123 -0
  7. package/src/github-live-drift.ts +8 -0
  8. package/src/github-paginate.ts +37 -0
  9. package/src/github-settings-merge.ts +167 -0
  10. package/src/github-settings-parse.ts +82 -7
  11. package/src/github-settings.ts +5 -123
  12. package/src/poller-approval.ts +80 -0
  13. package/src/poller-claim.ts +154 -0
  14. package/src/poller-codex.ts +93 -0
  15. package/src/poller-commands.ts +78 -0
  16. package/src/poller-config.ts +188 -0
  17. package/src/poller-fix-outcome.ts +36 -0
  18. package/src/poller-fix-output.ts +135 -0
  19. package/src/poller-fix-publication.ts +187 -0
  20. package/src/poller-fix-run.ts +175 -0
  21. package/src/poller-fix-validation.ts +64 -0
  22. package/src/poller-github-publication.ts +23 -0
  23. package/src/poller-github-pulls.ts +189 -0
  24. package/src/poller-github-write.ts +87 -0
  25. package/src/poller-github.ts +190 -0
  26. package/src/poller-job-shared.ts +132 -0
  27. package/src/poller-outcome.ts +136 -0
  28. package/src/poller-output-integrity.ts +107 -0
  29. package/src/poller-prompts.ts +70 -0
  30. package/src/poller-protected-paths.ts +51 -0
  31. package/src/poller-protocol.ts +137 -0
  32. package/src/poller-review-artifacts.ts +119 -0
  33. package/src/poller-review-execution.ts +108 -0
  34. package/src/poller-review-output.ts +189 -0
  35. package/src/poller-review-publication.ts +153 -0
  36. package/src/poller-review-run.ts +160 -0
  37. package/src/poller-review-state.ts +126 -0
  38. package/src/poller-review-validation.ts +66 -0
  39. package/src/poller-schedule.ts +121 -0
  40. package/src/poller-tick.ts +144 -0
  41. package/src/poller-trust.ts +96 -0
  42. package/src/poller-units.ts +82 -0
  43. 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 = 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,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
+ };