opencode-magi 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/dist/commands.js +4 -0
- package/dist/config/output.js +11 -2
- package/dist/config/resolve.js +81 -1
- package/dist/config/validate.js +341 -3
- package/dist/config/worktree.js +8 -2
- package/dist/github/commands.js +381 -19
- package/dist/index.js +252 -26
- package/dist/orchestrator/ci.js +1 -1
- package/dist/orchestrator/findings.js +4 -3
- package/dist/orchestrator/inline-comments.js +79 -0
- package/dist/orchestrator/majority.js +14 -0
- package/dist/orchestrator/merge.js +108 -34
- package/dist/orchestrator/report.js +25 -7
- package/dist/orchestrator/review-context.js +309 -0
- package/dist/orchestrator/review.js +122 -14
- package/dist/orchestrator/run-manager.js +408 -17
- package/dist/orchestrator/triage.js +1119 -0
- package/dist/permissions/editor.json +8 -1
- package/dist/prompts/compose.js +163 -1
- package/dist/prompts/contracts.js +131 -18
- package/dist/prompts/output.js +173 -22
- package/dist/prompts/templates/merge/edit.md +12 -5
- package/dist/prompts/templates/review/review.md +6 -0
- package/dist/prompts/templates/triage/acceptance.md +7 -0
- package/dist/prompts/templates/triage/action.md +5 -0
- package/dist/prompts/templates/triage/category.md +10 -0
- package/dist/prompts/templates/triage/comment-classification.md +7 -0
- package/dist/prompts/templates/triage/comment.md +5 -0
- package/dist/prompts/templates/triage/create.md +7 -0
- package/dist/prompts/templates/triage/duplicate.md +7 -0
- package/dist/prompts/templates/triage/existing-pr.md +7 -0
- package/dist/prompts/templates/triage/question.md +5 -0
- package/dist/prompts/templates/triage/reconsider.md +5 -0
- package/package.json +5 -2
- package/schema.json +162 -5
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { prRunOutputDir } from "../config/output";
|
|
4
|
-
import { closePullRequest, configureGitIdentity, fetchMergeQueueRequirement, fetchPullRequest, fetchUnresolvedThreads, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, pushHead, removeWorktree, resolveThread, waitForMergeQueue, } from "../github/commands";
|
|
4
|
+
import { closePullRequest, configureGitIdentity, fetchMergeQueueRequirement, fetchPullRequest, fetchUnresolvedThreads, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, pushHead, removeWorktree, resolveThread, shellQuote, waitForMergeQueue, } from "../github/commands";
|
|
5
5
|
import { composeEditPrompt, composeRereviewCloseReconsiderationPrompt, composeRereviewPrompt, } from "../prompts/compose";
|
|
6
6
|
import { parseEditOutput, parseRereviewCloseReconsiderationOutput, parseRereviewOutput, } from "../prompts/output";
|
|
7
7
|
import { throwIfAborted, withAbortSignal } from "./abort";
|
|
8
8
|
import { waitForChecksWithClassification } from "./ci";
|
|
9
|
+
import { parseRightSideDiffTargets, validateInlineCommentTargets, } from "./inline-comments";
|
|
9
10
|
import { closeMinorityReviewers, mergeVerdictForPolicy } from "./majority";
|
|
10
11
|
import { runModelWithRepair } from "./model";
|
|
11
12
|
import { mapPool } from "./pool";
|
|
@@ -49,7 +50,7 @@ async function withReviewerFailureProgress(input) {
|
|
|
49
50
|
throw error;
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
|
-
async function runEditor(input, worktreePath, cycle, unresolvedThreads) {
|
|
53
|
+
async function runEditor(input, worktreePath, cycle, reviewFindings, unresolvedThreads) {
|
|
53
54
|
const editor = input.repository.agents.editor;
|
|
54
55
|
if (!editor)
|
|
55
56
|
throw new Error("agents.editor is required for magi_merge");
|
|
@@ -63,6 +64,7 @@ async function runEditor(input, worktreePath, cycle, unresolvedThreads) {
|
|
|
63
64
|
directory: input.directory,
|
|
64
65
|
pr: input.pr,
|
|
65
66
|
repository: input.repository,
|
|
67
|
+
reviewFindings: JSON.stringify(reviewFindings, null, 2),
|
|
66
68
|
unresolvedThreads: JSON.stringify(unresolvedThreads, null, 2),
|
|
67
69
|
worktreePath,
|
|
68
70
|
});
|
|
@@ -144,21 +146,72 @@ async function postRereviewOutput(input, reviewerKey, output) {
|
|
|
144
146
|
if (output.verdict === "CLOSE") {
|
|
145
147
|
return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
|
|
146
148
|
}
|
|
147
|
-
if (output.newFindings.length) {
|
|
149
|
+
if (output.newFindings.length || output.requirementFindings.length) {
|
|
148
150
|
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
|
|
149
151
|
fix: "Please address this before merging.",
|
|
150
152
|
issue: finding.body,
|
|
151
|
-
line: finding.line,
|
|
152
153
|
path: finding.path,
|
|
154
|
+
...(finding.line == null ? {} : { line: finding.line }),
|
|
153
155
|
startLine: finding.startLine,
|
|
154
|
-
})));
|
|
156
|
+
})), output.requirementFindings);
|
|
155
157
|
}
|
|
156
158
|
return replies[0] ?? "";
|
|
157
159
|
}
|
|
160
|
+
function parseRereviewOutputWithInlineTargets(text, targets) {
|
|
161
|
+
const output = parseRereviewOutput(text);
|
|
162
|
+
validateInlineCommentTargets(output.newFindings, targets, "newFindings");
|
|
163
|
+
return output;
|
|
164
|
+
}
|
|
165
|
+
function newFindingToEditorFinding(reviewer, finding) {
|
|
166
|
+
return {
|
|
167
|
+
body: finding.body,
|
|
168
|
+
fix: "Please address this before merging.",
|
|
169
|
+
path: finding.path,
|
|
170
|
+
reviewer,
|
|
171
|
+
...(finding.line == null ? {} : { line: finding.line }),
|
|
172
|
+
...(finding.startLine == null ? {} : { startLine: finding.startLine }),
|
|
173
|
+
type: finding.line == null ? "file" : "inline",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
export function blockingReviewFindings(outputs) {
|
|
177
|
+
return Object.entries(outputs).flatMap(([reviewer, output]) => {
|
|
178
|
+
if (output.verdict !== "CHANGES_REQUESTED")
|
|
179
|
+
return [];
|
|
180
|
+
const requirementFindings = output.requirementFindings.map((finding) => ({
|
|
181
|
+
evidence: finding.evidence,
|
|
182
|
+
fix: finding.fix,
|
|
183
|
+
issueNumber: finding.issueNumber,
|
|
184
|
+
requirement: finding.requirement,
|
|
185
|
+
reviewer,
|
|
186
|
+
type: "requirement",
|
|
187
|
+
}));
|
|
188
|
+
if ("findings" in output) {
|
|
189
|
+
return [
|
|
190
|
+
...output.findings.map((finding) => ({
|
|
191
|
+
fix: finding.fix,
|
|
192
|
+
issue: finding.issue,
|
|
193
|
+
path: finding.path,
|
|
194
|
+
reviewer,
|
|
195
|
+
...(finding.line == null ? {} : { line: finding.line }),
|
|
196
|
+
...(finding.startLine == null
|
|
197
|
+
? {}
|
|
198
|
+
: { startLine: finding.startLine }),
|
|
199
|
+
type: finding.line == null ? "file" : "inline",
|
|
200
|
+
})),
|
|
201
|
+
...requirementFindings,
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
return [
|
|
205
|
+
...output.newFindings.map((finding) => newFindingToEditorFinding(reviewer, finding)),
|
|
206
|
+
...requirementFindings,
|
|
207
|
+
];
|
|
208
|
+
});
|
|
209
|
+
}
|
|
158
210
|
async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionIds, ciFailureContext, options = {}) {
|
|
159
211
|
throwIfAborted(input.signal);
|
|
160
212
|
const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
|
|
161
213
|
const headSha = options.dryRunHeadSha ?? meta.headRefOid;
|
|
214
|
+
const inlineCommentTargets = parseRightSideDiffTargets(await input.exec(`git diff --no-ext-diff --unified=3 ${shellQuote(meta.baseRefOid)} ${shellQuote(headSha)}`, { cwd: worktreePath }));
|
|
162
215
|
const artifactDir = outputDir(input);
|
|
163
216
|
let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
|
|
164
217
|
throwIfAborted(input.signal);
|
|
@@ -213,7 +266,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
|
|
|
213
266
|
}
|
|
214
267
|
},
|
|
215
268
|
options: reviewer.options,
|
|
216
|
-
parse:
|
|
269
|
+
parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
|
|
217
270
|
permission: reviewer.permission,
|
|
218
271
|
prompt,
|
|
219
272
|
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
@@ -296,7 +349,11 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
|
|
|
296
349
|
}
|
|
297
350
|
},
|
|
298
351
|
options: reviewer.options,
|
|
299
|
-
parse:
|
|
352
|
+
parse: (text) => {
|
|
353
|
+
const output = parseRereviewCloseReconsiderationOutput(text);
|
|
354
|
+
validateInlineCommentTargets(output.newFindings, inlineCommentTargets, "newFindings");
|
|
355
|
+
return output;
|
|
356
|
+
},
|
|
300
357
|
permission: reviewer.permission,
|
|
301
358
|
prompt,
|
|
302
359
|
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
@@ -359,6 +416,7 @@ async function finishMergeRun(input, result, reportInput) {
|
|
|
359
416
|
editorOutputs: reportInput.editorOutputs,
|
|
360
417
|
outputs: reportInput.outputs,
|
|
361
418
|
posted: reportInput.posted,
|
|
419
|
+
pr: input.pr,
|
|
362
420
|
repository: input.repository,
|
|
363
421
|
status: result.status,
|
|
364
422
|
});
|
|
@@ -448,15 +506,42 @@ function syntheticReviewThreads(outputs) {
|
|
|
448
506
|
const threads = {};
|
|
449
507
|
for (const [reviewer, output] of Object.entries(outputs)) {
|
|
450
508
|
if ("findings" in output) {
|
|
451
|
-
threads[reviewer] = output.findings.
|
|
509
|
+
threads[reviewer] = output.findings.flatMap((finding) => {
|
|
510
|
+
if (finding.line == null)
|
|
511
|
+
return [];
|
|
452
512
|
const commentId = nextCommentId--;
|
|
453
|
-
return
|
|
454
|
-
|
|
513
|
+
return [
|
|
514
|
+
{
|
|
515
|
+
body: `Issue: ${finding.issue}\n\nFix: ${finding.fix}`,
|
|
516
|
+
commentId,
|
|
517
|
+
comments: [
|
|
518
|
+
{
|
|
519
|
+
author: reviewer,
|
|
520
|
+
body: `Issue: ${finding.issue}\n\nFix: ${finding.fix}`,
|
|
521
|
+
commentId,
|
|
522
|
+
createdAt: new Date(0).toISOString(),
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
line: finding.line,
|
|
526
|
+
path: finding.path,
|
|
527
|
+
threadId: `dry-run:${reviewer}:${Math.abs(commentId)}`,
|
|
528
|
+
},
|
|
529
|
+
];
|
|
530
|
+
});
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
threads[reviewer] = output.newFindings.flatMap((finding) => {
|
|
534
|
+
if (finding.line == null)
|
|
535
|
+
return [];
|
|
536
|
+
const commentId = nextCommentId--;
|
|
537
|
+
return [
|
|
538
|
+
{
|
|
539
|
+
body: finding.body,
|
|
455
540
|
commentId,
|
|
456
541
|
comments: [
|
|
457
542
|
{
|
|
458
543
|
author: reviewer,
|
|
459
|
-
body:
|
|
544
|
+
body: finding.body,
|
|
460
545
|
commentId,
|
|
461
546
|
createdAt: new Date(0).toISOString(),
|
|
462
547
|
},
|
|
@@ -464,27 +549,8 @@ function syntheticReviewThreads(outputs) {
|
|
|
464
549
|
line: finding.line,
|
|
465
550
|
path: finding.path,
|
|
466
551
|
threadId: `dry-run:${reviewer}:${Math.abs(commentId)}`,
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
continue;
|
|
470
|
-
}
|
|
471
|
-
threads[reviewer] = output.newFindings.map((finding) => {
|
|
472
|
-
const commentId = nextCommentId--;
|
|
473
|
-
return {
|
|
474
|
-
body: finding.body,
|
|
475
|
-
commentId,
|
|
476
|
-
comments: [
|
|
477
|
-
{
|
|
478
|
-
author: reviewer,
|
|
479
|
-
body: finding.body,
|
|
480
|
-
commentId,
|
|
481
|
-
createdAt: new Date(0).toISOString(),
|
|
482
|
-
},
|
|
483
|
-
],
|
|
484
|
-
line: finding.line,
|
|
485
|
-
path: finding.path,
|
|
486
|
-
threadId: `dry-run:${reviewer}:${Math.abs(commentId)}`,
|
|
487
|
-
};
|
|
552
|
+
},
|
|
553
|
+
];
|
|
488
554
|
});
|
|
489
555
|
}
|
|
490
556
|
return threads;
|
|
@@ -542,6 +608,7 @@ export async function runMerge(input) {
|
|
|
542
608
|
editorOutputs: [],
|
|
543
609
|
outputs: {},
|
|
544
610
|
posted: {},
|
|
611
|
+
pr: input.pr,
|
|
545
612
|
repository: input.repository,
|
|
546
613
|
safety,
|
|
547
614
|
status: "safety_blocked",
|
|
@@ -653,7 +720,14 @@ export async function runMerge(input) {
|
|
|
653
720
|
maxThreadResolutionCycles: input.repository.merge.maxThreadResolutionCycles,
|
|
654
721
|
threads: unresolvedThreads,
|
|
655
722
|
});
|
|
656
|
-
|
|
723
|
+
const editorFindings = blockingReviewFindings(reportOutputs);
|
|
724
|
+
const editableFindings = editableThreads.length
|
|
725
|
+
? editorFindings
|
|
726
|
+
: editorFindings.filter((finding) => finding.type !== "inline");
|
|
727
|
+
const findingAttemptsExhausted = input.repository.merge.maxThreadResolutionCycles !== 0 &&
|
|
728
|
+
cycle > input.repository.merge.maxThreadResolutionCycles;
|
|
729
|
+
if (!editableThreads.length &&
|
|
730
|
+
(!editableFindings.length || findingAttemptsExhausted)) {
|
|
657
731
|
await input.onProgress?.({
|
|
658
732
|
status: "changes_unresolved",
|
|
659
733
|
type: "merge_completed",
|
|
@@ -680,7 +754,7 @@ export async function runMerge(input) {
|
|
|
680
754
|
});
|
|
681
755
|
if (!review.worktreePath)
|
|
682
756
|
throw new Error("Review worktree is missing");
|
|
683
|
-
const editorOutput = await runEditor(abortableInput, review.worktreePath, cycle, editableThreads);
|
|
757
|
+
const editorOutput = await runEditor(abortableInput, review.worktreePath, cycle, editableFindings, editableThreads);
|
|
684
758
|
editorOutputs.push(editorOutput);
|
|
685
759
|
dryRunThreads = input.dryRun
|
|
686
760
|
? appendDryRunEditorResponses({
|
|
@@ -10,18 +10,30 @@ function reportUrl(value) {
|
|
|
10
10
|
function linkOrText(text, url) {
|
|
11
11
|
return url ? `[${text}](${url})` : text;
|
|
12
12
|
}
|
|
13
|
+
function pullRequestLine(input) {
|
|
14
|
+
const host = input.repository.github.host || "github.com";
|
|
15
|
+
const url = `https://${host}/${input.repository.github.owner}/${input.repository.github.repo}/pull/${input.pr}`;
|
|
16
|
+
return `- **Pull Request**: [#${input.pr}](${url})`;
|
|
17
|
+
}
|
|
13
18
|
function formatFinding(finding) {
|
|
14
|
-
const line = finding.
|
|
15
|
-
?
|
|
16
|
-
:
|
|
19
|
+
const line = finding.line == null
|
|
20
|
+
? finding.path
|
|
21
|
+
: finding.startLine == null
|
|
22
|
+
? `${finding.path}:${finding.line}`
|
|
23
|
+
: `${finding.path}:${finding.startLine}-${finding.line}`;
|
|
17
24
|
return `\`${line}\`: ${finding.issue}`;
|
|
18
25
|
}
|
|
19
26
|
function formatRereviewFinding(finding) {
|
|
20
|
-
const line = finding.
|
|
21
|
-
?
|
|
22
|
-
:
|
|
27
|
+
const line = finding.line == null
|
|
28
|
+
? finding.path
|
|
29
|
+
: finding.startLine == null
|
|
30
|
+
? `${finding.path}:${finding.line}`
|
|
31
|
+
: `${finding.path}:${finding.startLine}-${finding.line}`;
|
|
23
32
|
return `\`${line}\`: ${finding.body}`;
|
|
24
33
|
}
|
|
34
|
+
function formatRequirementFinding(finding) {
|
|
35
|
+
return `Issue #${finding.issueNumber}: ${finding.requirement}`;
|
|
36
|
+
}
|
|
25
37
|
function isReviewOutput(output) {
|
|
26
38
|
return "findings" in output;
|
|
27
39
|
}
|
|
@@ -73,7 +85,10 @@ function reviewerDetailLines(output) {
|
|
|
73
85
|
return output.reason ? [output.reason] : [];
|
|
74
86
|
if (output.verdict !== "CHANGES_REQUESTED")
|
|
75
87
|
return [];
|
|
76
|
-
return
|
|
88
|
+
return [
|
|
89
|
+
...output.findings.map(formatFinding),
|
|
90
|
+
...output.requirementFindings.map(formatRequirementFinding),
|
|
91
|
+
];
|
|
77
92
|
}
|
|
78
93
|
if (output.verdict === "CLOSE")
|
|
79
94
|
return output.reason ? [output.reason] : [];
|
|
@@ -81,6 +96,7 @@ function reviewerDetailLines(output) {
|
|
|
81
96
|
return [];
|
|
82
97
|
return [
|
|
83
98
|
...output.newFindings.map(formatRereviewFinding),
|
|
99
|
+
...output.requirementFindings.map(formatRequirementFinding),
|
|
84
100
|
...output.followUps.map((item) => `Comment #${item.commentId}: ${item.body}`),
|
|
85
101
|
];
|
|
86
102
|
}
|
|
@@ -150,6 +166,7 @@ function editorLines(outputs) {
|
|
|
150
166
|
}
|
|
151
167
|
export function formatReviewReport(input) {
|
|
152
168
|
return [
|
|
169
|
+
pullRequestLine(input),
|
|
153
170
|
...dryRunLines(input.dryRun),
|
|
154
171
|
...safetyLines(input.safety),
|
|
155
172
|
...checkLines(input.ciReports),
|
|
@@ -158,6 +175,7 @@ export function formatReviewReport(input) {
|
|
|
158
175
|
}
|
|
159
176
|
export function formatMergeReport(input) {
|
|
160
177
|
return [
|
|
178
|
+
pullRequestLine(input),
|
|
161
179
|
...mergeStatusLines(input.status),
|
|
162
180
|
...dryRunLines(input.dryRun),
|
|
163
181
|
...safetyLines(input.safety),
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { fetchIssue, fetchIssueCommentPage, fetchPullRequestClosingIssues, fetchPullRequestCommentPage, fetchPullRequestReviewThreadPage, fetchPullRequestSafetyMeta, } from "../github/commands";
|
|
2
|
+
const LIMITS = {
|
|
3
|
+
closingIssueComments: 20,
|
|
4
|
+
commentBody: 4000,
|
|
5
|
+
prComments: 20,
|
|
6
|
+
referencedIssueComments: 10,
|
|
7
|
+
reviewThreadComments: 20,
|
|
8
|
+
reviewThreads: 50,
|
|
9
|
+
};
|
|
10
|
+
function escapeRegExp(value) {
|
|
11
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12
|
+
}
|
|
13
|
+
function truncateBody(body) {
|
|
14
|
+
if (body.length <= LIMITS.commentBody)
|
|
15
|
+
return { body };
|
|
16
|
+
return {
|
|
17
|
+
body: `${body.slice(0, LIMITS.commentBody)}\n[truncated after ${LIMITS.commentBody} characters]`,
|
|
18
|
+
truncated: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function boundedComments(comments, limit) {
|
|
22
|
+
return [...comments]
|
|
23
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
24
|
+
.slice(-limit)
|
|
25
|
+
.map((comment) => ({
|
|
26
|
+
author: comment.author,
|
|
27
|
+
createdAt: comment.createdAt,
|
|
28
|
+
id: comment.id,
|
|
29
|
+
url: comment.url,
|
|
30
|
+
...truncateBody(comment.body),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
function omittedCommentCount(input) {
|
|
34
|
+
return input.omitted + Math.max(0, input.comments.length - input.limit);
|
|
35
|
+
}
|
|
36
|
+
function quoteEvidence(value) {
|
|
37
|
+
const compact = value.replaceAll(/\s+/g, " ").trim();
|
|
38
|
+
return compact.length > 120 ? `${compact.slice(0, 117)}...` : compact;
|
|
39
|
+
}
|
|
40
|
+
function issueReferencePattern(repository) {
|
|
41
|
+
const host = escapeRegExp(repository.github.host || "github.com");
|
|
42
|
+
const owner = escapeRegExp(repository.github.owner);
|
|
43
|
+
const repo = escapeRegExp(repository.github.repo);
|
|
44
|
+
return new RegExp(`(?:https?://${host}/${owner}/${repo}/issues/(\\d+)|#(\\d+))`, "gi");
|
|
45
|
+
}
|
|
46
|
+
function issueNumberFromMatch(match) {
|
|
47
|
+
return Number(match[1] ?? match[2]);
|
|
48
|
+
}
|
|
49
|
+
function addRelationship(relationships, number, relationship, source) {
|
|
50
|
+
const current = relationships.get(number);
|
|
51
|
+
const nextRelationship = current?.relationship === "closing" || relationship === "closing"
|
|
52
|
+
? "closing"
|
|
53
|
+
: "referenced";
|
|
54
|
+
const sources = current?.sources ?? [];
|
|
55
|
+
relationships.set(number, {
|
|
56
|
+
number,
|
|
57
|
+
relationship: nextRelationship,
|
|
58
|
+
sources: sources.includes(source) ? sources : [...sources, source],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function scanRelationshipText(input) {
|
|
62
|
+
const referencePattern = issueReferencePattern(input.repository);
|
|
63
|
+
const closingPattern = new RegExp(`\\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\\b[\\s\\S]{0,80}?${referencePattern.source}`, "gi");
|
|
64
|
+
for (const match of input.text.matchAll(referencePattern)) {
|
|
65
|
+
const number = issueNumberFromMatch(match);
|
|
66
|
+
if (number === input.currentPr)
|
|
67
|
+
continue;
|
|
68
|
+
addRelationship(input.relationships, number, "referenced", `${input.label} "${quoteEvidence(match[0])}"`);
|
|
69
|
+
}
|
|
70
|
+
for (const match of input.text.matchAll(closingPattern)) {
|
|
71
|
+
const number = Number(match[1] ?? match[2]);
|
|
72
|
+
if (!number || number === input.currentPr)
|
|
73
|
+
continue;
|
|
74
|
+
addRelationship(input.relationships, number, "closing", `${input.label} "${quoteEvidence(match[0])}"`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function collectIssueRelationships(input) {
|
|
78
|
+
const relationships = new Map();
|
|
79
|
+
for (const issue of input.closingIssues) {
|
|
80
|
+
if (issue.number === input.pr.number)
|
|
81
|
+
continue;
|
|
82
|
+
addRelationship(relationships, issue.number, "closing", "GitHub closingIssuesReferences");
|
|
83
|
+
}
|
|
84
|
+
scanRelationshipText({
|
|
85
|
+
currentPr: input.pr.number,
|
|
86
|
+
label: "PR title",
|
|
87
|
+
relationships,
|
|
88
|
+
repository: input.repository,
|
|
89
|
+
text: input.pr.title,
|
|
90
|
+
});
|
|
91
|
+
scanRelationshipText({
|
|
92
|
+
currentPr: input.pr.number,
|
|
93
|
+
label: "PR body",
|
|
94
|
+
relationships,
|
|
95
|
+
repository: input.repository,
|
|
96
|
+
text: input.pr.body ?? "",
|
|
97
|
+
});
|
|
98
|
+
for (const comment of input.prComments) {
|
|
99
|
+
scanRelationshipText({
|
|
100
|
+
currentPr: input.pr.number,
|
|
101
|
+
label: `PR comment ${comment.id}`,
|
|
102
|
+
relationships,
|
|
103
|
+
repository: input.repository,
|
|
104
|
+
text: comment.body,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
for (const thread of input.reviewThreads) {
|
|
108
|
+
for (const comment of thread.comments) {
|
|
109
|
+
scanRelationshipText({
|
|
110
|
+
currentPr: input.pr.number,
|
|
111
|
+
label: `review thread ${thread.threadId} comment ${comment.commentId}`,
|
|
112
|
+
relationships,
|
|
113
|
+
repository: input.repository,
|
|
114
|
+
text: comment.body,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return [...relationships.values()].sort((a, b) => a.number - b.number);
|
|
119
|
+
}
|
|
120
|
+
async function contextIssue(input) {
|
|
121
|
+
const issue = input.issue ??
|
|
122
|
+
(await fetchIssue(input.exec, input.repository, input.relationship.number));
|
|
123
|
+
const commentPage = await fetchIssueCommentPage(input.exec, input.repository, issue.number, input.limit);
|
|
124
|
+
return {
|
|
125
|
+
author: issue.author,
|
|
126
|
+
body: issue.body,
|
|
127
|
+
comments: boundedComments(commentPage.comments, input.limit),
|
|
128
|
+
commentsOmitted: omittedCommentCount({
|
|
129
|
+
comments: commentPage.comments,
|
|
130
|
+
limit: input.limit,
|
|
131
|
+
omitted: commentPage.omitted,
|
|
132
|
+
}),
|
|
133
|
+
number: issue.number,
|
|
134
|
+
relationship: input.relationship.relationship,
|
|
135
|
+
source: input.relationship.sources.join("; "),
|
|
136
|
+
state: issue.state,
|
|
137
|
+
title: issue.title,
|
|
138
|
+
url: issue.url,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function orderReviewThreads(threads) {
|
|
142
|
+
return [...threads]
|
|
143
|
+
.sort((a, b) => {
|
|
144
|
+
if (a.isResolved !== b.isResolved)
|
|
145
|
+
return a.isResolved ? 1 : -1;
|
|
146
|
+
const aLatest = a.comments.at(-1)?.createdAt ?? "";
|
|
147
|
+
const bLatest = b.comments.at(-1)?.createdAt ?? "";
|
|
148
|
+
return bLatest.localeCompare(aLatest);
|
|
149
|
+
})
|
|
150
|
+
.slice(0, LIMITS.reviewThreads)
|
|
151
|
+
.map((thread) => ({
|
|
152
|
+
...thread,
|
|
153
|
+
comments: thread.comments
|
|
154
|
+
.slice(-LIMITS.reviewThreadComments)
|
|
155
|
+
.map((comment) => ({
|
|
156
|
+
...comment,
|
|
157
|
+
...truncateBody(comment.body),
|
|
158
|
+
})),
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
export async function buildReviewContextSnapshot(input) {
|
|
162
|
+
const [prCommentPage, reviewThreadPage, safetyMeta, closingIssues] = await Promise.all([
|
|
163
|
+
fetchPullRequestCommentPage(input.exec, input.repository, input.pr.number, LIMITS.prComments),
|
|
164
|
+
fetchPullRequestReviewThreadPage(input.exec, input.repository, input.pr.number, LIMITS.reviewThreads, LIMITS.reviewThreadComments),
|
|
165
|
+
fetchPullRequestSafetyMeta(input.exec, input.repository, input.pr.number),
|
|
166
|
+
fetchPullRequestClosingIssues(input.exec, input.repository, input.pr.number).catch(() => []),
|
|
167
|
+
]);
|
|
168
|
+
const prComments = prCommentPage.comments;
|
|
169
|
+
const orderedReviewThreads = orderReviewThreads(reviewThreadPage.threads);
|
|
170
|
+
const prCommentsOmitted = omittedCommentCount({
|
|
171
|
+
comments: prComments,
|
|
172
|
+
limit: LIMITS.prComments,
|
|
173
|
+
omitted: prCommentPage.omitted,
|
|
174
|
+
});
|
|
175
|
+
const relationships = collectIssueRelationships({
|
|
176
|
+
closingIssues,
|
|
177
|
+
pr: input.pr,
|
|
178
|
+
prComments,
|
|
179
|
+
repository: input.repository,
|
|
180
|
+
reviewThreads: orderedReviewThreads,
|
|
181
|
+
});
|
|
182
|
+
const closingIssueMap = new Map(closingIssues.map((issue) => [issue.number, issue]));
|
|
183
|
+
const closingRelationships = relationships.filter((relationship) => relationship.relationship === "closing");
|
|
184
|
+
const referencedRelationships = relationships.filter((relationship) => relationship.relationship === "referenced");
|
|
185
|
+
return {
|
|
186
|
+
closingIssues: await Promise.all(closingRelationships.map((relationship) => contextIssue({
|
|
187
|
+
exec: input.exec,
|
|
188
|
+
issue: closingIssueMap.get(relationship.number),
|
|
189
|
+
limit: LIMITS.closingIssueComments,
|
|
190
|
+
relationship,
|
|
191
|
+
repository: input.repository,
|
|
192
|
+
}))),
|
|
193
|
+
pullRequest: {
|
|
194
|
+
author: input.pr.author?.login ?? safetyMeta.author,
|
|
195
|
+
baseRef: input.pr.baseRefName,
|
|
196
|
+
baseSha: input.pr.baseRefOid,
|
|
197
|
+
body: input.pr.body ?? "",
|
|
198
|
+
changedFiles: safetyMeta.files,
|
|
199
|
+
comments: boundedComments(prComments, LIMITS.prComments),
|
|
200
|
+
commentsOmitted: prCommentsOmitted,
|
|
201
|
+
headRef: input.pr.headRefName,
|
|
202
|
+
headSha: input.pr.headRefOid,
|
|
203
|
+
number: input.pr.number,
|
|
204
|
+
relationship: "target",
|
|
205
|
+
source: "/magi:review input",
|
|
206
|
+
state: input.pr.state ?? "",
|
|
207
|
+
title: input.pr.title,
|
|
208
|
+
url: input.pr.url,
|
|
209
|
+
},
|
|
210
|
+
referencedIssues: await Promise.all(referencedRelationships.map((relationship) => contextIssue({
|
|
211
|
+
exec: input.exec,
|
|
212
|
+
limit: LIMITS.referencedIssueComments,
|
|
213
|
+
relationship,
|
|
214
|
+
repository: input.repository,
|
|
215
|
+
}))),
|
|
216
|
+
reviewDiscussion: {
|
|
217
|
+
prComments: boundedComments(prComments, LIMITS.prComments),
|
|
218
|
+
prCommentsOmitted,
|
|
219
|
+
reviewThreads: orderedReviewThreads,
|
|
220
|
+
reviewThreadsOmitted: reviewThreadPage.omitted,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function indented(value) {
|
|
225
|
+
return value.trim() ? value : "(empty)";
|
|
226
|
+
}
|
|
227
|
+
function renderOmissionNote(omitted, label, limit) {
|
|
228
|
+
return omitted > 0
|
|
229
|
+
? `\n[omitted ${omitted} older ${label} due to limit ${limit}]`
|
|
230
|
+
: "";
|
|
231
|
+
}
|
|
232
|
+
function renderComments(comments, omitted = 0, limit = comments.length) {
|
|
233
|
+
if (!comments.length)
|
|
234
|
+
return `(none)${renderOmissionNote(omitted, "comments", limit)}`;
|
|
235
|
+
return (comments
|
|
236
|
+
.map((comment) => {
|
|
237
|
+
const suffix = comment.truncated ? " [truncated]" : "";
|
|
238
|
+
return `- ${comment.createdAt} @${comment.author} (${comment.id})${suffix}\n${indented(comment.body)}`;
|
|
239
|
+
})
|
|
240
|
+
.join("\n") + renderOmissionNote(omitted, "comments", limit));
|
|
241
|
+
}
|
|
242
|
+
function renderIssue(issue) {
|
|
243
|
+
return `<issue>
|
|
244
|
+
number: ${issue.number}
|
|
245
|
+
title: ${issue.title}
|
|
246
|
+
url: ${issue.url}
|
|
247
|
+
state: ${issue.state}
|
|
248
|
+
author: ${issue.author}
|
|
249
|
+
relationship: ${issue.relationship}
|
|
250
|
+
source: ${issue.source}
|
|
251
|
+
body:
|
|
252
|
+
${indented(issue.body)}
|
|
253
|
+
comments:
|
|
254
|
+
${renderComments(issue.comments, issue.commentsOmitted, issue.relationship === "closing" ? LIMITS.closingIssueComments : LIMITS.referencedIssueComments)}
|
|
255
|
+
</issue>`;
|
|
256
|
+
}
|
|
257
|
+
function renderThreads(threads, omitted = 0) {
|
|
258
|
+
if (!threads.length) {
|
|
259
|
+
return `(none)${renderOmissionNote(omitted, "review threads", LIMITS.reviewThreads)}`;
|
|
260
|
+
}
|
|
261
|
+
return (threads
|
|
262
|
+
.map((thread) => {
|
|
263
|
+
const comments = thread.comments
|
|
264
|
+
.map((comment) => {
|
|
265
|
+
const suffix = comment.truncated ? " [truncated]" : "";
|
|
266
|
+
return ` - ${comment.createdAt} @${comment.author} (${comment.commentId})${suffix}\n${indented(comment.body)}`;
|
|
267
|
+
})
|
|
268
|
+
.join("\n") +
|
|
269
|
+
renderOmissionNote(thread.omittedComments ?? 0, "thread comments", LIMITS.reviewThreadComments);
|
|
270
|
+
return `- threadId: ${thread.threadId}\n resolved: ${Boolean(thread.isResolved)}\n path: ${thread.path}:${thread.line}\n comments:\n${comments}`;
|
|
271
|
+
})
|
|
272
|
+
.join("\n") +
|
|
273
|
+
renderOmissionNote(omitted, "review threads", LIMITS.reviewThreads));
|
|
274
|
+
}
|
|
275
|
+
export function renderReviewContext(snapshot) {
|
|
276
|
+
return [
|
|
277
|
+
`<pull_request_context>
|
|
278
|
+
number: ${snapshot.pullRequest.number}
|
|
279
|
+
title: ${snapshot.pullRequest.title}
|
|
280
|
+
url: ${snapshot.pullRequest.url}
|
|
281
|
+
state: ${snapshot.pullRequest.state}
|
|
282
|
+
author: ${snapshot.pullRequest.author}
|
|
283
|
+
relationship: ${snapshot.pullRequest.relationship}
|
|
284
|
+
source: ${snapshot.pullRequest.source}
|
|
285
|
+
baseRef: ${snapshot.pullRequest.baseRef}
|
|
286
|
+
headRef: ${snapshot.pullRequest.headRef}
|
|
287
|
+
baseSha: ${snapshot.pullRequest.baseSha}
|
|
288
|
+
headSha: ${snapshot.pullRequest.headSha}
|
|
289
|
+
body:
|
|
290
|
+
${indented(snapshot.pullRequest.body)}
|
|
291
|
+
comments:
|
|
292
|
+
${renderComments(snapshot.pullRequest.comments, snapshot.pullRequest.commentsOmitted, LIMITS.prComments)}
|
|
293
|
+
changedFiles:
|
|
294
|
+
${snapshot.pullRequest.changedFiles.length ? snapshot.pullRequest.changedFiles.map((file) => `- ${file}`).join("\n") : "(none)"}
|
|
295
|
+
</pull_request_context>`,
|
|
296
|
+
`<closing_issues>
|
|
297
|
+
${snapshot.closingIssues.length ? snapshot.closingIssues.map(renderIssue).join("\n") : "(none)"}
|
|
298
|
+
</closing_issues>`,
|
|
299
|
+
`<referenced_issues>
|
|
300
|
+
${snapshot.referencedIssues.length ? snapshot.referencedIssues.map(renderIssue).join("\n") : "(none)"}
|
|
301
|
+
</referenced_issues>`,
|
|
302
|
+
`<review_discussion>
|
|
303
|
+
prComments:
|
|
304
|
+
${renderComments(snapshot.reviewDiscussion.prComments, snapshot.reviewDiscussion.prCommentsOmitted, LIMITS.prComments)}
|
|
305
|
+
reviewThreads:
|
|
306
|
+
${renderThreads(snapshot.reviewDiscussion.reviewThreads, snapshot.reviewDiscussion.reviewThreadsOmitted)}
|
|
307
|
+
</review_discussion>`,
|
|
308
|
+
].join("\n\n");
|
|
309
|
+
}
|