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.
Files changed (43) hide show
  1. package/README.md +33 -10
  2. package/dist/commands.js +4 -0
  3. package/dist/config/output.js +11 -2
  4. package/dist/config/resolve.js +124 -26
  5. package/dist/config/validate.js +486 -191
  6. package/dist/config/worktree.js +19 -0
  7. package/dist/github/commands.js +349 -17
  8. package/dist/index.js +257 -27
  9. package/dist/orchestrator/ci.js +1 -1
  10. package/dist/orchestrator/findings.js +4 -3
  11. package/dist/orchestrator/inline-comments.js +73 -0
  12. package/dist/orchestrator/majority.js +14 -0
  13. package/dist/orchestrator/merge.js +24 -4
  14. package/dist/orchestrator/report.js +15 -1
  15. package/dist/orchestrator/review-context.js +309 -0
  16. package/dist/orchestrator/review.js +78 -10
  17. package/dist/orchestrator/run-manager.js +418 -20
  18. package/dist/orchestrator/triage.js +1119 -0
  19. package/dist/permissions/editor.json +8 -1
  20. package/dist/prompts/compose.js +172 -15
  21. package/dist/prompts/contracts.js +119 -12
  22. package/dist/prompts/output.js +149 -14
  23. package/dist/prompts/templates/{close-reconsideration.md → review/close-reconsideration.md} +1 -2
  24. package/dist/prompts/templates/review/review.md +13 -0
  25. package/dist/prompts/templates/triage/acceptance.md +7 -0
  26. package/dist/prompts/templates/triage/action.md +5 -0
  27. package/dist/prompts/templates/triage/category.md +10 -0
  28. package/dist/prompts/templates/triage/comment-classification.md +7 -0
  29. package/dist/prompts/templates/triage/comment.md +5 -0
  30. package/dist/prompts/templates/triage/create.md +7 -0
  31. package/dist/prompts/templates/triage/duplicate.md +7 -0
  32. package/dist/prompts/templates/triage/existing-pr.md +7 -0
  33. package/dist/prompts/templates/triage/question.md +5 -0
  34. package/dist/prompts/templates/triage/reconsider.md +5 -0
  35. package/package.json +28 -27
  36. package/schema.json +234 -90
  37. package/dist/prompts/templates/rereview-close-reconsideration.md +0 -6
  38. package/dist/prompts/templates/review.md +0 -7
  39. /package/dist/prompts/templates/{ci-classification-after-edit.md → merge/ci-classification.md} +0 -0
  40. /package/dist/prompts/templates/{edit.md → merge/edit.md} +0 -0
  41. /package/dist/prompts/templates/{ci-classification.md → review/ci-classification.md} +0 -0
  42. /package/dist/prompts/templates/{finding-validation.md → review/finding-validation.md} +0 -0
  43. /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 output.findings.map(formatFinding);
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
- ? { findings: [], reason: review.body || "Close requested.", verdict }
126
- : { findings: [], verdict };
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: parseCloseReconsiderationOutput,
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 = join(input.directory, input.config.worktree?.dir ?? ".magi/worktrees");
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: parseRereviewOutput,
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: parseReviewOutput,
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`);