@davidvornholt/standards 0.15.1 → 0.17.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.
- package/README.md +3 -3
- package/package.json +10 -2
- package/src/cli.ts +27 -2
- package/src/poller-acknowledgements.ts +159 -0
- package/src/poller-claim.ts +60 -28
- package/src/poller-codex.ts +5 -11
- package/src/poller-commands.ts +26 -5
- package/src/poller-comment-metadata.ts +25 -0
- package/src/poller-fix-run.ts +3 -0
- package/src/poller-github-write.ts +27 -1
- package/src/poller-outcome.ts +60 -18
- package/src/poller-prompts.ts +35 -9
- package/src/poller-protocol.ts +21 -11
- package/src/poller-queue-marker.ts +194 -0
- package/src/poller-review-artifacts.ts +4 -30
- package/src/poller-review-eligibility.ts +33 -0
- 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 +17 -26
- 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 +133 -0
- package/src/poller-trust.ts +3 -3
- package/src/poller-units.ts +38 -7
- package/src/poller-github-publication.ts +0 -23
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { isRecord } from './github-settings-parse';
|
|
4
3
|
import {
|
|
5
4
|
assertCleanOutputWorktree,
|
|
6
5
|
commitCountBetween,
|
|
7
6
|
isAncestor,
|
|
8
|
-
isGitObjectId,
|
|
9
7
|
singleParentOf,
|
|
10
8
|
} from './poller-output-integrity';
|
|
11
|
-
import type {
|
|
9
|
+
import type { ReviewThreadResolution } from './poller-protocol';
|
|
10
|
+
import { parseReviewPlanValue } from './poller-review-plan-parse';
|
|
12
11
|
import { runGit } from './poller-workspace';
|
|
13
12
|
|
|
14
13
|
const REVIEW_OUTPUT_MARKER = '<!-- standards-poller:review-output\n';
|
|
15
14
|
const REVIEW_OUTPUT_END = '\n-->';
|
|
16
15
|
const REVIEW_COMMIT_MARKER = 'standards-poller:review-output';
|
|
17
16
|
const OUTPUT_BRANCH_DIGEST_LENGTH = 16;
|
|
17
|
+
const REVIEW_OUTPUT_PROTOCOL_VERSION = 2;
|
|
18
18
|
|
|
19
19
|
export type ReviewPublicationPlan = {
|
|
20
20
|
readonly repo: string;
|
|
@@ -26,16 +26,9 @@ export type ReviewPublicationPlan = {
|
|
|
26
26
|
readonly baseSha: string;
|
|
27
27
|
readonly report: string;
|
|
28
28
|
readonly commits: number;
|
|
29
|
-
readonly
|
|
29
|
+
readonly threadsToResolve: ReadonlyArray<ReviewThreadResolution>;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
const isDeferredFinding = (value: unknown): value is DeferredFinding =>
|
|
33
|
-
isRecord(value) &&
|
|
34
|
-
typeof value.title === 'string' &&
|
|
35
|
-
value.title.length > 0 &&
|
|
36
|
-
typeof value.body === 'string' &&
|
|
37
|
-
value.body.length > 0;
|
|
38
|
-
|
|
39
32
|
export const reviewPlanMarker = (plan: ReviewPublicationPlan): string =>
|
|
40
33
|
`${REVIEW_OUTPUT_MARKER}${Buffer.from(JSON.stringify(plan)).toString('base64url')}${REVIEW_OUTPUT_END}`;
|
|
41
34
|
|
|
@@ -53,28 +46,7 @@ export const parseReviewPlan = (body: string): ReviewPublicationPlan | null => {
|
|
|
53
46
|
const raw = JSON.parse(
|
|
54
47
|
Buffer.from(body.slice(encodedStart, end), 'base64url').toString('utf8'),
|
|
55
48
|
) as unknown;
|
|
56
|
-
|
|
57
|
-
!isRecord(raw) ||
|
|
58
|
-
typeof raw.repo !== 'string' ||
|
|
59
|
-
typeof raw.prNumber !== 'number' ||
|
|
60
|
-
typeof raw.approvalId !== 'string' ||
|
|
61
|
-
typeof raw.approvedHead !== 'string' ||
|
|
62
|
-
typeof raw.publishedHead !== 'string' ||
|
|
63
|
-
typeof raw.baseRef !== 'string' ||
|
|
64
|
-
typeof raw.baseSha !== 'string' ||
|
|
65
|
-
!isGitObjectId(raw.approvedHead) ||
|
|
66
|
-
!isGitObjectId(raw.publishedHead) ||
|
|
67
|
-
!isGitObjectId(raw.baseSha) ||
|
|
68
|
-
typeof raw.report !== 'string' ||
|
|
69
|
-
typeof raw.commits !== 'number' ||
|
|
70
|
-
!Number.isInteger(raw.commits) ||
|
|
71
|
-
raw.commits < 0 ||
|
|
72
|
-
!Array.isArray(raw.deferred) ||
|
|
73
|
-
!raw.deferred.every(isDeferredFinding)
|
|
74
|
-
) {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
return raw as ReviewPublicationPlan;
|
|
49
|
+
return parseReviewPlanValue(raw);
|
|
78
50
|
} catch {
|
|
79
51
|
return null;
|
|
80
52
|
}
|
|
@@ -89,11 +61,18 @@ export const reviewOutputBranch = (
|
|
|
89
61
|
const { repo, prNumber, baseSha, approvedHead, approvalId } = identity;
|
|
90
62
|
const generation = createHash('sha256')
|
|
91
63
|
.update(
|
|
92
|
-
JSON.stringify({
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
protocol: REVIEW_OUTPUT_PROTOCOL_VERSION,
|
|
66
|
+
repo,
|
|
67
|
+
prNumber,
|
|
68
|
+
baseSha,
|
|
69
|
+
approvedHead,
|
|
70
|
+
approvalId,
|
|
71
|
+
}),
|
|
93
72
|
)
|
|
94
73
|
.digest('hex')
|
|
95
74
|
.slice(0, OUTPUT_BRANCH_DIGEST_LENGTH);
|
|
96
|
-
return `poller/review-pr-${prNumber}-${generation}`;
|
|
75
|
+
return `poller/review-v${REVIEW_OUTPUT_PROTOCOL_VERSION}-pr-${prNumber}-${generation}`;
|
|
97
76
|
};
|
|
98
77
|
|
|
99
78
|
const commitMessage = (plan: ReviewPublicationPlan): string =>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { isRecord } from './github-settings-parse';
|
|
2
|
+
import { isGitObjectId } from './poller-output-integrity';
|
|
3
|
+
import type { ReviewThreadResolution } from './poller-protocol';
|
|
4
|
+
import type { ReviewPublicationPlan } from './poller-review-output';
|
|
5
|
+
|
|
6
|
+
const PLAN_KEYS = [
|
|
7
|
+
'approvalId',
|
|
8
|
+
'approvedHead',
|
|
9
|
+
'baseRef',
|
|
10
|
+
'baseSha',
|
|
11
|
+
'commits',
|
|
12
|
+
'prNumber',
|
|
13
|
+
'publishedHead',
|
|
14
|
+
'repo',
|
|
15
|
+
'report',
|
|
16
|
+
'threadsToResolve',
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
const THREAD_KEYS = ['threadId', 'verificationReply'] as const;
|
|
20
|
+
|
|
21
|
+
const hasExactKeys = (
|
|
22
|
+
value: Readonly<Record<string, unknown>>,
|
|
23
|
+
keys: ReadonlyArray<string>,
|
|
24
|
+
): boolean => {
|
|
25
|
+
const actual = Object.keys(value).sort();
|
|
26
|
+
const expected = [...keys].sort();
|
|
27
|
+
return (
|
|
28
|
+
actual.length === expected.length &&
|
|
29
|
+
actual.every((key, index) => key === expected[index])
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const parseThreadsToResolve = (
|
|
34
|
+
value: unknown,
|
|
35
|
+
): ReadonlyArray<ReviewThreadResolution> | null => {
|
|
36
|
+
if (!Array.isArray(value)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const threads: Array<ReviewThreadResolution> = [];
|
|
40
|
+
const ids = new Set<string>();
|
|
41
|
+
for (const entry of value) {
|
|
42
|
+
if (
|
|
43
|
+
!(isRecord(entry) && hasExactKeys(entry, THREAD_KEYS)) ||
|
|
44
|
+
typeof entry.threadId !== 'string' ||
|
|
45
|
+
entry.threadId.length === 0 ||
|
|
46
|
+
typeof entry.verificationReply !== 'string' ||
|
|
47
|
+
entry.verificationReply.trim().length === 0 ||
|
|
48
|
+
ids.has(entry.threadId)
|
|
49
|
+
) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
ids.add(entry.threadId);
|
|
53
|
+
threads.push({
|
|
54
|
+
threadId: entry.threadId,
|
|
55
|
+
verificationReply: entry.verificationReply,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return threads;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const parseReviewPlanValue = (
|
|
62
|
+
raw: unknown,
|
|
63
|
+
): ReviewPublicationPlan | null => {
|
|
64
|
+
const threadsToResolve = isRecord(raw)
|
|
65
|
+
? parseThreadsToResolve(raw.threadsToResolve)
|
|
66
|
+
: null;
|
|
67
|
+
if (
|
|
68
|
+
!(isRecord(raw) && hasExactKeys(raw, PLAN_KEYS)) ||
|
|
69
|
+
typeof raw.repo !== 'string' ||
|
|
70
|
+
raw.repo.length === 0 ||
|
|
71
|
+
typeof raw.prNumber !== 'number' ||
|
|
72
|
+
!Number.isInteger(raw.prNumber) ||
|
|
73
|
+
raw.prNumber < 1 ||
|
|
74
|
+
typeof raw.approvalId !== 'string' ||
|
|
75
|
+
raw.approvalId.length === 0 ||
|
|
76
|
+
typeof raw.approvedHead !== 'string' ||
|
|
77
|
+
typeof raw.publishedHead !== 'string' ||
|
|
78
|
+
typeof raw.baseRef !== 'string' ||
|
|
79
|
+
raw.baseRef.length === 0 ||
|
|
80
|
+
typeof raw.baseSha !== 'string' ||
|
|
81
|
+
!isGitObjectId(raw.approvedHead) ||
|
|
82
|
+
!isGitObjectId(raw.publishedHead) ||
|
|
83
|
+
!isGitObjectId(raw.baseSha) ||
|
|
84
|
+
typeof raw.report !== 'string' ||
|
|
85
|
+
raw.report.trim().length === 0 ||
|
|
86
|
+
typeof raw.commits !== 'number' ||
|
|
87
|
+
!Number.isInteger(raw.commits) ||
|
|
88
|
+
raw.commits < 0 ||
|
|
89
|
+
threadsToResolve === null
|
|
90
|
+
) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return { ...raw, threadsToResolve } as ReviewPublicationPlan;
|
|
94
|
+
};
|
|
@@ -84,7 +84,7 @@ export const finishReviewedJob = async (options: {
|
|
|
84
84
|
readonly pr: PullRequest;
|
|
85
85
|
readonly claim: ClaimBinding;
|
|
86
86
|
readonly workDir: string;
|
|
87
|
-
readonly outcome: ReviewOutcome
|
|
87
|
+
readonly outcome: Extract<ReviewOutcome, { readonly status: 'reviewed' }>;
|
|
88
88
|
}): Promise<string> => {
|
|
89
89
|
const { deps, labels, pr, claim, workDir, outcome } = options;
|
|
90
90
|
const commits = commitCount(workDir, pr.headSha);
|
|
@@ -110,9 +110,9 @@ export const finishReviewedJob = async (options: {
|
|
|
110
110
|
publishedHead: headSha(workDir),
|
|
111
111
|
baseRef: pr.baseRef,
|
|
112
112
|
baseSha: pr.baseSha,
|
|
113
|
-
report: outcome.report
|
|
113
|
+
report: outcome.report,
|
|
114
114
|
commits,
|
|
115
|
-
|
|
115
|
+
threadsToResolve: outcome.threadsToResolve,
|
|
116
116
|
};
|
|
117
117
|
const outputBranch = outputBranchFor(plan);
|
|
118
118
|
const sealedHead = sealReviewPlan(workDir, plan);
|
package/src/poller-review-run.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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
7
|
import { prRevision } from './poller-approval';
|
|
8
8
|
import { acquireClaim } from './poller-claim';
|
|
9
|
+
import { runCodex } from './poller-codex';
|
|
9
10
|
import { getIssue, type IssueItem } from './poller-github';
|
|
10
11
|
import { getPullRequest } from './poller-github-pulls';
|
|
11
12
|
import { addLabels } from './poller-github-write';
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
REVIEW_FAILED,
|
|
22
23
|
REVIEW_IN_PROGRESS,
|
|
23
24
|
} from './poller-protocol';
|
|
25
|
+
import { reviewEligibility } from './poller-review-eligibility';
|
|
24
26
|
import { executeReviewJob } from './poller-review-execution';
|
|
25
27
|
import {
|
|
26
28
|
readSealedReviewPlan,
|
|
@@ -28,6 +30,7 @@ import {
|
|
|
28
30
|
} from './poller-review-output';
|
|
29
31
|
import { resumeReviewedJob } from './poller-review-publication';
|
|
30
32
|
import { publishReviewPlan, readReviewPlan } from './poller-review-state';
|
|
33
|
+
import { acknowledgeQueuedJob } from './poller-status';
|
|
31
34
|
import { ensureCacheClone, localBranchExists } from './poller-workspace';
|
|
32
35
|
|
|
33
36
|
const REVIEW_LABELS: JobLabels = {
|
|
@@ -50,6 +53,7 @@ export const runReviewJob = async (
|
|
|
50
53
|
deps: JobDeps,
|
|
51
54
|
prItem: IssueItem,
|
|
52
55
|
allowCodex = true,
|
|
56
|
+
runAgent: typeof runCodex = runCodex,
|
|
53
57
|
): Promise<JobResult> => {
|
|
54
58
|
const { config, token, repo } = deps;
|
|
55
59
|
const pr = await getPullRequest(token, repo, prItem.number);
|
|
@@ -73,30 +77,15 @@ export const runReviewJob = async (
|
|
|
73
77
|
ranCodex: false,
|
|
74
78
|
};
|
|
75
79
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
);
|
|
80
|
+
const eligibility = reviewEligibility({
|
|
81
|
+
repo,
|
|
82
|
+
pr,
|
|
83
|
+
hasPlan: plan !== null,
|
|
84
|
+
});
|
|
85
|
+
if (eligibility.kind === 'rejected') {
|
|
86
|
+
await failJob(deps, REVIEW_LABELS, pr.number, eligibility.message);
|
|
98
87
|
return {
|
|
99
|
-
lines: [`PR #${pr.number}:
|
|
88
|
+
lines: [`PR #${pr.number}: ${eligibility.result}`],
|
|
100
89
|
ranCodex: false,
|
|
101
90
|
};
|
|
102
91
|
}
|
|
@@ -113,6 +102,7 @@ export const runReviewJob = async (
|
|
|
113
102
|
throw new Error(`sealed output on ${outputBranch} is invalid`);
|
|
114
103
|
}
|
|
115
104
|
if (plan === null && sealed === null && !allowCodex) {
|
|
105
|
+
await acknowledgeQueuedJob(deps, pr.number, preamble.approval, 'review');
|
|
116
106
|
return {
|
|
117
107
|
lines: [`PR #${pr.number}: waiting for run capacity`],
|
|
118
108
|
ranCodex: false,
|
|
@@ -156,5 +146,6 @@ export const runReviewJob = async (
|
|
|
156
146
|
claim,
|
|
157
147
|
cacheClone,
|
|
158
148
|
answers: preamble.answers,
|
|
149
|
+
runAgent,
|
|
159
150
|
});
|
|
160
151
|
};
|
|
@@ -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
|
+
};
|