opencode-magi 0.2.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 (35) hide show
  1. package/README.md +19 -0
  2. package/dist/commands.js +4 -0
  3. package/dist/config/output.js +11 -2
  4. package/dist/config/resolve.js +81 -1
  5. package/dist/config/validate.js +290 -3
  6. package/dist/config/worktree.js +8 -2
  7. package/dist/github/commands.js +343 -15
  8. package/dist/index.js +252 -26
  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 +16 -3
  14. package/dist/orchestrator/report.js +15 -1
  15. package/dist/orchestrator/review-context.js +309 -0
  16. package/dist/orchestrator/review.js +49 -9
  17. package/dist/orchestrator/run-manager.js +408 -17
  18. package/dist/orchestrator/triage.js +1119 -0
  19. package/dist/permissions/editor.json +8 -1
  20. package/dist/prompts/compose.js +162 -1
  21. package/dist/prompts/contracts.js +119 -12
  22. package/dist/prompts/output.js +149 -14
  23. package/dist/prompts/templates/review/review.md +6 -0
  24. package/dist/prompts/templates/triage/acceptance.md +7 -0
  25. package/dist/prompts/templates/triage/action.md +5 -0
  26. package/dist/prompts/templates/triage/category.md +10 -0
  27. package/dist/prompts/templates/triage/comment-classification.md +7 -0
  28. package/dist/prompts/templates/triage/comment.md +5 -0
  29. package/dist/prompts/templates/triage/create.md +7 -0
  30. package/dist/prompts/templates/triage/duplicate.md +7 -0
  31. package/dist/prompts/templates/triage/existing-pr.md +7 -0
  32. package/dist/prompts/templates/triage/question.md +5 -0
  33. package/dist/prompts/templates/triage/reconsider.md +5 -0
  34. package/package.json +5 -2
  35. package/schema.json +127 -2
@@ -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,17 +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, closePullRequest, mergePullRequest, 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
6
  import { worktreeBaseDir } from "../config/worktree";
7
7
  import { parseCloseReconsiderationOutput, parseFindingValidationOutput, parseRereviewOutput, parseReviewOutput, } from "../prompts/output";
8
8
  import { throwIfAborted, withAbortSignal } from "./abort";
9
9
  import { waitForChecksWithClassification } from "./ci";
10
+ import { parseRightSideDiffTargets, validateInlineCommentTargets, } from "./inline-comments";
10
11
  import { applyFindingValidation, reviewFindingTargets, validateFindingVotes, } from "./findings";
11
12
  import { closeMinorityReviewers, mergeVerdictForPolicy, } from "./majority";
12
13
  import { runModelWithRepair } from "./model";
13
14
  import { mapPool } from "./pool";
14
15
  import { formatReviewReport } from "./report";
16
+ import { buildReviewContextSnapshot, renderReviewContext, } from "./review-context";
15
17
  import { checkSafetyGate, hasSafetyGate } from "./safety";
16
18
  function errorMessage(error) {
17
19
  return error instanceof Error ? error.message : String(error);
@@ -37,7 +39,7 @@ async function postReviewOutput(input, reviewerKey, output) {
37
39
  return postApproval(input.exec, input.repository, input.pr, reviewer.account);
38
40
  if (output.verdict === "CLOSE")
39
41
  return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
40
- 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);
41
43
  }
42
44
  function dryRunReviewPost(key, output) {
43
45
  if (output.verdict === "MERGE")
@@ -123,11 +125,26 @@ function previousReviewText(review) {
123
125
  submittedAt: review.submittedAt,
124
126
  }, null, 2);
125
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
+ }
126
138
  function reviewOutputFromState(review) {
127
139
  const verdict = reviewStateToVerdict(review.state);
128
140
  return verdict === "CLOSE"
129
- ? { findings: [], reason: review.body || "Close requested.", verdict }
130
- : { findings: [], verdict };
141
+ ? {
142
+ findings: [],
143
+ reason: review.body || "Close requested.",
144
+ requirementFindings: [],
145
+ verdict,
146
+ }
147
+ : { findings: [], requirementFindings: [], verdict };
131
148
  }
132
149
  export function hasPendingThreadReply(threads, reviewerAccount) {
133
150
  return threads.some((thread) => {
@@ -151,7 +168,7 @@ async function postRereviewOutput(input, reviewerKey, output) {
151
168
  return postApproval(input.exec, input.repository, input.pr, reviewer.account);
152
169
  if (output.verdict === "CLOSE")
153
170
  return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
154
- if (!output.newFindings.length)
171
+ if (!output.newFindings.length && !output.requirementFindings.length)
155
172
  return "";
156
173
  return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
157
174
  fix: "Please address this before merging.",
@@ -159,7 +176,7 @@ async function postRereviewOutput(input, reviewerKey, output) {
159
176
  line: finding.line,
160
177
  path: finding.path,
161
178
  startLine: finding.startLine,
162
- })));
179
+ })), output.requirementFindings);
163
180
  }
164
181
  function isReviewOutput(output) {
165
182
  return "findings" in output;
@@ -191,6 +208,7 @@ async function runFindingValidation(input) {
191
208
  includeSessionContext: !hasReviewerSession,
192
209
  pr: input.reviewInput.pr,
193
210
  repository: input.reviewInput.repository,
211
+ reviewContext: input.reviewContext,
194
212
  reviewer,
195
213
  worktreePath: input.worktreePath,
196
214
  });
@@ -303,6 +321,7 @@ async function runCloseReconsideration(input) {
303
321
  includeSessionContext: !hasReviewerSession,
304
322
  pr: input.reviewInput.pr,
305
323
  repository: input.reviewInput.repository,
324
+ reviewContext: input.reviewContext,
306
325
  reviewer,
307
326
  worktreePath: input.worktreePath,
308
327
  });
@@ -336,7 +355,11 @@ async function runCloseReconsideration(input) {
336
355
  }
337
356
  },
338
357
  options: reviewer.options,
339
- parse: parseCloseReconsiderationOutput,
358
+ parse: (text) => {
359
+ const output = parseCloseReconsiderationOutput(text);
360
+ validateInlineCommentTargets(output.findings, input.inlineCommentTargets);
361
+ return output;
362
+ },
340
363
  permission: reviewer.permission,
341
364
  prompt,
342
365
  repairAttempts: input.reviewInput.config.output?.repairAttempts ?? 3,
@@ -397,6 +420,7 @@ export async function runReview(input) {
397
420
  dryRun: input.dryRun,
398
421
  outputs: {},
399
422
  posted: {},
423
+ pr: input.pr,
400
424
  repository: input.repository,
401
425
  safety,
402
426
  });
@@ -448,6 +472,15 @@ export async function runReview(input) {
448
472
  pr: input.pr,
449
473
  }), ...(input.runId ? [input.runId] : []));
450
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`);
451
484
  await input.onProgress?.({ phase: "waiting for checks", type: "phase" });
452
485
  const checkResult = await waitForChecksWithClassification({
453
486
  client: input.client,
@@ -518,6 +551,7 @@ export async function runReview(input) {
518
551
  return [];
519
552
  return [{ assignment, reviewer }];
520
553
  });
554
+ const inlineCommentTargets = parseRightSideDiffTargets(await exec(`git diff --no-ext-diff --unified=3 ${shellQuote(meta.baseRefOid)} ${shellQuote(meta.headRefOid)}`, { cwd: worktreePath }));
521
555
  for (const reviewer of input.repository.agents.reviewers) {
522
556
  const assignment = mode.assignments.get(reviewer.account);
523
557
  if (assignment?.type !== "skip")
@@ -548,6 +582,7 @@ export async function runReview(input) {
548
582
  previousReview: previousReviewText(previous),
549
583
  previousHeadSha: previous.commit.oid,
550
584
  repository: input.repository,
585
+ reviewContext,
551
586
  reviewer,
552
587
  unresolvedThreads: JSON.stringify(unresolved, null, 2),
553
588
  worktreePath,
@@ -582,7 +617,7 @@ export async function runReview(input) {
582
617
  }
583
618
  },
584
619
  options: reviewer.options,
585
- parse: parseRereviewOutput,
620
+ parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
586
621
  permission: reviewer.permission,
587
622
  prompt,
588
623
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -614,6 +649,7 @@ export async function runReview(input) {
614
649
  headSha: meta.headRefOid,
615
650
  pr: input.pr,
616
651
  repository: input.repository,
652
+ reviewContext,
617
653
  reviewer,
618
654
  worktreePath,
619
655
  });
@@ -647,7 +683,7 @@ export async function runReview(input) {
647
683
  }
648
684
  },
649
685
  options: reviewer.options,
650
- parse: parseReviewOutput,
686
+ parse: (text) => parseReviewOutputWithInlineTargets(text, inlineCommentTargets),
651
687
  permission: reviewer.permission,
652
688
  prompt,
653
689
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -707,8 +743,10 @@ export async function runReview(input) {
707
743
  });
708
744
  entries = await runCloseReconsideration({
709
745
  entries: [...entries, ...skippedCloseEntries],
746
+ inlineCommentTargets,
710
747
  meta,
711
748
  outputDir,
749
+ reviewContext,
712
750
  reviewInput: { ...input, exec },
713
751
  sessionIds,
714
752
  targets: closeTargets,
@@ -718,6 +756,7 @@ export async function runReview(input) {
718
756
  entries,
719
757
  meta,
720
758
  outputDir,
759
+ reviewContext,
721
760
  reviewInput: { ...input, exec },
722
761
  sessionIds,
723
762
  worktreePath,
@@ -793,6 +832,7 @@ export async function runReview(input) {
793
832
  dryRun: input.dryRun,
794
833
  outputs,
795
834
  posted,
835
+ pr: input.pr,
796
836
  repository: input.repository,
797
837
  });
798
838
  await writeFile(join(outputDir, "report.md"), `${report}\n`);