@davidvornholt/standards 0.17.0 → 0.17.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.
- package/package.json +1 -1
- package/src/poller-acknowledgements.ts +2 -3
- package/src/poller-approval.ts +62 -5
- package/src/poller-claim.ts +6 -3
- package/src/poller-fix-run.ts +47 -30
- package/src/poller-github.ts +28 -19
- package/src/poller-job-shared.ts +47 -6
- package/src/poller-review-run.ts +57 -8
- package/src/poller-review-state.ts +4 -1
- package/src/poller-status.ts +3 -1
- package/src/poller-tick.ts +8 -6
package/package.json
CHANGED
|
@@ -91,9 +91,8 @@ const acknowledgeItems = async (options: {
|
|
|
91
91
|
// biome-ignore lint/performance/noAwaitInLoops: acknowledgement writes are deliberately serialized to avoid GitHub secondary rate limits.
|
|
92
92
|
const approval = await approvalFor(deps, item, kind);
|
|
93
93
|
if (
|
|
94
|
-
approval
|
|
95
|
-
|
|
96
|
-
(await acknowledgeQueuedJob(deps, item.number, approval, kind))
|
|
94
|
+
approval?.kind === 'approved' &&
|
|
95
|
+
(await acknowledgeQueuedJob(deps, item.number, approval.approval, kind))
|
|
97
96
|
) {
|
|
98
97
|
lines.push(
|
|
99
98
|
`${deps.repo} ${kind === 'fix' ? `#${item.number}` : `PR #${item.number}`}: acknowledged as queued`,
|
package/src/poller-approval.ts
CHANGED
|
@@ -19,15 +19,45 @@ export type ApprovalBinding = {
|
|
|
19
19
|
readonly target: string;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
export type ApprovalBindingResult =
|
|
23
|
+
| {
|
|
24
|
+
readonly kind: 'approved';
|
|
25
|
+
readonly approval: ApprovalBinding;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
readonly kind: 'absent';
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: 'rejected';
|
|
32
|
+
readonly generationId: string;
|
|
33
|
+
readonly reason: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
22
36
|
type ApprovalContext = {
|
|
23
37
|
readonly token: string | null;
|
|
24
38
|
readonly repo: string;
|
|
25
39
|
readonly issueNumber: number;
|
|
26
40
|
};
|
|
27
41
|
|
|
42
|
+
export type ApprovalTargetReader = () => Promise<string | null>;
|
|
43
|
+
|
|
28
44
|
const stableDigest = (value: unknown): string =>
|
|
29
45
|
createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
30
46
|
|
|
47
|
+
const rejectionGenerationId = (
|
|
48
|
+
context: ApprovalContext,
|
|
49
|
+
label: string,
|
|
50
|
+
target: string,
|
|
51
|
+
event: Awaited<ReturnType<typeof lastLabelEvent>>,
|
|
52
|
+
): string =>
|
|
53
|
+
stableDigest({
|
|
54
|
+
repo: context.repo,
|
|
55
|
+
issueNumber: context.issueNumber,
|
|
56
|
+
label,
|
|
57
|
+
target,
|
|
58
|
+
event,
|
|
59
|
+
});
|
|
60
|
+
|
|
31
61
|
export const issueRevision = (issue: IssueItem): string =>
|
|
32
62
|
`issue:${stableDigest({ title: issue.title, body: issue.body })}`;
|
|
33
63
|
|
|
@@ -41,14 +71,14 @@ export const readApprovalBinding = async (
|
|
|
41
71
|
context: ApprovalContext,
|
|
42
72
|
label: string,
|
|
43
73
|
target: string,
|
|
44
|
-
): Promise<
|
|
74
|
+
): Promise<ApprovalBindingResult> => {
|
|
45
75
|
const issue = await getIssue(
|
|
46
76
|
context.token,
|
|
47
77
|
context.repo,
|
|
48
78
|
context.issueNumber,
|
|
49
79
|
);
|
|
50
80
|
if (!hasLabel(issue.labels, label)) {
|
|
51
|
-
return
|
|
81
|
+
return { kind: 'absent' };
|
|
52
82
|
}
|
|
53
83
|
const event = await lastLabelEvent(
|
|
54
84
|
context.token,
|
|
@@ -57,7 +87,11 @@ export const readApprovalBinding = async (
|
|
|
57
87
|
label,
|
|
58
88
|
);
|
|
59
89
|
if (event === null) {
|
|
60
|
-
return
|
|
90
|
+
return {
|
|
91
|
+
kind: 'rejected',
|
|
92
|
+
generationId: rejectionGenerationId(context, label, target, event),
|
|
93
|
+
reason: `no "${label}" label event found on ${context.repo}#${context.issueNumber}`,
|
|
94
|
+
};
|
|
61
95
|
}
|
|
62
96
|
const role = await collaboratorRole(
|
|
63
97
|
context.token,
|
|
@@ -65,7 +99,11 @@ export const readApprovalBinding = async (
|
|
|
65
99
|
event.actorLogin,
|
|
66
100
|
);
|
|
67
101
|
if (!isTrustedRole(role)) {
|
|
68
|
-
return
|
|
102
|
+
return {
|
|
103
|
+
kind: 'rejected',
|
|
104
|
+
generationId: rejectionGenerationId(context, label, target, event),
|
|
105
|
+
reason: `"${label}" on ${context.repo}#${context.issueNumber} was applied by ${event.actorLogin} (role: ${role}); only admin or maintain roles may approve automation`,
|
|
106
|
+
};
|
|
69
107
|
}
|
|
70
108
|
const fields = {
|
|
71
109
|
repo: context.repo,
|
|
@@ -76,5 +114,24 @@ export const readApprovalBinding = async (
|
|
|
76
114
|
approvedAt: event.createdAt,
|
|
77
115
|
target,
|
|
78
116
|
};
|
|
79
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
kind: 'approved',
|
|
119
|
+
approval: { id: stableDigest(fields), ...fields },
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const approvalIsCurrent = async (
|
|
124
|
+
context: ApprovalContext,
|
|
125
|
+
approval: ApprovalBinding,
|
|
126
|
+
readTarget: ApprovalTargetReader,
|
|
127
|
+
): Promise<boolean> => {
|
|
128
|
+
const current = await readApprovalBinding(
|
|
129
|
+
context,
|
|
130
|
+
approval.label,
|
|
131
|
+
approval.target,
|
|
132
|
+
);
|
|
133
|
+
if (current.kind !== 'approved' || current.approval.id !== approval.id) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
return (await readTarget()) === approval.target;
|
|
80
137
|
};
|
package/src/poller-claim.ts
CHANGED
|
@@ -165,10 +165,13 @@ export const validateClaim = async (
|
|
|
165
165
|
claim.approval.label,
|
|
166
166
|
currentTarget,
|
|
167
167
|
);
|
|
168
|
-
if (
|
|
169
|
-
return
|
|
168
|
+
if (current.kind === 'absent') {
|
|
169
|
+
return `"${claim.approval.label}" is not currently present`;
|
|
170
170
|
}
|
|
171
|
-
if (current.
|
|
171
|
+
if (current.kind === 'rejected') {
|
|
172
|
+
return current.reason;
|
|
173
|
+
}
|
|
174
|
+
if (current.approval.id !== claim.approval.id) {
|
|
172
175
|
return 'approval no longer matches the exact approved revision/head';
|
|
173
176
|
}
|
|
174
177
|
const issue = await getIssue(
|
package/src/poller-fix-run.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
// precise question, or an explicit failure — never silence. Every transition
|
|
3
|
-
// is written back to GitHub so the next tick (or a human) resumes from there.
|
|
1
|
+
// A maintainer-approved issue becomes a verified draft PR, question, or failure.
|
|
4
2
|
|
|
5
3
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
4
|
+
import { hasLabel } from './github-label-identity';
|
|
5
|
+
import { approvalIsCurrent, issueRevision } from './poller-approval';
|
|
7
6
|
import { acquireClaim } from './poller-claim';
|
|
8
7
|
import { runCodex } from './poller-codex';
|
|
9
8
|
import { handleNonFixedOutcome } from './poller-fix-outcome';
|
|
@@ -55,29 +54,49 @@ const hasInvalidLocalOutput = (
|
|
|
55
54
|
branch: string,
|
|
56
55
|
): boolean => sealed === null && localBranchExists(cloneDir, branch);
|
|
57
56
|
|
|
57
|
+
const result = (issueNumber: number, message: string): JobResult => ({
|
|
58
|
+
lines: [`#${issueNumber}: ${message}`],
|
|
59
|
+
ranCodex: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const assertSealedOwnership = (
|
|
63
|
+
sealed: NonNullable<ReturnType<typeof readSealedFixOutput>>,
|
|
64
|
+
issueNumber: number,
|
|
65
|
+
approvalId: string,
|
|
66
|
+
branch: string,
|
|
67
|
+
): void => {
|
|
68
|
+
if (sealed.issueNumber !== issueNumber || sealed.approvalId !== approvalId) {
|
|
69
|
+
throw new Error(`sealed output on ${branch} has invalid ownership`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
58
73
|
export const runFixJob = async (
|
|
59
74
|
deps: JobDeps,
|
|
60
75
|
issue: IssueItem,
|
|
61
|
-
|
|
76
|
+
resolveDefaultBranch: () => Promise<string>,
|
|
62
77
|
allowCodex = true,
|
|
63
78
|
): Promise<JobResult> => {
|
|
64
79
|
const { config, token, repo } = deps;
|
|
65
80
|
const currentIssue = await getIssue(token, repo, issue.number);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
issueRevision(
|
|
71
|
-
|
|
81
|
+
if (!hasLabel(currentIssue.labels, APPROVED_FOR_FIX)) {
|
|
82
|
+
return result(issue.number, 'approval no longer present; skipped');
|
|
83
|
+
}
|
|
84
|
+
const readTarget = async (): Promise<string> =>
|
|
85
|
+
issueRevision(await getIssue(token, repo, issue.number));
|
|
86
|
+
const preamble = await jobPreamble(deps, currentIssue, FIX_LABELS, {
|
|
87
|
+
approved: issueRevision(currentIssue),
|
|
88
|
+
readCurrent: readTarget,
|
|
89
|
+
});
|
|
90
|
+
if (preamble.kind === 'stale') {
|
|
91
|
+
return result(issue.number, 'approval no longer present; skipped');
|
|
92
|
+
}
|
|
72
93
|
if (preamble.kind === 'rejected') {
|
|
73
|
-
return
|
|
94
|
+
return result(issue.number, 'approval rejected');
|
|
74
95
|
}
|
|
75
96
|
if (preamble.kind === 'waiting') {
|
|
76
|
-
return
|
|
77
|
-
lines: [`#${issue.number}: waiting on an answer`],
|
|
78
|
-
ranCodex: false,
|
|
79
|
-
};
|
|
97
|
+
return result(issue.number, 'waiting on an answer');
|
|
80
98
|
}
|
|
99
|
+
const defaultBranch = await resolveDefaultBranch();
|
|
81
100
|
const cacheClone = ensureCacheClone(config.cacheDir, repo, token);
|
|
82
101
|
const branch = branchNameForIssue(issue.number, preamble.approval.id);
|
|
83
102
|
const sealed = readSealedFixOutput(cacheClone, branch);
|
|
@@ -86,12 +105,18 @@ export const runFixJob = async (
|
|
|
86
105
|
`refusing to overwrite ${branch}: it is not valid sealed output for this approval`,
|
|
87
106
|
);
|
|
88
107
|
}
|
|
108
|
+
if (
|
|
109
|
+
!(await approvalIsCurrent(
|
|
110
|
+
{ token, repo, issueNumber: issue.number },
|
|
111
|
+
preamble.approval,
|
|
112
|
+
readTarget,
|
|
113
|
+
))
|
|
114
|
+
) {
|
|
115
|
+
return result(issue.number, 'approval generation changed; skipped');
|
|
116
|
+
}
|
|
89
117
|
if (sealed === null && !allowCodex) {
|
|
90
118
|
await acknowledgeQueuedJob(deps, issue.number, preamble.approval, 'fix');
|
|
91
|
-
return
|
|
92
|
-
lines: [`#${issue.number}: waiting for run capacity`],
|
|
93
|
-
ranCodex: false,
|
|
94
|
-
};
|
|
119
|
+
return result(issue.number, 'waiting for run capacity');
|
|
95
120
|
}
|
|
96
121
|
await addLabels(token, repo, issue.number, [FIX_IN_PROGRESS]);
|
|
97
122
|
const claim = await acquireClaim(
|
|
@@ -100,10 +125,7 @@ export const runFixJob = async (
|
|
|
100
125
|
FIX_IN_PROGRESS,
|
|
101
126
|
);
|
|
102
127
|
if (claim === null) {
|
|
103
|
-
return
|
|
104
|
-
lines: [`#${issue.number}: another poller owns the claim`],
|
|
105
|
-
ranCodex: false,
|
|
106
|
-
};
|
|
128
|
+
return result(issue.number, 'another poller owns the claim');
|
|
107
129
|
}
|
|
108
130
|
const resumableJob = {
|
|
109
131
|
deps,
|
|
@@ -114,12 +136,7 @@ export const runFixJob = async (
|
|
|
114
136
|
cloneDir: cacheClone,
|
|
115
137
|
};
|
|
116
138
|
if (sealed !== null) {
|
|
117
|
-
|
|
118
|
-
sealed.issueNumber !== issue.number ||
|
|
119
|
-
sealed.approvalId !== claim.approval.id
|
|
120
|
-
) {
|
|
121
|
-
throw new Error(`sealed output on ${branch} has invalid ownership`);
|
|
122
|
-
}
|
|
139
|
+
assertSealedOwnership(sealed, issue.number, claim.approval.id, branch);
|
|
123
140
|
return {
|
|
124
141
|
lines: [await publishFixedOutput(resumableJob, FIX_LABELS, sealed, null)],
|
|
125
142
|
ranCodex: false,
|
package/src/poller-github.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
// Read-side GitHub helpers
|
|
2
|
-
// narrow parsed shape or throws with the failing endpoint in the message; the
|
|
3
|
-
// tick converts per-job throws into reported problems. Lists are always read
|
|
4
|
-
// exhaustively (see github-paginate.ts) — the trust anchor is "the latest
|
|
5
|
-
// label event", which a truncated page would silently falsify.
|
|
1
|
+
// Read-side GitHub helpers return narrow parsed shapes or throw.
|
|
6
2
|
|
|
7
3
|
import { apiError, HTTP_OK, request } from './github-api';
|
|
8
|
-
import { labelIdentity } from './github-label-identity';
|
|
4
|
+
import { hasLabel, labelIdentity } from './github-label-identity';
|
|
9
5
|
import { listAllPages } from './github-paginate';
|
|
10
6
|
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
11
7
|
|
|
@@ -34,23 +30,32 @@ export type LabelEvent = {
|
|
|
34
30
|
const asString = (value: unknown): string =>
|
|
35
31
|
typeof value === 'string' ? value : '';
|
|
36
32
|
|
|
37
|
-
const labelNames = (raw: unknown): ReadonlyArray<string> =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
const labelNames = (raw: unknown): ReadonlyArray<string> | null => {
|
|
34
|
+
if (
|
|
35
|
+
!(
|
|
36
|
+
Array.isArray(raw) &&
|
|
37
|
+
raw.every((label) => isRecord(label) && isNonEmptyString(label.name))
|
|
38
|
+
)
|
|
39
|
+
) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return raw.map((label) => (label as { readonly name: string }).name);
|
|
43
|
+
};
|
|
43
44
|
|
|
44
45
|
export const parseIssue = (raw: unknown): IssueItem | null => {
|
|
45
46
|
if (!(isRecord(raw) && typeof raw.number === 'number')) {
|
|
46
47
|
return null;
|
|
47
48
|
}
|
|
49
|
+
const labels = labelNames(raw.labels);
|
|
50
|
+
if (labels === null) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
48
53
|
return {
|
|
49
54
|
number: raw.number,
|
|
50
55
|
title: asString(raw.title),
|
|
51
56
|
body: asString(raw.body),
|
|
52
57
|
isPullRequest: isRecord(raw.pull_request),
|
|
53
|
-
labels
|
|
58
|
+
labels,
|
|
54
59
|
authorLogin: isRecord(raw.user) ? asString(raw.user.login) : '',
|
|
55
60
|
};
|
|
56
61
|
};
|
|
@@ -82,9 +87,15 @@ export const listOpenIssuesWithLabel = async (
|
|
|
82
87
|
`/repos/${repo}/issues?state=open&labels=${encodeURIComponent(label)}`,
|
|
83
88
|
`list ${repo} issues labeled ${label}`,
|
|
84
89
|
);
|
|
85
|
-
return items
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
return items.flatMap((raw, index) => {
|
|
91
|
+
const issue = parseIssue(raw);
|
|
92
|
+
if (issue === null) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`list ${repo} issues labeled ${label}: invalid issue at index ${index}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return hasLabel(issue.labels, label) ? [issue] : [];
|
|
98
|
+
});
|
|
88
99
|
};
|
|
89
100
|
|
|
90
101
|
export const listIssueComments = async (
|
|
@@ -151,9 +162,7 @@ export const lastLabelEvent = async (
|
|
|
151
162
|
return latest;
|
|
152
163
|
};
|
|
153
164
|
|
|
154
|
-
// role_name
|
|
155
|
-
// collapses both to "write" and cannot express the trust boundary. A 404
|
|
156
|
-
// means "not a collaborator" — an untrusted answer, not an API failure.
|
|
165
|
+
// role_name preserves the maintain/write trust boundary. A 404 is untrusted.
|
|
157
166
|
export const collaboratorRole = async (
|
|
158
167
|
token: string | null,
|
|
159
168
|
repo: string,
|
package/src/poller-job-shared.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// approval/progress/failure labels the job family uses.
|
|
4
4
|
|
|
5
5
|
import { hasLabel } from './github-label-identity';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type ApprovalBinding,
|
|
8
|
+
type ApprovalTargetReader,
|
|
9
|
+
approvalIsCurrent,
|
|
10
|
+
readApprovalBinding,
|
|
11
|
+
} from './poller-approval';
|
|
7
12
|
import type { PollerConfig } from './poller-config';
|
|
8
13
|
import { addLabels, createComment, removeLabel } from './poller-github-write';
|
|
9
14
|
import {
|
|
@@ -31,11 +36,17 @@ export type JobLabels = {
|
|
|
31
36
|
readonly failed: string;
|
|
32
37
|
};
|
|
33
38
|
|
|
39
|
+
type JobTarget = {
|
|
40
|
+
readonly approved: string;
|
|
41
|
+
readonly readCurrent: ApprovalTargetReader;
|
|
42
|
+
};
|
|
43
|
+
|
|
34
44
|
// Must exceed runCodex's failure string at its longest (its stderr snippet
|
|
35
45
|
// plus the exit-cause prefix), so the diagnostic tail is never cut off.
|
|
36
46
|
const FAILURE_SNIPPET_LIMIT = 4000;
|
|
37
47
|
|
|
38
48
|
export type JobPreamble =
|
|
49
|
+
| { readonly kind: 'stale' }
|
|
39
50
|
| { readonly kind: 'rejected' }
|
|
40
51
|
| { readonly kind: 'waiting' }
|
|
41
52
|
| {
|
|
@@ -51,7 +62,7 @@ export const jobPreamble = async (
|
|
|
51
62
|
deps: JobDeps,
|
|
52
63
|
item: { readonly number: number; readonly labels: ReadonlyArray<string> },
|
|
53
64
|
labels: JobLabels,
|
|
54
|
-
target:
|
|
65
|
+
target: JobTarget,
|
|
55
66
|
): Promise<JobPreamble> => {
|
|
56
67
|
const trust = {
|
|
57
68
|
token: deps.token,
|
|
@@ -59,18 +70,44 @@ export const jobPreamble = async (
|
|
|
59
70
|
issueNumber: item.number,
|
|
60
71
|
roleCache: deps.roleCache,
|
|
61
72
|
};
|
|
62
|
-
const approval = await readApprovalBinding(
|
|
63
|
-
|
|
73
|
+
const approval = await readApprovalBinding(
|
|
74
|
+
trust,
|
|
75
|
+
labels.approved,
|
|
76
|
+
target.approved,
|
|
77
|
+
);
|
|
78
|
+
if (approval.kind === 'absent') {
|
|
79
|
+
return { kind: 'stale' };
|
|
80
|
+
}
|
|
81
|
+
if (approval.kind === 'rejected') {
|
|
82
|
+
const current = await readApprovalBinding(
|
|
83
|
+
trust,
|
|
84
|
+
labels.approved,
|
|
85
|
+
target.approved,
|
|
86
|
+
);
|
|
87
|
+
if (
|
|
88
|
+
current.kind !== 'rejected' ||
|
|
89
|
+
current.generationId !== approval.generationId
|
|
90
|
+
) {
|
|
91
|
+
return { kind: 'stale' };
|
|
92
|
+
}
|
|
93
|
+
if ((await target.readCurrent()) !== target.approved) {
|
|
94
|
+
return { kind: 'stale' };
|
|
95
|
+
}
|
|
64
96
|
await removeLabel(deps.token, deps.repo, item.number, labels.approved);
|
|
65
97
|
await createComment(
|
|
66
98
|
deps.token,
|
|
67
99
|
deps.repo,
|
|
68
100
|
item.number,
|
|
69
|
-
`${FAILURE_MARKER}\nApproval not honored: ${approval}`,
|
|
101
|
+
`${FAILURE_MARKER}\nApproval not honored: ${approval.reason}`,
|
|
70
102
|
);
|
|
71
103
|
return { kind: 'rejected' };
|
|
72
104
|
}
|
|
73
105
|
const answers = await answerState(trust);
|
|
106
|
+
if (
|
|
107
|
+
!(await approvalIsCurrent(trust, approval.approval, target.readCurrent))
|
|
108
|
+
) {
|
|
109
|
+
return { kind: 'stale' };
|
|
110
|
+
}
|
|
74
111
|
if (answers.waiting) {
|
|
75
112
|
if (!hasLabel(item.labels, NEEDS_CLARIFICATION)) {
|
|
76
113
|
await addLabels(deps.token, deps.repo, item.number, [
|
|
@@ -83,7 +120,11 @@ export const jobPreamble = async (
|
|
|
83
120
|
if (hasLabel(item.labels, NEEDS_CLARIFICATION)) {
|
|
84
121
|
await removeLabel(deps.token, deps.repo, item.number, NEEDS_CLARIFICATION);
|
|
85
122
|
}
|
|
86
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
kind: 'go',
|
|
125
|
+
answers: answers.answers,
|
|
126
|
+
approval: approval.approval,
|
|
127
|
+
};
|
|
87
128
|
};
|
|
88
129
|
|
|
89
130
|
export const failJob = async (
|
package/src/poller-review-run.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// verifies and pushes those commits, posts the final report, and flips the PR
|
|
5
5
|
// ready.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { hasLabel } from './github-label-identity';
|
|
8
|
+
import { approvalIsCurrent, prRevision } from './poller-approval';
|
|
8
9
|
import { acquireClaim } from './poller-claim';
|
|
9
10
|
import { runCodex } from './poller-codex';
|
|
10
11
|
import { getIssue, type IssueItem } from './poller-github';
|
|
@@ -49,6 +50,27 @@ const hasInvalidLocalOutput = (
|
|
|
49
50
|
): boolean =>
|
|
50
51
|
plan === null && sealed === null && localBranchExists(cloneDir, branch);
|
|
51
52
|
|
|
53
|
+
const currentReviewTarget = async (
|
|
54
|
+
token: string | null,
|
|
55
|
+
repo: string,
|
|
56
|
+
prNumber: number,
|
|
57
|
+
plan: Awaited<ReturnType<typeof readReviewPlan>>,
|
|
58
|
+
): Promise<string | null> => {
|
|
59
|
+
const current = await getPullRequest(token, repo, prNumber);
|
|
60
|
+
if (
|
|
61
|
+
plan !== null &&
|
|
62
|
+
current.headSha !== plan.approvedHead &&
|
|
63
|
+
current.headSha !== plan.publishedHead
|
|
64
|
+
) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return prRevision(
|
|
68
|
+
current.baseRef,
|
|
69
|
+
current.baseSha,
|
|
70
|
+
plan === null ? current.headSha : plan.approvedHead,
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
52
74
|
export const runReviewJob = async (
|
|
53
75
|
deps: JobDeps,
|
|
54
76
|
prItem: IssueItem,
|
|
@@ -56,15 +78,30 @@ export const runReviewJob = async (
|
|
|
56
78
|
runAgent: typeof runCodex = runCodex,
|
|
57
79
|
): Promise<JobResult> => {
|
|
58
80
|
const { config, token, repo } = deps;
|
|
81
|
+
const currentItem = await getIssue(token, repo, prItem.number);
|
|
82
|
+
if (!hasLabel(currentItem.labels, APPROVED_FOR_REVIEW)) {
|
|
83
|
+
return {
|
|
84
|
+
lines: [`PR #${prItem.number}: approval no longer present; skipped`],
|
|
85
|
+
ranCodex: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
59
88
|
const pr = await getPullRequest(token, repo, prItem.number);
|
|
60
89
|
let plan = await currentReviewPlan(deps, pr);
|
|
61
|
-
const
|
|
62
|
-
const preamble = await jobPreamble(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
90
|
+
const readTarget = () => currentReviewTarget(token, repo, pr.number, plan);
|
|
91
|
+
const preamble = await jobPreamble(deps, currentItem, REVIEW_LABELS, {
|
|
92
|
+
approved: prRevision(
|
|
93
|
+
pr.baseRef,
|
|
94
|
+
pr.baseSha,
|
|
95
|
+
plan?.approvedHead ?? pr.headSha,
|
|
96
|
+
),
|
|
97
|
+
readCurrent: readTarget,
|
|
98
|
+
});
|
|
99
|
+
if (preamble.kind === 'stale') {
|
|
100
|
+
return {
|
|
101
|
+
lines: [`PR #${prItem.number}: approval no longer present; skipped`],
|
|
102
|
+
ranCodex: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
68
105
|
if (preamble.kind === 'rejected') {
|
|
69
106
|
return {
|
|
70
107
|
lines: [`PR #${prItem.number}: approval rejected`],
|
|
@@ -101,6 +138,18 @@ export const runReviewJob = async (
|
|
|
101
138
|
if (hasInvalidLocalOutput(plan, sealed, cacheClone, outputBranch)) {
|
|
102
139
|
throw new Error(`sealed output on ${outputBranch} is invalid`);
|
|
103
140
|
}
|
|
141
|
+
if (
|
|
142
|
+
!(await approvalIsCurrent(
|
|
143
|
+
{ token, repo, issueNumber: pr.number },
|
|
144
|
+
preamble.approval,
|
|
145
|
+
readTarget,
|
|
146
|
+
))
|
|
147
|
+
) {
|
|
148
|
+
return {
|
|
149
|
+
lines: [`PR #${pr.number}: approval generation changed; skipped`],
|
|
150
|
+
ranCodex: false,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
104
153
|
if (plan === null && sealed === null && !allowCodex) {
|
|
105
154
|
await acknowledgeQueuedJob(deps, pr.number, preamble.approval, 'review');
|
|
106
155
|
return {
|
|
@@ -43,7 +43,10 @@ export const readReviewPlan = async (
|
|
|
43
43
|
APPROVED_FOR_REVIEW,
|
|
44
44
|
prRevision(plan.baseRef, plan.baseSha, plan.approvedHead),
|
|
45
45
|
);
|
|
46
|
-
if (
|
|
46
|
+
if (
|
|
47
|
+
binding.kind === 'approved' &&
|
|
48
|
+
binding.approval.id === plan.approvalId
|
|
49
|
+
) {
|
|
47
50
|
currentPlans.push(plan);
|
|
48
51
|
}
|
|
49
52
|
}
|
package/src/poller-status.ts
CHANGED
|
@@ -74,7 +74,8 @@ const isStillQueueable = async (
|
|
|
74
74
|
target,
|
|
75
75
|
);
|
|
76
76
|
return (
|
|
77
|
-
|
|
77
|
+
currentApproval.kind === 'approved' &&
|
|
78
|
+
currentApproval.approval.id === approval.id
|
|
78
79
|
);
|
|
79
80
|
};
|
|
80
81
|
|
|
@@ -87,6 +88,7 @@ export const acknowledgeQueuedJob = async (
|
|
|
87
88
|
const expected = queueMarkerFor(approval, kind);
|
|
88
89
|
const current = await getIssue(deps.token, deps.repo, issueNumber);
|
|
89
90
|
if (
|
|
91
|
+
!hasLabel(current.labels, approvalLabelFor(kind)) ||
|
|
90
92
|
hasLabel(current.labels, inProgressLabelFor(kind)) ||
|
|
91
93
|
hasLabel(current.labels, NEEDS_CLARIFICATION)
|
|
92
94
|
) {
|
package/src/poller-tick.ts
CHANGED
|
@@ -77,15 +77,17 @@ const runQueue = async (options: {
|
|
|
77
77
|
// biome-ignore lint/performance/noAwaitInLoops: jobs are heavyweight Codex runs and the shared run budget is the concurrency cap.
|
|
78
78
|
result = await runners.review(job.deps, job.item, allowCodex);
|
|
79
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
80
|
result = await runners.fix(
|
|
86
81
|
job.deps,
|
|
87
82
|
job.item,
|
|
88
|
-
|
|
83
|
+
async () => {
|
|
84
|
+
let branch = defaultBranches.get(job.deps.repo);
|
|
85
|
+
if (branch === undefined) {
|
|
86
|
+
branch = await repoDefaultBranch(token, job.deps.repo);
|
|
87
|
+
defaultBranches.set(job.deps.repo, branch);
|
|
88
|
+
}
|
|
89
|
+
return branch;
|
|
90
|
+
},
|
|
89
91
|
allowCodex,
|
|
90
92
|
);
|
|
91
93
|
}
|