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.
Files changed (36) hide show
  1. package/README.md +19 -0
  2. package/dist/commands.js +4 -0
  3. package/dist/config/output.js +11 -2
  4. package/dist/config/resolve.js +81 -1
  5. package/dist/config/validate.js +341 -3
  6. package/dist/config/worktree.js +8 -2
  7. package/dist/github/commands.js +381 -19
  8. package/dist/index.js +252 -26
  9. package/dist/orchestrator/ci.js +1 -1
  10. package/dist/orchestrator/findings.js +4 -3
  11. package/dist/orchestrator/inline-comments.js +79 -0
  12. package/dist/orchestrator/majority.js +14 -0
  13. package/dist/orchestrator/merge.js +108 -34
  14. package/dist/orchestrator/report.js +25 -7
  15. package/dist/orchestrator/review-context.js +309 -0
  16. package/dist/orchestrator/review.js +122 -14
  17. package/dist/orchestrator/run-manager.js +408 -17
  18. package/dist/orchestrator/triage.js +1119 -0
  19. package/dist/permissions/editor.json +8 -1
  20. package/dist/prompts/compose.js +163 -1
  21. package/dist/prompts/contracts.js +131 -18
  22. package/dist/prompts/output.js +173 -22
  23. package/dist/prompts/templates/merge/edit.md +12 -5
  24. package/dist/prompts/templates/review/review.md +6 -0
  25. package/dist/prompts/templates/triage/acceptance.md +7 -0
  26. package/dist/prompts/templates/triage/action.md +5 -0
  27. package/dist/prompts/templates/triage/category.md +10 -0
  28. package/dist/prompts/templates/triage/comment-classification.md +7 -0
  29. package/dist/prompts/templates/triage/comment.md +5 -0
  30. package/dist/prompts/templates/triage/create.md +7 -0
  31. package/dist/prompts/templates/triage/duplicate.md +7 -0
  32. package/dist/prompts/templates/triage/existing-pr.md +7 -0
  33. package/dist/prompts/templates/triage/question.md +5 -0
  34. package/dist/prompts/templates/triage/reconsider.md +5 -0
  35. package/package.json +5 -2
  36. 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: parseRereviewOutput,
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: parseRereviewCloseReconsiderationOutput,
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.map((finding) => {
509
+ threads[reviewer] = output.findings.flatMap((finding) => {
510
+ if (finding.line == null)
511
+ return [];
452
512
  const commentId = nextCommentId--;
453
- return {
454
- body: `Issue: ${finding.issue}\n\nFix: ${finding.fix}`,
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: `Issue: ${finding.issue}\n\nFix: ${finding.fix}`,
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
- if (!editableThreads.length) {
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.startLine == null
15
- ? `${finding.path}:${finding.line}`
16
- : `${finding.path}:${finding.startLine}-${finding.line}`;
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.startLine == null
21
- ? `${finding.path}:${finding.line}`
22
- : `${finding.path}:${finding.startLine}-${finding.line}`;
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 output.findings.map(formatFinding);
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
+ }