opencode-magi 0.0.0-dev-20260520163717 → 0.0.0-dev-20260520165420

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ function parseDiffPath(value) {
2
+ if (value === "/dev/null")
3
+ return undefined;
4
+ let path = value;
5
+ if (path.startsWith('"') && path.endsWith('"')) {
6
+ try {
7
+ path = JSON.parse(path);
8
+ }
9
+ catch {
10
+ path = path.slice(1, -1);
11
+ }
12
+ }
13
+ return path.startsWith("b/") ? path.slice(2) : path;
14
+ }
15
+ function addTargetLine(targets, path, line) {
16
+ const lines = targets.get(path) ?? new Set();
17
+ lines.add(line);
18
+ targets.set(path, lines);
19
+ }
20
+ export function parseRightSideDiffTargets(diff) {
21
+ const targets = new Map();
22
+ let currentPath;
23
+ let rightLine;
24
+ for (const line of diff.split("\n")) {
25
+ if (line.startsWith("+++ ")) {
26
+ currentPath = parseDiffPath(line.slice(4));
27
+ rightLine = undefined;
28
+ continue;
29
+ }
30
+ if (line.startsWith("@@ ")) {
31
+ const match = line.match(/\+(\d+)(?:,\d+)?/);
32
+ rightLine = match ? Number(match[1]) : undefined;
33
+ continue;
34
+ }
35
+ if (!currentPath || rightLine == null)
36
+ continue;
37
+ if (line.startsWith("+") || line.startsWith(" ")) {
38
+ addTargetLine(targets, currentPath, rightLine);
39
+ rightLine += 1;
40
+ continue;
41
+ }
42
+ if (line.startsWith("-"))
43
+ continue;
44
+ }
45
+ return targets;
46
+ }
47
+ function assertPositiveInteger(value, name) {
48
+ if (!Number.isInteger(value) || value < 1) {
49
+ throw new Error(`${name} must be a positive integer`);
50
+ }
51
+ }
52
+ export function validateInlineCommentTargets(findings, targets, label = "findings") {
53
+ for (const [index, finding] of findings.entries()) {
54
+ const name = `${label}[${index}]`;
55
+ assertPositiveInteger(finding.line, `${name}.line`);
56
+ if (finding.startLine != null) {
57
+ assertPositiveInteger(finding.startLine, `${name}.startLine`);
58
+ if (finding.startLine > finding.line) {
59
+ throw new Error(`${name}.startLine must be before or equal to line`);
60
+ }
61
+ }
62
+ const lines = targets.get(finding.path);
63
+ if (!lines) {
64
+ throw new Error(`${name} targets ${finding.path}:${finding.line}, but path is not in the PR diff`);
65
+ }
66
+ const startLine = finding.startLine ?? finding.line;
67
+ for (let line = startLine; line <= finding.line; line += 1) {
68
+ if (!lines.has(line)) {
69
+ throw new Error(`${name} targets ${finding.path}:${line}, but line is not in a right-side PR diff hunk`);
70
+ }
71
+ }
72
+ }
73
+ }
@@ -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";
@@ -155,10 +156,16 @@ async function postRereviewOutput(input, reviewerKey, output) {
155
156
  }
156
157
  return replies[0] ?? "";
157
158
  }
159
+ function parseRereviewOutputWithInlineTargets(text, targets) {
160
+ const output = parseRereviewOutput(text);
161
+ validateInlineCommentTargets(output.newFindings, targets, "newFindings");
162
+ return output;
163
+ }
158
164
  async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionIds, ciFailureContext, options = {}) {
159
165
  throwIfAborted(input.signal);
160
166
  const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
161
167
  const headSha = options.dryRunHeadSha ?? meta.headRefOid;
168
+ const inlineCommentTargets = parseRightSideDiffTargets(await input.exec(`git diff --no-ext-diff --unified=3 ${shellQuote(meta.baseRefOid)} ${shellQuote(headSha)}`, { cwd: worktreePath }));
162
169
  const artifactDir = outputDir(input);
163
170
  let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
164
171
  throwIfAborted(input.signal);
@@ -213,7 +220,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
213
220
  }
214
221
  },
215
222
  options: reviewer.options,
216
- parse: parseRereviewOutput,
223
+ parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
217
224
  permission: reviewer.permission,
218
225
  prompt,
219
226
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -296,7 +303,11 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
296
303
  }
297
304
  },
298
305
  options: reviewer.options,
299
- parse: parseRereviewCloseReconsiderationOutput,
306
+ parse: (text) => {
307
+ const output = parseRereviewCloseReconsiderationOutput(text);
308
+ validateInlineCommentTargets(output.newFindings, inlineCommentTargets, "newFindings");
309
+ return output;
310
+ },
300
311
  permission: reviewer.permission,
301
312
  prompt,
302
313
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -1,12 +1,13 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { createWorktree, fetchPullRequest, fetchPullRequestCommits, fetchPullRequestReviews, fetchUnresolvedThreads, closePullRequest, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, removeWorktree, resolveThread, } from "../github/commands";
3
+ import { createWorktree, fetchPullRequest, fetchPullRequestCommits, fetchPullRequestReviews, fetchUnresolvedThreads, closePullRequest, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, removeWorktree, resolveThread, shellQuote, } from "../github/commands";
4
4
  import { composeFindingValidationPrompt, composeCloseReconsiderationPrompt, composeRereviewPrompt, composeReviewPrompt, } from "../prompts/compose";
5
5
  import { prRunOutputDir } from "../config/output";
6
6
  import { worktreeBaseDir } from "../config/worktree";
7
7
  import { parseCloseReconsiderationOutput, parseFindingValidationOutput, parseRereviewOutput, parseReviewOutput, } from "../prompts/output";
8
8
  import { throwIfAborted, withAbortSignal } from "./abort";
9
9
  import { waitForChecksWithClassification } from "./ci";
10
+ import { parseRightSideDiffTargets, validateInlineCommentTargets, } from "./inline-comments";
10
11
  import { applyFindingValidation, reviewFindingTargets, validateFindingVotes, } from "./findings";
11
12
  import { closeMinorityReviewers, mergeVerdictForPolicy, } from "./majority";
12
13
  import { runModelWithRepair } from "./model";
@@ -123,6 +124,16 @@ function previousReviewText(review) {
123
124
  submittedAt: review.submittedAt,
124
125
  }, null, 2);
125
126
  }
127
+ function parseReviewOutputWithInlineTargets(text, targets) {
128
+ const output = parseReviewOutput(text);
129
+ validateInlineCommentTargets(output.findings, targets);
130
+ return output;
131
+ }
132
+ function parseRereviewOutputWithInlineTargets(text, targets) {
133
+ const output = parseRereviewOutput(text);
134
+ validateInlineCommentTargets(output.newFindings, targets, "newFindings");
135
+ return output;
136
+ }
126
137
  function reviewOutputFromState(review) {
127
138
  const verdict = reviewStateToVerdict(review.state);
128
139
  return verdict === "CLOSE"
@@ -336,7 +347,11 @@ async function runCloseReconsideration(input) {
336
347
  }
337
348
  },
338
349
  options: reviewer.options,
339
- parse: parseCloseReconsiderationOutput,
350
+ parse: (text) => {
351
+ const output = parseCloseReconsiderationOutput(text);
352
+ validateInlineCommentTargets(output.findings, input.inlineCommentTargets);
353
+ return output;
354
+ },
340
355
  permission: reviewer.permission,
341
356
  prompt,
342
357
  repairAttempts: input.reviewInput.config.output?.repairAttempts ?? 3,
@@ -519,6 +534,7 @@ export async function runReview(input) {
519
534
  return [];
520
535
  return [{ assignment, reviewer }];
521
536
  });
537
+ const inlineCommentTargets = parseRightSideDiffTargets(await exec(`git diff --no-ext-diff --unified=3 ${shellQuote(meta.baseRefOid)} ${shellQuote(meta.headRefOid)}`, { cwd: worktreePath }));
522
538
  for (const reviewer of input.repository.agents.reviewers) {
523
539
  const assignment = mode.assignments.get(reviewer.account);
524
540
  if (assignment?.type !== "skip")
@@ -583,7 +599,7 @@ export async function runReview(input) {
583
599
  }
584
600
  },
585
601
  options: reviewer.options,
586
- parse: parseRereviewOutput,
602
+ parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
587
603
  permission: reviewer.permission,
588
604
  prompt,
589
605
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -648,7 +664,7 @@ export async function runReview(input) {
648
664
  }
649
665
  },
650
666
  options: reviewer.options,
651
- parse: parseReviewOutput,
667
+ parse: (text) => parseReviewOutputWithInlineTargets(text, inlineCommentTargets),
652
668
  permission: reviewer.permission,
653
669
  prompt,
654
670
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -708,6 +724,7 @@ export async function runReview(input) {
708
724
  });
709
725
  entries = await runCloseReconsideration({
710
726
  entries: [...entries, ...skippedCloseEntries],
727
+ inlineCommentTargets,
711
728
  meta,
712
729
  outputDir,
713
730
  reviewInput: { ...input, exec },
@@ -4,7 +4,7 @@ import { issueRunOutputDir } from "../config/output";
4
4
  import { worktreeBaseDir } from "../config/worktree";
5
5
  import { assignIssue, closeIssue, closePullRequest, configureGitIdentity, createPullRequest, fetchIssue, fetchIssueComments, fetchRelatedPullRequests, postIssueComment, pushHead, removeIssueLabels, removeWorktree, searchDuplicateIssues, shellQuote, updateIssueComment, } from "../github/commands";
6
6
  import { composeTriageBugPrompt, composeTriageActionPrompt, composeTriageCommentClassificationPrompt, composeTriageCommentPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageFeaturePrompt, composeTriageKindPrompt, composeTriageQuestionPrompt, composeTriageReconsiderPrompt, } from "../prompts/compose";
7
- import { parseEditOutput, parseTriageActionOutput, parseTriageBinaryOutput, parseTriageCommentClassificationOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, parseTriageFinalOutput, parseTriageKindOutput, } from "../prompts/output";
7
+ import { parseTriageActionOutput, parseTriageBinaryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, parseTriageFinalOutput, parseTriageKindOutput, } from "../prompts/output";
8
8
  import { aggregateStringMajority, majorityThreshold } from "./majority";
9
9
  import { runModelText, runModelWithRepair } from "./model";
10
10
  const MARKER_PREFIX = "opencode-magi:triage";
@@ -65,6 +65,10 @@ function labelsContain(labels, targets) {
65
65
  const set = new Set(labels.map((label) => label.toLowerCase()));
66
66
  return targets.some((target) => set.has(target.toLowerCase()));
67
67
  }
68
+ function existingClearLabels(issue, labels) {
69
+ const existing = new Set(issue.labels.map((label) => label.toLowerCase()));
70
+ return labels.filter((label) => existing.has(label.toLowerCase()));
71
+ }
68
72
  export function resolveIssueKind(issue, repository) {
69
73
  const triage = repository.triage;
70
74
  if (!triage)
@@ -251,6 +255,11 @@ function markerPr(marker) {
251
255
  const pr = Number(marker.pr);
252
256
  return Number.isInteger(pr) && pr > 0 ? pr : undefined;
253
257
  }
258
+ function pullRequestNumberFromUrl(url) {
259
+ const match = url.match(/\/pull\/(\d+)(?:\D|$)/);
260
+ const number = match ? Number(match[1]) : undefined;
261
+ return number && Number.isInteger(number) ? number : undefined;
262
+ }
254
263
  export function mentionAllowed(comment, repository) {
255
264
  const safety = repository.triage?.safety;
256
265
  if (!safety)
@@ -329,6 +338,30 @@ function actionPlan(input) {
329
338
  postComment: true,
330
339
  };
331
340
  }
341
+ function previousAutomationPlan(input) {
342
+ const base = actionPlan({ result: input.result, triage: input.triage });
343
+ const clearLabels = base.clearLabels &&
344
+ existingClearLabels(input.issue, input.triage.automation.clear).length > 0;
345
+ const closeIssue = input.marker.action === "CLOSE" &&
346
+ base.closeIssue &&
347
+ input.issue.state === "OPEN";
348
+ const createPr = input.marker.action === "PR" &&
349
+ base.createPr &&
350
+ !markerPr(input.marker) &&
351
+ !input.relationship.relatedPullRequests.length;
352
+ if (!clearLabels && !closeIssue && !createPr)
353
+ return undefined;
354
+ const action = closeIssue ? "CLOSE" : createPr ? "PR" : "CLEAR_ONLY";
355
+ return {
356
+ ...base,
357
+ action,
358
+ allowedActions: [action],
359
+ clearLabels,
360
+ closeIssue,
361
+ createPr,
362
+ postComment: false,
363
+ };
364
+ }
332
365
  async function runActionPrompt(input) {
333
366
  const agent = input.input.repository.agents.triage?.[0];
334
367
  if (!agent)
@@ -454,7 +487,7 @@ async function persistProcessedMarker(input) {
454
487
  action: input.marker.action ?? input.marker.result ?? "ASK",
455
488
  checkpoint: markerCheckpoint(input.marker),
456
489
  issue: input.issue.number,
457
- pr: markerPr(input.marker),
490
+ pr: input.pr ?? markerPr(input.marker),
458
491
  processed: input.processed,
459
492
  result: input.marker.result ?? "ASK",
460
493
  });
@@ -471,7 +504,7 @@ async function finishWithResult(input) {
471
504
  const triage = input.input.repository.triage;
472
505
  if (!triage)
473
506
  throw new Error("triage configuration is required");
474
- const plan = actionPlan({ result: input.result, triage });
507
+ const plan = input.plan ?? actionPlan({ result: input.result, triage });
475
508
  await runActionPrompt({
476
509
  context: input.context,
477
510
  input: input.input,
@@ -502,6 +535,12 @@ async function finishWithResult(input) {
502
535
  repository: input.input.repository,
503
536
  });
504
537
  }
538
+ if (plan.clearLabels) {
539
+ const clearLabels = existingClearLabels(input.issue, triage.automation.clear);
540
+ if (clearLabels.length) {
541
+ await removeIssueLabels(input.input.exec, input.input.repository, input.issue.number, clearLabels, triage.account ?? "");
542
+ }
543
+ }
505
544
  if (plan.closeIssue) {
506
545
  const closedPrs = [];
507
546
  for (const pr of input.relationship.relatedPullRequests.filter((pr) => pr.state === "OPEN")) {
@@ -522,8 +561,18 @@ async function finishWithResult(input) {
522
561
  if (prUrl)
523
562
  await writeJson(join(input.outputDir, "pr.json"), { url: prUrl });
524
563
  }
525
- if (plan.clearLabels) {
526
- await removeIssueLabels(input.input.exec, input.input.repository, input.issue.number, triage.automation.clear, triage.account ?? "");
564
+ if (input.previousMarker && prUrl) {
565
+ await persistProcessedMarker({
566
+ account: triage.account ?? "",
567
+ comments: input.relationship.comments,
568
+ exec: input.input.exec,
569
+ issue: input.issue,
570
+ marker: input.previousMarker,
571
+ outputDir: input.outputDir,
572
+ pr: pullRequestNumberFromUrl(prUrl),
573
+ processed: input.processed ?? input.previousMarker.processed,
574
+ repository: input.input.repository,
575
+ });
527
576
  }
528
577
  }
529
578
  const report = [
@@ -589,7 +638,7 @@ async function createImplementationPr(input) {
589
638
  client: input.input.client,
590
639
  model: creator.model,
591
640
  options: creator.options,
592
- parse: parseEditOutput,
641
+ parse: parseTriageCreatePrOutput,
593
642
  permission: creator.permission,
594
643
  prompt,
595
644
  repairAttempts: 3,
@@ -650,6 +699,26 @@ export async function runTriage(input) {
650
699
  if (relationship.previousMarker) {
651
700
  if (!relationship.mentionReplies.length) {
652
701
  const result = finalResultFromMarker(relationship.previousMarker);
702
+ const plan = previousAutomationPlan({
703
+ issue,
704
+ marker: relationship.previousMarker,
705
+ relationship,
706
+ result,
707
+ triage,
708
+ });
709
+ if (plan) {
710
+ return finishWithResult({
711
+ context,
712
+ input,
713
+ issue,
714
+ outputDir,
715
+ plan,
716
+ previousMarker: relationship.previousMarker,
717
+ processed,
718
+ relationship,
719
+ result,
720
+ });
721
+ }
653
722
  const report = `Magi triage skipped #${issue.number} because no eligible mention replies were found for reconsideration.`;
654
723
  await writeFile(join(outputDir, "report.md"), `${report}\n`);
655
724
  return { issue: issue.number, outputDir, report, result };
@@ -746,6 +815,10 @@ export async function runTriage(input) {
746
815
  outputDir,
747
816
  repository: input.repository,
748
817
  });
818
+ const clearLabels = existingClearLabels(issue, triage.automation.clear);
819
+ if (clearLabels.length) {
820
+ await removeIssueLabels(input.exec, input.repository, issue.number, clearLabels, triage.account);
821
+ }
749
822
  const closedPrs = [];
750
823
  for (const pr of relationship.relatedPullRequests.filter((pr) => pr.state === "OPEN")) {
751
824
  await closePullRequest(input.exec, input.repository, pr.number, triage.account);
@@ -754,7 +827,6 @@ export async function runTriage(input) {
754
827
  if (closedPrs.length)
755
828
  await writeJson(join(outputDir, "closed-prs.json"), closedPrs);
756
829
  await closeIssue(input.exec, input.repository, issue.number, triage.account);
757
- await removeIssueLabels(input.exec, input.repository, issue.number, triage.automation.clear, triage.account);
758
830
  }
759
831
  const report = `Magi triage closed #${issue.number} because a related PR was merged.`;
760
832
  await writeFile(join(outputDir, "report.md"), `${report}\n`);
@@ -305,7 +305,7 @@ export function parseCiClassificationOutput(text) {
305
305
  }),
306
306
  };
307
307
  }
308
- export function parseEditOutput(text) {
308
+ function parseEditOutputWithOptions(text, options) {
309
309
  const data = extractJson(text);
310
310
  if (!data || typeof data !== "object")
311
311
  throw new Error("edit output must be an object");
@@ -323,7 +323,7 @@ export function parseEditOutput(text) {
323
323
  commentId: requireNumber(value.commentId, `responses[${index}].commentId`),
324
324
  };
325
325
  });
326
- if (!responses.length)
326
+ if (options.requireResponses && !responses.length)
327
327
  throw new Error("responses must not be empty");
328
328
  if (data.mode === "EDITED") {
329
329
  if (!filesTouched.length)
@@ -351,3 +351,9 @@ export function parseEditOutput(text) {
351
351
  responses,
352
352
  };
353
353
  }
354
+ export function parseEditOutput(text) {
355
+ return parseEditOutputWithOptions(text, { requireResponses: true });
356
+ }
357
+ export function parseTriageCreatePrOutput(text) {
358
+ return parseEditOutputWithOptions(text, { requireResponses: false });
359
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260520163717",
3
+ "version": "0.0.0-dev-20260520165420",
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>",