opencode-magi 0.1.0 → 0.3.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 +33 -10
- package/dist/commands.js +4 -0
- package/dist/config/output.js +11 -2
- package/dist/config/resolve.js +124 -26
- package/dist/config/validate.js +486 -191
- package/dist/config/worktree.js +19 -0
- package/dist/github/commands.js +349 -17
- package/dist/index.js +257 -27
- package/dist/orchestrator/ci.js +1 -1
- package/dist/orchestrator/findings.js +4 -3
- package/dist/orchestrator/inline-comments.js +73 -0
- package/dist/orchestrator/majority.js +14 -0
- package/dist/orchestrator/merge.js +24 -4
- package/dist/orchestrator/report.js +15 -1
- package/dist/orchestrator/review-context.js +309 -0
- package/dist/orchestrator/review.js +78 -10
- package/dist/orchestrator/run-manager.js +418 -20
- package/dist/orchestrator/triage.js +1119 -0
- package/dist/permissions/editor.json +8 -1
- package/dist/prompts/compose.js +172 -15
- package/dist/prompts/contracts.js +119 -12
- package/dist/prompts/output.js +149 -14
- package/dist/prompts/templates/{close-reconsideration.md → review/close-reconsideration.md} +1 -2
- package/dist/prompts/templates/review/review.md +13 -0
- package/dist/prompts/templates/triage/acceptance.md +7 -0
- package/dist/prompts/templates/triage/action.md +5 -0
- package/dist/prompts/templates/triage/category.md +10 -0
- package/dist/prompts/templates/triage/comment-classification.md +7 -0
- package/dist/prompts/templates/triage/comment.md +5 -0
- package/dist/prompts/templates/triage/create.md +7 -0
- package/dist/prompts/templates/triage/duplicate.md +7 -0
- package/dist/prompts/templates/triage/existing-pr.md +7 -0
- package/dist/prompts/templates/triage/question.md +5 -0
- package/dist/prompts/templates/triage/reconsider.md +5 -0
- package/package.json +28 -27
- package/schema.json +234 -90
- package/dist/prompts/templates/rereview-close-reconsideration.md +0 -6
- package/dist/prompts/templates/review.md +0 -7
- /package/dist/prompts/templates/{ci-classification-after-edit.md → merge/ci-classification.md} +0 -0
- /package/dist/prompts/templates/{edit.md → merge/edit.md} +0 -0
- /package/dist/prompts/templates/{ci-classification.md → review/ci-classification.md} +0 -0
- /package/dist/prompts/templates/{finding-validation.md → review/finding-validation.md} +0 -0
- /package/dist/prompts/templates/{rereview.md → review/rereview.md} +0 -0
|
@@ -10,6 +10,11 @@ function reportUrl(value) {
|
|
|
10
10
|
function linkOrText(text, url) {
|
|
11
11
|
return url ? `[${text}](${url})` : text;
|
|
12
12
|
}
|
|
13
|
+
function pullRequestLine(input) {
|
|
14
|
+
const host = input.repository.github.host || "github.com";
|
|
15
|
+
const url = `https://${host}/${input.repository.github.owner}/${input.repository.github.repo}/pull/${input.pr}`;
|
|
16
|
+
return `- **Pull Request**: [#${input.pr}](${url})`;
|
|
17
|
+
}
|
|
13
18
|
function formatFinding(finding) {
|
|
14
19
|
const line = finding.startLine == null
|
|
15
20
|
? `${finding.path}:${finding.line}`
|
|
@@ -22,6 +27,9 @@ function formatRereviewFinding(finding) {
|
|
|
22
27
|
: `${finding.path}:${finding.startLine}-${finding.line}`;
|
|
23
28
|
return `\`${line}\`: ${finding.body}`;
|
|
24
29
|
}
|
|
30
|
+
function formatRequirementFinding(finding) {
|
|
31
|
+
return `Issue #${finding.issueNumber}: ${finding.requirement}`;
|
|
32
|
+
}
|
|
25
33
|
function isReviewOutput(output) {
|
|
26
34
|
return "findings" in output;
|
|
27
35
|
}
|
|
@@ -73,7 +81,10 @@ function reviewerDetailLines(output) {
|
|
|
73
81
|
return output.reason ? [output.reason] : [];
|
|
74
82
|
if (output.verdict !== "CHANGES_REQUESTED")
|
|
75
83
|
return [];
|
|
76
|
-
return
|
|
84
|
+
return [
|
|
85
|
+
...output.findings.map(formatFinding),
|
|
86
|
+
...output.requirementFindings.map(formatRequirementFinding),
|
|
87
|
+
];
|
|
77
88
|
}
|
|
78
89
|
if (output.verdict === "CLOSE")
|
|
79
90
|
return output.reason ? [output.reason] : [];
|
|
@@ -81,6 +92,7 @@ function reviewerDetailLines(output) {
|
|
|
81
92
|
return [];
|
|
82
93
|
return [
|
|
83
94
|
...output.newFindings.map(formatRereviewFinding),
|
|
95
|
+
...output.requirementFindings.map(formatRequirementFinding),
|
|
84
96
|
...output.followUps.map((item) => `Comment #${item.commentId}: ${item.body}`),
|
|
85
97
|
];
|
|
86
98
|
}
|
|
@@ -150,6 +162,7 @@ function editorLines(outputs) {
|
|
|
150
162
|
}
|
|
151
163
|
export function formatReviewReport(input) {
|
|
152
164
|
return [
|
|
165
|
+
pullRequestLine(input),
|
|
153
166
|
...dryRunLines(input.dryRun),
|
|
154
167
|
...safetyLines(input.safety),
|
|
155
168
|
...checkLines(input.ciReports),
|
|
@@ -158,6 +171,7 @@ export function formatReviewReport(input) {
|
|
|
158
171
|
}
|
|
159
172
|
export function formatMergeReport(input) {
|
|
160
173
|
return [
|
|
174
|
+
pullRequestLine(input),
|
|
161
175
|
...mergeStatusLines(input.status),
|
|
162
176
|
...dryRunLines(input.dryRun),
|
|
163
177
|
...safetyLines(input.safety),
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { fetchIssue, fetchIssueCommentPage, fetchPullRequestClosingIssues, fetchPullRequestCommentPage, fetchPullRequestReviewThreadPage, fetchPullRequestSafetyMeta, } from "../github/commands";
|
|
2
|
+
const LIMITS = {
|
|
3
|
+
closingIssueComments: 20,
|
|
4
|
+
commentBody: 4000,
|
|
5
|
+
prComments: 20,
|
|
6
|
+
referencedIssueComments: 10,
|
|
7
|
+
reviewThreadComments: 20,
|
|
8
|
+
reviewThreads: 50,
|
|
9
|
+
};
|
|
10
|
+
function escapeRegExp(value) {
|
|
11
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12
|
+
}
|
|
13
|
+
function truncateBody(body) {
|
|
14
|
+
if (body.length <= LIMITS.commentBody)
|
|
15
|
+
return { body };
|
|
16
|
+
return {
|
|
17
|
+
body: `${body.slice(0, LIMITS.commentBody)}\n[truncated after ${LIMITS.commentBody} characters]`,
|
|
18
|
+
truncated: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function boundedComments(comments, limit) {
|
|
22
|
+
return [...comments]
|
|
23
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
24
|
+
.slice(-limit)
|
|
25
|
+
.map((comment) => ({
|
|
26
|
+
author: comment.author,
|
|
27
|
+
createdAt: comment.createdAt,
|
|
28
|
+
id: comment.id,
|
|
29
|
+
url: comment.url,
|
|
30
|
+
...truncateBody(comment.body),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
function omittedCommentCount(input) {
|
|
34
|
+
return input.omitted + Math.max(0, input.comments.length - input.limit);
|
|
35
|
+
}
|
|
36
|
+
function quoteEvidence(value) {
|
|
37
|
+
const compact = value.replaceAll(/\s+/g, " ").trim();
|
|
38
|
+
return compact.length > 120 ? `${compact.slice(0, 117)}...` : compact;
|
|
39
|
+
}
|
|
40
|
+
function issueReferencePattern(repository) {
|
|
41
|
+
const host = escapeRegExp(repository.github.host || "github.com");
|
|
42
|
+
const owner = escapeRegExp(repository.github.owner);
|
|
43
|
+
const repo = escapeRegExp(repository.github.repo);
|
|
44
|
+
return new RegExp(`(?:https?://${host}/${owner}/${repo}/issues/(\\d+)|#(\\d+))`, "gi");
|
|
45
|
+
}
|
|
46
|
+
function issueNumberFromMatch(match) {
|
|
47
|
+
return Number(match[1] ?? match[2]);
|
|
48
|
+
}
|
|
49
|
+
function addRelationship(relationships, number, relationship, source) {
|
|
50
|
+
const current = relationships.get(number);
|
|
51
|
+
const nextRelationship = current?.relationship === "closing" || relationship === "closing"
|
|
52
|
+
? "closing"
|
|
53
|
+
: "referenced";
|
|
54
|
+
const sources = current?.sources ?? [];
|
|
55
|
+
relationships.set(number, {
|
|
56
|
+
number,
|
|
57
|
+
relationship: nextRelationship,
|
|
58
|
+
sources: sources.includes(source) ? sources : [...sources, source],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function scanRelationshipText(input) {
|
|
62
|
+
const referencePattern = issueReferencePattern(input.repository);
|
|
63
|
+
const closingPattern = new RegExp(`\\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\\b[\\s\\S]{0,80}?${referencePattern.source}`, "gi");
|
|
64
|
+
for (const match of input.text.matchAll(referencePattern)) {
|
|
65
|
+
const number = issueNumberFromMatch(match);
|
|
66
|
+
if (number === input.currentPr)
|
|
67
|
+
continue;
|
|
68
|
+
addRelationship(input.relationships, number, "referenced", `${input.label} "${quoteEvidence(match[0])}"`);
|
|
69
|
+
}
|
|
70
|
+
for (const match of input.text.matchAll(closingPattern)) {
|
|
71
|
+
const number = Number(match[1] ?? match[2]);
|
|
72
|
+
if (!number || number === input.currentPr)
|
|
73
|
+
continue;
|
|
74
|
+
addRelationship(input.relationships, number, "closing", `${input.label} "${quoteEvidence(match[0])}"`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function collectIssueRelationships(input) {
|
|
78
|
+
const relationships = new Map();
|
|
79
|
+
for (const issue of input.closingIssues) {
|
|
80
|
+
if (issue.number === input.pr.number)
|
|
81
|
+
continue;
|
|
82
|
+
addRelationship(relationships, issue.number, "closing", "GitHub closingIssuesReferences");
|
|
83
|
+
}
|
|
84
|
+
scanRelationshipText({
|
|
85
|
+
currentPr: input.pr.number,
|
|
86
|
+
label: "PR title",
|
|
87
|
+
relationships,
|
|
88
|
+
repository: input.repository,
|
|
89
|
+
text: input.pr.title,
|
|
90
|
+
});
|
|
91
|
+
scanRelationshipText({
|
|
92
|
+
currentPr: input.pr.number,
|
|
93
|
+
label: "PR body",
|
|
94
|
+
relationships,
|
|
95
|
+
repository: input.repository,
|
|
96
|
+
text: input.pr.body ?? "",
|
|
97
|
+
});
|
|
98
|
+
for (const comment of input.prComments) {
|
|
99
|
+
scanRelationshipText({
|
|
100
|
+
currentPr: input.pr.number,
|
|
101
|
+
label: `PR comment ${comment.id}`,
|
|
102
|
+
relationships,
|
|
103
|
+
repository: input.repository,
|
|
104
|
+
text: comment.body,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
for (const thread of input.reviewThreads) {
|
|
108
|
+
for (const comment of thread.comments) {
|
|
109
|
+
scanRelationshipText({
|
|
110
|
+
currentPr: input.pr.number,
|
|
111
|
+
label: `review thread ${thread.threadId} comment ${comment.commentId}`,
|
|
112
|
+
relationships,
|
|
113
|
+
repository: input.repository,
|
|
114
|
+
text: comment.body,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return [...relationships.values()].sort((a, b) => a.number - b.number);
|
|
119
|
+
}
|
|
120
|
+
async function contextIssue(input) {
|
|
121
|
+
const issue = input.issue ??
|
|
122
|
+
(await fetchIssue(input.exec, input.repository, input.relationship.number));
|
|
123
|
+
const commentPage = await fetchIssueCommentPage(input.exec, input.repository, issue.number, input.limit);
|
|
124
|
+
return {
|
|
125
|
+
author: issue.author,
|
|
126
|
+
body: issue.body,
|
|
127
|
+
comments: boundedComments(commentPage.comments, input.limit),
|
|
128
|
+
commentsOmitted: omittedCommentCount({
|
|
129
|
+
comments: commentPage.comments,
|
|
130
|
+
limit: input.limit,
|
|
131
|
+
omitted: commentPage.omitted,
|
|
132
|
+
}),
|
|
133
|
+
number: issue.number,
|
|
134
|
+
relationship: input.relationship.relationship,
|
|
135
|
+
source: input.relationship.sources.join("; "),
|
|
136
|
+
state: issue.state,
|
|
137
|
+
title: issue.title,
|
|
138
|
+
url: issue.url,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function orderReviewThreads(threads) {
|
|
142
|
+
return [...threads]
|
|
143
|
+
.sort((a, b) => {
|
|
144
|
+
if (a.isResolved !== b.isResolved)
|
|
145
|
+
return a.isResolved ? 1 : -1;
|
|
146
|
+
const aLatest = a.comments.at(-1)?.createdAt ?? "";
|
|
147
|
+
const bLatest = b.comments.at(-1)?.createdAt ?? "";
|
|
148
|
+
return bLatest.localeCompare(aLatest);
|
|
149
|
+
})
|
|
150
|
+
.slice(0, LIMITS.reviewThreads)
|
|
151
|
+
.map((thread) => ({
|
|
152
|
+
...thread,
|
|
153
|
+
comments: thread.comments
|
|
154
|
+
.slice(-LIMITS.reviewThreadComments)
|
|
155
|
+
.map((comment) => ({
|
|
156
|
+
...comment,
|
|
157
|
+
...truncateBody(comment.body),
|
|
158
|
+
})),
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
export async function buildReviewContextSnapshot(input) {
|
|
162
|
+
const [prCommentPage, reviewThreadPage, safetyMeta, closingIssues] = await Promise.all([
|
|
163
|
+
fetchPullRequestCommentPage(input.exec, input.repository, input.pr.number, LIMITS.prComments),
|
|
164
|
+
fetchPullRequestReviewThreadPage(input.exec, input.repository, input.pr.number, LIMITS.reviewThreads, LIMITS.reviewThreadComments),
|
|
165
|
+
fetchPullRequestSafetyMeta(input.exec, input.repository, input.pr.number),
|
|
166
|
+
fetchPullRequestClosingIssues(input.exec, input.repository, input.pr.number).catch(() => []),
|
|
167
|
+
]);
|
|
168
|
+
const prComments = prCommentPage.comments;
|
|
169
|
+
const orderedReviewThreads = orderReviewThreads(reviewThreadPage.threads);
|
|
170
|
+
const prCommentsOmitted = omittedCommentCount({
|
|
171
|
+
comments: prComments,
|
|
172
|
+
limit: LIMITS.prComments,
|
|
173
|
+
omitted: prCommentPage.omitted,
|
|
174
|
+
});
|
|
175
|
+
const relationships = collectIssueRelationships({
|
|
176
|
+
closingIssues,
|
|
177
|
+
pr: input.pr,
|
|
178
|
+
prComments,
|
|
179
|
+
repository: input.repository,
|
|
180
|
+
reviewThreads: orderedReviewThreads,
|
|
181
|
+
});
|
|
182
|
+
const closingIssueMap = new Map(closingIssues.map((issue) => [issue.number, issue]));
|
|
183
|
+
const closingRelationships = relationships.filter((relationship) => relationship.relationship === "closing");
|
|
184
|
+
const referencedRelationships = relationships.filter((relationship) => relationship.relationship === "referenced");
|
|
185
|
+
return {
|
|
186
|
+
closingIssues: await Promise.all(closingRelationships.map((relationship) => contextIssue({
|
|
187
|
+
exec: input.exec,
|
|
188
|
+
issue: closingIssueMap.get(relationship.number),
|
|
189
|
+
limit: LIMITS.closingIssueComments,
|
|
190
|
+
relationship,
|
|
191
|
+
repository: input.repository,
|
|
192
|
+
}))),
|
|
193
|
+
pullRequest: {
|
|
194
|
+
author: input.pr.author?.login ?? safetyMeta.author,
|
|
195
|
+
baseRef: input.pr.baseRefName,
|
|
196
|
+
baseSha: input.pr.baseRefOid,
|
|
197
|
+
body: input.pr.body ?? "",
|
|
198
|
+
changedFiles: safetyMeta.files,
|
|
199
|
+
comments: boundedComments(prComments, LIMITS.prComments),
|
|
200
|
+
commentsOmitted: prCommentsOmitted,
|
|
201
|
+
headRef: input.pr.headRefName,
|
|
202
|
+
headSha: input.pr.headRefOid,
|
|
203
|
+
number: input.pr.number,
|
|
204
|
+
relationship: "target",
|
|
205
|
+
source: "/magi:review input",
|
|
206
|
+
state: input.pr.state ?? "",
|
|
207
|
+
title: input.pr.title,
|
|
208
|
+
url: input.pr.url,
|
|
209
|
+
},
|
|
210
|
+
referencedIssues: await Promise.all(referencedRelationships.map((relationship) => contextIssue({
|
|
211
|
+
exec: input.exec,
|
|
212
|
+
limit: LIMITS.referencedIssueComments,
|
|
213
|
+
relationship,
|
|
214
|
+
repository: input.repository,
|
|
215
|
+
}))),
|
|
216
|
+
reviewDiscussion: {
|
|
217
|
+
prComments: boundedComments(prComments, LIMITS.prComments),
|
|
218
|
+
prCommentsOmitted,
|
|
219
|
+
reviewThreads: orderedReviewThreads,
|
|
220
|
+
reviewThreadsOmitted: reviewThreadPage.omitted,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function indented(value) {
|
|
225
|
+
return value.trim() ? value : "(empty)";
|
|
226
|
+
}
|
|
227
|
+
function renderOmissionNote(omitted, label, limit) {
|
|
228
|
+
return omitted > 0
|
|
229
|
+
? `\n[omitted ${omitted} older ${label} due to limit ${limit}]`
|
|
230
|
+
: "";
|
|
231
|
+
}
|
|
232
|
+
function renderComments(comments, omitted = 0, limit = comments.length) {
|
|
233
|
+
if (!comments.length)
|
|
234
|
+
return `(none)${renderOmissionNote(omitted, "comments", limit)}`;
|
|
235
|
+
return (comments
|
|
236
|
+
.map((comment) => {
|
|
237
|
+
const suffix = comment.truncated ? " [truncated]" : "";
|
|
238
|
+
return `- ${comment.createdAt} @${comment.author} (${comment.id})${suffix}\n${indented(comment.body)}`;
|
|
239
|
+
})
|
|
240
|
+
.join("\n") + renderOmissionNote(omitted, "comments", limit));
|
|
241
|
+
}
|
|
242
|
+
function renderIssue(issue) {
|
|
243
|
+
return `<issue>
|
|
244
|
+
number: ${issue.number}
|
|
245
|
+
title: ${issue.title}
|
|
246
|
+
url: ${issue.url}
|
|
247
|
+
state: ${issue.state}
|
|
248
|
+
author: ${issue.author}
|
|
249
|
+
relationship: ${issue.relationship}
|
|
250
|
+
source: ${issue.source}
|
|
251
|
+
body:
|
|
252
|
+
${indented(issue.body)}
|
|
253
|
+
comments:
|
|
254
|
+
${renderComments(issue.comments, issue.commentsOmitted, issue.relationship === "closing" ? LIMITS.closingIssueComments : LIMITS.referencedIssueComments)}
|
|
255
|
+
</issue>`;
|
|
256
|
+
}
|
|
257
|
+
function renderThreads(threads, omitted = 0) {
|
|
258
|
+
if (!threads.length) {
|
|
259
|
+
return `(none)${renderOmissionNote(omitted, "review threads", LIMITS.reviewThreads)}`;
|
|
260
|
+
}
|
|
261
|
+
return (threads
|
|
262
|
+
.map((thread) => {
|
|
263
|
+
const comments = thread.comments
|
|
264
|
+
.map((comment) => {
|
|
265
|
+
const suffix = comment.truncated ? " [truncated]" : "";
|
|
266
|
+
return ` - ${comment.createdAt} @${comment.author} (${comment.commentId})${suffix}\n${indented(comment.body)}`;
|
|
267
|
+
})
|
|
268
|
+
.join("\n") +
|
|
269
|
+
renderOmissionNote(thread.omittedComments ?? 0, "thread comments", LIMITS.reviewThreadComments);
|
|
270
|
+
return `- threadId: ${thread.threadId}\n resolved: ${Boolean(thread.isResolved)}\n path: ${thread.path}:${thread.line}\n comments:\n${comments}`;
|
|
271
|
+
})
|
|
272
|
+
.join("\n") +
|
|
273
|
+
renderOmissionNote(omitted, "review threads", LIMITS.reviewThreads));
|
|
274
|
+
}
|
|
275
|
+
export function renderReviewContext(snapshot) {
|
|
276
|
+
return [
|
|
277
|
+
`<pull_request_context>
|
|
278
|
+
number: ${snapshot.pullRequest.number}
|
|
279
|
+
title: ${snapshot.pullRequest.title}
|
|
280
|
+
url: ${snapshot.pullRequest.url}
|
|
281
|
+
state: ${snapshot.pullRequest.state}
|
|
282
|
+
author: ${snapshot.pullRequest.author}
|
|
283
|
+
relationship: ${snapshot.pullRequest.relationship}
|
|
284
|
+
source: ${snapshot.pullRequest.source}
|
|
285
|
+
baseRef: ${snapshot.pullRequest.baseRef}
|
|
286
|
+
headRef: ${snapshot.pullRequest.headRef}
|
|
287
|
+
baseSha: ${snapshot.pullRequest.baseSha}
|
|
288
|
+
headSha: ${snapshot.pullRequest.headSha}
|
|
289
|
+
body:
|
|
290
|
+
${indented(snapshot.pullRequest.body)}
|
|
291
|
+
comments:
|
|
292
|
+
${renderComments(snapshot.pullRequest.comments, snapshot.pullRequest.commentsOmitted, LIMITS.prComments)}
|
|
293
|
+
changedFiles:
|
|
294
|
+
${snapshot.pullRequest.changedFiles.length ? snapshot.pullRequest.changedFiles.map((file) => `- ${file}`).join("\n") : "(none)"}
|
|
295
|
+
</pull_request_context>`,
|
|
296
|
+
`<closing_issues>
|
|
297
|
+
${snapshot.closingIssues.length ? snapshot.closingIssues.map(renderIssue).join("\n") : "(none)"}
|
|
298
|
+
</closing_issues>`,
|
|
299
|
+
`<referenced_issues>
|
|
300
|
+
${snapshot.referencedIssues.length ? snapshot.referencedIssues.map(renderIssue).join("\n") : "(none)"}
|
|
301
|
+
</referenced_issues>`,
|
|
302
|
+
`<review_discussion>
|
|
303
|
+
prComments:
|
|
304
|
+
${renderComments(snapshot.reviewDiscussion.prComments, snapshot.reviewDiscussion.prCommentsOmitted, LIMITS.prComments)}
|
|
305
|
+
reviewThreads:
|
|
306
|
+
${renderThreads(snapshot.reviewDiscussion.reviewThreads, snapshot.reviewDiscussion.reviewThreadsOmitted)}
|
|
307
|
+
</review_discussion>`,
|
|
308
|
+
].join("\n\n");
|
|
309
|
+
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { createWorktree, fetchPullRequest, fetchPullRequestCommits, fetchPullRequestReviews, fetchUnresolvedThreads, postApproval, postChangesRequested, postCloseComment, postReply, removeWorktree, resolveThread, } from "../github/commands";
|
|
3
|
+
import { createWorktree, fetchPullRequest, fetchPullRequestCommits, fetchPullRequestReviews, fetchUnresolvedThreads, closePullRequest, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, removeWorktree, resolveThread, shellQuote, } from "../github/commands";
|
|
4
4
|
import { composeFindingValidationPrompt, composeCloseReconsiderationPrompt, composeRereviewPrompt, composeReviewPrompt, } from "../prompts/compose";
|
|
5
5
|
import { prRunOutputDir } from "../config/output";
|
|
6
|
+
import { worktreeBaseDir } from "../config/worktree";
|
|
6
7
|
import { parseCloseReconsiderationOutput, parseFindingValidationOutput, parseRereviewOutput, parseReviewOutput, } from "../prompts/output";
|
|
7
8
|
import { throwIfAborted, withAbortSignal } from "./abort";
|
|
8
9
|
import { waitForChecksWithClassification } from "./ci";
|
|
10
|
+
import { parseRightSideDiffTargets, validateInlineCommentTargets, } from "./inline-comments";
|
|
9
11
|
import { applyFindingValidation, reviewFindingTargets, validateFindingVotes, } from "./findings";
|
|
10
12
|
import { closeMinorityReviewers, mergeVerdictForPolicy, } from "./majority";
|
|
11
13
|
import { runModelWithRepair } from "./model";
|
|
12
14
|
import { mapPool } from "./pool";
|
|
13
15
|
import { formatReviewReport } from "./report";
|
|
16
|
+
import { buildReviewContextSnapshot, renderReviewContext, } from "./review-context";
|
|
14
17
|
import { checkSafetyGate, hasSafetyGate } from "./safety";
|
|
15
18
|
function errorMessage(error) {
|
|
16
19
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -36,7 +39,7 @@ async function postReviewOutput(input, reviewerKey, output) {
|
|
|
36
39
|
return postApproval(input.exec, input.repository, input.pr, reviewer.account);
|
|
37
40
|
if (output.verdict === "CLOSE")
|
|
38
41
|
return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
|
|
39
|
-
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.findings);
|
|
42
|
+
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.findings, output.requirementFindings);
|
|
40
43
|
}
|
|
41
44
|
function dryRunReviewPost(key, output) {
|
|
42
45
|
if (output.verdict === "MERGE")
|
|
@@ -111,6 +114,9 @@ function reviewStateToVerdict(state) {
|
|
|
111
114
|
return "CHANGES_REQUESTED";
|
|
112
115
|
return "CLOSE";
|
|
113
116
|
}
|
|
117
|
+
function hasBlockingCiReports(reports) {
|
|
118
|
+
return reports.some((report) => report.scopeInside.length || report.scopeOutsideUnresolved.length);
|
|
119
|
+
}
|
|
114
120
|
function previousReviewText(review) {
|
|
115
121
|
return JSON.stringify({
|
|
116
122
|
body: review.body ?? "",
|
|
@@ -119,11 +125,26 @@ function previousReviewText(review) {
|
|
|
119
125
|
submittedAt: review.submittedAt,
|
|
120
126
|
}, null, 2);
|
|
121
127
|
}
|
|
128
|
+
function parseReviewOutputWithInlineTargets(text, targets) {
|
|
129
|
+
const output = parseReviewOutput(text);
|
|
130
|
+
validateInlineCommentTargets(output.findings, targets);
|
|
131
|
+
return output;
|
|
132
|
+
}
|
|
133
|
+
function parseRereviewOutputWithInlineTargets(text, targets) {
|
|
134
|
+
const output = parseRereviewOutput(text);
|
|
135
|
+
validateInlineCommentTargets(output.newFindings, targets, "newFindings");
|
|
136
|
+
return output;
|
|
137
|
+
}
|
|
122
138
|
function reviewOutputFromState(review) {
|
|
123
139
|
const verdict = reviewStateToVerdict(review.state);
|
|
124
140
|
return verdict === "CLOSE"
|
|
125
|
-
? {
|
|
126
|
-
|
|
141
|
+
? {
|
|
142
|
+
findings: [],
|
|
143
|
+
reason: review.body || "Close requested.",
|
|
144
|
+
requirementFindings: [],
|
|
145
|
+
verdict,
|
|
146
|
+
}
|
|
147
|
+
: { findings: [], requirementFindings: [], verdict };
|
|
127
148
|
}
|
|
128
149
|
export function hasPendingThreadReply(threads, reviewerAccount) {
|
|
129
150
|
return threads.some((thread) => {
|
|
@@ -147,7 +168,7 @@ async function postRereviewOutput(input, reviewerKey, output) {
|
|
|
147
168
|
return postApproval(input.exec, input.repository, input.pr, reviewer.account);
|
|
148
169
|
if (output.verdict === "CLOSE")
|
|
149
170
|
return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
|
|
150
|
-
if (!output.newFindings.length)
|
|
171
|
+
if (!output.newFindings.length && !output.requirementFindings.length)
|
|
151
172
|
return "";
|
|
152
173
|
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
|
|
153
174
|
fix: "Please address this before merging.",
|
|
@@ -155,7 +176,7 @@ async function postRereviewOutput(input, reviewerKey, output) {
|
|
|
155
176
|
line: finding.line,
|
|
156
177
|
path: finding.path,
|
|
157
178
|
startLine: finding.startLine,
|
|
158
|
-
})));
|
|
179
|
+
})), output.requirementFindings);
|
|
159
180
|
}
|
|
160
181
|
function isReviewOutput(output) {
|
|
161
182
|
return "findings" in output;
|
|
@@ -187,6 +208,7 @@ async function runFindingValidation(input) {
|
|
|
187
208
|
includeSessionContext: !hasReviewerSession,
|
|
188
209
|
pr: input.reviewInput.pr,
|
|
189
210
|
repository: input.reviewInput.repository,
|
|
211
|
+
reviewContext: input.reviewContext,
|
|
190
212
|
reviewer,
|
|
191
213
|
worktreePath: input.worktreePath,
|
|
192
214
|
});
|
|
@@ -299,6 +321,7 @@ async function runCloseReconsideration(input) {
|
|
|
299
321
|
includeSessionContext: !hasReviewerSession,
|
|
300
322
|
pr: input.reviewInput.pr,
|
|
301
323
|
repository: input.reviewInput.repository,
|
|
324
|
+
reviewContext: input.reviewContext,
|
|
302
325
|
reviewer,
|
|
303
326
|
worktreePath: input.worktreePath,
|
|
304
327
|
});
|
|
@@ -332,7 +355,11 @@ async function runCloseReconsideration(input) {
|
|
|
332
355
|
}
|
|
333
356
|
},
|
|
334
357
|
options: reviewer.options,
|
|
335
|
-
parse:
|
|
358
|
+
parse: (text) => {
|
|
359
|
+
const output = parseCloseReconsiderationOutput(text);
|
|
360
|
+
validateInlineCommentTargets(output.findings, input.inlineCommentTargets);
|
|
361
|
+
return output;
|
|
362
|
+
},
|
|
336
363
|
permission: reviewer.permission,
|
|
337
364
|
prompt,
|
|
338
365
|
repairAttempts: input.reviewInput.config.output?.repairAttempts ?? 3,
|
|
@@ -393,6 +420,7 @@ export async function runReview(input) {
|
|
|
393
420
|
dryRun: input.dryRun,
|
|
394
421
|
outputs: {},
|
|
395
422
|
posted: {},
|
|
423
|
+
pr: input.pr,
|
|
396
424
|
repository: input.repository,
|
|
397
425
|
safety,
|
|
398
426
|
});
|
|
@@ -444,6 +472,15 @@ export async function runReview(input) {
|
|
|
444
472
|
pr: input.pr,
|
|
445
473
|
}), ...(input.runId ? [input.runId] : []));
|
|
446
474
|
await mkdir(outputDir, { recursive: true });
|
|
475
|
+
await input.onProgress?.({ phase: "fetching review context", type: "phase" });
|
|
476
|
+
const reviewContextSnapshot = await buildReviewContextSnapshot({
|
|
477
|
+
exec,
|
|
478
|
+
pr: meta,
|
|
479
|
+
repository: input.repository,
|
|
480
|
+
});
|
|
481
|
+
const reviewContext = renderReviewContext(reviewContextSnapshot);
|
|
482
|
+
await writeFile(join(outputDir, "review-context.json"), JSON.stringify(reviewContextSnapshot, null, 2));
|
|
483
|
+
await writeFile(join(outputDir, "review-context.md"), `${reviewContext}\n`);
|
|
447
484
|
await input.onProgress?.({ phase: "waiting for checks", type: "phase" });
|
|
448
485
|
const checkResult = await waitForChecksWithClassification({
|
|
449
486
|
client: input.client,
|
|
@@ -497,7 +534,7 @@ export async function runReview(input) {
|
|
|
497
534
|
checkResult.report.scopeInside.length)) {
|
|
498
535
|
await input.onProgress?.({ report: checkResult.report, type: "ci_report" });
|
|
499
536
|
}
|
|
500
|
-
const worktreeRoot =
|
|
537
|
+
const worktreeRoot = worktreeBaseDir(input.directory, input.config, "pr");
|
|
501
538
|
await input.onProgress?.({ phase: "creating worktree", type: "phase" });
|
|
502
539
|
const worktree = await createWorktree(exec, input.repository, input.pr, worktreeRoot);
|
|
503
540
|
const worktreePath = worktree.path;
|
|
@@ -514,6 +551,7 @@ export async function runReview(input) {
|
|
|
514
551
|
return [];
|
|
515
552
|
return [{ assignment, reviewer }];
|
|
516
553
|
});
|
|
554
|
+
const inlineCommentTargets = parseRightSideDiffTargets(await exec(`git diff --no-ext-diff --unified=3 ${shellQuote(meta.baseRefOid)} ${shellQuote(meta.headRefOid)}`, { cwd: worktreePath }));
|
|
517
555
|
for (const reviewer of input.repository.agents.reviewers) {
|
|
518
556
|
const assignment = mode.assignments.get(reviewer.account);
|
|
519
557
|
if (assignment?.type !== "skip")
|
|
@@ -544,6 +582,7 @@ export async function runReview(input) {
|
|
|
544
582
|
previousReview: previousReviewText(previous),
|
|
545
583
|
previousHeadSha: previous.commit.oid,
|
|
546
584
|
repository: input.repository,
|
|
585
|
+
reviewContext,
|
|
547
586
|
reviewer,
|
|
548
587
|
unresolvedThreads: JSON.stringify(unresolved, null, 2),
|
|
549
588
|
worktreePath,
|
|
@@ -578,7 +617,7 @@ export async function runReview(input) {
|
|
|
578
617
|
}
|
|
579
618
|
},
|
|
580
619
|
options: reviewer.options,
|
|
581
|
-
parse:
|
|
620
|
+
parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
|
|
582
621
|
permission: reviewer.permission,
|
|
583
622
|
prompt,
|
|
584
623
|
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
@@ -610,6 +649,7 @@ export async function runReview(input) {
|
|
|
610
649
|
headSha: meta.headRefOid,
|
|
611
650
|
pr: input.pr,
|
|
612
651
|
repository: input.repository,
|
|
652
|
+
reviewContext,
|
|
613
653
|
reviewer,
|
|
614
654
|
worktreePath,
|
|
615
655
|
});
|
|
@@ -643,7 +683,7 @@ export async function runReview(input) {
|
|
|
643
683
|
}
|
|
644
684
|
},
|
|
645
685
|
options: reviewer.options,
|
|
646
|
-
parse:
|
|
686
|
+
parse: (text) => parseReviewOutputWithInlineTargets(text, inlineCommentTargets),
|
|
647
687
|
permission: reviewer.permission,
|
|
648
688
|
prompt,
|
|
649
689
|
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
@@ -703,8 +743,10 @@ export async function runReview(input) {
|
|
|
703
743
|
});
|
|
704
744
|
entries = await runCloseReconsideration({
|
|
705
745
|
entries: [...entries, ...skippedCloseEntries],
|
|
746
|
+
inlineCommentTargets,
|
|
706
747
|
meta,
|
|
707
748
|
outputDir,
|
|
749
|
+
reviewContext,
|
|
708
750
|
reviewInput: { ...input, exec },
|
|
709
751
|
sessionIds,
|
|
710
752
|
targets: closeTargets,
|
|
@@ -714,6 +756,7 @@ export async function runReview(input) {
|
|
|
714
756
|
entries,
|
|
715
757
|
meta,
|
|
716
758
|
outputDir,
|
|
759
|
+
reviewContext,
|
|
717
760
|
reviewInput: { ...input, exec },
|
|
718
761
|
sessionIds,
|
|
719
762
|
worktreePath,
|
|
@@ -752,6 +795,30 @@ export async function runReview(input) {
|
|
|
752
795
|
: await postReviewOutput({ ...input, exec }, key, output),
|
|
753
796
|
]))),
|
|
754
797
|
};
|
|
798
|
+
const automationAccount = input.repository.agents.reviewers[0]?.account;
|
|
799
|
+
const enableReviewAutomation = input.enableReviewAutomation ?? true;
|
|
800
|
+
if (enableReviewAutomation &&
|
|
801
|
+
verdict === "MERGE" &&
|
|
802
|
+
input.repository.reviewAutomation?.merge) {
|
|
803
|
+
await input.onProgress?.({ phase: "merging PR", type: "phase" });
|
|
804
|
+
posted.automation = hasBlockingCiReports(ciReports)
|
|
805
|
+
? "skipped: unresolved CI"
|
|
806
|
+
: input.dryRun
|
|
807
|
+
? "dry-run:would-merge"
|
|
808
|
+
: automationAccount
|
|
809
|
+
? await mergePullRequest(input.exec, input.repository, input.pr, automationAccount)
|
|
810
|
+
: "skipped: no review automation account";
|
|
811
|
+
}
|
|
812
|
+
if (enableReviewAutomation &&
|
|
813
|
+
verdict === "CLOSE" &&
|
|
814
|
+
input.repository.reviewAutomation?.close) {
|
|
815
|
+
await input.onProgress?.({ phase: "closing PR", type: "phase" });
|
|
816
|
+
posted.automation = input.dryRun
|
|
817
|
+
? "dry-run:would-close"
|
|
818
|
+
: automationAccount
|
|
819
|
+
? await closePullRequest(input.exec, input.repository, input.pr, automationAccount)
|
|
820
|
+
: "skipped: no review automation account";
|
|
821
|
+
}
|
|
755
822
|
await writeFile(join(outputDir, "majority.json"), JSON.stringify({
|
|
756
823
|
approvalPolicy: input.approvalPolicy ?? "majority",
|
|
757
824
|
verdict,
|
|
@@ -765,6 +832,7 @@ export async function runReview(input) {
|
|
|
765
832
|
dryRun: input.dryRun,
|
|
766
833
|
outputs,
|
|
767
834
|
posted,
|
|
835
|
+
pr: input.pr,
|
|
768
836
|
repository: input.repository,
|
|
769
837
|
});
|
|
770
838
|
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|