opencode-magi 0.0.0-dev-20260520165420 → 0.0.0-dev-20260520171120

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.
@@ -102,9 +102,24 @@ async function fetchPullRequestQueueInput(exec, repository, pr, token) {
102
102
  return { headRefOid: pullRequest.headRefOid, id: pullRequest.id };
103
103
  }
104
104
  export async function fetchPullRequest(exec, repository, pr) {
105
- const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,isDraft,baseRefOid,headRefOid,baseRefName,headRefName,headRepository,headRepositoryOwner`);
105
+ const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,body,url,state,author,isDraft,baseRefOid,headRefOid,baseRefName,headRefName,headRepository,headRepositoryOwner,changedFiles`);
106
106
  return JSON.parse(json);
107
107
  }
108
+ export async function fetchPullRequestClosingIssues(exec, repository, pr) {
109
+ const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { closingIssuesReferences(first: 20) { nodes { number title body url state author { login } labels(first: 100) { nodes { name } } issueType { name } } } } } }`;
110
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}`);
111
+ const data = JSON.parse(raw);
112
+ return (data.data?.repository?.pullRequest?.closingIssuesReferences?.nodes?.map((issue) => ({
113
+ author: issue.author?.login ?? "",
114
+ body: issue.body ?? "",
115
+ labels: issue.labels?.nodes?.map((label) => label.name) ?? [],
116
+ number: issue.number,
117
+ state: issue.state,
118
+ title: issue.title,
119
+ type: issue.issueType?.name,
120
+ url: issue.url,
121
+ })) ?? []);
122
+ }
108
123
  export async function fetchIssue(exec, repository, issue) {
109
124
  const query = `query($owner: String!, $repo: String!, $issue: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { number title body url state author { login } labels(first: 100) { nodes { name } } issueType { name } } } }`;
110
125
  try {
@@ -142,17 +157,47 @@ async function fetchIssueWithCli(exec, repository, issue) {
142
157
  };
143
158
  }
144
159
  export async function fetchIssueComments(exec, repository, issue, limit = 50) {
145
- const query = `query($owner: String!, $repo: String!, $issue: Int!, $limit: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { comments(last: $limit) { nodes { databaseId author { login } authorAssociation body createdAt url } } } } }`;
160
+ return (await fetchIssueCommentPage(exec, repository, issue, limit)).comments;
161
+ }
162
+ export async function fetchIssueCommentPage(exec, repository, issue, limit = 50) {
163
+ const query = `query($owner: String!, $repo: String!, $issue: Int!, $limit: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { comments(last: $limit) { totalCount nodes { databaseId author { login } authorAssociation body createdAt url } } } } }`;
146
164
  const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F issue=${issue} -F limit=${limit}`);
147
165
  const data = JSON.parse(raw);
148
- return (data.data?.repository?.issue?.comments?.nodes?.map((comment) => ({
166
+ const connection = data.data?.repository?.issue?.comments;
167
+ const comments = connection?.nodes?.map((comment) => ({
149
168
  author: comment.author?.login ?? "",
150
169
  authorAssociation: comment.authorAssociation,
151
170
  body: comment.body ?? "",
152
171
  createdAt: comment.createdAt,
153
172
  id: comment.databaseId,
154
173
  url: comment.url,
155
- })) ?? []);
174
+ })) ?? [];
175
+ return {
176
+ comments,
177
+ omitted: Math.max(0, (connection?.totalCount ?? comments.length) - comments.length),
178
+ };
179
+ }
180
+ export async function fetchPullRequestComments(exec, repository, pr, limit = 50) {
181
+ return (await fetchPullRequestCommentPage(exec, repository, pr, limit))
182
+ .comments;
183
+ }
184
+ export async function fetchPullRequestCommentPage(exec, repository, pr, limit = 50) {
185
+ const query = `query($owner: String!, $repo: String!, $pr: Int!, $limit: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { comments(last: $limit) { totalCount nodes { databaseId author { login } authorAssociation body createdAt url } } } } }`;
186
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr} -F limit=${limit}`);
187
+ const data = JSON.parse(raw);
188
+ const connection = data.data?.repository?.pullRequest?.comments;
189
+ const comments = connection?.nodes?.map((comment) => ({
190
+ author: comment.author?.login ?? "",
191
+ authorAssociation: comment.authorAssociation,
192
+ body: comment.body ?? "",
193
+ createdAt: comment.createdAt,
194
+ id: comment.databaseId,
195
+ url: comment.url,
196
+ })) ?? [];
197
+ return {
198
+ comments,
199
+ omitted: Math.max(0, (connection?.totalCount ?? comments.length) - comments.length),
200
+ };
156
201
  }
157
202
  export async function fetchRelatedPullRequests(exec, repository, issue) {
158
203
  const query = `query($owner: String!, $repo: String!, $issue: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { timelineItems(first: 50, itemTypes: [CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) { nodes { __typename ... on ConnectedEvent { subject { __typename ... on PullRequest { number title url state mergedAt body author { login } } } } ... on CrossReferencedEvent { source { __typename ... on PullRequest { number title url state mergedAt body author { login } } } } } } } } }`;
@@ -295,7 +340,7 @@ export async function fetchPullRequestCommits(exec, repository, pr) {
295
340
  }));
296
341
  }
297
342
  export async function fetchPullRequestSafetyMeta(exec, repository, pr) {
298
- const query = `query($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { author { login } changedFiles labels(first: 100) { nodes { name } } files(first: 100, after: $filesCursor) { nodes { path } pageInfo { hasNextPage endCursor } } } } } }`;
343
+ const query = `query($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { author { login } changedFiles labels(first: 100) { nodes { name } } files(first: 100, after: $filesCursor) { nodes { path } pageInfo { hasNextPage endCursor } } } } }`;
299
344
  const files = [];
300
345
  let author = "";
301
346
  let changedFiles = 0;
@@ -467,11 +512,19 @@ function findingComment(finding) {
467
512
  }
468
513
  return comment;
469
514
  }
470
- export async function postChangesRequested(exec, repository, pr, account, findings) {
515
+ function requirementFindingSummary(finding) {
516
+ return [
517
+ `- Missing issue #${finding.issueNumber} requirement: ${finding.requirement}`,
518
+ ` Evidence: ${finding.evidence}`,
519
+ ` Fix: ${finding.fix}`,
520
+ ].join("\n");
521
+ }
522
+ export async function postChangesRequested(exec, repository, pr, account, findings, requirementFindings = []) {
471
523
  const token = await ghToken(exec, repository, account);
472
524
  const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
473
525
  const body = findings
474
526
  .map((finding) => `- ${finding.issue.split("\n")[0]}`)
527
+ .concat(requirementFindings.map(requirementFindingSummary))
475
528
  .join("\n");
476
529
  await writeFile(payloadPath, JSON.stringify({
477
530
  body,
@@ -610,6 +663,45 @@ export async function fetchUnresolvedThreads(exec, repository, pr, author) {
610
663
  ];
611
664
  });
612
665
  }
666
+ export async function fetchPullRequestReviewThreads(exec, repository, pr, threadLimit = 50, commentsPerThread = 20) {
667
+ return (await fetchPullRequestReviewThreadPage(exec, repository, pr, threadLimit, commentsPerThread)).threads;
668
+ }
669
+ export async function fetchPullRequestReviewThreadPage(exec, repository, pr, threadLimit = 50, commentsPerThread = 20) {
670
+ const query = `query($owner: String!, $repo: String!, $pr: Int!, $threadLimit: Int!, $commentsPerThread: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(last: $threadLimit) { totalCount nodes { id isResolved comments(last: $commentsPerThread) { totalCount nodes { databaseId author { login } path line body createdAt } } } } } } }`;
671
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr} -F threadLimit=${threadLimit} -F commentsPerThread=${commentsPerThread}`);
672
+ const data = JSON.parse(raw);
673
+ const connection = data.data?.repository?.pullRequest?.reviewThreads;
674
+ const nodes = connection?.nodes ?? [];
675
+ const threads = nodes.flatMap((thread) => {
676
+ const comments = [...thread.comments.nodes]
677
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
678
+ .map((comment) => ({
679
+ author: comment.author?.login ?? "",
680
+ body: comment.body ?? "",
681
+ commentId: comment.databaseId,
682
+ createdAt: comment.createdAt,
683
+ }));
684
+ const first = thread.comments.nodes[0];
685
+ if (!first)
686
+ return [];
687
+ return [
688
+ {
689
+ body: first.body ?? "",
690
+ commentId: first.databaseId,
691
+ comments,
692
+ isResolved: thread.isResolved,
693
+ line: first.line,
694
+ omittedComments: Math.max(0, (thread.comments.totalCount ?? comments.length) - comments.length),
695
+ path: first.path,
696
+ threadId: thread.id,
697
+ },
698
+ ];
699
+ });
700
+ return {
701
+ omitted: Math.max(0, (connection?.totalCount ?? threads.length) - threads.length),
702
+ threads,
703
+ };
704
+ }
613
705
  export async function postReply(exec, repository, pr, account, commentId, body) {
614
706
  const token = await ghToken(exec, repository, account);
615
707
  const payloadPath = join(tmpdir(), `magi-reply-${process.pid}-${Date.now()}-${commentId}.json`);
package/dist/index.js CHANGED
@@ -118,6 +118,11 @@ function parseOptionalPr(value) {
118
118
  return undefined;
119
119
  return parsePrToken(value);
120
120
  }
121
+ function parseOptionalPrs(value) {
122
+ if (!value?.trim())
123
+ return undefined;
124
+ return parsePrs(value);
125
+ }
121
126
  function parseOptionalIssue(value) {
122
127
  if (!value?.trim())
123
128
  return undefined;
@@ -432,7 +437,7 @@ export const MagiPlugin = async ({ client, directory }) => {
432
437
  block: args.block,
433
438
  issue: parseOptionalIssue(args.issue),
434
439
  outputDir: await configuredOutputDir(),
435
- pr: parseOptionalPr(args.pr),
440
+ pr: parseOptionalPrs(args.pr),
436
441
  runId: args.runId,
437
442
  timeoutMs: args.timeoutSeconds == null
438
443
  ? undefined
@@ -58,9 +58,10 @@ export function applyFindingValidation(input) {
58
58
  discarded.push(target);
59
59
  return false;
60
60
  });
61
- next[reviewer] = findings.length
62
- ? { ...output, findings }
63
- : { findings: [], verdict: "MERGE" };
61
+ next[reviewer] =
62
+ findings.length || output.requirementFindings.length
63
+ ? { ...output, findings }
64
+ : { findings: [], requirementFindings: [], verdict: "MERGE" };
64
65
  }
65
66
  return { outputs: next, summary: { discarded, kept } };
66
67
  }
@@ -27,6 +27,9 @@ function formatRereviewFinding(finding) {
27
27
  : `${finding.path}:${finding.startLine}-${finding.line}`;
28
28
  return `\`${line}\`: ${finding.body}`;
29
29
  }
30
+ function formatRequirementFinding(finding) {
31
+ return `Issue #${finding.issueNumber}: ${finding.requirement}`;
32
+ }
30
33
  function isReviewOutput(output) {
31
34
  return "findings" in output;
32
35
  }
@@ -78,7 +81,10 @@ function reviewerDetailLines(output) {
78
81
  return output.reason ? [output.reason] : [];
79
82
  if (output.verdict !== "CHANGES_REQUESTED")
80
83
  return [];
81
- return output.findings.map(formatFinding);
84
+ return [
85
+ ...output.findings.map(formatFinding),
86
+ ...output.requirementFindings.map(formatRequirementFinding),
87
+ ];
82
88
  }
83
89
  if (output.verdict === "CLOSE")
84
90
  return output.reason ? [output.reason] : [];
@@ -86,6 +92,7 @@ function reviewerDetailLines(output) {
86
92
  return [];
87
93
  return [
88
94
  ...output.newFindings.map(formatRereviewFinding),
95
+ ...output.requirementFindings.map(formatRequirementFinding),
89
96
  ...output.followUps.map((item) => `Comment #${item.commentId}: ${item.body}`),
90
97
  ];
91
98
  }
@@ -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,
@@ -35,6 +35,19 @@ function isActiveStatus(status) {
35
35
  status === "running" ||
36
36
  status === "posting");
37
37
  }
38
+ function matchesNumberFilter(value, filter) {
39
+ if (filter == null)
40
+ return true;
41
+ return Array.isArray(filter)
42
+ ? value != null && filter.includes(value)
43
+ : value === filter;
44
+ }
45
+ function hasAllRequestedPrStates(states, pr) {
46
+ if (pr == null)
47
+ return true;
48
+ const prs = Array.isArray(pr) ? pr : [pr];
49
+ return prs.every((item) => states.some((state) => state.pr === item));
50
+ }
38
51
  function isWithinDirectory(directory, path) {
39
52
  const relation = relative(directory, path);
40
53
  return (relation === "" || (!relation.startsWith("..") && !isAbsolute(relation)));
@@ -548,6 +561,7 @@ export class MagiRunManager {
548
561
  while (input.block) {
549
562
  const states = await this.filteredStates(input);
550
563
  if (states.length &&
564
+ hasAllRequestedPrStates(states, input.pr) &&
551
565
  states.every((state) => !isActiveStatus(state.status)))
552
566
  return states;
553
567
  if (Date.now() - startedAt >= timeoutMs)
@@ -1661,7 +1675,7 @@ export class MagiRunManager {
1661
1675
  return states
1662
1676
  .filter((state) => input.command == null || state.command === input.command)
1663
1677
  .filter((state) => input.issue == null || state.issue === input.issue)
1664
- .filter((state) => input.pr == null || state.pr === input.pr)
1678
+ .filter((state) => matchesNumberFilter(state.pr, input.pr))
1665
1679
  .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1666
1680
  }
1667
1681
  async selectState(input) {
@@ -45,6 +45,7 @@ function reviewValues(input) {
45
45
  headSha: input.headSha,
46
46
  jsonEncodedWorktreePath: JSON.stringify(input.worktreePath),
47
47
  pr: String(input.pr),
48
+ reviewContext: input.reviewContext ?? "",
48
49
  worktreePath: input.worktreePath,
49
50
  };
50
51
  }
@@ -85,6 +86,9 @@ function previousReviewBlock(previousReview) {
85
86
  ? `<previous_review>\n${previousReview.trim()}\n</previous_review>`
86
87
  : "";
87
88
  }
89
+ function reviewContextBlock(reviewContext) {
90
+ return reviewContext?.trim() ? reviewContext.trim() : "";
91
+ }
88
92
  async function reviewGuidelinesBlock(input) {
89
93
  const body = (await readOptionalPrompt(input.directory, input.path, input.values)).trim();
90
94
  return body ? `<review_guidelines>\n${body}\n</review_guidelines>` : "";
@@ -95,6 +99,9 @@ async function editGuidelinesBlock(input) {
95
99
  }
96
100
  async function sessionContextBlocks(input) {
97
101
  return [
102
+ input.includeSessionContext
103
+ ? reviewContextBlock(input.values.reviewContext)
104
+ : "",
98
105
  input.includeSessionContext ? languageBlock(input.repository.language) : "",
99
106
  input.includeSessionContext ? personaBlock(input.reviewer.persona) : "",
100
107
  input.includeReviewGuidelines
@@ -116,6 +123,7 @@ export async function composeReviewPrompt(input) {
116
123
  });
117
124
  return [
118
125
  task,
126
+ reviewContextBlock(input.reviewContext),
119
127
  languageBlock(input.repository.language),
120
128
  personaBlock(input.reviewer.persona),
121
129
  await reviewGuidelinesBlock({
@@ -138,6 +146,7 @@ export async function composeRereviewPrompt(input) {
138
146
  });
139
147
  return [
140
148
  task,
149
+ reviewContextBlock(input.reviewContext),
141
150
  input.includeSessionContext === false
142
151
  ? ""
143
152
  : languageBlock(input.repository.language),
@@ -15,16 +15,25 @@ The object must match this shape:
15
15
  "perspective": "Optional review perspective."
16
16
  }
17
17
  ],
18
+ "requirementFindings": [
19
+ {
20
+ "issueNumber": 47,
21
+ "requirement": "Required closing-issue behavior that is missing.",
22
+ "evidence": "Why the PR does not satisfy the requirement.",
23
+ "fix": "How to satisfy the requirement."
24
+ }
25
+ ],
18
26
  "reason": "Required only for CLOSE."
19
27
  }
20
28
 
21
29
  Rules:
22
- - MERGE requires an empty findings array.
23
- - CHANGES_REQUESTED requires at least one finding.
24
- - CLOSE requires a reason and an empty findings array.
30
+ - MERGE requires empty findings and requirementFindings arrays.
31
+ - CHANGES_REQUESTED requires at least one finding or requirementFinding.
32
+ - CLOSE requires a reason and empty findings and requirementFindings arrays.
25
33
  - path must be repository-relative.
26
34
  - line and startLine must refer to lines inside the PR diff hunk.
27
35
  - Omit startLine for single-line findings.
36
+ - Use requirementFindings for missing closing-issue requirements that do not map cleanly to a diff line.
28
37
  </output_contract>`.trim();
29
38
  export const rereviewOutputContract = `
30
39
  <output_contract>
@@ -36,15 +45,17 @@ The object must match this shape:
36
45
  "resolve": [{ "commentId": 123, "threadId": "..." }],
37
46
  "followUps": [{ "commentId": 123, "body": "..." }],
38
47
  "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }],
48
+ "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }],
39
49
  "reason": "Required only for CLOSE."
40
50
  }
41
51
 
42
52
  Rules:
43
- - MERGE requires empty followUps and newFindings arrays.
44
- - CHANGES_REQUESTED requires at least one followUp or newFinding.
45
- - CLOSE requires a reason and empty followUps and newFindings arrays.
53
+ - MERGE requires empty followUps, newFindings, and requirementFindings arrays.
54
+ - CHANGES_REQUESTED requires at least one followUp, newFinding, or requirementFinding.
55
+ - CLOSE requires a reason and empty followUps, newFindings, and requirementFindings arrays.
46
56
  - line and startLine must refer to lines inside the latest PR diff hunk.
47
57
  - Omit startLine for single-line findings.
58
+ - Use requirementFindings for missing closing-issue requirements that do not map cleanly to a diff line.
48
59
  </output_contract>`.trim();
49
60
  export const findingValidationOutputContract = `
50
61
  <output_contract>
@@ -83,12 +94,13 @@ The object must match this shape:
83
94
  "issue": "What is wrong.",
84
95
  "fix": "How to fix it."
85
96
  }
86
- ]
97
+ ],
98
+ "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }]
87
99
  }
88
100
 
89
101
  Rules:
90
- - MERGE requires an empty findings array.
91
- - CHANGES_REQUESTED requires at least one finding.
102
+ - MERGE requires empty findings and requirementFindings arrays.
103
+ - CHANGES_REQUESTED requires at least one finding or requirementFinding.
92
104
  - CLOSE is not allowed in this reconsideration step.
93
105
  - Omit startLine for single-line findings.
94
106
  </output_contract>`.trim();
@@ -101,12 +113,13 @@ The object must match this shape:
101
113
  "verdict": "MERGE" | "CHANGES_REQUESTED",
102
114
  "resolve": [{ "commentId": 123, "threadId": "..." }],
103
115
  "followUps": [{ "commentId": 123, "body": "..." }],
104
- "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }]
116
+ "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }],
117
+ "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }]
105
118
  }
106
119
 
107
120
  Rules:
108
- - MERGE requires empty followUps and newFindings arrays.
109
- - CHANGES_REQUESTED requires at least one followUp or newFinding.
121
+ - MERGE requires empty followUps, newFindings, and requirementFindings arrays.
122
+ - CHANGES_REQUESTED requires at least one followUp, newFinding, or requirementFinding.
110
123
  - CLOSE is not allowed in this reconsideration step.
111
124
  - Omit startLine for single-line findings.
112
125
  </output_contract>`.trim();
@@ -71,6 +71,17 @@ function requireNumber(value, path) {
71
71
  throw new Error(`${path} must be an integer`);
72
72
  return value;
73
73
  }
74
+ function parseRequirementFindings(value) {
75
+ return (value == null ? [] : requireArray(value, "requirementFindings")).map((finding, index) => {
76
+ const item = finding;
77
+ return {
78
+ evidence: requireString(item.evidence, `requirementFindings[${index}].evidence`),
79
+ fix: requireString(item.fix, `requirementFindings[${index}].fix`),
80
+ issueNumber: requireNumber(item.issueNumber, `requirementFindings[${index}].issueNumber`),
81
+ requirement: requireString(item.requirement, `requirementFindings[${index}].requirement`),
82
+ };
83
+ });
84
+ }
74
85
  function requireOneOf(value, path, values) {
75
86
  const text = requireString(value, path);
76
87
  if (!values.includes(text)) {
@@ -185,12 +196,17 @@ export function parseReviewOutput(text) {
185
196
  : requireNumber(item.startLine, `findings[${index}].startLine`),
186
197
  };
187
198
  });
188
- if (data.verdict === "MERGE" && findings.length)
189
- throw new Error("MERGE requires no findings");
190
- if (data.verdict === "CHANGES_REQUESTED" && !findings.length)
191
- throw new Error("CHANGES_REQUESTED requires findings");
192
- if (data.verdict === "CLOSE" && findings.length)
193
- throw new Error("CLOSE requires no findings");
199
+ const requirementFindings = parseRequirementFindings(data.requirementFindings);
200
+ if (data.verdict === "MERGE" &&
201
+ (findings.length || requirementFindings.length))
202
+ throw new Error("MERGE requires no findings or requirementFindings");
203
+ if (data.verdict === "CHANGES_REQUESTED" &&
204
+ !findings.length &&
205
+ !requirementFindings.length)
206
+ throw new Error("CHANGES_REQUESTED requires findings or requirementFindings");
207
+ if (data.verdict === "CLOSE" &&
208
+ (findings.length || requirementFindings.length))
209
+ throw new Error("CLOSE requires no findings or requirementFindings");
194
210
  const reason = typeof data.reason === "string" && data.reason.trim()
195
211
  ? data.reason
196
212
  : undefined;
@@ -199,6 +215,7 @@ export function parseReviewOutput(text) {
199
215
  return {
200
216
  findings,
201
217
  reason,
218
+ requirementFindings,
202
219
  verdict: data.verdict,
203
220
  };
204
221
  }
@@ -231,24 +248,29 @@ export function parseRereviewOutput(text) {
231
248
  : requireNumber(value.startLine, `newFindings[${index}].startLine`),
232
249
  };
233
250
  });
234
- if (data.verdict === "MERGE" && (followUps.length || newFindings.length)) {
235
- throw new Error("MERGE requires no followUps or newFindings");
251
+ const requirementFindings = parseRequirementFindings(data.requirementFindings);
252
+ if (data.verdict === "MERGE" &&
253
+ (followUps.length || newFindings.length || requirementFindings.length)) {
254
+ throw new Error("MERGE requires no followUps, newFindings, or requirementFindings");
236
255
  }
237
- if (data.verdict === "CLOSE" && (followUps.length || newFindings.length)) {
238
- throw new Error("CLOSE requires no followUps or newFindings");
256
+ if (data.verdict === "CLOSE" &&
257
+ (followUps.length || newFindings.length || requirementFindings.length)) {
258
+ throw new Error("CLOSE requires no followUps, newFindings, or requirementFindings");
239
259
  }
240
260
  if (data.verdict === "CLOSE" && !data.reason) {
241
261
  throw new Error("CLOSE requires reason");
242
262
  }
243
263
  if (data.verdict === "CHANGES_REQUESTED" &&
244
264
  !followUps.length &&
245
- !newFindings.length) {
246
- throw new Error("CHANGES_REQUESTED requires followUps or newFindings");
265
+ !newFindings.length &&
266
+ !requirementFindings.length) {
267
+ throw new Error("CHANGES_REQUESTED requires followUps, newFindings, or requirementFindings");
247
268
  }
248
269
  return {
249
270
  followUps,
250
271
  newFindings,
251
272
  reason: data.reason == null ? undefined : requireString(data.reason, "reason"),
273
+ requirementFindings,
252
274
  resolve,
253
275
  verdict: data.verdict,
254
276
  };
@@ -4,4 +4,10 @@ Review only the diff from {baseSha} to {headSha}.
4
4
  Use: git -C {jsonEncodedWorktreePath} diff {baseSha}...{headSha}
5
5
  Do not edit files or perform write operations.
6
6
 
7
+ This PR may include closing issue references.
8
+ For each closing issue, review whether the PR fully satisfies the issue body, acceptance criteria, required behavior, required tests, required documentation, and bounded issue comments.
9
+ Request changes if a closing issue requirement is missing, only documented, only schema-exposed, or not wired into runtime behavior.
10
+ Do not approve solely because the PR improves the codebase if it claims to close an issue that remains incomplete.
11
+ For referenced non-closing issues, use them as context only unless the PR body explicitly claims to complete them.
12
+
7
13
  {ciFailureContextBlock}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260520165420",
3
+ "version": "0.0.0-dev-20260520171120",
4
4
  "description": "Multi-agent PR review and merge orchestration plugin for OpenCode.",
5
5
  "license": "MIT",
6
6
  "author": "Hirotomo Yamada <hirotomo.yamada@avap.co.jp>",