opencode-magi 0.1.0 → 0.2.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.
@@ -12,25 +12,20 @@ const validateSchema = AJV.compile(schema);
12
12
  const CONFIG_KEYS = new Set([
13
13
  "$schema",
14
14
  "agents",
15
- "automation",
16
15
  "clear",
17
- "checks",
18
- "concurrency",
19
16
  "github",
20
17
  "language",
21
18
  "merge",
22
19
  "output",
23
- "prompts",
24
- "safety",
25
- "worktree",
20
+ "review",
26
21
  ]);
27
- const AGENTS_KEYS = new Set(["editor", "permissions", "reviewers"]);
22
+ const AGENTS_KEYS = new Set(["permissions"]);
28
23
  const REVIEWER_KEYS = new Set([
29
24
  "account",
30
25
  "id",
31
26
  "model",
32
27
  "options",
33
- "permission",
28
+ "permissions",
34
29
  "persona",
35
30
  ]);
36
31
  const EDITOR_KEYS = new Set([
@@ -38,50 +33,61 @@ const EDITOR_KEYS = new Set([
38
33
  "author",
39
34
  "model",
40
35
  "options",
41
- "permission",
36
+ "permissions",
42
37
  "persona",
43
38
  ]);
44
39
  const AUTHOR_KEYS = new Set(["email", "name"]);
45
40
  const GITHUB_KEYS = new Set(["apiRetryAttempts", "host", "owner", "repo"]);
41
+ const REVIEW_KEYS = new Set([
42
+ "agents",
43
+ "automation",
44
+ "checks",
45
+ "concurrency",
46
+ "merge",
47
+ "output",
48
+ "prompts",
49
+ "safety",
50
+ "worktree",
51
+ ]);
46
52
  const MERGE_KEYS = new Set([
53
+ "automation",
54
+ "checks",
55
+ "editor",
56
+ "maxThreadResolutionCycles",
57
+ "prompts",
58
+ ]);
59
+ const REVIEW_MERGE_KEYS = new Set([
47
60
  "approvalPolicy",
48
61
  "auto",
49
62
  "deleteBranch",
50
- "maxThreadResolutionCycles",
51
- "mergeQueue",
52
63
  "method",
64
+ "queue",
53
65
  ]);
54
- const CHECKS_KEYS = new Set([
55
- "exclude",
56
- "retryFailedJobs",
57
- "waitAfterEdit",
58
- "waitBeforeReview",
59
- ]);
66
+ const REVIEW_CHECKS_KEYS = new Set(["exclude", "retryFailedJobs", "wait"]);
67
+ const MERGE_CHECKS_KEYS = new Set(["wait"]);
60
68
  const AUTOMATION_KEYS = new Set(["close", "merge"]);
61
69
  const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
62
70
  const CONCURRENCY_KEYS = new Set(["reviewers", "runs"]);
63
- const OUTPUT_KEYS = new Set(["dirs", "repairAttempts"]);
64
- const OUTPUT_DIR_KEYS = new Set(["pr"]);
65
- const WORKTREE_KEYS = new Set(["dir"]);
71
+ const OUTPUT_KEYS = new Set(["repairAttempts"]);
66
72
  const SAFETY_KEYS = new Set([
67
73
  "allowAuthors",
68
74
  "blockedPaths",
69
75
  "maxChangedFiles",
70
76
  "requiredLabels",
71
77
  ]);
72
- const PROMPT_KEYS = new Set([
78
+ const REVIEW_PROMPT_KEYS = new Set([
73
79
  "ciClassification",
74
- "ciClassificationAfterEdit",
75
80
  "closeReconsideration",
76
- "edit",
77
- "editGuidelines",
78
81
  "findingValidation",
79
- "report",
80
82
  "rereview",
81
- "rereviewCloseReconsideration",
82
83
  "review",
83
84
  "reviewGuidelines",
84
85
  ]);
86
+ const MERGE_PROMPT_KEYS = new Set([
87
+ "ciClassification",
88
+ "edit",
89
+ "editGuidelines",
90
+ ]);
85
91
  function githubHost(config) {
86
92
  return config.github?.host ?? "github.com";
87
93
  }
@@ -210,7 +216,7 @@ function validateReviewerList(reviewers, path, errors, catalog) {
210
216
  validateString(reviewer.persona, `${path}[${index}].persona`, errors);
211
217
  if (reviewer.options != null && !isPlainObject(reviewer.options))
212
218
  errors.push(`${path}[${index}].options must be an object`);
213
- validatePermissionConfig(reviewer.permission, `${path}[${index}].permission`, errors);
219
+ validatePermissionConfig(reviewer.permissions, `${path}[${index}].permissions`, errors);
214
220
  if (reviewer.id) {
215
221
  if (!validateReviewerId(reviewer.id)) {
216
222
  errors.push(`${path}[${index}].id may contain only letters, numbers, underscores, and hyphens`);
@@ -233,7 +239,51 @@ function validateResolvedReviewers(reviewers, path, errors) {
233
239
  accounts.add(reviewer.account);
234
240
  }
235
241
  }
242
+ function validateEditor(editor, path, errors, catalog) {
243
+ if (!editor)
244
+ return;
245
+ if (!isPlainObject(editor)) {
246
+ errors.push(`${path} must be an object`);
247
+ return;
248
+ }
249
+ if (!editor.model)
250
+ errors.push(`${path}.model is required`);
251
+ validateKnownKeys(editor, path, EDITOR_KEYS, errors);
252
+ validateString(editor.model, `${path}.model`, errors);
253
+ validateString(editor.account, `${path}.account`, errors);
254
+ validateString(editor.persona, `${path}.persona`, errors);
255
+ validateModel(editor.model, `${path}.model`, errors, catalog);
256
+ if (!editor.account)
257
+ errors.push(`${path}.account is required`);
258
+ if (editor.options != null && !isPlainObject(editor.options)) {
259
+ errors.push(`${path}.options must be an object`);
260
+ }
261
+ validatePermissionConfig(editor.permissions, `${path}.permissions`, errors);
262
+ const author = editor.author;
263
+ if (!author || !isPlainObject(author)) {
264
+ if (author != null)
265
+ errors.push(`${path}.author must be an object`);
266
+ errors.push(`${path}.author.name is required`);
267
+ errors.push(`${path}.author.email is required`);
268
+ }
269
+ else {
270
+ validateKnownKeys(author, `${path}.author`, AUTHOR_KEYS, errors);
271
+ if (!author.name) {
272
+ errors.push(`${path}.author.name is required`);
273
+ }
274
+ else if (typeof author.name !== "string") {
275
+ errors.push(`${path}.author.name must be a string`);
276
+ }
277
+ if (!author.email) {
278
+ errors.push(`${path}.author.email is required`);
279
+ }
280
+ else if (typeof author.email !== "string") {
281
+ errors.push(`${path}.author.email must be a string`);
282
+ }
283
+ }
284
+ }
236
285
  function validateMerge(config, errors, options) {
286
+ const merge = config.merge;
237
287
  if (options.requireGithub ?? true) {
238
288
  if (!config.github?.owner)
239
289
  errors.push("github.owner is required");
@@ -247,102 +297,102 @@ function validateMerge(config, errors, options) {
247
297
  if (config.github != null && !isPlainObject(config.github)) {
248
298
  errors.push("github must be an object");
249
299
  }
250
- if (config.merge != null && !isPlainObject(config.merge)) {
251
- errors.push("merge must be an object");
252
- }
253
- validateKnownKeys(config.merge, "merge", MERGE_KEYS, errors);
254
- validateBoolean(config.merge?.auto, "merge.auto", errors);
255
- validateBoolean(config.merge?.deleteBranch, "merge.deleteBranch", errors);
256
- validateBoolean(config.merge?.mergeQueue, "merge.mergeQueue", errors);
257
300
  if (config.github?.apiRetryAttempts != null &&
258
301
  (typeof config.github.apiRetryAttempts !== "number" ||
259
302
  !Number.isInteger(config.github.apiRetryAttempts) ||
260
303
  config.github.apiRetryAttempts < 0)) {
261
304
  errors.push("github.apiRetryAttempts must be a non-negative integer");
262
305
  }
263
- if (config.merge?.method != null &&
264
- (typeof config.merge.method !== "string" ||
265
- !["merge", "rebase", "squash"].includes(config.merge.method))) {
266
- errors.push("merge.method must be merge, squash, or rebase");
267
- }
268
- if (config.merge?.approvalPolicy != null &&
269
- (typeof config.merge.approvalPolicy !== "string" ||
270
- !["majority", "unanimous"].includes(config.merge.approvalPolicy))) {
271
- errors.push("merge.approvalPolicy must be majority or unanimous");
306
+ if (merge != null && !isPlainObject(merge)) {
307
+ errors.push("merge must be an object");
272
308
  }
273
- if (config.merge?.maxThreadResolutionCycles != null &&
274
- (typeof config.merge.maxThreadResolutionCycles !== "number" ||
275
- !Number.isInteger(config.merge.maxThreadResolutionCycles) ||
276
- config.merge.maxThreadResolutionCycles < 0)) {
309
+ validateKnownKeys(merge, "merge", MERGE_KEYS, errors);
310
+ validateBooleanObject(merge?.automation, "merge.automation", AUTOMATION_KEYS, errors);
311
+ const checks = merge?.checks;
312
+ validateKnownKeys(checks, "merge.checks", MERGE_CHECKS_KEYS, errors);
313
+ validateBoolean(checks?.wait, "merge.checks.wait", errors);
314
+ validateEditor(merge?.editor, "merge.editor", errors, options.modelCatalog);
315
+ if (merge?.maxThreadResolutionCycles != null &&
316
+ (typeof merge.maxThreadResolutionCycles !== "number" ||
317
+ !Number.isInteger(merge.maxThreadResolutionCycles) ||
318
+ merge.maxThreadResolutionCycles < 0)) {
277
319
  errors.push("merge.maxThreadResolutionCycles must be a non-negative integer");
278
320
  }
321
+ if (options.requireEditor && !merge?.editor)
322
+ errors.push("merge.editor is required");
323
+ }
324
+ function validateReviewMerge(config, errors) {
325
+ const merge = config.review?.merge;
326
+ if (merge != null && !isPlainObject(merge)) {
327
+ errors.push("review.merge must be an object");
328
+ }
329
+ validateKnownKeys(merge, "review.merge", REVIEW_MERGE_KEYS, errors);
330
+ validateBoolean(merge?.auto, "review.merge.auto", errors);
331
+ validateBoolean(merge?.deleteBranch, "review.merge.deleteBranch", errors);
332
+ validateBoolean(merge?.queue, "review.merge.queue", errors);
333
+ if (merge?.method != null &&
334
+ (typeof merge.method !== "string" ||
335
+ !["merge", "rebase", "squash"].includes(merge.method))) {
336
+ errors.push("review.merge.method must be merge, squash, or rebase");
337
+ }
338
+ if (merge?.approvalPolicy != null &&
339
+ (typeof merge.approvalPolicy !== "string" ||
340
+ !["majority", "unanimous"].includes(merge.approvalPolicy))) {
341
+ errors.push("review.merge.approvalPolicy must be majority or unanimous");
342
+ }
279
343
  }
280
344
  function validateConcurrency(config, errors) {
281
- if (config.concurrency != null && !isPlainObject(config.concurrency)) {
282
- errors.push("concurrency must be an object");
283
- }
284
- validateKnownKeys(config.concurrency, "concurrency", CONCURRENCY_KEYS, errors);
285
- if (config.concurrency?.runs != null) {
286
- if (typeof config.concurrency.runs !== "number" ||
287
- !Number.isInteger(config.concurrency.runs) ||
288
- config.concurrency.runs < 1) {
289
- errors.push("concurrency.runs must be a positive integer");
345
+ const concurrency = config.review?.concurrency;
346
+ if (concurrency != null && !isPlainObject(concurrency)) {
347
+ errors.push("review.concurrency must be an object");
348
+ }
349
+ validateKnownKeys(concurrency, "review.concurrency", CONCURRENCY_KEYS, errors);
350
+ if (concurrency?.runs != null) {
351
+ if (typeof concurrency.runs !== "number" ||
352
+ !Number.isInteger(concurrency.runs) ||
353
+ concurrency.runs < 1) {
354
+ errors.push("review.concurrency.runs must be a positive integer");
290
355
  }
291
356
  }
292
- if (config.concurrency?.reviewers != null) {
293
- if (typeof config.concurrency.reviewers !== "number" ||
294
- !Number.isInteger(config.concurrency.reviewers) ||
295
- config.concurrency.reviewers < 1) {
296
- errors.push("concurrency.reviewers must be a positive integer");
357
+ if (concurrency?.reviewers != null) {
358
+ if (typeof concurrency.reviewers !== "number" ||
359
+ !Number.isInteger(concurrency.reviewers) ||
360
+ concurrency.reviewers < 1) {
361
+ errors.push("review.concurrency.reviewers must be a positive integer");
297
362
  }
298
363
  }
299
364
  }
300
365
  function validateAutomation(config, errors) {
301
- if (config.automation != null && !isPlainObject(config.automation)) {
302
- errors.push("automation must be an object");
303
- }
304
- validateKnownKeys(config.automation, "automation", AUTOMATION_KEYS, errors);
305
- if (config.automation?.merge != null &&
306
- typeof config.automation.merge !== "boolean") {
307
- errors.push("automation.merge must be a boolean");
308
- }
309
- if (config.automation?.close != null &&
310
- typeof config.automation.close !== "boolean") {
311
- errors.push("automation.close must be a boolean");
312
- }
366
+ validateBooleanObject(config.review?.automation, "review.automation", AUTOMATION_KEYS, errors);
313
367
  }
314
368
  function validateClear(config, errors) {
315
369
  validateBooleanObject(config.clear, "clear", CLEAR_KEYS, errors);
316
370
  }
317
371
  function validateChecks(config, errors) {
318
- if (config.checks != null && !isPlainObject(config.checks)) {
319
- errors.push("checks must be an object");
320
- }
321
- validateKnownKeys(config.checks, "checks", CHECKS_KEYS, errors);
322
- if (config.checks?.exclude != null) {
323
- if (!Array.isArray(config.checks.exclude)) {
324
- errors.push("checks.exclude must be an array");
372
+ const checks = config.review?.checks;
373
+ if (checks != null && !isPlainObject(checks)) {
374
+ errors.push("review.checks must be an object");
375
+ }
376
+ validateKnownKeys(checks, "review.checks", REVIEW_CHECKS_KEYS, errors);
377
+ if (checks?.exclude != null) {
378
+ if (!Array.isArray(checks.exclude)) {
379
+ errors.push("review.checks.exclude must be an array");
325
380
  }
326
381
  else {
327
- config.checks.exclude.forEach((item, index) => {
382
+ checks.exclude.forEach((item, index) => {
328
383
  if (typeof item !== "string")
329
- errors.push(`checks.exclude[${index}] must be a string`);
384
+ errors.push(`review.checks.exclude[${index}] must be a string`);
330
385
  });
331
386
  }
332
387
  }
333
- if (config.checks?.waitBeforeReview != null &&
334
- typeof config.checks.waitBeforeReview !== "boolean") {
335
- errors.push("checks.waitBeforeReview must be a boolean");
336
- }
337
- if (config.checks?.waitAfterEdit != null &&
338
- typeof config.checks.waitAfterEdit !== "boolean") {
339
- errors.push("checks.waitAfterEdit must be a boolean");
388
+ if (checks?.wait != null && typeof checks.wait !== "boolean") {
389
+ errors.push("review.checks.wait must be a boolean");
340
390
  }
341
- if (config.checks?.retryFailedJobs != null &&
342
- (typeof config.checks.retryFailedJobs !== "number" ||
343
- !Number.isInteger(config.checks.retryFailedJobs) ||
344
- config.checks.retryFailedJobs < 0)) {
345
- errors.push("checks.retryFailedJobs must be a non-negative integer");
391
+ if (checks?.retryFailedJobs != null &&
392
+ (typeof checks.retryFailedJobs !== "number" ||
393
+ !Number.isInteger(checks.retryFailedJobs) ||
394
+ checks.retryFailedJobs < 0)) {
395
+ errors.push("review.checks.retryFailedJobs must be a non-negative integer");
346
396
  }
347
397
  }
348
398
  function validateStringArray(value, path, errors) {
@@ -358,33 +408,44 @@ function validateStringArray(value, path, errors) {
358
408
  });
359
409
  }
360
410
  function validateSafety(config, errors) {
361
- if (config.safety != null && !isPlainObject(config.safety)) {
362
- errors.push("safety must be an object");
363
- }
364
- validateKnownKeys(config.safety, "safety", SAFETY_KEYS, errors);
365
- validateStringArray(config.safety?.allowAuthors, "safety.allowAuthors", errors);
366
- validateStringArray(config.safety?.blockedPaths, "safety.blockedPaths", errors);
367
- validateStringArray(config.safety?.requiredLabels, "safety.requiredLabels", errors);
368
- if (config.safety?.maxChangedFiles != null &&
369
- (typeof config.safety.maxChangedFiles !== "number" ||
370
- !Number.isInteger(config.safety.maxChangedFiles) ||
371
- config.safety.maxChangedFiles < 0)) {
372
- errors.push("safety.maxChangedFiles must be a non-negative integer");
411
+ const safety = config.review?.safety;
412
+ if (safety != null && !isPlainObject(safety)) {
413
+ errors.push("review.safety must be an object");
414
+ }
415
+ validateKnownKeys(safety, "review.safety", SAFETY_KEYS, errors);
416
+ validateStringArray(safety?.allowAuthors, "review.safety.allowAuthors", errors);
417
+ validateStringArray(safety?.blockedPaths, "review.safety.blockedPaths", errors);
418
+ validateStringArray(safety?.requiredLabels, "review.safety.requiredLabels", errors);
419
+ if (safety?.maxChangedFiles != null &&
420
+ (typeof safety.maxChangedFiles !== "number" ||
421
+ !Number.isInteger(safety.maxChangedFiles) ||
422
+ safety.maxChangedFiles < 0)) {
423
+ errors.push("review.safety.maxChangedFiles must be a non-negative integer");
373
424
  }
374
425
  }
375
- async function validatePrompts(config, errors, directory) {
376
- if (config.prompts == null)
426
+ function validatePromptObject(prompts, path, keys, errors) {
427
+ if (prompts == null)
377
428
  return;
378
- if (!isPlainObject(config.prompts)) {
379
- errors.push("prompts must be an object");
429
+ if (!isPlainObject(prompts)) {
430
+ errors.push(`${path} must be an object`);
380
431
  return;
381
432
  }
382
- validateKnownKeys(config.prompts, "prompts", PROMPT_KEYS, errors);
383
- await Promise.all(Object.entries(config.prompts).map(async ([key, value]) => {
384
- if (typeof value !== "string") {
385
- errors.push(`prompts.${key} must be a string`);
433
+ validateKnownKeys(prompts, path, keys, errors);
434
+ for (const [key, value] of Object.entries(prompts)) {
435
+ if (typeof value !== "string")
436
+ errors.push(`${path}.${key} must be a string`);
437
+ }
438
+ }
439
+ async function validatePrompts(config, errors, directory) {
440
+ validatePromptObject(config.review?.prompts, "review.prompts", REVIEW_PROMPT_KEYS, errors);
441
+ validatePromptObject(config.merge?.prompts, "merge.prompts", MERGE_PROMPT_KEYS, errors);
442
+ const promptEntries = [
443
+ ...Object.entries(config.review?.prompts ?? {}).map(([key, value]) => [`review.prompts.${key}`, value]),
444
+ ...Object.entries(config.merge?.prompts ?? {}).map(([key, value]) => [`merge.prompts.${key}`, value]),
445
+ ];
446
+ await Promise.all(promptEntries.map(async ([path, value]) => {
447
+ if (typeof value !== "string")
386
448
  return;
387
- }
388
449
  if (!directory)
389
450
  return;
390
451
  const fullPath = promptPath(directory, value);
@@ -392,13 +453,13 @@ async function validatePrompts(config, errors, directory) {
392
453
  await access(fullPath, constants.R_OK);
393
454
  }
394
455
  catch {
395
- errors.push(`prompts.${key} file is not readable: ${value}`);
456
+ errors.push(`${path} file is not readable: ${value}`);
396
457
  }
397
458
  }));
398
459
  }
399
460
  async function validateAuth(config, exec, errors) {
400
461
  const accounts = new Set();
401
- const agents = resolveAgents(config.agents);
462
+ const agents = resolveAgents(config);
402
463
  for (const reviewer of agents.reviewers)
403
464
  accounts.add(reviewer.account);
404
465
  if (agents.editor)
@@ -420,7 +481,7 @@ async function fetchPermissions(config, exec, account) {
420
481
  async function validateRepositoryPermissions(config, exec, errors, warnings) {
421
482
  if (!config.github?.owner || !config.github.repo)
422
483
  return;
423
- const agents = resolveAgents(config.agents);
484
+ const agents = resolveAgents(config);
424
485
  await Promise.all(agents.reviewers.map(async (reviewer) => {
425
486
  try {
426
487
  const permissions = await fetchPermissions(config, exec, reviewer.account);
@@ -454,65 +515,32 @@ export async function validateConfig(config, options = {}) {
454
515
  validateKnownKeys(config, "config", CONFIG_KEYS, errors);
455
516
  validateString(config.$schema, "$schema", errors);
456
517
  validateString(config.language, "language", errors);
457
- if (!config.agents) {
458
- errors.push("agents is required");
518
+ if (config.agents != null && !isPlainObject(config.agents)) {
519
+ errors.push("agents must be an object");
459
520
  }
460
521
  else {
461
- if (!isPlainObject(config.agents)) {
462
- errors.push("agents must be an object");
522
+ validateKnownKeys(config.agents, "agents", AGENTS_KEYS, errors);
523
+ validatePermissionConfig(config.agents?.permissions, "agents.permissions", errors);
524
+ }
525
+ if (!config.review) {
526
+ errors.push("review is required");
527
+ }
528
+ else {
529
+ if (!isPlainObject(config.review)) {
530
+ errors.push("review must be an object");
463
531
  }
464
532
  else {
465
- validateKnownKeys(config.agents, "agents", AGENTS_KEYS, errors);
466
- }
467
- validatePermissionConfig(config.agents.permissions, "agents.permissions", errors);
468
- if (!config.agents.reviewers)
469
- errors.push("agents.reviewers is required");
470
- validateReviewerList(config.agents.reviewers, "agents.reviewers", errors, options.modelCatalog);
471
- if (options.requireEditor && !config.agents.editor)
472
- errors.push("agents.editor is required");
473
- if (config.agents.editor) {
474
- if (!config.agents.editor.model)
475
- errors.push("agents.editor.model is required");
476
- validateKnownKeys(config.agents.editor, "agents.editor", EDITOR_KEYS, errors);
477
- validateString(config.agents.editor.model, "agents.editor.model", errors);
478
- validateString(config.agents.editor.account, "agents.editor.account", errors);
479
- validateString(config.agents.editor.persona, "agents.editor.persona", errors);
480
- validateModel(config.agents.editor.model, "agents.editor.model", errors, options.modelCatalog);
481
- if (!config.agents.editor.account)
482
- errors.push("agents.editor.account is required");
483
- if (config.agents.editor.options != null &&
484
- !isPlainObject(config.agents.editor.options)) {
485
- errors.push("agents.editor.options must be an object");
486
- }
487
- validatePermissionConfig(config.agents.editor.permission, "agents.editor.permission", errors);
488
- const author = config.agents.editor.author;
489
- if (!author || !isPlainObject(author)) {
490
- if (author != null)
491
- errors.push("agents.editor.author must be an object");
492
- errors.push("agents.editor.author.name is required");
493
- errors.push("agents.editor.author.email is required");
494
- }
495
- else {
496
- validateKnownKeys(author, "agents.editor.author", AUTHOR_KEYS, errors);
497
- if (!author.name) {
498
- errors.push("agents.editor.author.name is required");
499
- }
500
- else if (typeof author.name !== "string") {
501
- errors.push("agents.editor.author.name must be a string");
502
- }
503
- if (!author.email) {
504
- errors.push("agents.editor.author.email is required");
505
- }
506
- else if (typeof author.email !== "string") {
507
- errors.push("agents.editor.author.email must be a string");
508
- }
509
- }
533
+ validateKnownKeys(config.review, "review", REVIEW_KEYS, errors);
510
534
  }
511
- if (Array.isArray(config.agents.reviewers)) {
512
- validateResolvedReviewers(resolveAgents(config.agents).reviewers, "agents.resolvedReviewers", errors);
535
+ if (!config.review.agents)
536
+ errors.push("review.agents is required");
537
+ validateReviewerList(config.review.agents, "review.agents", errors, options.modelCatalog);
538
+ if (Array.isArray(config.review.agents)) {
539
+ validateResolvedReviewers(resolveAgents(config).reviewers, "review.resolvedAgents", errors);
513
540
  }
514
541
  }
515
542
  validateMerge(config, errors, options);
543
+ validateReviewMerge(config, errors);
516
544
  validateAutomation(config, errors);
517
545
  validateClear(config, errors);
518
546
  validateChecks(config, errors);
@@ -530,28 +558,8 @@ export async function validateConfig(config, options = {}) {
530
558
  errors.push("output.repairAttempts must be a non-negative integer");
531
559
  }
532
560
  }
533
- if (config.output?.dirs != null) {
534
- if (!isPlainObject(config.output.dirs)) {
535
- errors.push("output.dirs must be an object");
536
- }
537
- else {
538
- validateKnownKeys(config.output.dirs, "output.dirs", OUTPUT_DIR_KEYS, errors);
539
- const dirs = config.output.dirs;
540
- for (const key of OUTPUT_DIR_KEYS) {
541
- const value = dirs[key];
542
- if (value != null && typeof value !== "string") {
543
- errors.push(`output.dirs.${key} must be a string`);
544
- }
545
- }
546
- }
547
- }
548
- if (config.worktree != null && !isPlainObject(config.worktree)) {
549
- errors.push("worktree must be an object");
550
- }
551
- validateKnownKeys(config.worktree, "worktree", WORKTREE_KEYS, errors);
552
- if (config.worktree?.dir != null && typeof config.worktree.dir !== "string") {
553
- errors.push("worktree.dir must be a string");
554
- }
561
+ validateString(config.review?.output, "review.output", errors);
562
+ validateString(config.review?.worktree, "review.worktree", errors);
555
563
  if (options.checkAuth && !errors.length) {
556
564
  if (!options.exec) {
557
565
  errors.push("validateConfig requires exec when checkAuth is true");
@@ -0,0 +1,13 @@
1
+ import { isAbsolute, join } from "node:path";
2
+ const DEFAULT_WORKTREE_DIRS = {
3
+ pr: ".magi/worktrees/pr",
4
+ };
5
+ function resolvePath(directory, path) {
6
+ return isAbsolute(path) ? path : join(directory, path);
7
+ }
8
+ export function worktreeBaseDir(directory, config, kind) {
9
+ return resolvePath(directory, config.review?.worktree ?? DEFAULT_WORKTREE_DIRS[kind]);
10
+ }
11
+ export function worktreeBaseDirs(directory, config = {}) {
12
+ return [worktreeBaseDir(directory, config, "pr")];
13
+ }
@@ -41,7 +41,7 @@ async function withWorktreeCreateLock(key, run) {
41
41
  async function checkoutPullRequestWithRetry(exec, repository, pr, worktreePath) {
42
42
  for (let attempt = 0;; attempt += 1) {
43
43
  try {
44
- await exec(`gh pr checkout ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, {
44
+ await exec(`gh pr checkout ${pr} --repo ${shellQuote(repoSpecifier(repository))} --detach`, {
45
45
  cwd: worktreePath,
46
46
  });
47
47
  return;
@@ -70,6 +70,9 @@ export function repoSpecifier(repository) {
70
70
  ? repoSlug(repository)
71
71
  : `${host}/${repoSlug(repository)}`;
72
72
  }
73
+ function repositoryGitUrl(repository, owner, repo) {
74
+ return `https://${githubHost(repository)}/${owner}/${repo}.git`;
75
+ }
73
76
  export function ghHostOption(repository) {
74
77
  const host = githubHost(repository);
75
78
  return host === "github.com" ? "" : ` --hostname ${shellQuote(host)}`;
@@ -78,7 +81,7 @@ export async function ghToken(exec, repository, account) {
78
81
  return (await exec(`gh auth token${ghHostOption(repository)} --user ${shellQuote(account)}`)).trim();
79
82
  }
80
83
  export async function fetchPullRequest(exec, repository, pr) {
81
- const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,isDraft,baseRefOid,headRefOid,baseRefName,headRefName`);
84
+ const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,isDraft,baseRefOid,headRefOid,baseRefName,headRefName,headRepository,headRepositoryOwner`);
82
85
  return JSON.parse(json);
83
86
  }
84
87
  export async function fetchPullRequestReviews(exec, repository, pr) {
@@ -318,9 +321,10 @@ export async function closePullRequest(exec, repository, pr, account) {
318
321
  const token = await ghToken(exec, repository, account);
319
322
  return exec(`GH_TOKEN=${shellQuote(token)} gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`);
320
323
  }
321
- export async function pushHead(exec, repository, worktreePath, account) {
324
+ export async function pushHead(exec, repository, worktreePath, account, head) {
322
325
  const token = await ghToken(exec, repository, account);
323
- await exec(`git -c credential.helper= -c credential.helper=${shellQuote(`!f() { echo username=x-access-token; echo password=${token}; }; f`)} push origin HEAD`, { cwd: worktreePath });
326
+ const url = repositoryGitUrl(repository, head.owner, head.repo);
327
+ await exec(`git -c credential.helper= -c credential.helper=${shellQuote(`!f() { echo username=x-access-token; echo password=${token}; }; f`)} push ${shellQuote(url)} ${shellQuote(`HEAD:refs/heads/${head.ref}`)}`, { cwd: worktreePath });
324
328
  }
325
329
  export async function configureGitIdentity(exec, worktreePath, identity) {
326
330
  if (identity.name) {
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";
@@ -189,7 +190,7 @@ export async function validateMagiConfigFiles(directory, options = {}) {
189
190
  ? withGitHubApiRetry(options.exec, mergedConfig.github?.apiRetryAttempts ?? 3)
190
191
  : undefined,
191
192
  modelCatalog: options.modelCatalog,
192
- requireGithub: hasProjectConfig && Boolean(mergedConfig.agents?.reviewers),
193
+ requireGithub: hasProjectConfig && Boolean(mergedConfig.review?.agents),
193
194
  });
194
195
  loadedFrom = existing.map((status) => status.path).join(", ");
195
196
  errors.push(...validation.errors);
@@ -435,6 +436,9 @@ export const MagiPlugin = async ({ client, directory }) => {
435
436
  : undefined,
436
437
  pr: parseOptionalPr(args.pr),
437
438
  runId: args.runId,
439
+ worktreeDir: loaded
440
+ ? worktreeBaseDirs(directory, loaded.config)
441
+ : undefined,
438
442
  });
439
443
  },
440
444
  }),
@@ -109,7 +109,13 @@ async function runEditor(input, worktreePath, cycle, unresolvedThreads) {
109
109
  await input.onProgress?.({ cycle, type: "editor_completed" });
110
110
  if (!input.dryRun) {
111
111
  if (result.value.mode === "EDITED") {
112
- await pushHead(input.exec, input.repository, worktreePath, editor.account);
112
+ const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
113
+ const headOwner = meta.headRepositoryOwner?.login;
114
+ const headRepo = meta.headRepository?.name;
115
+ if (!headOwner || !headRepo) {
116
+ throw new Error("Pull request head repository is missing");
117
+ }
118
+ await pushHead(input.exec, input.repository, worktreePath, editor.account, { owner: headOwner, ref: meta.headRefName, repo: headRepo });
113
119
  }
114
120
  }
115
121
  throwIfAborted(input.signal);
@@ -565,6 +571,7 @@ export async function runMerge(input) {
565
571
  ...abortableInput,
566
572
  allowAlreadyReviewed: true,
567
573
  approvalPolicy: input.repository.merge.approvalPolicy,
574
+ enableReviewAutomation: false,
568
575
  onProgress: (progress) => input.onProgress?.(progress),
569
576
  runId: input.runId,
570
577
  dryRun: input.dryRun,