@davidvornholt/standards 0.16.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/README.md +3 -3
- package/package.json +5 -2
- package/src/poller-acknowledgements.ts +2 -3
- package/src/poller-approval.ts +62 -5
- package/src/poller-claim.ts +6 -3
- package/src/poller-codex.ts +5 -11
- package/src/poller-commands.ts +20 -3
- package/src/poller-fix-run.ts +48 -30
- package/src/poller-github.ts +28 -19
- package/src/poller-job-shared.ts +47 -6
- package/src/poller-outcome.ts +60 -18
- package/src/poller-prompts.ts +35 -9
- package/src/poller-protocol.ts +19 -10
- package/src/poller-review-artifacts.ts +4 -30
- package/src/poller-review-execution.ts +7 -4
- package/src/poller-review-output.ts +14 -35
- package/src/poller-review-plan-parse.ts +94 -0
- package/src/poller-review-publication.ts +3 -3
- package/src/poller-review-run.ts +63 -11
- package/src/poller-review-state.ts +4 -1
- package/src/poller-review-thread-api.ts +166 -0
- package/src/poller-review-thread-write.ts +64 -0
- package/src/poller-review-threads.ts +119 -0
- package/src/poller-status.ts +3 -1
- package/src/poller-tick.ts +8 -6
- package/src/poller-github-publication.ts +0 -23
package/src/poller-review-run.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// One review job: a maintainer-approved draft PR gets a full review-fix
|
|
2
2
|
// cycle — lens fan-out inside the Codex run, fixes as new commits — then the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Codex maintains the GitHub review ledger and commits fixes, then the poller
|
|
4
|
+
// verifies and pushes those commits, posts the final report, and flips the PR
|
|
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';
|
|
10
|
+
import { runCodex } from './poller-codex';
|
|
9
11
|
import { getIssue, type IssueItem } from './poller-github';
|
|
10
12
|
import { getPullRequest } from './poller-github-pulls';
|
|
11
13
|
import { addLabels } from './poller-github-write';
|
|
@@ -48,21 +50,58 @@ const hasInvalidLocalOutput = (
|
|
|
48
50
|
): boolean =>
|
|
49
51
|
plan === null && sealed === null && localBranchExists(cloneDir, branch);
|
|
50
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
|
+
|
|
51
74
|
export const runReviewJob = async (
|
|
52
75
|
deps: JobDeps,
|
|
53
76
|
prItem: IssueItem,
|
|
54
77
|
allowCodex = true,
|
|
78
|
+
runAgent: typeof runCodex = runCodex,
|
|
55
79
|
): Promise<JobResult> => {
|
|
56
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
|
+
}
|
|
57
88
|
const pr = await getPullRequest(token, repo, prItem.number);
|
|
58
89
|
let plan = await currentReviewPlan(deps, pr);
|
|
59
|
-
const
|
|
60
|
-
const preamble = await jobPreamble(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|
|
66
105
|
if (preamble.kind === 'rejected') {
|
|
67
106
|
return {
|
|
68
107
|
lines: [`PR #${prItem.number}: approval rejected`],
|
|
@@ -99,6 +138,18 @@ export const runReviewJob = async (
|
|
|
99
138
|
if (hasInvalidLocalOutput(plan, sealed, cacheClone, outputBranch)) {
|
|
100
139
|
throw new Error(`sealed output on ${outputBranch} is invalid`);
|
|
101
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
|
+
}
|
|
102
153
|
if (plan === null && sealed === null && !allowCodex) {
|
|
103
154
|
await acknowledgeQueuedJob(deps, pr.number, preamble.approval, 'review');
|
|
104
155
|
return {
|
|
@@ -144,5 +195,6 @@ export const runReviewJob = async (
|
|
|
144
195
|
claim,
|
|
145
196
|
cacheClone,
|
|
146
197
|
answers: preamble.answers,
|
|
198
|
+
runAgent,
|
|
147
199
|
});
|
|
148
200
|
};
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { apiError, HTTP_OK, request } from './github-api';
|
|
2
|
+
import { isRecord } from './github-settings-parse';
|
|
3
|
+
|
|
4
|
+
export type ReviewThreadComment = {
|
|
5
|
+
readonly body: string;
|
|
6
|
+
readonly viewerDidAuthor: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ReviewThread = {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly repo: string;
|
|
12
|
+
readonly prNumber: number;
|
|
13
|
+
readonly isResolved: boolean;
|
|
14
|
+
readonly creation: ReviewThreadComment | null;
|
|
15
|
+
readonly viewerAuthoredBodies: ReadonlyArray<string>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const asRecord = (
|
|
19
|
+
value: unknown,
|
|
20
|
+
context: string,
|
|
21
|
+
): Readonly<Record<string, unknown>> => {
|
|
22
|
+
if (!isRecord(value)) {
|
|
23
|
+
throw new Error(`${context}: expected an object`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const stringField = (
|
|
29
|
+
value: Readonly<Record<string, unknown>>,
|
|
30
|
+
key: string,
|
|
31
|
+
context: string,
|
|
32
|
+
): string => {
|
|
33
|
+
const field = value[key];
|
|
34
|
+
if (typeof field !== 'string') {
|
|
35
|
+
throw new Error(`${context}: expected "${key}" to be a string`);
|
|
36
|
+
}
|
|
37
|
+
return field;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const booleanField = (
|
|
41
|
+
value: Readonly<Record<string, unknown>>,
|
|
42
|
+
key: string,
|
|
43
|
+
context: string,
|
|
44
|
+
): boolean => {
|
|
45
|
+
const field = value[key];
|
|
46
|
+
if (typeof field !== 'boolean') {
|
|
47
|
+
throw new Error(`${context}: expected "${key}" to be a boolean`);
|
|
48
|
+
}
|
|
49
|
+
return field;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const numberField = (
|
|
53
|
+
value: Readonly<Record<string, unknown>>,
|
|
54
|
+
key: string,
|
|
55
|
+
context: string,
|
|
56
|
+
): number => {
|
|
57
|
+
const field = value[key];
|
|
58
|
+
if (typeof field !== 'number') {
|
|
59
|
+
throw new Error(`${context}: expected "${key}" to be a number`);
|
|
60
|
+
}
|
|
61
|
+
return field;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const reviewThreadComment = (
|
|
65
|
+
value: unknown,
|
|
66
|
+
context: string,
|
|
67
|
+
): ReviewThreadComment => {
|
|
68
|
+
const comment = asRecord(value, context);
|
|
69
|
+
return {
|
|
70
|
+
body: stringField(comment, 'body', context),
|
|
71
|
+
viewerDidAuthor: booleanField(comment, 'viewerDidAuthor', context),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const readThreadPage = async (
|
|
76
|
+
token: string | null,
|
|
77
|
+
threadId: string,
|
|
78
|
+
after: string | null,
|
|
79
|
+
): Promise<{
|
|
80
|
+
readonly thread: ReviewThread;
|
|
81
|
+
readonly nextCursor: string | null;
|
|
82
|
+
}> => {
|
|
83
|
+
const response = await request(token, 'POST', '/graphql', {
|
|
84
|
+
query:
|
|
85
|
+
'query($id: ID!, $after: String) { node(id: $id) { ... on PullRequestReviewThread { id isResolved pullRequest { number repository { nameWithOwner } } comments(first: 100, after: $after) { nodes { body viewerDidAuthor } pageInfo { hasNextPage endCursor } } } } }',
|
|
86
|
+
variables: { id: threadId, after },
|
|
87
|
+
});
|
|
88
|
+
if (response.status !== HTTP_OK) {
|
|
89
|
+
throw new Error(apiError(`read review thread ${threadId}`, response));
|
|
90
|
+
}
|
|
91
|
+
const context = `read review thread ${threadId}`;
|
|
92
|
+
const body = asRecord(response.body, context);
|
|
93
|
+
if (body.errors !== undefined) {
|
|
94
|
+
throw new Error(apiError(context, response));
|
|
95
|
+
}
|
|
96
|
+
const data = asRecord(body.data, context);
|
|
97
|
+
const node = asRecord(data.node, context);
|
|
98
|
+
if (stringField(node, 'id', context) !== threadId) {
|
|
99
|
+
throw new Error(`${context}: GitHub returned a different node`);
|
|
100
|
+
}
|
|
101
|
+
const pullRequest = asRecord(node.pullRequest, context);
|
|
102
|
+
const repository = asRecord(pullRequest.repository, context);
|
|
103
|
+
const comments = asRecord(node.comments, context);
|
|
104
|
+
const pageInfo = asRecord(comments.pageInfo, context);
|
|
105
|
+
if (!Array.isArray(comments.nodes)) {
|
|
106
|
+
throw new Error(`${context}: expected thread comments`);
|
|
107
|
+
}
|
|
108
|
+
const parsedComments = comments.nodes.map((comment) =>
|
|
109
|
+
reviewThreadComment(comment, context),
|
|
110
|
+
);
|
|
111
|
+
const viewerAuthoredBodies: Array<string> = [];
|
|
112
|
+
for (const comment of parsedComments) {
|
|
113
|
+
if (comment.viewerDidAuthor) {
|
|
114
|
+
viewerAuthoredBodies.push(comment.body);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const hasNextPage = booleanField(pageInfo, 'hasNextPage', context);
|
|
118
|
+
const { endCursor } = pageInfo;
|
|
119
|
+
if (hasNextPage && typeof endCursor !== 'string') {
|
|
120
|
+
throw new Error(`${context}: expected the next comment cursor`);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
thread: {
|
|
124
|
+
id: threadId,
|
|
125
|
+
repo: stringField(repository, 'nameWithOwner', context),
|
|
126
|
+
prNumber: numberField(pullRequest, 'number', context),
|
|
127
|
+
isResolved: booleanField(node, 'isResolved', context),
|
|
128
|
+
creation: parsedComments[0] ?? null,
|
|
129
|
+
viewerAuthoredBodies,
|
|
130
|
+
},
|
|
131
|
+
nextCursor: hasNextPage ? (endCursor as string) : null,
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const readReviewThread = async (
|
|
136
|
+
token: string | null,
|
|
137
|
+
threadId: string,
|
|
138
|
+
): Promise<ReviewThread> => {
|
|
139
|
+
let after: string | null = null;
|
|
140
|
+
let combined: ReviewThread | null = null;
|
|
141
|
+
const viewerAuthoredBodies: Array<string> = [];
|
|
142
|
+
do {
|
|
143
|
+
// biome-ignore lint/performance/noAwaitInLoops: all comment pages are required for replay-safe marker lookup.
|
|
144
|
+
const page = await readThreadPage(token, threadId, after);
|
|
145
|
+
if (
|
|
146
|
+
combined !== null &&
|
|
147
|
+
(combined.repo !== page.thread.repo ||
|
|
148
|
+
combined.prNumber !== page.thread.prNumber ||
|
|
149
|
+
combined.isResolved !== page.thread.isResolved)
|
|
150
|
+
) {
|
|
151
|
+
throw new Error(`read review thread ${threadId}: identity changed`);
|
|
152
|
+
}
|
|
153
|
+
if (combined === null) {
|
|
154
|
+
combined = { ...page.thread, viewerAuthoredBodies };
|
|
155
|
+
} else if (combined.creation === null && page.thread.creation !== null) {
|
|
156
|
+
const existing = combined as ReviewThread;
|
|
157
|
+
combined = { ...existing, creation: page.thread.creation };
|
|
158
|
+
}
|
|
159
|
+
viewerAuthoredBodies.push(...page.thread.viewerAuthoredBodies);
|
|
160
|
+
after = page.nextCursor;
|
|
161
|
+
} while (after !== null);
|
|
162
|
+
if (combined === null) {
|
|
163
|
+
throw new Error(`read review thread ${threadId}: no response`);
|
|
164
|
+
}
|
|
165
|
+
return combined;
|
|
166
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { apiError, HTTP_OK, request } from './github-api';
|
|
2
|
+
import { isRecord } from './github-settings-parse';
|
|
3
|
+
|
|
4
|
+
const REPLY_MUTATION =
|
|
5
|
+
// biome-ignore lint/security/noSecrets: a GraphQL mutation string, not a credential.
|
|
6
|
+
'mutation($id: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $id, body: $body }) { comment { id } } }';
|
|
7
|
+
|
|
8
|
+
const RESOLVE_MUTATION =
|
|
9
|
+
'mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { id isResolved } } }';
|
|
10
|
+
|
|
11
|
+
const mutationBody = (
|
|
12
|
+
operation: 'reply' | 'resolve',
|
|
13
|
+
threadId: string,
|
|
14
|
+
response: Awaited<ReturnType<typeof request>>,
|
|
15
|
+
): Readonly<Record<string, unknown>> => {
|
|
16
|
+
const context = `${operation} review thread ${threadId}`;
|
|
17
|
+
if (
|
|
18
|
+
response.status !== HTTP_OK ||
|
|
19
|
+
!isRecord(response.body) ||
|
|
20
|
+
response.body.errors !== undefined ||
|
|
21
|
+
!isRecord(response.body.data)
|
|
22
|
+
) {
|
|
23
|
+
throw new Error(apiError(context, response));
|
|
24
|
+
}
|
|
25
|
+
return response.body.data;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const replyToReviewThread = async (
|
|
29
|
+
token: string | null,
|
|
30
|
+
threadId: string,
|
|
31
|
+
body: string,
|
|
32
|
+
): Promise<void> => {
|
|
33
|
+
const response = await request(token, 'POST', '/graphql', {
|
|
34
|
+
query: REPLY_MUTATION,
|
|
35
|
+
variables: { id: threadId, body },
|
|
36
|
+
});
|
|
37
|
+
const data = mutationBody('reply', threadId, response);
|
|
38
|
+
const payload = data.addPullRequestReviewThreadReply;
|
|
39
|
+
if (
|
|
40
|
+
!(isRecord(payload) && isRecord(payload.comment)) ||
|
|
41
|
+
typeof payload.comment.id !== 'string'
|
|
42
|
+
) {
|
|
43
|
+
throw new Error(apiError(`reply review thread ${threadId}`, response));
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const resolveReviewThread = async (
|
|
48
|
+
token: string | null,
|
|
49
|
+
threadId: string,
|
|
50
|
+
): Promise<void> => {
|
|
51
|
+
const response = await request(token, 'POST', '/graphql', {
|
|
52
|
+
query: RESOLVE_MUTATION,
|
|
53
|
+
variables: { id: threadId },
|
|
54
|
+
});
|
|
55
|
+
const data = mutationBody('resolve', threadId, response);
|
|
56
|
+
const payload = data.resolveReviewThread;
|
|
57
|
+
if (
|
|
58
|
+
!(isRecord(payload) && isRecord(payload.thread)) ||
|
|
59
|
+
payload.thread.id !== threadId ||
|
|
60
|
+
payload.thread.isResolved !== true
|
|
61
|
+
) {
|
|
62
|
+
throw new Error(apiError(`resolve review thread ${threadId}`, response));
|
|
63
|
+
}
|
|
64
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { ClaimBinding } from './poller-claim';
|
|
3
|
+
import type { PullRequest } from './poller-github-pulls';
|
|
4
|
+
import type { JobDeps } from './poller-job-shared';
|
|
5
|
+
import type { ReviewThreadResolution } from './poller-protocol';
|
|
6
|
+
import type { ReviewPublicationPlan } from './poller-review-output';
|
|
7
|
+
import { validateReviewClaim } from './poller-review-state';
|
|
8
|
+
import {
|
|
9
|
+
type ReviewThread,
|
|
10
|
+
readReviewThread,
|
|
11
|
+
} from './poller-review-thread-api';
|
|
12
|
+
import {
|
|
13
|
+
replyToReviewThread,
|
|
14
|
+
resolveReviewThread,
|
|
15
|
+
} from './poller-review-thread-write';
|
|
16
|
+
|
|
17
|
+
const AGENT_REVIEW_THREAD_MARKER =
|
|
18
|
+
/<!-- standards-poller:review-thread approval=(?<approval>[a-f0-9]{64}) operation=(?:[a-f0-9]{64}) -->/gu;
|
|
19
|
+
|
|
20
|
+
export const agentReviewThreadMarker = (
|
|
21
|
+
approvalId: string,
|
|
22
|
+
operationId: string,
|
|
23
|
+
): string =>
|
|
24
|
+
`<!-- standards-poller:review-thread approval=${approvalId} operation=${operationId} -->`;
|
|
25
|
+
|
|
26
|
+
const hasAgentCreationMarker = (body: string, approvalId: string): boolean =>
|
|
27
|
+
[...body.matchAll(AGENT_REVIEW_THREAD_MARKER)].some(
|
|
28
|
+
(marker) => marker.groups?.approval === approvalId,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export const threadResolutionMarker = (
|
|
32
|
+
plan: ReviewPublicationPlan,
|
|
33
|
+
resolution: ReviewThreadResolution,
|
|
34
|
+
): string => {
|
|
35
|
+
const digest = createHash('sha256')
|
|
36
|
+
.update(
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
repo: plan.repo,
|
|
39
|
+
prNumber: plan.prNumber,
|
|
40
|
+
approvalId: plan.approvalId,
|
|
41
|
+
threadId: resolution.threadId,
|
|
42
|
+
verificationReply: resolution.verificationReply,
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
.digest('hex');
|
|
46
|
+
return `<!-- standards-poller:thread-resolution approval=${plan.approvalId} operation=${digest} -->`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const assertThreadTarget = (
|
|
50
|
+
thread: ReviewThread,
|
|
51
|
+
plan: ReviewPublicationPlan,
|
|
52
|
+
): void => {
|
|
53
|
+
if (thread.repo !== plan.repo || thread.prNumber !== plan.prNumber) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`publication blocked: review thread ${thread.id} belongs to ${thread.repo}#${thread.prNumber}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
thread.creation === null ||
|
|
60
|
+
!thread.creation.viewerDidAuthor ||
|
|
61
|
+
!hasAgentCreationMarker(thread.creation.body, plan.approvalId)
|
|
62
|
+
) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`publication blocked: review thread ${thread.id} was not created by this approval generation`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const publishReviewThreadResolutions = async (options: {
|
|
70
|
+
readonly deps: JobDeps;
|
|
71
|
+
readonly pr: PullRequest;
|
|
72
|
+
readonly claim: ClaimBinding;
|
|
73
|
+
readonly plan: ReviewPublicationPlan;
|
|
74
|
+
}): Promise<void> => {
|
|
75
|
+
const { deps, pr, claim, plan } = options;
|
|
76
|
+
for (const resolution of plan.threadsToResolve) {
|
|
77
|
+
const marker = threadResolutionMarker(plan, resolution);
|
|
78
|
+
const reply = `${resolution.verificationReply}\n\n${marker}`;
|
|
79
|
+
// biome-ignore lint/performance/noAwaitInLoops: each thread write has its own immediate claim revalidation.
|
|
80
|
+
let thread = await readReviewThread(deps.token, resolution.threadId);
|
|
81
|
+
assertThreadTarget(thread, plan);
|
|
82
|
+
if (!thread.viewerAuthoredBodies.includes(reply)) {
|
|
83
|
+
await validateReviewClaim({
|
|
84
|
+
deps,
|
|
85
|
+
pr,
|
|
86
|
+
claim,
|
|
87
|
+
plan,
|
|
88
|
+
expectedHead: plan.publishedHead,
|
|
89
|
+
requireDraft: true,
|
|
90
|
+
});
|
|
91
|
+
await replyToReviewThread(deps.token, resolution.threadId, reply);
|
|
92
|
+
}
|
|
93
|
+
thread = await readReviewThread(deps.token, resolution.threadId);
|
|
94
|
+
assertThreadTarget(thread, plan);
|
|
95
|
+
if (!thread.viewerAuthoredBodies.includes(reply)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`publication blocked: verification reply missing from review thread ${resolution.threadId}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!thread.isResolved) {
|
|
101
|
+
await validateReviewClaim({
|
|
102
|
+
deps,
|
|
103
|
+
pr,
|
|
104
|
+
claim,
|
|
105
|
+
plan,
|
|
106
|
+
expectedHead: plan.publishedHead,
|
|
107
|
+
requireDraft: true,
|
|
108
|
+
});
|
|
109
|
+
await resolveReviewThread(deps.token, resolution.threadId);
|
|
110
|
+
}
|
|
111
|
+
thread = await readReviewThread(deps.token, resolution.threadId);
|
|
112
|
+
assertThreadTarget(thread, plan);
|
|
113
|
+
if (!thread.isResolved) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`publication blocked: review thread ${resolution.threadId} remained unresolved`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
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
|
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { listAllPages } from './github-paginate';
|
|
2
|
-
import { isRecord } from './github-settings-parse';
|
|
3
|
-
|
|
4
|
-
export const repositoryIssueMarkerAuthors = async (
|
|
5
|
-
token: string | null,
|
|
6
|
-
repo: string,
|
|
7
|
-
marker: string,
|
|
8
|
-
): Promise<ReadonlyArray<string>> => {
|
|
9
|
-
const items = await listAllPages(
|
|
10
|
-
token,
|
|
11
|
-
`/repos/${repo}/issues?state=all`,
|
|
12
|
-
`list ${repo} issues for publication marker`,
|
|
13
|
-
);
|
|
14
|
-
return items.flatMap((item) =>
|
|
15
|
-
isRecord(item) &&
|
|
16
|
-
typeof item.body === 'string' &&
|
|
17
|
-
item.body.includes(marker) &&
|
|
18
|
-
isRecord(item.user) &&
|
|
19
|
-
typeof item.user.login === 'string'
|
|
20
|
-
? [item.user.login]
|
|
21
|
-
: [],
|
|
22
|
-
);
|
|
23
|
-
};
|