opencode-magi 0.5.0 → 0.6.1

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.
@@ -176,7 +176,15 @@ function ciFailureContextForClassified(items, classified) {
176
176
  return "";
177
177
  return [
178
178
  "CI has scope-in failures that may be caused by this PR.",
179
- "Use this as a review hint; still inspect the PR diff before reporting findings.",
179
+ "Treat these failures as blocking review issues until the checks pass.",
180
+ [
181
+ "Do not approve this PR while this ci_failure_context is present.",
182
+ "Return CHANGES_REQUESTED and include a finding for each failing CI check.",
183
+ ].join(" "),
184
+ [
185
+ "Still inspect the PR diff before reporting findings.",
186
+ "If a CI failure does not map to an exact changed line, anchor the finding to the nearest responsible or first relevant changed line.",
187
+ ].join(" "),
180
188
  "",
181
189
  ...sections,
182
190
  ].join("\n\n");
@@ -221,7 +229,7 @@ async function watchRerunRuns(exec, repository, checks) {
221
229
  await Promise.all(runIds.map((runId) => watchRun(exec, repository, runId)));
222
230
  }
223
231
  async function checksForHead(input) {
224
- const checks = await fetchPullRequestChecks(input.exec, input.repository, input.pr, { tolerateMissingChecks: Boolean(input.headSha) });
232
+ const checks = await fetchPullRequestChecks(input.exec, input.repository, input.pr, { requiredOnly: true, tolerateMissingChecks: Boolean(input.headSha) });
225
233
  const targetChecks = [];
226
234
  let hasAnyActionCheck = false;
227
235
  let hasTargetActionCheck = false;
@@ -246,7 +254,6 @@ async function checksForHead(input) {
246
254
  return {
247
255
  blocking: targetChecks.filter((check) => isFailedCheck(check) || isCancelledCheck(check)),
248
256
  hasAnyActionCheck,
249
- hasAnyCheck: checks.length > 0,
250
257
  hasPending: targetChecks.some(isPendingCheck),
251
258
  hasTargetActionCheck,
252
259
  };
@@ -343,6 +350,7 @@ async function classifyChecks(input) {
343
350
  }
344
351
  },
345
352
  options: reviewer.options,
353
+ parentSessionId: input.parentSessionId,
346
354
  parse: (text) => {
347
355
  const output = parseCiClassificationOutput(text);
348
356
  for (const check of output.checks) {
@@ -366,18 +374,20 @@ async function classifyChecks(input) {
366
374
  const rawPath = input.outputDir
367
375
  ? join(input.outputDir, `${reviewer.key}.ci-classification.raw.txt`)
368
376
  : undefined;
369
- const check = result.value.checks[0];
377
+ const checks = result.value.checks.map((check) => ({
378
+ classification: check.classification,
379
+ name: check.name,
380
+ reason: check.reason,
381
+ }));
370
382
  if (rawPath)
371
383
  await writeFile(rawPath, result.raw);
372
- run.classification = check?.classification;
384
+ run.checks = checks;
373
385
  run.rawPath = rawPath;
374
- run.reason = check?.reason;
375
386
  run.sessionId = result.sessionId;
376
387
  run.status = "completed";
377
388
  await input.onClassifierProgress?.({
378
- classification: check?.classification ?? "SCOPE_IN",
389
+ checks,
379
390
  rawPath,
380
- reason: check?.reason ?? "No classification reason was provided.",
381
391
  reviewer: reviewer.key,
382
392
  sessionId: result.sessionId,
383
393
  type: "classifier_completed",
@@ -392,22 +402,20 @@ async function classifyChecks(input) {
392
402
  reviewer: reviewer.key,
393
403
  type: "classifier_failed",
394
404
  });
395
- return { reviewer: reviewer.key, output: undefined };
405
+ throw error;
396
406
  }
397
407
  }, { signal: input.signal });
398
408
  const threshold = majorityThreshold(reviewers.length);
399
409
  return {
400
410
  classified: input.checks.map((item) => {
401
- const successfulVotes = votes.filter((vote) => vote.output);
402
- const checkVotes = successfulVotes.map((vote) => {
403
- const check = vote.output?.checks.find((output) => output.name === item.check.name);
411
+ const checkVotes = votes.map((vote) => {
412
+ const check = vote.output.checks.find((output) => output.name === item.check.name);
404
413
  return {
405
414
  classification: check?.classification ?? "SCOPE_IN",
406
415
  reason: check?.reason ?? "Missing classification; treated as scope-in.",
407
416
  reviewer: vote.reviewer,
408
417
  };
409
418
  });
410
- const failures = votes.filter((vote) => !vote.output);
411
419
  const scopeIn = checkVotes.filter((vote) => vote.classification === "SCOPE_IN");
412
420
  const scopeOut = checkVotes.filter((vote) => vote.classification === "SCOPE_OUT");
413
421
  const classification = scopeOut.length >= threshold
@@ -421,9 +429,6 @@ async function classifyChecks(input) {
421
429
  const reasons = checkVotes
422
430
  .filter((vote) => vote.classification === classification)
423
431
  .map((vote) => `${vote.reviewer}: ${vote.reason}`);
424
- for (const failure of failures) {
425
- reasons.push(`${failure.reviewer}: classifier failed; vote ignored`);
426
- }
427
432
  return {
428
433
  check: item.check,
429
434
  classification,
@@ -457,15 +462,17 @@ export async function waitForChecksWithClassification(input) {
457
462
  await input.onProgress?.("waiting for CI checks");
458
463
  for (let attempt = 0;; attempt += 1) {
459
464
  try {
460
- await watchChecks(input.exec, input.repository, input.pr);
465
+ await watchChecks(input.exec, input.repository, input.pr, {
466
+ requiredOnly: true,
467
+ });
461
468
  }
462
469
  catch {
463
470
  // gh exits non-zero for pending checks too; re-read check state below.
464
471
  }
465
472
  const target = await readTargetChecks();
466
473
  const waitingForTargetHead = Boolean(input.headSha) &&
467
- (!target.hasAnyCheck ||
468
- (target.hasAnyActionCheck && !target.hasTargetActionCheck));
474
+ target.hasAnyActionCheck &&
475
+ !target.hasTargetActionCheck;
469
476
  if (!waitingForTargetHead && !target.hasPending) {
470
477
  await assignBlockingChecks(target.blocking);
471
478
  break;
@@ -505,6 +512,7 @@ export async function waitForChecksWithClassification(input) {
505
512
  directory: input.directory,
506
513
  onClassifierProgress: input.onClassifierProgress,
507
514
  outputDir: input.outputDir,
515
+ parentSessionId: input.parentSessionId,
508
516
  pr: input.pr,
509
517
  repairAttempts: input.repairAttempts,
510
518
  repository: input.repository,
@@ -545,8 +553,11 @@ export async function waitForChecksWithClassification(input) {
545
553
  try {
546
554
  await input.onProgress?.("waiting for rerun CI checks");
547
555
  await watchRerunRuns(input.exec, input.repository, rerunnable);
548
- if (input.wait)
549
- await watchChecks(input.exec, input.repository, input.pr);
556
+ if (input.wait) {
557
+ await watchChecks(input.exec, input.repository, input.pr, {
558
+ requiredOnly: true,
559
+ });
560
+ }
550
561
  }
551
562
  catch {
552
563
  // Re-read the PR checks below so stale failed checks are not trusted.
@@ -1,9 +1,20 @@
1
1
  import { majorityThreshold } from "./majority";
2
+ function validationFindings(output) {
3
+ if ("findings" in output)
4
+ return output.findings;
5
+ return output.newFindings.map((finding) => ({
6
+ fix: "Please address this before merging.",
7
+ issue: finding.body,
8
+ line: finding.line,
9
+ path: finding.path,
10
+ startLine: finding.startLine,
11
+ }));
12
+ }
2
13
  export function reviewFindingTargets(outputs) {
3
14
  return Object.entries(outputs).flatMap(([reviewer, output]) => {
4
15
  if (output.verdict !== "CHANGES_REQUESTED")
5
16
  return [];
6
- return output.findings.map((finding, findingIndex) => ({
17
+ return validationFindings(output).map((finding, findingIndex) => ({
7
18
  finding,
8
19
  findingIndex,
9
20
  reviewer,
@@ -41,7 +52,9 @@ export function applyFindingValidation(input) {
41
52
  next[reviewer] = output;
42
53
  continue;
43
54
  }
44
- const findings = output.findings.filter((finding, findingIndex) => {
55
+ const keptIndexes = new Set();
56
+ const findings = validationFindings(output);
57
+ findings.forEach((finding, findingIndex) => {
45
58
  let agrees = 1;
46
59
  for (const validator of input.reviewerKeys) {
47
60
  if (validator === reviewer)
@@ -53,14 +66,23 @@ export function applyFindingValidation(input) {
53
66
  const target = { finding, findingIndex, reviewer };
54
67
  if (agrees >= threshold) {
55
68
  kept.push(target);
56
- return true;
69
+ keptIndexes.add(findingIndex);
70
+ return;
57
71
  }
58
72
  discarded.push(target);
59
- return false;
60
73
  });
61
- next[reviewer] = findings.length
62
- ? { ...output, findings }
63
- : { findings: [], verdict: "MERGE" };
74
+ if ("findings" in output) {
75
+ const keptFindings = output.findings.filter((_finding, index) => keptIndexes.has(index));
76
+ next[reviewer] = keptFindings.length
77
+ ? { ...output, findings: keptFindings }
78
+ : { findings: [], verdict: "MERGE" };
79
+ continue;
80
+ }
81
+ const newFindings = output.newFindings.filter((_finding, index) => keptIndexes.has(index));
82
+ next[reviewer] =
83
+ newFindings.length || output.followUps.length
84
+ ? { ...output, newFindings }
85
+ : { ...output, newFindings, verdict: "MERGE" };
64
86
  }
65
87
  return { outputs: next, summary: { discarded, kept } };
66
88
  }
@@ -10,7 +10,7 @@ export function aggregateStringMajority(results, votes) {
10
10
  const voters = Object.fromEntries(votes.map((vote) => [vote, []]));
11
11
  for (const result of results) {
12
12
  counts[result.vote] += 1;
13
- voters[result.vote].push(result.reviewer);
13
+ voters[result.vote].push(result.voter);
14
14
  }
15
15
  const threshold = majorityThreshold(results.length);
16
16
  const vote = votes.find((item) => counts[item] >= threshold);
@@ -1,17 +1,17 @@
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, shellQuote, waitForMergeQueue, } from "../github/commands";
4
+ import { closePullRequest, configureGitIdentity, fetchMergeQueueRequirement, fetchPullRequest, fetchUnresolvedThreads, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, pushHead, removeWorktree, resolveThread, waitForAutoMerge, 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
+ import { validateInlineCommentTargets, } from "./inline-comments";
10
10
  import { closeMinorityReviewers, mergeVerdictForPolicy } from "./majority";
11
11
  import { runModelWithRepair } from "./model";
12
12
  import { mapPool } from "./pool";
13
13
  import { formatMergeReport } from "./report";
14
- import { runReview } from "./review";
14
+ import { inlineCommentTargetsForDiff, runReview, } from "./review";
15
15
  import { checkSafetyGate, hasSafetyGate } from "./safety";
16
16
  function outputDir(input) {
17
17
  return prRunOutputDir({
@@ -53,7 +53,7 @@ async function withReviewerFailureProgress(input) {
53
53
  async function runEditor(input, worktreePath, cycle, reviewFindings, unresolvedThreads) {
54
54
  const editor = input.repository.agents.editor;
55
55
  if (!editor)
56
- throw new Error("agents.editor is required for magi_merge");
56
+ throw new Error("merge.editor is required for magi_merge");
57
57
  throwIfAborted(input.signal);
58
58
  await configureGitIdentity(input.exec, worktreePath, {
59
59
  email: editor.author?.email,
@@ -96,6 +96,7 @@ async function runEditor(input, worktreePath, cycle, reviewFindings, unresolvedT
96
96
  }
97
97
  },
98
98
  options: editor.options,
99
+ parentSessionId: input.parentSessionId,
99
100
  parse: parseEditOutput,
100
101
  permission: editor.permission,
101
102
  prompt,
@@ -195,7 +196,21 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
195
196
  throwIfAborted(input.signal);
196
197
  const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
197
198
  const headSha = options.dryRunHeadSha ?? meta.headRefOid;
198
- const inlineCommentTargets = parseRightSideDiffTargets(await input.exec(`git diff --no-ext-diff --unified=3 ${shellQuote(meta.baseRefOid)} ${shellQuote(headSha)}`, { cwd: worktreePath }));
199
+ const inlineCommentTargets = await inlineCommentTargetsForDiff({
200
+ ensure: options.dryRunHeadSha
201
+ ? undefined
202
+ : {
203
+ fromSource: "base",
204
+ meta,
205
+ repository: input.repository,
206
+ toSource: "head",
207
+ },
208
+ exec: input.exec,
209
+ fromSha: meta.baseRefOid,
210
+ range: "direct",
211
+ toSha: headSha,
212
+ worktreePath,
213
+ });
199
214
  const artifactDir = outputDir(input);
200
215
  let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
201
216
  throwIfAborted(input.signal);
@@ -250,6 +265,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
250
265
  }
251
266
  },
252
267
  options: reviewer.options,
268
+ parentSessionId: input.parentSessionId,
253
269
  parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
254
270
  permission: reviewer.permission,
255
271
  prompt,
@@ -294,7 +310,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
294
310
  baseSha: meta.baseRefOid,
295
311
  closeReason: entry.output.reason,
296
312
  directory: input.directory,
297
- headSha: meta.headRefOid,
313
+ headSha,
298
314
  includeReviewGuidelines: !hasReviewerSession,
299
315
  includeSessionContext: !hasReviewerSession,
300
316
  pr: input.pr,
@@ -333,6 +349,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
333
349
  }
334
350
  },
335
351
  options: reviewer.options,
352
+ parentSessionId: input.parentSessionId,
336
353
  parse: (text) => {
337
354
  const output = parseRereviewCloseReconsiderationOutput(text);
338
355
  validateInlineCommentTargets(output.newFindings, inlineCommentTargets, "newFindings");
@@ -409,8 +426,11 @@ async function finishMergeRun(input, result, reportInput) {
409
426
  }
410
427
  async function mergeWithQueue(input, exec, editorAccount) {
411
428
  await mergePullRequest(exec, input.repository, input.pr, editorAccount);
412
- if (!input.repository.merge.mergeQueue)
413
- return "merged";
429
+ if (!input.repository.merge.mergeQueue) {
430
+ if (!input.repository.merge.auto)
431
+ return "merged";
432
+ return waitForAutoMerge(exec, input.repository, input.pr);
433
+ }
414
434
  return waitForMergeQueue(exec, input.repository, input.pr);
415
435
  }
416
436
  export function hasBlockingCiReports(reports) {
@@ -570,7 +590,7 @@ export async function runMerge(input) {
570
590
  const abortableInput = { ...input, exec };
571
591
  const editor = input.repository.agents.editor;
572
592
  if (!editor)
573
- throw new Error("agents.editor is required for magi_merge");
593
+ throw new Error("merge.editor is required for magi_merge");
574
594
  throwIfAborted(input.signal);
575
595
  const artifactDir = outputDir(input);
576
596
  await mkdir(artifactDir, { recursive: true });
@@ -701,7 +721,7 @@ export async function runMerge(input) {
701
721
  threads: unresolvedThreads,
702
722
  });
703
723
  const editorFindings = blockingReviewFindings(reportOutputs);
704
- const editableFindings = editableThreads.length ? editorFindings : [];
724
+ const editableFindings = editorFindings;
705
725
  const findingAttemptsExhausted = input.repository.merge.maxThreadResolutionCycles !== 0 &&
706
726
  cycle > input.repository.merge.maxThreadResolutionCycles;
707
727
  if (!editableThreads.length &&
@@ -781,6 +801,7 @@ export async function runMerge(input) {
781
801
  exec,
782
802
  headSha: editedHeadSha,
783
803
  onProgress: (phase) => input.onProgress?.({ phase, type: "phase" }),
804
+ parentSessionId: input.parentSessionId,
784
805
  pr: input.pr,
785
806
  repairAttempts: input.config.output?.repairAttempts ?? 3,
786
807
  repository: input.repository,
@@ -89,6 +89,7 @@ function extractText(result, allowEmpty = false) {
89
89
  export async function createModelSession(input) {
90
90
  return extractSessionId(await input.client.session.create({
91
91
  body: {
92
+ parentID: input.parentSessionId,
92
93
  permission: toOpenCodePermissionRules(input.permission),
93
94
  title: input.title,
94
95
  },
@@ -96,15 +97,26 @@ export async function createModelSession(input) {
96
97
  }
97
98
  export async function promptModelText(input) {
98
99
  throwIfAborted(input.signal);
99
- const result = await input.client.session.prompt({
100
- body: {
101
- model: modelBody(input.model),
102
- parts: [{ type: "text", text: input.prompt }],
103
- },
104
- path: { id: input.sessionId },
105
- });
106
- throwIfAborted(input.signal);
107
- return extractText(result, input.allowEmpty);
100
+ const abort = () => {
101
+ void input.client.session
102
+ .abort?.({ path: { id: input.sessionId } })
103
+ .catch(() => undefined);
104
+ };
105
+ input.signal?.addEventListener("abort", abort, { once: true });
106
+ try {
107
+ const result = await input.client.session.prompt({
108
+ body: {
109
+ model: modelBody(input.model),
110
+ parts: [{ type: "text", text: input.prompt }],
111
+ },
112
+ path: { id: input.sessionId },
113
+ });
114
+ throwIfAborted(input.signal);
115
+ return extractText(result, input.allowEmpty);
116
+ }
117
+ finally {
118
+ input.signal?.removeEventListener("abort", abort);
119
+ }
108
120
  }
109
121
  async function sendPrompt(client, sessionId, model, prompt, signal) {
110
122
  return promptModelText({ client, model, prompt, sessionId, signal });
@@ -113,6 +125,7 @@ export async function runModelText(input) {
113
125
  throwIfAborted(input.signal);
114
126
  const sessionId = await createModelSession({
115
127
  client: input.client,
128
+ parentSessionId: input.parentSessionId,
116
129
  permission: input.permission,
117
130
  title: input.title,
118
131
  });
@@ -152,6 +165,7 @@ export async function runModelWithRepair(input) {
152
165
  ? input.sessionId
153
166
  : extractSessionId(await input.client.session.create({
154
167
  body: {
168
+ parentID: input.parentSessionId,
155
169
  permission: toOpenCodePermissionRules(input.permission),
156
170
  title: input.title,
157
171
  },