@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
@@ -0,0 +1,153 @@
1
+ import type { ClaimBinding } from './poller-claim';
2
+ import type { PullRequest } from './poller-github-pulls';
3
+ import { failJob, type JobDeps, type JobLabels } from './poller-job-shared';
4
+ import {
5
+ changedWorkspaceQualityManifests,
6
+ lockedPathsOf,
7
+ } from './poller-protected-paths';
8
+ import { forbiddenDiffPaths, type ReviewOutcome } from './poller-protocol';
9
+ import { publishReviewArtifacts } from './poller-review-artifacts';
10
+ import {
11
+ type ReviewPublicationPlan,
12
+ readSealedReviewPlan,
13
+ reviewOutputBranch,
14
+ sealReviewPlan,
15
+ } from './poller-review-output';
16
+ import { publishReviewPlan, validateReviewClaim } from './poller-review-state';
17
+ import { validateSealedReviewOutput } from './poller-review-validation';
18
+ import {
19
+ changedPaths,
20
+ commitCount,
21
+ headSha,
22
+ localBranchExists,
23
+ pushBranch,
24
+ } from './poller-workspace';
25
+
26
+ const outputBranchFor = (plan: ReviewPublicationPlan): string =>
27
+ reviewOutputBranch(plan);
28
+
29
+ export const resumeReviewedJob = async (options: {
30
+ readonly deps: JobDeps;
31
+ readonly labels: JobLabels;
32
+ readonly pr: PullRequest;
33
+ readonly claim: ClaimBinding;
34
+ readonly plan: ReviewPublicationPlan;
35
+ readonly cloneDir: string;
36
+ }): Promise<string> => {
37
+ const { deps, labels, pr, claim, plan, cloneDir } = options;
38
+ if (claim.approval.id !== plan.approvalId) {
39
+ throw new Error('publication blocked: review plan approval changed');
40
+ }
41
+ const outputBranch = outputBranchFor(plan);
42
+ const sealed = readSealedReviewPlan(cloneDir, outputBranch);
43
+ if (sealed === null) {
44
+ const detail = localBranchExists(cloneDir, outputBranch)
45
+ ? 'is not valid sealed review output'
46
+ : 'is missing';
47
+ throw new Error(`publication blocked: ${outputBranch} ${detail}`);
48
+ }
49
+ if (JSON.stringify(sealed) !== JSON.stringify(plan)) {
50
+ throw new Error('publication blocked: sealed review plan changed');
51
+ }
52
+ await validateSealedReviewOutput({ deps, pr, plan, cloneDir });
53
+ if (pr.headSha === plan.approvedHead && plan.publishedHead !== pr.headSha) {
54
+ await validateReviewClaim({
55
+ deps,
56
+ pr,
57
+ claim,
58
+ plan,
59
+ expectedHead: plan.approvedHead,
60
+ requireDraft: true,
61
+ });
62
+ pushBranch(cloneDir, {
63
+ repo: deps.repo,
64
+ branch: pr.headRef,
65
+ token: deps.token,
66
+ sourceRef: plan.publishedHead,
67
+ expectedRemoteSha: plan.approvedHead,
68
+ });
69
+ }
70
+ await validateReviewClaim({
71
+ deps,
72
+ pr,
73
+ claim,
74
+ plan,
75
+ expectedHead: plan.publishedHead,
76
+ requireDraft: false,
77
+ });
78
+ return publishReviewArtifacts({ deps, labels, pr, claim, plan });
79
+ };
80
+
81
+ export const finishReviewedJob = async (options: {
82
+ readonly deps: JobDeps;
83
+ readonly labels: JobLabels;
84
+ readonly pr: PullRequest;
85
+ readonly claim: ClaimBinding;
86
+ readonly workDir: string;
87
+ readonly outcome: ReviewOutcome;
88
+ }): Promise<string> => {
89
+ const { deps, labels, pr, claim, workDir, outcome } = options;
90
+ const commits = commitCount(workDir, pr.headSha);
91
+ const paths = commits > 0 ? changedPaths(workDir, pr.headSha) : [];
92
+ const forbidden = [
93
+ ...forbiddenDiffPaths(paths, await lockedPathsOf(workDir)),
94
+ ...changedWorkspaceQualityManifests(workDir, pr.headSha, paths),
95
+ ];
96
+ if (forbidden.length > 0) {
97
+ await failJob(
98
+ deps,
99
+ labels,
100
+ pr.number,
101
+ `review fixes modified protected paths:\n${forbidden.map((path) => `- ${path}`).join('\n')}`,
102
+ );
103
+ return `PR #${pr.number}: failed (protected paths)`;
104
+ }
105
+ const plan: ReviewPublicationPlan = {
106
+ repo: deps.repo,
107
+ prNumber: pr.number,
108
+ approvalId: claim.approval.id,
109
+ approvedHead: pr.headSha,
110
+ publishedHead: headSha(workDir),
111
+ baseRef: pr.baseRef,
112
+ baseSha: pr.baseSha,
113
+ report: outcome.report ?? '',
114
+ commits,
115
+ deferred: outcome.deferred ?? [],
116
+ };
117
+ const outputBranch = outputBranchFor(plan);
118
+ const sealedHead = sealReviewPlan(workDir, plan);
119
+ pushBranch(workDir, {
120
+ repo: deps.repo,
121
+ branch: outputBranch,
122
+ token: deps.token,
123
+ expectedRemoteSha: '',
124
+ sourceRef: sealedHead,
125
+ });
126
+ await publishReviewPlan(deps, pr, claim, plan);
127
+ if (commits > 0) {
128
+ await validateReviewClaim({
129
+ deps,
130
+ pr,
131
+ claim,
132
+ plan,
133
+ expectedHead: plan.approvedHead,
134
+ requireDraft: true,
135
+ });
136
+ pushBranch(workDir, {
137
+ repo: deps.repo,
138
+ branch: pr.headRef,
139
+ token: deps.token,
140
+ sourceRef: plan.publishedHead,
141
+ expectedRemoteSha: plan.approvedHead,
142
+ });
143
+ }
144
+ await validateReviewClaim({
145
+ deps,
146
+ pr,
147
+ claim,
148
+ plan,
149
+ expectedHead: plan.publishedHead,
150
+ requireDraft: true,
151
+ });
152
+ return publishReviewArtifacts({ deps, labels, pr, claim, plan });
153
+ };
@@ -0,0 +1,160 @@
1
+ // One review job: a maintainer-approved draft PR gets a full review-fix
2
+ // cycle — lens fan-out inside the Codex run, fixes as new commits — then the
3
+ // poller posts the report, files deferred findings as issues, and flips the
4
+ // PR to ready. GitHub writes stay in deterministic poller code; the agent
5
+ // never holds credentials.
6
+
7
+ import { prRevision } from './poller-approval';
8
+ import { acquireClaim } from './poller-claim';
9
+ import { getIssue, type IssueItem } from './poller-github';
10
+ import { getPullRequest } from './poller-github-pulls';
11
+ import { addLabels } from './poller-github-write';
12
+ import {
13
+ failJob,
14
+ type JobDeps,
15
+ type JobLabels,
16
+ type JobResult,
17
+ jobPreamble,
18
+ } from './poller-job-shared';
19
+ import {
20
+ APPROVED_FOR_REVIEW,
21
+ REVIEW_FAILED,
22
+ REVIEW_IN_PROGRESS,
23
+ } from './poller-protocol';
24
+ import { executeReviewJob } from './poller-review-execution';
25
+ import {
26
+ readSealedReviewPlan,
27
+ reviewOutputBranch,
28
+ } from './poller-review-output';
29
+ import { resumeReviewedJob } from './poller-review-publication';
30
+ import { publishReviewPlan, readReviewPlan } from './poller-review-state';
31
+ import { ensureCacheClone, localBranchExists } from './poller-workspace';
32
+
33
+ const REVIEW_LABELS: JobLabels = {
34
+ approved: APPROVED_FOR_REVIEW,
35
+ inProgress: REVIEW_IN_PROGRESS,
36
+ failed: REVIEW_FAILED,
37
+ };
38
+
39
+ const currentReviewPlan = readReviewPlan;
40
+
41
+ const hasInvalidLocalOutput = (
42
+ plan: Awaited<ReturnType<typeof readReviewPlan>>,
43
+ sealed: ReturnType<typeof readSealedReviewPlan>,
44
+ cloneDir: string,
45
+ branch: string,
46
+ ): boolean =>
47
+ plan === null && sealed === null && localBranchExists(cloneDir, branch);
48
+
49
+ export const runReviewJob = async (
50
+ deps: JobDeps,
51
+ prItem: IssueItem,
52
+ allowCodex = true,
53
+ ): Promise<JobResult> => {
54
+ const { config, token, repo } = deps;
55
+ const pr = await getPullRequest(token, repo, prItem.number);
56
+ let plan = await currentReviewPlan(deps, pr);
57
+ const currentItem = await getIssue(token, repo, prItem.number);
58
+ const preamble = await jobPreamble(
59
+ deps,
60
+ currentItem,
61
+ REVIEW_LABELS,
62
+ prRevision(pr.baseRef, pr.baseSha, plan?.approvedHead ?? pr.headSha),
63
+ );
64
+ if (preamble.kind === 'rejected') {
65
+ return {
66
+ lines: [`PR #${prItem.number}: approval rejected`],
67
+ ranCodex: false,
68
+ };
69
+ }
70
+ if (preamble.kind === 'waiting') {
71
+ return {
72
+ lines: [`PR #${prItem.number}: waiting on an answer`],
73
+ ranCodex: false,
74
+ };
75
+ }
76
+ if (!pr.draft && plan === null) {
77
+ await failJob(
78
+ deps,
79
+ REVIEW_LABELS,
80
+ pr.number,
81
+ 'automated review requires a draft PR',
82
+ );
83
+ return {
84
+ lines: [`PR #${pr.number}: rejected (not draft)`],
85
+ ranCodex: false,
86
+ };
87
+ }
88
+ // Fork PRs are out of scope: their head branch lives in a repository the
89
+ // poller must not push to, and pushing a same-named branch to the base repo
90
+ // would fake a review. Fail explicitly instead of pretending.
91
+ if (pr.headRepo !== repo) {
92
+ await failJob(
93
+ deps,
94
+ REVIEW_LABELS,
95
+ pr.number,
96
+ `this PR's head branch lives in ${pr.headRepo || 'an unknown repository'}; automated review runs only support same-repository branches`,
97
+ );
98
+ return {
99
+ lines: [`PR #${pr.number}: rejected (fork head)`],
100
+ ranCodex: false,
101
+ };
102
+ }
103
+ const cacheClone = ensureCacheClone(config.cacheDir, repo, token);
104
+ const outputBranch = reviewOutputBranch({
105
+ repo,
106
+ prNumber: pr.number,
107
+ baseSha: pr.baseSha,
108
+ approvedHead: plan?.approvedHead ?? pr.headSha,
109
+ approvalId: preamble.approval.id,
110
+ });
111
+ const sealed = readSealedReviewPlan(cacheClone, outputBranch);
112
+ if (hasInvalidLocalOutput(plan, sealed, cacheClone, outputBranch)) {
113
+ throw new Error(`sealed output on ${outputBranch} is invalid`);
114
+ }
115
+ if (plan === null && sealed === null && !allowCodex) {
116
+ return {
117
+ lines: [`PR #${pr.number}: waiting for run capacity`],
118
+ ranCodex: false,
119
+ };
120
+ }
121
+ await addLabels(token, repo, prItem.number, [REVIEW_IN_PROGRESS]);
122
+ const claim = await acquireClaim(
123
+ { token, repo, issueNumber: pr.number },
124
+ preamble.approval,
125
+ REVIEW_IN_PROGRESS,
126
+ );
127
+ if (claim === null) {
128
+ return {
129
+ lines: [`PR #${pr.number}: another poller owns the claim`],
130
+ ranCodex: false,
131
+ };
132
+ }
133
+ if (plan === null && sealed !== null) {
134
+ await publishReviewPlan(deps, pr, claim, sealed);
135
+ plan = sealed;
136
+ }
137
+ if (plan !== null) {
138
+ return {
139
+ lines: [
140
+ await resumeReviewedJob({
141
+ deps,
142
+ labels: REVIEW_LABELS,
143
+ pr,
144
+ claim,
145
+ plan,
146
+ cloneDir: cacheClone,
147
+ }),
148
+ ],
149
+ ranCodex: false,
150
+ };
151
+ }
152
+ return executeReviewJob({
153
+ deps,
154
+ labels: REVIEW_LABELS,
155
+ pr,
156
+ claim,
157
+ cacheClone,
158
+ answers: preamble.answers,
159
+ });
160
+ };
@@ -0,0 +1,126 @@
1
+ import { prRevision, readApprovalBinding } from './poller-approval';
2
+ import { type ClaimBinding, validateClaim } from './poller-claim';
3
+ import { collaboratorRole, listIssueComments } from './poller-github';
4
+ import { getPullRequest, type PullRequest } from './poller-github-pulls';
5
+ import { createComment } from './poller-github-write';
6
+ import type { JobDeps } from './poller-job-shared';
7
+ import { APPROVED_FOR_REVIEW, isTrustedRole } from './poller-protocol';
8
+ import {
9
+ parseReviewPlan,
10
+ type ReviewPublicationPlan,
11
+ reviewPlanMarker,
12
+ } from './poller-review-output';
13
+
14
+ export const readReviewPlan = async (
15
+ deps: JobDeps,
16
+ pr: PullRequest,
17
+ ): Promise<ReviewPublicationPlan | null> => {
18
+ const comments = await listIssueComments(deps.token, deps.repo, pr.number);
19
+ const currentPlans: Array<ReviewPublicationPlan> = [];
20
+ for (const comment of comments) {
21
+ const plan = parseReviewPlan(comment.body);
22
+ if (
23
+ plan !== null &&
24
+ plan.repo === deps.repo &&
25
+ plan.prNumber === pr.number &&
26
+ plan.baseRef === pr.baseRef &&
27
+ plan.baseSha === pr.baseSha &&
28
+ (plan.approvedHead === pr.headSha || plan.publishedHead === pr.headSha)
29
+ ) {
30
+ // biome-ignore lint/performance/noAwaitInLoops: plan authorship is a publication trust boundary and must fail closed against the current role.
31
+ const role = await collaboratorRole(
32
+ deps.token,
33
+ deps.repo,
34
+ comment.authorLogin,
35
+ );
36
+ if (isTrustedRole(role)) {
37
+ const binding = await readApprovalBinding(
38
+ {
39
+ token: deps.token,
40
+ repo: deps.repo,
41
+ issueNumber: pr.number,
42
+ },
43
+ APPROVED_FOR_REVIEW,
44
+ prRevision(plan.baseRef, plan.baseSha, plan.approvedHead),
45
+ );
46
+ if (typeof binding !== 'string' && binding.id === plan.approvalId) {
47
+ currentPlans.push(plan);
48
+ }
49
+ }
50
+ }
51
+ }
52
+ const distinct = new Map(
53
+ currentPlans.map((plan) => [JSON.stringify(plan), plan]),
54
+ );
55
+ if (distinct.size > 1) {
56
+ throw new Error(
57
+ 'publication blocked: conflicting review plans exist for this approval generation',
58
+ );
59
+ }
60
+ return distinct.values().next().value ?? null;
61
+ };
62
+
63
+ export const validateReviewClaim = async (options: {
64
+ readonly deps: JobDeps;
65
+ readonly pr: PullRequest;
66
+ readonly claim: ClaimBinding;
67
+ readonly plan: ReviewPublicationPlan;
68
+ readonly expectedHead: string;
69
+ readonly requireDraft: boolean;
70
+ }): Promise<PullRequest> => {
71
+ const { deps, pr, claim, plan, expectedHead, requireDraft } = options;
72
+ const current = await getPullRequest(deps.token, deps.repo, pr.number);
73
+ if (
74
+ current.headSha !== expectedHead ||
75
+ current.baseRef !== plan.baseRef ||
76
+ current.baseSha !== plan.baseSha ||
77
+ current.headRepo !== deps.repo ||
78
+ (requireDraft && !current.draft)
79
+ ) {
80
+ throw new Error(
81
+ 'publication blocked: PR head, base, repository, or draft state changed',
82
+ );
83
+ }
84
+ const problem = await validateClaim(
85
+ { token: deps.token, repo: deps.repo, issueNumber: pr.number },
86
+ claim,
87
+ prRevision(plan.baseRef, plan.baseSha, plan.approvedHead),
88
+ );
89
+ if (problem !== null) {
90
+ throw new Error(`publication blocked: ${problem}`);
91
+ }
92
+ return current;
93
+ };
94
+
95
+ export const publishReviewPlan = async (
96
+ deps: JobDeps,
97
+ pr: PullRequest,
98
+ claim: ClaimBinding,
99
+ plan: ReviewPublicationPlan,
100
+ ): Promise<void> => {
101
+ if (plan.approvalId !== claim.approval.id) {
102
+ throw new Error('publication blocked: sealed review approval changed');
103
+ }
104
+ if (plan.repo !== deps.repo || plan.prNumber !== pr.number) {
105
+ throw new Error('publication blocked: sealed review repository changed');
106
+ }
107
+ const existing = await readReviewPlan(deps, pr);
108
+ if (existing !== null) {
109
+ if (
110
+ existing.approvalId !== plan.approvalId ||
111
+ JSON.stringify(existing) !== JSON.stringify(plan)
112
+ ) {
113
+ throw new Error('publication blocked: a different review plan exists');
114
+ }
115
+ return;
116
+ }
117
+ await validateReviewClaim({
118
+ deps,
119
+ pr,
120
+ claim,
121
+ plan,
122
+ expectedHead: plan.approvedHead,
123
+ requireDraft: true,
124
+ });
125
+ await createComment(deps.token, deps.repo, pr.number, reviewPlanMarker(plan));
126
+ };
@@ -0,0 +1,66 @@
1
+ import { join } from 'node:path';
2
+ import type { PullRequest } from './poller-github-pulls';
3
+ import type { JobDeps } from './poller-job-shared';
4
+ import {
5
+ changedPathsBetween,
6
+ commitCountBetween,
7
+ createValidationWorktree,
8
+ isAncestor,
9
+ } from './poller-output-integrity';
10
+ import {
11
+ changedWorkspaceQualityManifests,
12
+ lockedPathsOf,
13
+ } from './poller-protected-paths';
14
+ import { forbiddenDiffPaths } from './poller-protocol';
15
+ import type { ReviewPublicationPlan } from './poller-review-output';
16
+
17
+ export const validateSealedReviewOutput = async (options: {
18
+ readonly deps: JobDeps;
19
+ readonly pr: PullRequest;
20
+ readonly plan: ReviewPublicationPlan;
21
+ readonly cloneDir: string;
22
+ }): Promise<void> => {
23
+ const { deps, pr, plan, cloneDir } = options;
24
+ if (
25
+ plan.repo !== deps.repo ||
26
+ plan.prNumber !== pr.number ||
27
+ plan.baseRef !== pr.baseRef ||
28
+ plan.baseSha !== pr.baseSha ||
29
+ !isAncestor(cloneDir, plan.approvedHead, plan.publishedHead) ||
30
+ commitCountBetween(cloneDir, plan.approvedHead, plan.publishedHead) !==
31
+ plan.commits
32
+ ) {
33
+ throw new Error('publication blocked: sealed review identity changed');
34
+ }
35
+ const paths = changedPathsBetween(
36
+ cloneDir,
37
+ plan.approvedHead,
38
+ plan.publishedHead,
39
+ );
40
+ const workspace = createValidationWorktree(
41
+ cloneDir,
42
+ plan.publishedHead,
43
+ join(
44
+ deps.config.cacheDir,
45
+ 'work',
46
+ `${deps.repo.replace('/', '--')}-pr-${pr.number}-validate`,
47
+ ),
48
+ );
49
+ try {
50
+ const forbidden = [
51
+ ...forbiddenDiffPaths(paths, await lockedPathsOf(workspace.dir)),
52
+ ...changedWorkspaceQualityManifests(
53
+ workspace.dir,
54
+ plan.approvedHead,
55
+ paths,
56
+ ),
57
+ ];
58
+ if (forbidden.length > 0) {
59
+ throw new Error(
60
+ `publication blocked: sealed review modified protected paths:\n${forbidden.join('\n')}`,
61
+ );
62
+ }
63
+ } finally {
64
+ workspace.cleanup();
65
+ }
66
+ };
@@ -0,0 +1,121 @@
1
+ import { hasLabel } from './github-label-identity';
2
+ import {
3
+ type IssueItem,
4
+ lastLabelEvent,
5
+ listOpenIssuesWithLabel,
6
+ } from './poller-github';
7
+ import { createComment, removeLabel } from './poller-github-write';
8
+ import type { JobDeps } from './poller-job-shared';
9
+ import {
10
+ APPROVED_FOR_FIX,
11
+ APPROVED_FOR_REVIEW,
12
+ FIX_IN_PROGRESS,
13
+ REVIEW_IN_PROGRESS,
14
+ } from './poller-protocol';
15
+
16
+ const MS_PER_SECOND = 1000;
17
+ const SECONDS_PER_HOUR = 3600;
18
+ const MS_PER_HOUR = SECONDS_PER_HOUR * MS_PER_SECOND;
19
+
20
+ export type ScheduledJob = {
21
+ readonly deps: JobDeps;
22
+ readonly item: IssueItem;
23
+ readonly approvedAt: string;
24
+ };
25
+
26
+ export const byApprovalAge = (
27
+ left: ScheduledJob,
28
+ right: ScheduledJob,
29
+ ): number =>
30
+ Date.parse(left.approvedAt) - Date.parse(right.approvedAt) ||
31
+ left.deps.repo.localeCompare(right.deps.repo) ||
32
+ left.item.number - right.item.number;
33
+
34
+ const sweepStaleClaims = async (
35
+ deps: JobDeps,
36
+ claimLabel: string,
37
+ now: number,
38
+ ): Promise<ReadonlyArray<string>> => {
39
+ const { config, token, repo } = deps;
40
+ const claimed = await listOpenIssuesWithLabel(token, repo, claimLabel);
41
+ const lines: Array<string> = [];
42
+ for (const item of claimed) {
43
+ // biome-ignore lint/performance/noAwaitInLoops: label removals and comments are GitHub writes; GitHub advises against concurrent write requests (secondary rate limits).
44
+ const event = await lastLabelEvent(token, repo, item.number, claimLabel);
45
+ const ageMs =
46
+ event === null
47
+ ? Number.POSITIVE_INFINITY
48
+ : now - Date.parse(event.createdAt);
49
+ if (ageMs > config.staleClaimHours * MS_PER_HOUR) {
50
+ await removeLabel(token, repo, item.number, claimLabel);
51
+ await createComment(
52
+ token,
53
+ repo,
54
+ item.number,
55
+ `Released a stale \`${claimLabel}\` claim (older than ${config.staleClaimHours}h); the job will retry on a later tick.`,
56
+ );
57
+ lines.push(`${repo}#${item.number}: released stale ${claimLabel}`);
58
+ }
59
+ }
60
+ return lines;
61
+ };
62
+
63
+ const unclaimed = (
64
+ items: ReadonlyArray<IssueItem>,
65
+ claimLabel: string,
66
+ ): ReadonlyArray<IssueItem> =>
67
+ [...items]
68
+ .filter((item) => !hasLabel(item.labels, claimLabel))
69
+ .sort((left, right) => left.number - right.number);
70
+
71
+ const schedule = async (
72
+ deps: JobDeps,
73
+ items: ReadonlyArray<IssueItem>,
74
+ approvalLabel: string,
75
+ ): Promise<ReadonlyArray<ScheduledJob>> => {
76
+ const jobs: Array<ScheduledJob> = [];
77
+ for (const item of items) {
78
+ // biome-ignore lint/performance/noAwaitInLoops: approval times are trust-bearing timeline reads and sequential requests avoid GitHub secondary rate limits.
79
+ const event = await lastLabelEvent(
80
+ deps.token,
81
+ deps.repo,
82
+ item.number,
83
+ approvalLabel,
84
+ );
85
+ jobs.push({ deps, item, approvedAt: event?.createdAt ?? '' });
86
+ }
87
+ return jobs;
88
+ };
89
+
90
+ export const discoverRepositoryJobs = async (
91
+ deps: JobDeps,
92
+ now: number,
93
+ ): Promise<{
94
+ readonly lines: ReadonlyArray<string>;
95
+ readonly reviews: ReadonlyArray<ScheduledJob>;
96
+ readonly fixes: ReadonlyArray<ScheduledJob>;
97
+ }> => {
98
+ const { token, repo } = deps;
99
+ const lines: Array<string> = [];
100
+ for (const claimLabel of [FIX_IN_PROGRESS, REVIEW_IN_PROGRESS]) {
101
+ // biome-ignore lint/performance/noAwaitInLoops: sweeps issue GitHub writes; GitHub advises against concurrent write requests (secondary rate limits).
102
+ lines.push(...(await sweepStaleClaims(deps, claimLabel, now)));
103
+ }
104
+ const reviewItems = unclaimed(
105
+ (await listOpenIssuesWithLabel(token, repo, APPROVED_FOR_REVIEW)).filter(
106
+ (item) => item.isPullRequest,
107
+ ),
108
+ REVIEW_IN_PROGRESS,
109
+ );
110
+ const fixItems = unclaimed(
111
+ (await listOpenIssuesWithLabel(token, repo, APPROVED_FOR_FIX)).filter(
112
+ (item) => !item.isPullRequest,
113
+ ),
114
+ FIX_IN_PROGRESS,
115
+ );
116
+ return {
117
+ lines,
118
+ reviews: await schedule(deps, reviewItems, APPROVED_FOR_REVIEW),
119
+ fixes: await schedule(deps, fixItems, APPROVED_FOR_FIX),
120
+ };
121
+ };