@davidvornholt/standards 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,34 +1,33 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { hasLabel } from './github-label-identity';
3
- import { isRecord } from './github-settings-parse';
4
3
  import { type ApprovalBinding, readApprovalBinding } from './poller-approval';
5
4
  import {
6
- hiddenCommentMetadata,
7
- parseHiddenCommentMetadata,
8
- } from './poller-comment-metadata';
5
+ type ClaimMarkerBinding,
6
+ reconcileTrustedClaimElection,
7
+ reconcileTrustedClaimHistory,
8
+ } from './poller-claim-reconciliation';
9
+ import { hiddenCommentMetadata } from './poller-comment-metadata';
10
+ import { getIssue, lastLabelEvent } from './poller-github';
9
11
  import {
10
- collaboratorRole,
11
- getIssue,
12
- type IssueComment,
13
- lastLabelEvent,
14
- listIssueComments,
15
- } from './poller-github';
16
- import { createComment, deleteComment } from './poller-github-write';
12
+ addLabels,
13
+ createComment,
14
+ deleteComment,
15
+ removeLabel,
16
+ } from './poller-github-write';
17
+ import type { JobDeps } from './poller-job-shared';
17
18
  import {
18
19
  CLAIM_METADATA_MARKER,
19
20
  FIX_IN_PROGRESS,
20
- isTrustedRole,
21
21
  REVIEW_IN_PROGRESS,
22
22
  } from './poller-protocol';
23
+ import { inProgressLabelFor, type PollerJobKind } from './poller-queue-marker';
24
+ import { reconcileExistingQueuedJob } from './poller-status';
23
25
 
24
- export type ClaimBinding = {
25
- readonly approval: ApprovalBinding;
26
- readonly claimLabel: string;
27
- readonly claimEpoch: string;
26
+ export type ClaimBinding = ClaimMarkerBinding & {
28
27
  readonly markerId: number;
29
28
  };
30
29
 
31
- type ClaimMarker = Omit<ClaimBinding, 'markerId'> & {
30
+ type ClaimMarker = ClaimMarkerBinding & {
32
31
  readonly nonce: string;
33
32
  };
34
33
 
@@ -54,73 +53,6 @@ const markerBody = (marker: ClaimMarker): string =>
54
53
  marker,
55
54
  )}`;
56
55
 
57
- const isApprovalBinding = (value: unknown): value is ApprovalBinding =>
58
- isRecord(value) &&
59
- typeof value.id === 'string' &&
60
- typeof value.repo === 'string' &&
61
- typeof value.issueNumber === 'number' &&
62
- typeof value.eventId === 'number' &&
63
- typeof value.label === 'string' &&
64
- typeof value.actorLogin === 'string' &&
65
- typeof value.approvedAt === 'string' &&
66
- typeof value.target === 'string';
67
-
68
- const parseMarker = (comment: IssueComment): ClaimMarker | null => {
69
- const payload = parseHiddenCommentMetadata(
70
- comment.body,
71
- CLAIM_METADATA_MARKER,
72
- );
73
- if (!isRecord(payload)) {
74
- return null;
75
- }
76
- const raw = payload;
77
- if (
78
- typeof raw.claimLabel !== 'string' ||
79
- typeof raw.claimEpoch !== 'string' ||
80
- typeof raw.nonce !== 'string' ||
81
- !isApprovalBinding(raw.approval)
82
- ) {
83
- return null;
84
- }
85
- return {
86
- approval: raw.approval,
87
- claimLabel: raw.claimLabel,
88
- claimEpoch: raw.claimEpoch,
89
- nonce: raw.nonce,
90
- };
91
- };
92
-
93
- const winningMarkerId = async (
94
- context: ClaimContext,
95
- binding: Omit<ClaimBinding, 'markerId'>,
96
- ): Promise<number | null> => {
97
- const comments = await listIssueComments(
98
- context.token,
99
- context.repo,
100
- context.issueNumber,
101
- );
102
- let winner: number | null = null;
103
- for (const comment of comments) {
104
- const marker = parseMarker(comment);
105
- const differs =
106
- marker?.approval.id !== binding.approval.id ||
107
- marker.claimLabel !== binding.claimLabel ||
108
- marker.claimEpoch !== binding.claimEpoch;
109
- if (!differs) {
110
- // biome-ignore lint/performance/noAwaitInLoops: claim authors must be checked against their current role; the usually-one-item list is deliberately fail-closed.
111
- const role = await collaboratorRole(
112
- context.token,
113
- context.repo,
114
- comment.authorLogin,
115
- );
116
- if (isTrustedRole(role) && (winner === null || comment.id < winner)) {
117
- winner = comment.id;
118
- }
119
- }
120
- }
121
- return winner;
122
- };
123
-
124
56
  export const acquireClaim = async (
125
57
  context: ClaimContext,
126
58
  approval: ApprovalBinding,
@@ -147,14 +79,36 @@ export const acquireClaim = async (
147
79
  context.issueNumber,
148
80
  markerBody({ ...provisional, nonce }),
149
81
  );
150
- const winner = await winningMarkerId(context, provisional);
151
- if (winner !== markerId) {
152
- await deleteComment(context.token, context.repo, markerId);
82
+ const election = await reconcileTrustedClaimElection(context, provisional);
83
+ if (election.retainedId !== markerId) {
84
+ if (!election.markerIds.includes(markerId)) {
85
+ await deleteComment(context.token, context.repo, markerId);
86
+ }
153
87
  return null;
154
88
  }
155
89
  return { ...provisional, markerId };
156
90
  };
157
91
 
92
+ export const startClaim = async (
93
+ deps: JobDeps,
94
+ issueNumber: number,
95
+ approval: ApprovalBinding,
96
+ kind: PollerJobKind,
97
+ ): Promise<ClaimBinding | null> => {
98
+ await reconcileExistingQueuedJob(deps, issueNumber, approval, kind);
99
+ const claimLabel = inProgressLabelFor(kind);
100
+ await reconcileTrustedClaimHistory(
101
+ { token: deps.token, repo: deps.repo, issueNumber },
102
+ claimLabel,
103
+ );
104
+ await addLabels(deps.token, deps.repo, issueNumber, [claimLabel]);
105
+ return acquireClaim(
106
+ { token: deps.token, repo: deps.repo, issueNumber },
107
+ approval,
108
+ claimLabel,
109
+ );
110
+ };
111
+
158
112
  export const validateClaim = async (
159
113
  context: ClaimContext,
160
114
  claim: ClaimBinding,
@@ -182,8 +136,57 @@ export const validateClaim = async (
182
136
  if (!hasLabel(issue.labels, claim.claimLabel)) {
183
137
  return `"${claim.claimLabel}" is no longer present`;
184
138
  }
185
- const winner = await winningMarkerId(context, claim);
186
- return winner === claim.markerId
139
+ const election = await reconcileTrustedClaimElection(context, claim);
140
+ return election.retainedId === claim.markerId
187
141
  ? null
188
142
  : 'claim ownership changed or could not be proven';
189
143
  };
144
+
145
+ const errorMessage = (error: unknown): string =>
146
+ error instanceof Error ? error.message : String(error);
147
+
148
+ const releaseOwnedClaim = async (
149
+ deps: JobDeps,
150
+ issueNumber: number,
151
+ claim: ClaimBinding,
152
+ ): Promise<void> => {
153
+ const context = { token: deps.token, repo: deps.repo, issueNumber };
154
+ const election = await reconcileTrustedClaimElection(context, claim);
155
+ const issue = await getIssue(deps.token, deps.repo, issueNumber);
156
+ const event = await lastLabelEvent(
157
+ deps.token,
158
+ deps.repo,
159
+ issueNumber,
160
+ claim.claimLabel,
161
+ );
162
+ if (
163
+ election.retainedId === claim.markerId &&
164
+ hasLabel(issue.labels, claim.claimLabel) &&
165
+ String(event?.id) === claim.claimEpoch
166
+ ) {
167
+ await removeLabel(deps.token, deps.repo, issueNumber, claim.claimLabel);
168
+ }
169
+ };
170
+
171
+ export const withClaimReleasedOnFailure = async <Result>(
172
+ deps: JobDeps,
173
+ issueNumber: number,
174
+ claim: ClaimBinding,
175
+ operation: () => Promise<Result>,
176
+ ): Promise<Result> => {
177
+ try {
178
+ return await operation();
179
+ } catch (operationError) {
180
+ try {
181
+ await releaseOwnedClaim(deps, issueNumber, claim);
182
+ } catch (releaseError) {
183
+ throw new Error(
184
+ `${errorMessage(operationError)}; releasing the claim for an automatic retry also failed: ${errorMessage(releaseError)}`,
185
+ {
186
+ cause: releaseError,
187
+ },
188
+ );
189
+ }
190
+ throw operationError;
191
+ }
192
+ };
@@ -0,0 +1,29 @@
1
+ import { deleteComment } from './poller-github-write';
2
+
3
+ type CommentContext = {
4
+ readonly token: string | null;
5
+ readonly repo: string;
6
+ };
7
+
8
+ export const earliestCommentId = (
9
+ commentIds: ReadonlyArray<number>,
10
+ ): number | null =>
11
+ commentIds.reduce<number | null>(
12
+ (earliest, commentId) =>
13
+ earliest === null || commentId < earliest ? commentId : earliest,
14
+ null,
15
+ );
16
+
17
+ export const retainEarliestComment = async (
18
+ context: CommentContext,
19
+ commentIds: ReadonlyArray<number>,
20
+ ): Promise<number | null> => {
21
+ const retainedId = earliestCommentId(commentIds);
22
+ for (const commentId of commentIds) {
23
+ if (commentId !== retainedId) {
24
+ // biome-ignore lint/performance/noAwaitInLoops: GitHub mutations are deliberately serialized to avoid secondary rate limits.
25
+ await deleteComment(context.token, context.repo, commentId);
26
+ }
27
+ }
28
+ return retainedId;
29
+ };
@@ -3,7 +3,7 @@
3
3
  import { join } from 'node:path';
4
4
  import { hasLabel } from './github-label-identity';
5
5
  import { approvalIsCurrent, issueRevision } from './poller-approval';
6
- import { acquireClaim } from './poller-claim';
6
+ import { startClaim, withClaimReleasedOnFailure } from './poller-claim';
7
7
  import { runCodex } from './poller-codex';
8
8
  import { handleNonFixedOutcome } from './poller-fix-outcome';
9
9
  import { readSealedFixOutput } from './poller-fix-output';
@@ -14,7 +14,6 @@ import {
14
14
  validateFixClaim,
15
15
  } from './poller-fix-publication';
16
16
  import { getIssue, type IssueItem } from './poller-github';
17
- import { addLabels } from './poller-github-write';
18
17
  import {
19
18
  failJob,
20
19
  type JobDeps,
@@ -118,12 +117,7 @@ export const runFixJob = async (
118
117
  await acknowledgeQueuedJob(deps, issue.number, preamble.approval, 'fix');
119
118
  return result(issue.number, 'waiting for run capacity');
120
119
  }
121
- await addLabels(token, repo, issue.number, [FIX_IN_PROGRESS]);
122
- const claim = await acquireClaim(
123
- { token, repo, issueNumber: issue.number },
124
- preamble.approval,
125
- FIX_IN_PROGRESS,
126
- );
120
+ const claim = await startClaim(deps, issue.number, preamble.approval, 'fix');
127
121
  if (claim === null) {
128
122
  return result(issue.number, 'another poller owns the claim');
129
123
  }
@@ -138,7 +132,11 @@ export const runFixJob = async (
138
132
  if (sealed !== null) {
139
133
  assertSealedOwnership(sealed, issue.number, claim.approval.id, branch);
140
134
  return {
141
- lines: [await publishFixedOutput(resumableJob, FIX_LABELS, sealed, null)],
135
+ lines: [
136
+ await withClaimReleasedOnFailure(deps, issue.number, claim, () =>
137
+ publishFixedOutput(resumableJob, FIX_LABELS, sealed, null),
138
+ ),
139
+ ],
142
140
  ranCodex: false,
143
141
  };
144
142
  }
@@ -186,8 +184,13 @@ export const runFixJob = async (
186
184
  };
187
185
  }
188
186
  const nonFixed = await handleNonFixedOutcome(job, FIX_LABELS, outcome);
187
+ const message =
188
+ nonFixed ??
189
+ (await withClaimReleasedOnFailure(deps, issue.number, claim, () =>
190
+ finishFixedJob(job, FIX_LABELS, outcome),
191
+ ));
189
192
  return {
190
- lines: [nonFixed ?? (await finishFixedJob(job, FIX_LABELS, outcome))],
193
+ lines: [message],
191
194
  ranCodex: true,
192
195
  };
193
196
  } finally {
@@ -182,13 +182,14 @@ export const inspectQueuedAcknowledgement = async (options: {
182
182
  item.number,
183
183
  expectedLabel,
184
184
  );
185
- return (
186
- event !== null &&
187
- checkpoints.some(
188
- (checkpoint) =>
189
- checkpoint.eventId === event.id &&
190
- checkpoint.actorLogin === event.actorLogin &&
191
- checkpoint.approvedAt === event.createdAt,
192
- )
193
- );
185
+ if (event === null) {
186
+ return false;
187
+ }
188
+ const currentCheckpointCount = checkpoints.filter(
189
+ (checkpoint) =>
190
+ checkpoint.eventId === event.id &&
191
+ checkpoint.actorLogin === event.actorLogin &&
192
+ checkpoint.approvedAt === event.createdAt,
193
+ ).length;
194
+ return currentCheckpointCount === 1;
194
195
  };
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import type { ClaimBinding } from './poller-claim';
2
+ import { type ClaimBinding, withClaimReleasedOnFailure } from './poller-claim';
3
3
  import type { runCodex } from './poller-codex';
4
4
  import type { PullRequest } from './poller-github-pulls';
5
5
  import {
@@ -95,14 +95,16 @@ export const executeReviewJob = async (options: {
95
95
  }
96
96
  return {
97
97
  lines: [
98
- await finishReviewedJob({
99
- deps,
100
- labels,
101
- pr,
102
- claim,
103
- workDir: workspace.dir,
104
- outcome,
105
- }),
98
+ await withClaimReleasedOnFailure(deps, pr.number, claim, () =>
99
+ finishReviewedJob({
100
+ deps,
101
+ labels,
102
+ pr,
103
+ claim,
104
+ workDir: workspace.dir,
105
+ outcome,
106
+ }),
107
+ ),
106
108
  ],
107
109
  ranCodex: true,
108
110
  };
@@ -6,11 +6,10 @@
6
6
 
7
7
  import { hasLabel } from './github-label-identity';
8
8
  import { approvalIsCurrent, prRevision } from './poller-approval';
9
- import { acquireClaim } from './poller-claim';
9
+ import { startClaim, withClaimReleasedOnFailure } from './poller-claim';
10
10
  import { runCodex } from './poller-codex';
11
11
  import { getIssue, type IssueItem } from './poller-github';
12
12
  import { getPullRequest } from './poller-github-pulls';
13
- import { addLabels } from './poller-github-write';
14
13
  import {
15
14
  failJob,
16
15
  type JobDeps,
@@ -86,7 +85,7 @@ export const runReviewJob = async (
86
85
  };
87
86
  }
88
87
  const pr = await getPullRequest(token, repo, prItem.number);
89
- let plan = await currentReviewPlan(deps, pr);
88
+ const plan = await currentReviewPlan(deps, pr);
90
89
  const readTarget = () => currentReviewTarget(token, repo, pr.number, plan);
91
90
  const preamble = await jobPreamble(deps, currentItem, REVIEW_LABELS, {
92
91
  approved: prRevision(
@@ -157,32 +156,29 @@ export const runReviewJob = async (
157
156
  ranCodex: false,
158
157
  };
159
158
  }
160
- await addLabels(token, repo, prItem.number, [REVIEW_IN_PROGRESS]);
161
- const claim = await acquireClaim(
162
- { token, repo, issueNumber: pr.number },
163
- preamble.approval,
164
- REVIEW_IN_PROGRESS,
165
- );
159
+ const claim = await startClaim(deps, pr.number, preamble.approval, 'review');
166
160
  if (claim === null) {
167
161
  return {
168
162
  lines: [`PR #${pr.number}: another poller owns the claim`],
169
163
  ranCodex: false,
170
164
  };
171
165
  }
172
- if (plan === null && sealed !== null) {
173
- await publishReviewPlan(deps, pr, claim, sealed);
174
- plan = sealed;
175
- }
176
- if (plan !== null) {
166
+ const resumablePlan = plan ?? sealed;
167
+ if (resumablePlan !== null) {
177
168
  return {
178
169
  lines: [
179
- await resumeReviewedJob({
180
- deps,
181
- labels: REVIEW_LABELS,
182
- pr,
183
- claim,
184
- plan,
185
- cloneDir: cacheClone,
170
+ await withClaimReleasedOnFailure(deps, pr.number, claim, async () => {
171
+ if (plan === null) {
172
+ await publishReviewPlan(deps, pr, claim, resumablePlan);
173
+ }
174
+ return resumeReviewedJob({
175
+ deps,
176
+ labels: REVIEW_LABELS,
177
+ pr,
178
+ claim,
179
+ plan: resumablePlan,
180
+ cloneDir: cacheClone,
181
+ });
186
182
  }),
187
183
  ],
188
184
  ranCodex: false,
@@ -1,4 +1,5 @@
1
1
  import { hasLabel } from './github-label-identity';
2
+ import { reconcileTrustedClaimHistory } from './poller-claim-reconciliation';
2
3
  import {
3
4
  type IssueItem,
4
5
  lastLabelEvent,
@@ -47,6 +48,10 @@ const sweepStaleClaims = async (
47
48
  ? Number.POSITIVE_INFINITY
48
49
  : now - Date.parse(event.createdAt);
49
50
  if (ageMs > config.staleClaimHours * MS_PER_HOUR) {
51
+ await reconcileTrustedClaimHistory(
52
+ { token, repo, issueNumber: item.number },
53
+ claimLabel,
54
+ );
50
55
  await removeLabel(token, repo, item.number, claimLabel);
51
56
  await createComment(
52
57
  token,
@@ -6,6 +6,7 @@ import {
6
6
  readApprovalBinding,
7
7
  } from './poller-approval';
8
8
  import { hiddenCommentMetadata } from './poller-comment-metadata';
9
+ import { retainEarliestComment } from './poller-comment-reconciliation';
9
10
  import { getIssue } from './poller-github';
10
11
  import { getPullRequest } from './poller-github-pulls';
11
12
  import { createComment, deleteComment } from './poller-github-write';
@@ -94,16 +95,14 @@ export const acknowledgeQueuedJob = async (
94
95
  ) {
95
96
  return false;
96
97
  }
97
- if (
98
- (
99
- await matchingTrustedQueueCommentIds(
100
- deps,
101
- issueNumber,
102
- expected.approvalId,
103
- kind,
104
- )
105
- ).length > 0
106
- ) {
98
+ const existingMarkerIds = await matchingTrustedQueueCommentIds(
99
+ deps,
100
+ issueNumber,
101
+ expected.approvalId,
102
+ kind,
103
+ );
104
+ if (existingMarkerIds.length > 0) {
105
+ await retainEarliestComment(deps, existingMarkerIds);
107
106
  return false;
108
107
  }
109
108
  const markerId = await createComment(
@@ -115,16 +114,17 @@ export const acknowledgeQueuedJob = async (
115
114
  expected,
116
115
  )}`,
117
116
  );
118
- const [winner] = [
119
- ...(await matchingTrustedQueueCommentIds(
120
- deps,
121
- issueNumber,
122
- expected.approvalId,
123
- kind,
124
- )),
125
- ].sort((left, right) => left - right);
117
+ const markerIds = await matchingTrustedQueueCommentIds(
118
+ deps,
119
+ issueNumber,
120
+ expected.approvalId,
121
+ kind,
122
+ );
123
+ const winner = await retainEarliestComment(deps, markerIds);
126
124
  if (winner !== markerId) {
127
- await deleteComment(deps.token, deps.repo, markerId);
125
+ if (!markerIds.includes(markerId)) {
126
+ await deleteComment(deps.token, deps.repo, markerId);
127
+ }
128
128
  return false;
129
129
  }
130
130
  if (!(await isStillQueueable(deps, issueNumber, approval, kind))) {
@@ -133,3 +133,18 @@ export const acknowledgeQueuedJob = async (
133
133
  }
134
134
  return true;
135
135
  };
136
+
137
+ export const reconcileExistingQueuedJob = async (
138
+ deps: JobDeps,
139
+ issueNumber: number,
140
+ approval: ApprovalBinding,
141
+ kind: PollerJobKind,
142
+ ): Promise<void> => {
143
+ const markerIds = await matchingTrustedQueueCommentIds(
144
+ deps,
145
+ issueNumber,
146
+ approval.id,
147
+ kind,
148
+ );
149
+ await retainEarliestComment(deps, markerIds);
150
+ };