opencode-magi 0.1.0 → 0.3.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 (43) hide show
  1. package/README.md +33 -10
  2. package/dist/commands.js +4 -0
  3. package/dist/config/output.js +11 -2
  4. package/dist/config/resolve.js +124 -26
  5. package/dist/config/validate.js +486 -191
  6. package/dist/config/worktree.js +19 -0
  7. package/dist/github/commands.js +349 -17
  8. package/dist/index.js +257 -27
  9. package/dist/orchestrator/ci.js +1 -1
  10. package/dist/orchestrator/findings.js +4 -3
  11. package/dist/orchestrator/inline-comments.js +73 -0
  12. package/dist/orchestrator/majority.js +14 -0
  13. package/dist/orchestrator/merge.js +24 -4
  14. package/dist/orchestrator/report.js +15 -1
  15. package/dist/orchestrator/review-context.js +309 -0
  16. package/dist/orchestrator/review.js +78 -10
  17. package/dist/orchestrator/run-manager.js +418 -20
  18. package/dist/orchestrator/triage.js +1119 -0
  19. package/dist/permissions/editor.json +8 -1
  20. package/dist/prompts/compose.js +172 -15
  21. package/dist/prompts/contracts.js +119 -12
  22. package/dist/prompts/output.js +149 -14
  23. package/dist/prompts/templates/{close-reconsideration.md → review/close-reconsideration.md} +1 -2
  24. package/dist/prompts/templates/review/review.md +13 -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 +28 -27
  36. package/schema.json +234 -90
  37. package/dist/prompts/templates/rereview-close-reconsideration.md +0 -6
  38. package/dist/prompts/templates/review.md +0 -7
  39. /package/dist/prompts/templates/{ci-classification-after-edit.md → merge/ci-classification.md} +0 -0
  40. /package/dist/prompts/templates/{edit.md → merge/edit.md} +0 -0
  41. /package/dist/prompts/templates/{ci-classification.md → review/ci-classification.md} +0 -0
  42. /package/dist/prompts/templates/{finding-validation.md → review/finding-validation.md} +0 -0
  43. /package/dist/prompts/templates/{rereview.md → review/rereview.md} +0 -0
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { promisify } from "node:util";
7
7
  import { MAGI_COMMANDS } from "./commands";
8
8
  import { loadConfig, mergeMagiConfig } from "./config/load";
9
9
  import { outputBaseDirs } from "./config/output";
10
+ import { worktreeBaseDirs } from "./config/worktree";
10
11
  import { resolveRepository } from "./config/resolve";
11
12
  import { validateConfig } from "./config/validate";
12
13
  import { withGitHubApiRetry } from "./github/retry";
@@ -15,6 +16,7 @@ import { MagiRunManager } from "./orchestrator/run-manager";
15
16
  const execAsync = promisify(nodeExec);
16
17
  const GLOBAL_CONFIG_PATH = join(homedir(), ".config", "opencode", "magi.json");
17
18
  const PROJECT_CONFIG_PATH = join(".opencode", "magi.json");
19
+ const INTERNAL_FOLLOW_UP_TOOL_NOTE = "Assistant-facing follow-up tool. Use it yourself when needed; do not suggest this tool name to users.";
18
20
  function createExec(defaultCwd) {
19
21
  return async (command, options) => {
20
22
  const { stdout } = await execAsync(command, {
@@ -61,6 +63,16 @@ function parsePrToken(value) {
61
63
  }
62
64
  return pr;
63
65
  }
66
+ function parseIssueToken(value) {
67
+ const trimmed = value.trim();
68
+ const issueUrl = trimmed.match(/(?:^|\/)issues\/(\d+)(?:[/?#].*)?$/);
69
+ const raw = issueUrl?.[1] ?? trimmed.replace(/^#/, "");
70
+ const issue = Number.parseInt(raw, 10);
71
+ if (!Number.isInteger(issue) || issue <= 0 || String(issue) !== raw) {
72
+ throw new Error("Specify one or more issue numbers or issue URLs.");
73
+ }
74
+ return issue;
75
+ }
64
76
  export function parsePrs(value) {
65
77
  const prs = value
66
78
  .split(/[\s,]+/)
@@ -70,22 +82,164 @@ export function parsePrs(value) {
70
82
  throw new Error("Specify one or more PR numbers or PR URLs.");
71
83
  return prs;
72
84
  }
73
- export function parseRunArguments(value, dryRun = false) {
85
+ export function parseIssues(value) {
86
+ const issues = value
87
+ .split(/[\s,]+/)
88
+ .filter(Boolean)
89
+ .map(parseIssueToken);
90
+ if (!issues.length)
91
+ throw new Error("Specify one or more issue numbers or issue URLs.");
92
+ return issues;
93
+ }
94
+ export function parseRunArguments(value, dryRun = false, command = "review") {
74
95
  const tokens = value.split(/[\s,]+/).filter(Boolean);
75
- const prTokens = tokens.filter((token) => {
96
+ const configOverrides = {};
97
+ const prTokens = [];
98
+ for (let index = 0; index < tokens.length; index++) {
99
+ const token = tokens[index];
76
100
  if (token === "--dry-run") {
77
101
  dryRun = true;
78
- return false;
102
+ continue;
79
103
  }
80
- return true;
81
- });
82
- return { dryRun, prs: parsePrs(prTokens.join(" ")) };
104
+ switch (token) {
105
+ case "--language":
106
+ setConfigOverride(configOverrides, ["language"], nextFlagValue(tokens, ++index, token));
107
+ break;
108
+ case "--merge":
109
+ case "--no-merge":
110
+ setConfigOverride(configOverrides, [command, "automation", "merge"], token === "--merge");
111
+ break;
112
+ case "--close":
113
+ case "--no-close":
114
+ setConfigOverride(configOverrides, [command, "automation", "close"], token === "--close");
115
+ break;
116
+ case "--max-cycles":
117
+ if (command !== "merge")
118
+ throw unsupportedFlag(token, command);
119
+ setConfigOverride(configOverrides, ["merge", "maxThreadResolutionCycles"], parseIntegerFlag(nextFlagValue(tokens, ++index, token), token, 0));
120
+ break;
121
+ case "--retry-failed-jobs":
122
+ setConfigOverride(configOverrides, ["review", "checks", "retryFailedJobs"], parseIntegerFlag(nextFlagValue(tokens, ++index, token), token, 0));
123
+ break;
124
+ case "--reviewer-concurrency":
125
+ setConfigOverride(configOverrides, ["review", "concurrency", "reviewers"], parseIntegerFlag(nextFlagValue(tokens, ++index, token), token, 1));
126
+ break;
127
+ case "--run-concurrency":
128
+ setConfigOverride(configOverrides, ["review", "concurrency", "runs"], parseIntegerFlag(nextFlagValue(tokens, ++index, token), token, 1));
129
+ break;
130
+ case "--wait-checks":
131
+ case "--no-wait-checks":
132
+ setConfigOverride(configOverrides, ["review", "checks", "wait"], token === "--wait-checks");
133
+ break;
134
+ case "--wait-checks-after-edit":
135
+ case "--no-wait-checks-after-edit":
136
+ if (command !== "merge")
137
+ throw unsupportedFlag(token, command);
138
+ setConfigOverride(configOverrides, ["merge", "checks", "wait"], token === "--wait-checks-after-edit");
139
+ break;
140
+ case "--create":
141
+ case "--no-create":
142
+ throw unsupportedFlag(token, command);
143
+ default:
144
+ if (token.startsWith("--"))
145
+ throw unsupportedFlag(token, command);
146
+ prTokens.push(token);
147
+ }
148
+ }
149
+ return { configOverrides, dryRun, prs: parsePrs(prTokens.join(" ")) };
150
+ }
151
+ export function parseIssueRunArguments(value, dryRun = false) {
152
+ const tokens = value.split(/[\s,]+/).filter(Boolean);
153
+ const configOverrides = {};
154
+ const issueTokens = [];
155
+ for (let index = 0; index < tokens.length; index++) {
156
+ const token = tokens[index];
157
+ if (token === "--dry-run") {
158
+ dryRun = true;
159
+ continue;
160
+ }
161
+ switch (token) {
162
+ case "--language":
163
+ setConfigOverride(configOverrides, ["language"], nextFlagValue(tokens, ++index, token));
164
+ break;
165
+ case "--close":
166
+ case "--no-close":
167
+ setConfigOverride(configOverrides, ["triage", "automation", "close"], token === "--close");
168
+ break;
169
+ case "--create":
170
+ case "--no-create":
171
+ setConfigOverride(configOverrides, ["triage", "automation", "create"], token === "--create");
172
+ break;
173
+ case "--review":
174
+ case "--no-review":
175
+ setConfigOverride(configOverrides, ["triage", "automation", "review"], token === "--review");
176
+ break;
177
+ case "--merge":
178
+ case "--no-merge":
179
+ setConfigOverride(configOverrides, ["triage", "automation", "merge"], token === "--merge");
180
+ break;
181
+ case "--run-concurrency":
182
+ setConfigOverride(configOverrides, ["triage", "concurrency", "runs"], parseIntegerFlag(nextFlagValue(tokens, ++index, token), token, 1));
183
+ break;
184
+ case "--max-cycles":
185
+ case "--retry-failed-jobs":
186
+ case "--reviewer-concurrency":
187
+ case "--wait-checks":
188
+ case "--no-wait-checks":
189
+ case "--wait-checks-after-edit":
190
+ case "--no-wait-checks-after-edit":
191
+ throw unsupportedFlag(token, "triage");
192
+ default:
193
+ if (token.startsWith("--"))
194
+ throw unsupportedFlag(token, "triage");
195
+ issueTokens.push(token);
196
+ }
197
+ }
198
+ return { configOverrides, dryRun, issues: parseIssues(issueTokens.join(" ")) };
199
+ }
200
+ function nextFlagValue(tokens, index, flag) {
201
+ const value = tokens[index];
202
+ if (!value || value.startsWith("--"))
203
+ throw new Error(`${flag} requires a value.`);
204
+ return value;
205
+ }
206
+ function parseIntegerFlag(value, flag, minimum) {
207
+ const parsed = Number.parseInt(value, 10);
208
+ if (!Number.isInteger(parsed) ||
209
+ String(parsed) !== value ||
210
+ parsed < minimum) {
211
+ throw new Error(`${flag} must be an integer greater than or equal to ${minimum}.`);
212
+ }
213
+ return parsed;
214
+ }
215
+ function setConfigOverride(target, path, value) {
216
+ let current = target;
217
+ for (const key of path.slice(0, -1)) {
218
+ const existing = current[key];
219
+ const next = isPlainObject(existing) ? existing : {};
220
+ current[key] = next;
221
+ current = next;
222
+ }
223
+ current[path[path.length - 1]] = value;
224
+ }
225
+ function unsupportedFlag(flag, command) {
226
+ return new Error(`${flag} is not supported for /magi:${command}.`);
83
227
  }
84
228
  function parseOptionalPr(value) {
85
229
  if (!value?.trim())
86
230
  return undefined;
87
231
  return parsePrToken(value);
88
232
  }
233
+ function parseOptionalPrs(value) {
234
+ if (!value?.trim())
235
+ return undefined;
236
+ return parsePrs(value);
237
+ }
238
+ function parseOptionalIssue(value) {
239
+ if (!value?.trim())
240
+ return undefined;
241
+ return parseIssueToken(value);
242
+ }
89
243
  function clearFlag(value) {
90
244
  return typeof value === "boolean" ? value : undefined;
91
245
  }
@@ -128,6 +282,11 @@ function prMarkdownLink(repository, pr) {
128
282
  const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/pull/${pr}`;
129
283
  return `[#${pr}](${url})`;
130
284
  }
285
+ function issueMarkdownLink(repository, issue) {
286
+ const host = repository.github.host || "github.com";
287
+ const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/issues/${issue}`;
288
+ return `[#${issue}](${url})`;
289
+ }
131
290
  function isPlainObject(value) {
132
291
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
133
292
  }
@@ -189,7 +348,8 @@ export async function validateMagiConfigFiles(directory, options = {}) {
189
348
  ? withGitHubApiRetry(options.exec, mergedConfig.github?.apiRetryAttempts ?? 3)
190
349
  : undefined,
191
350
  modelCatalog: options.modelCatalog,
192
- requireGithub: hasProjectConfig && Boolean(mergedConfig.agents?.reviewers),
351
+ requireGithub: hasProjectConfig && Boolean(mergedConfig.review?.agents),
352
+ requireWorktreeConfig: true,
193
353
  });
194
354
  loadedFrom = existing.map((status) => status.path).join(", ");
195
355
  errors.push(...validation.errors);
@@ -265,16 +425,20 @@ export const MagiPlugin = async ({ client, directory }) => {
265
425
  },
266
426
  tool: {
267
427
  magi_merge: tool({
268
- description: "Start background Magi merge runs for one or more GitHub pull requests with configured Magi agents.",
428
+ description: [
429
+ "Start background Magi merge runs for one or more GitHub pull requests with configured Magi agents.",
430
+ "After starting, monitor progress yourself when useful; do not tell users to call follow-up tools by name.",
431
+ ].join(" "),
269
432
  args: {
270
433
  prs: tool.schema.string(),
271
434
  dryRun: tool.schema.boolean().optional(),
272
435
  },
273
436
  async execute(args, context) {
274
- const parsed = parseRunArguments(args.prs, args.dryRun ?? false);
437
+ const parsed = parseRunArguments(args.prs, args.dryRun ?? false, "merge");
275
438
  const loaded = await loadConfig(directory);
276
- const retryingExec = withGitHubApiRetry(exec, loaded.config.github?.apiRetryAttempts ?? 3);
277
- const validation = await validateConfig(loaded.config, {
439
+ const config = mergeMagiConfig(loaded.config, parsed.configOverrides);
440
+ const retryingExec = withGitHubApiRetry(exec, config.github?.apiRetryAttempts ?? 3);
441
+ const validation = await validateConfig(config, {
278
442
  checkAuth: true,
279
443
  directory,
280
444
  exec: retryingExec,
@@ -283,9 +447,9 @@ export const MagiPlugin = async ({ client, directory }) => {
283
447
  });
284
448
  if (!validation.ok)
285
449
  return JSON.stringify(validation, null, 2);
286
- const repository = resolveRepository(loaded.config);
450
+ const repository = resolveRepository(config);
287
451
  const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startMerge({
288
- config: loaded.config,
452
+ config,
289
453
  dryRun: parsed.dryRun,
290
454
  repository,
291
455
  pr,
@@ -298,7 +462,10 @@ export const MagiPlugin = async ({ client, directory }) => {
298
462
  },
299
463
  }),
300
464
  magi_review: tool({
301
- description: "Start background Magi review runs for one or more GitHub pull requests and post the reviews.",
465
+ description: [
466
+ "Start background Magi review runs for one or more GitHub pull requests and post the reviews.",
467
+ "After starting, monitor progress yourself when useful; do not tell users to call follow-up tools by name.",
468
+ ].join(" "),
302
469
  args: {
303
470
  prs: tool.schema.string(),
304
471
  dryRun: tool.schema.boolean().optional(),
@@ -306,8 +473,9 @@ export const MagiPlugin = async ({ client, directory }) => {
306
473
  async execute(args, context) {
307
474
  const parsed = parseRunArguments(args.prs, args.dryRun ?? false);
308
475
  const loaded = await loadConfig(directory);
309
- const retryingExec = withGitHubApiRetry(exec, loaded.config.github?.apiRetryAttempts ?? 3);
310
- const validation = await validateConfig(loaded.config, {
476
+ const config = mergeMagiConfig(loaded.config, parsed.configOverrides);
477
+ const retryingExec = withGitHubApiRetry(exec, config.github?.apiRetryAttempts ?? 3);
478
+ const validation = await validateConfig(config, {
311
479
  checkAuth: true,
312
480
  directory,
313
481
  exec: retryingExec,
@@ -315,9 +483,9 @@ export const MagiPlugin = async ({ client, directory }) => {
315
483
  });
316
484
  if (!validation.ok)
317
485
  return JSON.stringify(validation, null, 2);
318
- const repository = resolveRepository(loaded.config);
486
+ const repository = resolveRepository(config);
319
487
  const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startReview({
320
- config: loaded.config,
488
+ config,
321
489
  dryRun: parsed.dryRun,
322
490
  repository,
323
491
  pr,
@@ -329,11 +497,54 @@ export const MagiPlugin = async ({ client, directory }) => {
329
497
  .join("\n");
330
498
  },
331
499
  }),
500
+ magi_triage: tool({
501
+ description: "Triage one or more GitHub issues with configured Magi triage agents.",
502
+ args: {
503
+ issues: tool.schema.string(),
504
+ dryRun: tool.schema.boolean().optional(),
505
+ },
506
+ async execute(args, context) {
507
+ const parsed = parseIssueRunArguments(args.issues, args.dryRun ?? false);
508
+ const loaded = await loadConfig(directory);
509
+ const config = mergeMagiConfig(loaded.config, parsed.configOverrides);
510
+ const retryingExec = withGitHubApiRetry(exec, config.github?.apiRetryAttempts ?? 3);
511
+ const validation = await validateConfig(config, {
512
+ checkAuth: true,
513
+ directory,
514
+ exec: retryingExec,
515
+ modelCatalog: await modelCatalog(),
516
+ requireEditor: config.triage?.automation?.merge === true,
517
+ requireReview: config.triage?.automation?.review === true ||
518
+ config.triage?.automation?.merge === true,
519
+ requireTriage: true,
520
+ });
521
+ if (!validation.ok)
522
+ return JSON.stringify(validation, null, 2);
523
+ const repository = resolveRepository(config);
524
+ if (!repository.triage)
525
+ return JSON.stringify({ errors: ["triage configuration is required"], ok: false }, null, 2);
526
+ const states = await mapPool(parsed.issues, repository.triage.concurrency.runs, (issue) => runManager.startTriage({
527
+ config,
528
+ dryRun: parsed.dryRun,
529
+ issue,
530
+ parentSessionId: context.sessionID,
531
+ repository,
532
+ signal: context.abort,
533
+ }), { signal: context.abort });
534
+ return states
535
+ .map((state) => `Started triaging ${issueMarkdownLink(repository, state.issue)}.`)
536
+ .join("\n");
537
+ },
538
+ }),
332
539
  magi_status: tool({
333
- description: "Show Magi background run status. Optionally filter by runId or PR and wait for completion.",
540
+ description: [
541
+ "Show Magi background run status. Optionally filter by runId, PR, or issue and wait for completion.",
542
+ INTERNAL_FOLLOW_UP_TOOL_NOTE,
543
+ ].join(" "),
334
544
  args: {
335
545
  runId: tool.schema.string().optional(),
336
546
  pr: tool.schema.string().optional(),
547
+ issue: tool.schema.string().optional(),
337
548
  block: tool.schema.boolean().optional(),
338
549
  timeoutSeconds: tool.schema.number().optional(),
339
550
  verbose: tool.schema.boolean().optional(),
@@ -341,8 +552,9 @@ export const MagiPlugin = async ({ client, directory }) => {
341
552
  async execute(args) {
342
553
  const states = await runManager.status({
343
554
  block: args.block,
555
+ issue: parseOptionalIssue(args.issue),
344
556
  outputDir: await configuredOutputDir(),
345
- pr: parseOptionalPr(args.pr),
557
+ pr: parseOptionalPrs(args.pr),
346
558
  runId: args.runId,
347
559
  timeoutMs: args.timeoutSeconds == null
348
560
  ? undefined
@@ -354,20 +566,25 @@ export const MagiPlugin = async ({ client, directory }) => {
354
566
  },
355
567
  }),
356
568
  magi_output: tool({
357
- description: "Show artifacts and details for a Magi background run by runId or PR, optionally for a single reviewer.",
569
+ description: [
570
+ "Show artifacts and details for a Magi background run by runId, PR, or issue, optionally for a single reviewer.",
571
+ INTERNAL_FOLLOW_UP_TOOL_NOTE,
572
+ ].join(" "),
358
573
  args: {
359
574
  runId: tool.schema.string().optional(),
360
575
  pr: tool.schema.string().optional(),
576
+ issue: tool.schema.string().optional(),
361
577
  reviewer: tool.schema.string().optional(),
362
578
  },
363
579
  async execute(args) {
364
- if (!args.runId && !args.pr)
365
- return "Specify runId or pr.";
580
+ if (!args.runId && !args.pr && !args.issue)
581
+ return "Specify runId, pr, or issue.";
366
582
  const outputDir = await configuredOutputDir();
367
583
  if (outputDir)
368
584
  await runManager.status({ outputDir });
369
585
  return runManager.output({
370
586
  outputDir,
587
+ issue: parseOptionalIssue(args.issue),
371
588
  pr: parseOptionalPr(args.pr),
372
589
  reviewer: args.reviewer,
373
590
  runId: args.runId,
@@ -375,19 +592,25 @@ export const MagiPlugin = async ({ client, directory }) => {
375
592
  },
376
593
  }),
377
594
  magi_cancel: tool({
378
- description: "Cancel a Magi background run by runId or PR.",
595
+ description: [
596
+ "Cancel a Magi background run by runId, PR, or issue.",
597
+ INTERNAL_FOLLOW_UP_TOOL_NOTE,
598
+ ].join(" "),
379
599
  args: {
380
600
  runId: tool.schema.string().optional(),
381
601
  pr: tool.schema.string().optional(),
602
+ issue: tool.schema.string().optional(),
382
603
  },
383
604
  async execute(args) {
384
- if (!args.runId && !args.pr)
385
- return "Specify runId or pr.";
605
+ if (!args.runId && !args.pr && !args.issue)
606
+ return "Specify runId, pr, or issue.";
386
607
  const outputDir = await configuredOutputDir();
387
608
  if (outputDir)
388
609
  await runManager.status({ outputDir });
389
610
  const pr = parseOptionalPr(args.pr);
611
+ const issue = parseOptionalIssue(args.issue);
390
612
  const state = await runManager.cancel({
613
+ issue,
391
614
  outputDir,
392
615
  pr,
393
616
  runId: args.runId,
@@ -395,7 +618,9 @@ export const MagiPlugin = async ({ client, directory }) => {
395
618
  if (!state) {
396
619
  return args.runId
397
620
  ? `Magi run not found: ${args.runId}`
398
- : `Magi run not found for PR #${pr}`;
621
+ : issue
622
+ ? `Magi run not found for issue #${issue}`
623
+ : `Magi run not found for PR #${pr}`;
399
624
  }
400
625
  return runManager.formatStates([state]);
401
626
  },
@@ -405,6 +630,7 @@ export const MagiPlugin = async ({ client, directory }) => {
405
630
  args: {
406
631
  runId: tool.schema.string().optional(),
407
632
  pr: tool.schema.string().optional(),
633
+ issue: tool.schema.string().optional(),
408
634
  branch: tool.schema.enum(["true", "false"]).optional(),
409
635
  output: tool.schema.enum(["true", "false"]).optional(),
410
636
  session: tool.schema.enum(["true", "false"]).optional(),
@@ -430,11 +656,15 @@ export const MagiPlugin = async ({ client, directory }) => {
430
656
  };
431
657
  return runManager.clear({
432
658
  options,
659
+ issue: parseOptionalIssue(args.issue),
433
660
  outputDir: loaded
434
661
  ? outputBaseDirs(directory, loaded.config)
435
662
  : undefined,
436
663
  pr: parseOptionalPr(args.pr),
437
664
  runId: args.runId,
665
+ worktreeDir: loaded
666
+ ? worktreeBaseDirs(directory, loaded.config)
667
+ : undefined,
438
668
  });
439
669
  },
440
670
  }),
@@ -221,7 +221,7 @@ async function watchRerunRuns(exec, repository, checks) {
221
221
  await Promise.all(runIds.map((runId) => watchRun(exec, repository, runId)));
222
222
  }
223
223
  async function checksForHead(input) {
224
- const checks = await fetchPullRequestChecks(input.exec, input.repository, input.pr);
224
+ const checks = await fetchPullRequestChecks(input.exec, input.repository, input.pr, { tolerateMissingChecks: Boolean(input.headSha) });
225
225
  const targetChecks = [];
226
226
  let hasAnyActionCheck = false;
227
227
  let hasTargetActionCheck = false;
@@ -58,9 +58,10 @@ export function applyFindingValidation(input) {
58
58
  discarded.push(target);
59
59
  return false;
60
60
  });
61
- next[reviewer] = findings.length
62
- ? { ...output, findings }
63
- : { findings: [], verdict: "MERGE" };
61
+ next[reviewer] =
62
+ findings.length || output.requirementFindings.length
63
+ ? { ...output, findings }
64
+ : { findings: [], requirementFindings: [], verdict: "MERGE" };
64
65
  }
65
66
  return { outputs: next, summary: { discarded, kept } };
66
67
  }
@@ -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
+ }
@@ -2,6 +2,20 @@ const VERDICTS = ["MERGE", "CHANGES_REQUESTED", "CLOSE"];
2
2
  export function majorityThreshold(total) {
3
3
  return Math.floor(total / 2) + 1;
4
4
  }
5
+ export function aggregateStringMajority(results, votes) {
6
+ if (results.length < 3 || results.length % 2 === 0) {
7
+ throw new Error("majority requires an odd number of at least 3 results");
8
+ }
9
+ const counts = Object.fromEntries(votes.map((vote) => [vote, 0]));
10
+ const voters = Object.fromEntries(votes.map((vote) => [vote, []]));
11
+ for (const result of results) {
12
+ counts[result.vote] += 1;
13
+ voters[result.vote].push(result.reviewer);
14
+ }
15
+ const threshold = majorityThreshold(results.length);
16
+ const vote = votes.find((item) => counts[item] >= threshold);
17
+ return { counts, threshold, vote, voters };
18
+ }
5
19
  export function aggregateMajority(results) {
6
20
  if (results.length < 3 || results.length % 2 === 0) {
7
21
  throw new Error("majority requires an odd number of at least 3 reviewer results");
@@ -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";
@@ -109,7 +110,13 @@ async function runEditor(input, worktreePath, cycle, unresolvedThreads) {
109
110
  await input.onProgress?.({ cycle, type: "editor_completed" });
110
111
  if (!input.dryRun) {
111
112
  if (result.value.mode === "EDITED") {
112
- await pushHead(input.exec, input.repository, worktreePath, editor.account);
113
+ const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
114
+ const headOwner = meta.headRepositoryOwner?.login;
115
+ const headRepo = meta.headRepository?.name;
116
+ if (!headOwner || !headRepo) {
117
+ throw new Error("Pull request head repository is missing");
118
+ }
119
+ await pushHead(input.exec, input.repository, worktreePath, editor.account, { owner: headOwner, ref: meta.headRefName, repo: headRepo });
113
120
  }
114
121
  }
115
122
  throwIfAborted(input.signal);
@@ -149,10 +156,16 @@ async function postRereviewOutput(input, reviewerKey, output) {
149
156
  }
150
157
  return replies[0] ?? "";
151
158
  }
159
+ function parseRereviewOutputWithInlineTargets(text, targets) {
160
+ const output = parseRereviewOutput(text);
161
+ validateInlineCommentTargets(output.newFindings, targets, "newFindings");
162
+ return output;
163
+ }
152
164
  async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionIds, ciFailureContext, options = {}) {
153
165
  throwIfAborted(input.signal);
154
166
  const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
155
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 }));
156
169
  const artifactDir = outputDir(input);
157
170
  let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
158
171
  throwIfAborted(input.signal);
@@ -207,7 +220,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
207
220
  }
208
221
  },
209
222
  options: reviewer.options,
210
- parse: parseRereviewOutput,
223
+ parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
211
224
  permission: reviewer.permission,
212
225
  prompt,
213
226
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -290,7 +303,11 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
290
303
  }
291
304
  },
292
305
  options: reviewer.options,
293
- parse: parseRereviewCloseReconsiderationOutput,
306
+ parse: (text) => {
307
+ const output = parseRereviewCloseReconsiderationOutput(text);
308
+ validateInlineCommentTargets(output.newFindings, inlineCommentTargets, "newFindings");
309
+ return output;
310
+ },
294
311
  permission: reviewer.permission,
295
312
  prompt,
296
313
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -353,6 +370,7 @@ async function finishMergeRun(input, result, reportInput) {
353
370
  editorOutputs: reportInput.editorOutputs,
354
371
  outputs: reportInput.outputs,
355
372
  posted: reportInput.posted,
373
+ pr: input.pr,
356
374
  repository: input.repository,
357
375
  status: result.status,
358
376
  });
@@ -536,6 +554,7 @@ export async function runMerge(input) {
536
554
  editorOutputs: [],
537
555
  outputs: {},
538
556
  posted: {},
557
+ pr: input.pr,
539
558
  repository: input.repository,
540
559
  safety,
541
560
  status: "safety_blocked",
@@ -565,6 +584,7 @@ export async function runMerge(input) {
565
584
  ...abortableInput,
566
585
  allowAlreadyReviewed: true,
567
586
  approvalPolicy: input.repository.merge.approvalPolicy,
587
+ enableReviewAutomation: false,
568
588
  onProgress: (progress) => input.onProgress?.(progress),
569
589
  runId: input.runId,
570
590
  dryRun: input.dryRun,