@davidvornholt/standards 0.10.2 → 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 +68 -11
  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,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
+ };
@@ -0,0 +1,144 @@
1
+ // One poller tick: sweep stale claims, then run approved jobs oldest-first —
2
+ // review jobs before fix jobs, because reviews unblock merges — under the
3
+ // per-tick Codex run cap. All state lives in GitHub, so a tick is re-runnable
4
+ // and a crash costs at most one stale claim that the next tick sweeps.
5
+
6
+ import type { PollerConfig } from './poller-config';
7
+ import { runFixJob } from './poller-fix-run';
8
+ import { repoDefaultBranch } from './poller-github';
9
+ import type { JobDeps, JobResult } from './poller-job-shared';
10
+ import { runReviewJob } from './poller-review-run';
11
+ import { discoverRepositoryJobs, type ScheduledJob } from './poller-schedule';
12
+ import type { RoleCache } from './poller-trust';
13
+
14
+ export type TickReport = {
15
+ readonly lines: ReadonlyArray<string>;
16
+ readonly problems: ReadonlyArray<string>;
17
+ };
18
+
19
+ type RunBudget = {
20
+ remaining: number;
21
+ };
22
+
23
+ export type TickJobRunners = {
24
+ readonly review: typeof runReviewJob;
25
+ readonly fix: typeof runFixJob;
26
+ };
27
+
28
+ const DEFAULT_JOB_RUNNERS: TickJobRunners = {
29
+ review: runReviewJob,
30
+ fix: runFixJob,
31
+ };
32
+
33
+ type TypedScheduledJob = ScheduledJob & {
34
+ readonly kind: 'review' | 'fix';
35
+ };
36
+
37
+ const approvalTime = (job: ScheduledJob): number => {
38
+ const parsed = Date.parse(job.approvedAt);
39
+ return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
40
+ };
41
+
42
+ const kindPriority = (
43
+ left: TypedScheduledJob,
44
+ right: TypedScheduledJob,
45
+ ): number => {
46
+ if (left.kind === right.kind) {
47
+ return 0;
48
+ }
49
+ return left.kind === 'review' ? -1 : 1;
50
+ };
51
+
52
+ const byGlobalPriority = (
53
+ left: TypedScheduledJob,
54
+ right: TypedScheduledJob,
55
+ ): number =>
56
+ approvalTime(left) - approvalTime(right) ||
57
+ kindPriority(left, right) ||
58
+ left.deps.repo.localeCompare(right.deps.repo) ||
59
+ left.item.number - right.item.number;
60
+
61
+ const runQueue = async (options: {
62
+ readonly queue: ReadonlyArray<TypedScheduledJob>;
63
+ readonly token: string | null;
64
+ readonly runners: TickJobRunners;
65
+ readonly budget: RunBudget;
66
+ readonly lines: Array<string>;
67
+ readonly problems: Array<string>;
68
+ readonly defaultBranches: Map<string, string>;
69
+ }): Promise<void> => {
70
+ const { queue, token, runners, budget, lines, problems, defaultBranches } =
71
+ options;
72
+ for (const job of queue) {
73
+ try {
74
+ let result: JobResult;
75
+ const allowCodex = budget.remaining > 0;
76
+ if (job.kind === 'review') {
77
+ // biome-ignore lint/performance/noAwaitInLoops: jobs are heavyweight Codex runs and the shared run budget is the concurrency cap.
78
+ result = await runners.review(job.deps, job.item, allowCodex);
79
+ } else {
80
+ let defaultBranch = defaultBranches.get(job.deps.repo);
81
+ if (defaultBranch === undefined) {
82
+ defaultBranch = await repoDefaultBranch(token, job.deps.repo);
83
+ defaultBranches.set(job.deps.repo, defaultBranch);
84
+ }
85
+ result = await runners.fix(
86
+ job.deps,
87
+ job.item,
88
+ defaultBranch,
89
+ allowCodex,
90
+ );
91
+ }
92
+ lines.push(...result.lines.map((line) => `${job.deps.repo} ${line}`));
93
+ budget.remaining -= result.ranCodex ? 1 : 0;
94
+ } catch (error) {
95
+ problems.push(
96
+ `${job.deps.repo}: ${error instanceof Error ? error.message : String(error)}`,
97
+ );
98
+ }
99
+ }
100
+ };
101
+
102
+ export const runPollerTick = async (
103
+ config: PollerConfig,
104
+ token: string | null,
105
+ now: number,
106
+ runners: TickJobRunners = DEFAULT_JOB_RUNNERS,
107
+ ): Promise<TickReport> => {
108
+ const lines: Array<string> = [];
109
+ const problems: Array<string> = [];
110
+ const roleCache: RoleCache = new Map();
111
+ const budget: RunBudget = { remaining: config.maxJobsPerTick };
112
+ const reviews: Array<ScheduledJob> = [];
113
+ const fixes: Array<ScheduledJob> = [];
114
+
115
+ for (const repo of config.repos) {
116
+ const deps: JobDeps = { config, token, repo, roleCache };
117
+ try {
118
+ // biome-ignore lint/performance/noAwaitInLoops: discovery includes GitHub timeline reads and stale-claim writes; sequential requests avoid secondary rate limits.
119
+ const discovered = await discoverRepositoryJobs(deps, now);
120
+ lines.push(...discovered.lines);
121
+ reviews.push(...discovered.reviews);
122
+ fixes.push(...discovered.fixes);
123
+ } catch (error) {
124
+ problems.push(
125
+ `${repo}: ${error instanceof Error ? error.message : String(error)}`,
126
+ );
127
+ }
128
+ }
129
+ const defaultBranches = new Map<string, string>();
130
+ const queue: ReadonlyArray<TypedScheduledJob> = [
131
+ ...reviews.map((job) => ({ ...job, kind: 'review' as const })),
132
+ ...fixes.map((job) => ({ ...job, kind: 'fix' as const })),
133
+ ].sort(byGlobalPriority);
134
+ await runQueue({
135
+ queue,
136
+ token,
137
+ runners,
138
+ budget,
139
+ lines,
140
+ problems,
141
+ defaultBranches,
142
+ });
143
+ return { lines, problems };
144
+ };