opencode-magi 0.0.0-dev-20260520165753 → 0.0.0-dev-20260520173258

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.
@@ -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
+ }
@@ -13,6 +13,7 @@ import { closeMinorityReviewers, mergeVerdictForPolicy, } from "./majority";
13
13
  import { runModelWithRepair } from "./model";
14
14
  import { mapPool } from "./pool";
15
15
  import { formatReviewReport } from "./report";
16
+ import { buildReviewContextSnapshot, renderReviewContext, } from "./review-context";
16
17
  import { checkSafetyGate, hasSafetyGate } from "./safety";
17
18
  function errorMessage(error) {
18
19
  return error instanceof Error ? error.message : String(error);
@@ -38,7 +39,7 @@ async function postReviewOutput(input, reviewerKey, output) {
38
39
  return postApproval(input.exec, input.repository, input.pr, reviewer.account);
39
40
  if (output.verdict === "CLOSE")
40
41
  return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
41
- 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);
42
43
  }
43
44
  function dryRunReviewPost(key, output) {
44
45
  if (output.verdict === "MERGE")
@@ -137,8 +138,13 @@ function parseRereviewOutputWithInlineTargets(text, targets) {
137
138
  function reviewOutputFromState(review) {
138
139
  const verdict = reviewStateToVerdict(review.state);
139
140
  return verdict === "CLOSE"
140
- ? { findings: [], reason: review.body || "Close requested.", verdict }
141
- : { findings: [], verdict };
141
+ ? {
142
+ findings: [],
143
+ reason: review.body || "Close requested.",
144
+ requirementFindings: [],
145
+ verdict,
146
+ }
147
+ : { findings: [], requirementFindings: [], verdict };
142
148
  }
143
149
  export function hasPendingThreadReply(threads, reviewerAccount) {
144
150
  return threads.some((thread) => {
@@ -162,7 +168,7 @@ async function postRereviewOutput(input, reviewerKey, output) {
162
168
  return postApproval(input.exec, input.repository, input.pr, reviewer.account);
163
169
  if (output.verdict === "CLOSE")
164
170
  return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
165
- if (!output.newFindings.length)
171
+ if (!output.newFindings.length && !output.requirementFindings.length)
166
172
  return "";
167
173
  return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
168
174
  fix: "Please address this before merging.",
@@ -170,7 +176,7 @@ async function postRereviewOutput(input, reviewerKey, output) {
170
176
  line: finding.line,
171
177
  path: finding.path,
172
178
  startLine: finding.startLine,
173
- })));
179
+ })), output.requirementFindings);
174
180
  }
175
181
  function isReviewOutput(output) {
176
182
  return "findings" in output;
@@ -202,6 +208,7 @@ async function runFindingValidation(input) {
202
208
  includeSessionContext: !hasReviewerSession,
203
209
  pr: input.reviewInput.pr,
204
210
  repository: input.reviewInput.repository,
211
+ reviewContext: input.reviewContext,
205
212
  reviewer,
206
213
  worktreePath: input.worktreePath,
207
214
  });
@@ -314,6 +321,7 @@ async function runCloseReconsideration(input) {
314
321
  includeSessionContext: !hasReviewerSession,
315
322
  pr: input.reviewInput.pr,
316
323
  repository: input.reviewInput.repository,
324
+ reviewContext: input.reviewContext,
317
325
  reviewer,
318
326
  worktreePath: input.worktreePath,
319
327
  });
@@ -464,6 +472,15 @@ export async function runReview(input) {
464
472
  pr: input.pr,
465
473
  }), ...(input.runId ? [input.runId] : []));
466
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`);
467
484
  await input.onProgress?.({ phase: "waiting for checks", type: "phase" });
468
485
  const checkResult = await waitForChecksWithClassification({
469
486
  client: input.client,
@@ -565,6 +582,7 @@ export async function runReview(input) {
565
582
  previousReview: previousReviewText(previous),
566
583
  previousHeadSha: previous.commit.oid,
567
584
  repository: input.repository,
585
+ reviewContext,
568
586
  reviewer,
569
587
  unresolvedThreads: JSON.stringify(unresolved, null, 2),
570
588
  worktreePath,
@@ -631,6 +649,7 @@ export async function runReview(input) {
631
649
  headSha: meta.headRefOid,
632
650
  pr: input.pr,
633
651
  repository: input.repository,
652
+ reviewContext,
634
653
  reviewer,
635
654
  worktreePath,
636
655
  });
@@ -727,6 +746,7 @@ export async function runReview(input) {
727
746
  inlineCommentTargets,
728
747
  meta,
729
748
  outputDir,
749
+ reviewContext,
730
750
  reviewInput: { ...input, exec },
731
751
  sessionIds,
732
752
  targets: closeTargets,
@@ -736,6 +756,7 @@ export async function runReview(input) {
736
756
  entries,
737
757
  meta,
738
758
  outputDir,
759
+ reviewContext,
739
760
  reviewInput: { ...input, exec },
740
761
  sessionIds,
741
762
  worktreePath,
@@ -1273,10 +1273,12 @@ export class MagiRunManager {
1273
1273
  const completed = this.active.get(input.runId);
1274
1274
  if (!completed || completed.status === "cancelled")
1275
1275
  return;
1276
- completed.status = result.result === "FAILED" ? "failed" : "completed";
1277
- completed.phase = result.result;
1276
+ const triageResult = JSON.stringify(result.result);
1277
+ completed.status =
1278
+ result.result.disposition === "failed" ? "failed" : "completed";
1279
+ completed.phase = triageResult;
1278
1280
  completed.completedAt = now();
1279
- completed.verdict = result.result;
1281
+ completed.verdict = triageResult;
1280
1282
  completed.reportPath = join(completed.outputDir, "report.md");
1281
1283
  for (const agent of Object.values(completed.reviewers)) {
1282
1284
  if (agent.status === "pending")