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.
- package/dist/github/commands.js +98 -6
- package/dist/index.js +6 -1
- package/dist/orchestrator/findings.js +4 -3
- package/dist/orchestrator/report.js +8 -1
- package/dist/orchestrator/review-context.js +309 -0
- package/dist/orchestrator/review.js +26 -5
- package/dist/orchestrator/run-manager.js +15 -1
- package/dist/prompts/compose.js +9 -0
- package/dist/prompts/contracts.js +25 -12
- package/dist/prompts/output.js +34 -12
- package/dist/prompts/templates/review/review.md +6 -0
- package/package.json +1 -1
package/dist/github/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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] =
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
? {
|
|
141
|
-
|
|
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) =>
|
|
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) {
|
package/dist/prompts/compose.js
CHANGED
|
@@ -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
|
|
23
|
-
- CHANGES_REQUESTED requires at least one finding.
|
|
24
|
-
- CLOSE requires a reason and
|
|
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
|
|
44
|
-
- CHANGES_REQUESTED requires at least one followUp or
|
|
45
|
-
- CLOSE requires a reason and empty followUps and
|
|
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
|
|
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
|
|
109
|
-
- CHANGES_REQUESTED requires at least one followUp or
|
|
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();
|
package/dist/prompts/output.js
CHANGED
|
@@ -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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
throw new Error("
|
|
192
|
-
if (data.verdict === "
|
|
193
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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" &&
|
|
238
|
-
|
|
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
|
-
|
|
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-
|
|
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>",
|