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
@@ -9,28 +9,26 @@ const RESERVED_REVIEWER_KEYS = new Set(["editor", "orchestrator", "system"]);
9
9
  const PERMISSION_ACTIONS = new Set(["allow", "ask", "deny"]);
10
10
  const AJV = new Ajv2020({ allErrors: true, strict: false });
11
11
  const validateSchema = AJV.compile(schema);
12
+ const TRIAGE_CATEGORY_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
13
+ const RESERVED_TRIAGE_CATEGORY_IDS = new Set(["ASK", "none"]);
12
14
  const CONFIG_KEYS = new Set([
13
15
  "$schema",
14
16
  "agents",
15
- "automation",
16
17
  "clear",
17
- "checks",
18
- "concurrency",
19
18
  "github",
20
19
  "language",
21
20
  "merge",
22
21
  "output",
23
- "prompts",
24
- "safety",
25
- "worktree",
22
+ "review",
23
+ "triage",
26
24
  ]);
27
- const AGENTS_KEYS = new Set(["editor", "permissions", "reviewers"]);
25
+ const AGENTS_KEYS = new Set(["permissions"]);
28
26
  const REVIEWER_KEYS = new Set([
29
27
  "account",
30
28
  "id",
31
29
  "model",
32
30
  "options",
33
- "permission",
31
+ "permissions",
34
32
  "persona",
35
33
  ]);
36
34
  const EDITOR_KEYS = new Set([
@@ -38,50 +36,117 @@ const EDITOR_KEYS = new Set([
38
36
  "author",
39
37
  "model",
40
38
  "options",
41
- "permission",
39
+ "permissions",
40
+ "persona",
41
+ ]);
42
+ const TRIAGE_AGENT_KEYS = new Set([
43
+ "id",
44
+ "model",
45
+ "options",
46
+ "permissions",
47
+ "persona",
48
+ ]);
49
+ const TRIAGE_CREATOR_KEYS = new Set([
50
+ "account",
51
+ "author",
52
+ "model",
53
+ "options",
54
+ "permissions",
42
55
  "persona",
43
56
  ]);
44
57
  const AUTHOR_KEYS = new Set(["email", "name"]);
45
58
  const GITHUB_KEYS = new Set(["apiRetryAttempts", "host", "owner", "repo"]);
59
+ const REVIEW_KEYS = new Set([
60
+ "agents",
61
+ "automation",
62
+ "checks",
63
+ "concurrency",
64
+ "merge",
65
+ "output",
66
+ "prompts",
67
+ "safety",
68
+ "worktree",
69
+ ]);
46
70
  const MERGE_KEYS = new Set([
71
+ "automation",
72
+ "checks",
73
+ "editor",
74
+ "maxThreadResolutionCycles",
75
+ "prompts",
76
+ ]);
77
+ const TRIAGE_KEYS = new Set([
78
+ "account",
79
+ "agents",
80
+ "automation",
81
+ "categories",
82
+ "concurrency",
83
+ "creator",
84
+ "output",
85
+ "prompts",
86
+ "safety",
87
+ "worktree",
88
+ ]);
89
+ const REVIEW_MERGE_KEYS = new Set([
47
90
  "approvalPolicy",
48
91
  "auto",
49
92
  "deleteBranch",
50
- "maxThreadResolutionCycles",
51
- "mergeQueue",
52
93
  "method",
94
+ "queue",
53
95
  ]);
54
- const CHECKS_KEYS = new Set([
55
- "exclude",
56
- "retryFailedJobs",
57
- "waitAfterEdit",
58
- "waitBeforeReview",
59
- ]);
96
+ const REVIEW_CHECKS_KEYS = new Set(["exclude", "retryFailedJobs", "wait"]);
97
+ const MERGE_CHECKS_KEYS = new Set(["wait"]);
60
98
  const AUTOMATION_KEYS = new Set(["close", "merge"]);
61
99
  const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
62
100
  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"]);
101
+ const OUTPUT_KEYS = new Set(["repairAttempts"]);
102
+ const TRIAGE_AUTOMATION_KEYS = new Set([
103
+ "clear",
104
+ "close",
105
+ "create",
106
+ "merge",
107
+ "review",
108
+ ]);
109
+ const TRIAGE_CATEGORY_KEYS = new Set(["description", "id", "labels", "types"]);
110
+ const TRIAGE_CONCURRENCY_KEYS = new Set(["runs"]);
111
+ const TRIAGE_SAFETY_KEYS = new Set([
112
+ "allowAuthors",
113
+ "allowMentionActors",
114
+ "allowMentionRoles",
115
+ "blockedLabels",
116
+ "requiredLabels",
117
+ ]);
66
118
  const SAFETY_KEYS = new Set([
67
119
  "allowAuthors",
68
120
  "blockedPaths",
69
121
  "maxChangedFiles",
70
122
  "requiredLabels",
71
123
  ]);
72
- const PROMPT_KEYS = new Set([
124
+ const REVIEW_PROMPT_KEYS = new Set([
73
125
  "ciClassification",
74
- "ciClassificationAfterEdit",
75
126
  "closeReconsideration",
76
- "edit",
77
- "editGuidelines",
78
127
  "findingValidation",
79
- "report",
80
128
  "rereview",
81
- "rereviewCloseReconsideration",
82
129
  "review",
83
130
  "reviewGuidelines",
84
131
  ]);
132
+ const MERGE_PROMPT_KEYS = new Set([
133
+ "ciClassification",
134
+ "edit",
135
+ "editGuidelines",
136
+ ]);
137
+ const TRIAGE_PROMPT_KEYS = new Set([
138
+ "action",
139
+ "acceptance",
140
+ "category",
141
+ "comment",
142
+ "commentClassification",
143
+ "create",
144
+ "createGuidelines",
145
+ "duplicate",
146
+ "existingPr",
147
+ "question",
148
+ "reconsider",
149
+ ]);
85
150
  function githubHost(config) {
86
151
  return config.github?.host ?? "github.com";
87
152
  }
@@ -210,7 +275,7 @@ function validateReviewerList(reviewers, path, errors, catalog) {
210
275
  validateString(reviewer.persona, `${path}[${index}].persona`, errors);
211
276
  if (reviewer.options != null && !isPlainObject(reviewer.options))
212
277
  errors.push(`${path}[${index}].options must be an object`);
213
- validatePermissionConfig(reviewer.permission, `${path}[${index}].permission`, errors);
278
+ validatePermissionConfig(reviewer.permissions, `${path}[${index}].permissions`, errors);
214
279
  if (reviewer.id) {
215
280
  if (!validateReviewerId(reviewer.id)) {
216
281
  errors.push(`${path}[${index}].id may contain only letters, numbers, underscores, and hyphens`);
@@ -221,6 +286,41 @@ function validateReviewerList(reviewers, path, errors, catalog) {
221
286
  }
222
287
  });
223
288
  }
289
+ function validateTriageAgentList(agents, path, errors, catalog) {
290
+ if (agents == null)
291
+ return;
292
+ if (!Array.isArray(agents)) {
293
+ errors.push(`${path} must be an array`);
294
+ return;
295
+ }
296
+ if (agents.length < 3)
297
+ errors.push(`${path} must contain at least 3 agents`);
298
+ if (agents.length % 2 === 0)
299
+ errors.push(`${path} must contain an odd number of agents`);
300
+ agents.forEach((agent, index) => {
301
+ if (!agent || typeof agent !== "object") {
302
+ errors.push(`${path}[${index}] must be an object`);
303
+ return;
304
+ }
305
+ validateKnownKeys(agent, `${path}[${index}]`, TRIAGE_AGENT_KEYS, errors);
306
+ if (!agent.model)
307
+ errors.push(`${path}[${index}].model is required`);
308
+ validateString(agent.model, `${path}[${index}].model`, errors);
309
+ validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
310
+ validateString(agent.persona, `${path}[${index}].persona`, errors);
311
+ if (agent.options != null && !isPlainObject(agent.options))
312
+ errors.push(`${path}[${index}].options must be an object`);
313
+ validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
314
+ if (agent.id) {
315
+ if (!validateReviewerId(agent.id)) {
316
+ errors.push(`${path}[${index}].id may contain only letters, numbers, underscores, and hyphens`);
317
+ }
318
+ if (RESERVED_REVIEWER_KEYS.has(agent.id)) {
319
+ errors.push(`${path}[${index}].id is reserved: ${agent.id}`);
320
+ }
321
+ }
322
+ });
323
+ }
224
324
  function validateResolvedReviewers(reviewers, path, errors) {
225
325
  const keys = new Set();
226
326
  const accounts = new Set();
@@ -233,7 +333,93 @@ function validateResolvedReviewers(reviewers, path, errors) {
233
333
  accounts.add(reviewer.account);
234
334
  }
235
335
  }
336
+ function validateResolvedAgentKeys(agents, path, errors) {
337
+ const keys = new Set();
338
+ for (const agent of agents) {
339
+ if (keys.has(agent.key))
340
+ errors.push(`${path} has duplicate agent key: ${agent.key}`);
341
+ keys.add(agent.key);
342
+ }
343
+ }
344
+ function validateEditor(editor, path, errors, catalog) {
345
+ if (!editor)
346
+ return;
347
+ if (!isPlainObject(editor)) {
348
+ errors.push(`${path} must be an object`);
349
+ return;
350
+ }
351
+ if (!editor.model)
352
+ errors.push(`${path}.model is required`);
353
+ validateKnownKeys(editor, path, EDITOR_KEYS, errors);
354
+ validateString(editor.model, `${path}.model`, errors);
355
+ validateString(editor.account, `${path}.account`, errors);
356
+ validateString(editor.persona, `${path}.persona`, errors);
357
+ validateModel(editor.model, `${path}.model`, errors, catalog);
358
+ if (!editor.account)
359
+ errors.push(`${path}.account is required`);
360
+ if (editor.options != null && !isPlainObject(editor.options)) {
361
+ errors.push(`${path}.options must be an object`);
362
+ }
363
+ validatePermissionConfig(editor.permissions, `${path}.permissions`, errors);
364
+ const author = editor.author;
365
+ if (!author || !isPlainObject(author)) {
366
+ if (author != null)
367
+ errors.push(`${path}.author must be an object`);
368
+ errors.push(`${path}.author.name is required`);
369
+ errors.push(`${path}.author.email is required`);
370
+ }
371
+ else {
372
+ validateKnownKeys(author, `${path}.author`, AUTHOR_KEYS, errors);
373
+ if (!author.name) {
374
+ errors.push(`${path}.author.name is required`);
375
+ }
376
+ else if (typeof author.name !== "string") {
377
+ errors.push(`${path}.author.name must be a string`);
378
+ }
379
+ if (!author.email) {
380
+ errors.push(`${path}.author.email is required`);
381
+ }
382
+ else if (typeof author.email !== "string") {
383
+ errors.push(`${path}.author.email must be a string`);
384
+ }
385
+ }
386
+ }
387
+ function validateTriageCreator(creator, path, errors, catalog) {
388
+ if (!creator)
389
+ return;
390
+ if (!isPlainObject(creator)) {
391
+ errors.push(`${path} must be an object`);
392
+ return;
393
+ }
394
+ validateKnownKeys(creator, path, TRIAGE_CREATOR_KEYS, errors);
395
+ if (!creator.model)
396
+ errors.push(`${path}.model is required`);
397
+ validateString(creator.account, `${path}.account`, errors);
398
+ validateString(creator.model, `${path}.model`, errors);
399
+ validateString(creator.persona, `${path}.persona`, errors);
400
+ validateModel(creator.model, `${path}.model`, errors, catalog);
401
+ if (creator.options != null && !isPlainObject(creator.options)) {
402
+ errors.push(`${path}.options must be an object`);
403
+ }
404
+ validatePermissionConfig(creator.permissions, `${path}.permissions`, errors);
405
+ const author = creator.author;
406
+ if (!author || !isPlainObject(author)) {
407
+ if (author != null)
408
+ errors.push(`${path}.author must be an object`);
409
+ errors.push(`${path}.author.name is required`);
410
+ errors.push(`${path}.author.email is required`);
411
+ return;
412
+ }
413
+ validateKnownKeys(author, `${path}.author`, AUTHOR_KEYS, errors);
414
+ if (!author.name)
415
+ errors.push(`${path}.author.name is required`);
416
+ validateString(author.name, `${path}.author.name`, errors);
417
+ if (!author.email)
418
+ errors.push(`${path}.author.email is required`);
419
+ validateString(author.email, `${path}.author.email`, errors);
420
+ }
236
421
  function validateMerge(config, errors, options) {
422
+ const merge = config.merge;
237
423
  if (options.requireGithub ?? true) {
238
424
  if (!config.github?.owner)
239
425
  errors.push("github.owner is required");
@@ -247,102 +433,102 @@ function validateMerge(config, errors, options) {
247
433
  if (config.github != null && !isPlainObject(config.github)) {
248
434
  errors.push("github must be an object");
249
435
  }
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
436
  if (config.github?.apiRetryAttempts != null &&
258
437
  (typeof config.github.apiRetryAttempts !== "number" ||
259
438
  !Number.isInteger(config.github.apiRetryAttempts) ||
260
439
  config.github.apiRetryAttempts < 0)) {
261
440
  errors.push("github.apiRetryAttempts must be a non-negative integer");
262
441
  }
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");
442
+ if (merge != null && !isPlainObject(merge)) {
443
+ errors.push("merge must be an object");
272
444
  }
273
- if (config.merge?.maxThreadResolutionCycles != null &&
274
- (typeof config.merge.maxThreadResolutionCycles !== "number" ||
275
- !Number.isInteger(config.merge.maxThreadResolutionCycles) ||
276
- config.merge.maxThreadResolutionCycles < 0)) {
445
+ validateKnownKeys(merge, "merge", MERGE_KEYS, errors);
446
+ validateBooleanObject(merge?.automation, "merge.automation", AUTOMATION_KEYS, errors);
447
+ const checks = merge?.checks;
448
+ validateKnownKeys(checks, "merge.checks", MERGE_CHECKS_KEYS, errors);
449
+ validateBoolean(checks?.wait, "merge.checks.wait", errors);
450
+ validateEditor(merge?.editor, "merge.editor", errors, options.modelCatalog);
451
+ if (merge?.maxThreadResolutionCycles != null &&
452
+ (typeof merge.maxThreadResolutionCycles !== "number" ||
453
+ !Number.isInteger(merge.maxThreadResolutionCycles) ||
454
+ merge.maxThreadResolutionCycles < 0)) {
277
455
  errors.push("merge.maxThreadResolutionCycles must be a non-negative integer");
278
456
  }
457
+ if (options.requireEditor && !merge?.editor)
458
+ errors.push("merge.editor is required");
279
459
  }
280
- function validateConcurrency(config, errors) {
281
- if (config.concurrency != null && !isPlainObject(config.concurrency)) {
282
- errors.push("concurrency must be an object");
460
+ function validateReviewMerge(config, errors) {
461
+ const merge = config.review?.merge;
462
+ if (merge != null && !isPlainObject(merge)) {
463
+ errors.push("review.merge must be an object");
464
+ }
465
+ validateKnownKeys(merge, "review.merge", REVIEW_MERGE_KEYS, errors);
466
+ validateBoolean(merge?.auto, "review.merge.auto", errors);
467
+ validateBoolean(merge?.deleteBranch, "review.merge.deleteBranch", errors);
468
+ validateBoolean(merge?.queue, "review.merge.queue", errors);
469
+ if (merge?.method != null &&
470
+ (typeof merge.method !== "string" ||
471
+ !["merge", "rebase", "squash"].includes(merge.method))) {
472
+ errors.push("review.merge.method must be merge, squash, or rebase");
473
+ }
474
+ if (merge?.approvalPolicy != null &&
475
+ (typeof merge.approvalPolicy !== "string" ||
476
+ !["majority", "unanimous"].includes(merge.approvalPolicy))) {
477
+ errors.push("review.merge.approvalPolicy must be majority or unanimous");
283
478
  }
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");
479
+ }
480
+ function validateConcurrency(config, errors) {
481
+ const concurrency = config.review?.concurrency;
482
+ if (concurrency != null && !isPlainObject(concurrency)) {
483
+ errors.push("review.concurrency must be an object");
484
+ }
485
+ validateKnownKeys(concurrency, "review.concurrency", CONCURRENCY_KEYS, errors);
486
+ if (concurrency?.runs != null) {
487
+ if (typeof concurrency.runs !== "number" ||
488
+ !Number.isInteger(concurrency.runs) ||
489
+ concurrency.runs < 1) {
490
+ errors.push("review.concurrency.runs must be a positive integer");
290
491
  }
291
492
  }
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");
493
+ if (concurrency?.reviewers != null) {
494
+ if (typeof concurrency.reviewers !== "number" ||
495
+ !Number.isInteger(concurrency.reviewers) ||
496
+ concurrency.reviewers < 1) {
497
+ errors.push("review.concurrency.reviewers must be a positive integer");
297
498
  }
298
499
  }
299
500
  }
300
501
  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
- }
502
+ validateBooleanObject(config.review?.automation, "review.automation", AUTOMATION_KEYS, errors);
313
503
  }
314
504
  function validateClear(config, errors) {
315
505
  validateBooleanObject(config.clear, "clear", CLEAR_KEYS, errors);
316
506
  }
317
507
  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");
508
+ const checks = config.review?.checks;
509
+ if (checks != null && !isPlainObject(checks)) {
510
+ errors.push("review.checks must be an object");
511
+ }
512
+ validateKnownKeys(checks, "review.checks", REVIEW_CHECKS_KEYS, errors);
513
+ if (checks?.exclude != null) {
514
+ if (!Array.isArray(checks.exclude)) {
515
+ errors.push("review.checks.exclude must be an array");
325
516
  }
326
517
  else {
327
- config.checks.exclude.forEach((item, index) => {
518
+ checks.exclude.forEach((item, index) => {
328
519
  if (typeof item !== "string")
329
- errors.push(`checks.exclude[${index}] must be a string`);
520
+ errors.push(`review.checks.exclude[${index}] must be a string`);
330
521
  });
331
522
  }
332
523
  }
333
- if (config.checks?.waitBeforeReview != null &&
334
- typeof config.checks.waitBeforeReview !== "boolean") {
335
- errors.push("checks.waitBeforeReview must be a boolean");
524
+ if (checks?.wait != null && typeof checks.wait !== "boolean") {
525
+ errors.push("review.checks.wait must be a boolean");
336
526
  }
337
- if (config.checks?.waitAfterEdit != null &&
338
- typeof config.checks.waitAfterEdit !== "boolean") {
339
- errors.push("checks.waitAfterEdit must be a boolean");
340
- }
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");
527
+ if (checks?.retryFailedJobs != null &&
528
+ (typeof checks.retryFailedJobs !== "number" ||
529
+ !Number.isInteger(checks.retryFailedJobs) ||
530
+ checks.retryFailedJobs < 0)) {
531
+ errors.push("review.checks.retryFailedJobs must be a non-negative integer");
346
532
  }
347
533
  }
348
534
  function validateStringArray(value, path, errors) {
@@ -357,34 +543,142 @@ function validateStringArray(value, path, errors) {
357
543
  errors.push(`${path}[${index}] must be a string`);
358
544
  });
359
545
  }
546
+ function validateTriageCategories(categories, path, errors) {
547
+ if (categories == null)
548
+ return;
549
+ if (!Array.isArray(categories)) {
550
+ errors.push(`${path} must be an array`);
551
+ return;
552
+ }
553
+ const ids = new Set();
554
+ categories.forEach((item, index) => {
555
+ const itemPath = `${path}[${index}]`;
556
+ if (!isPlainObject(item)) {
557
+ errors.push(`${itemPath} must be an object`);
558
+ return;
559
+ }
560
+ const category = item;
561
+ validateKnownKeys(category, itemPath, TRIAGE_CATEGORY_KEYS, errors);
562
+ if (!category.id) {
563
+ errors.push(`${itemPath}.id is required`);
564
+ }
565
+ else if (typeof category.id !== "string") {
566
+ errors.push(`${itemPath}.id must be a string`);
567
+ }
568
+ else if (!TRIAGE_CATEGORY_ID_PATTERN.test(category.id)) {
569
+ errors.push(`${itemPath}.id must match /^[A-Za-z0-9_-]+$/`);
570
+ }
571
+ else if (RESERVED_TRIAGE_CATEGORY_IDS.has(category.id)) {
572
+ errors.push(`${itemPath}.id is reserved: ${category.id}`);
573
+ }
574
+ else if (ids.has(category.id)) {
575
+ errors.push(`${itemPath}.id must be unique`);
576
+ }
577
+ else {
578
+ ids.add(category.id);
579
+ }
580
+ validateStringArray(category.labels, `${itemPath}.labels`, errors);
581
+ validateStringArray(category.types, `${itemPath}.types`, errors);
582
+ validateString(category.description, `${itemPath}.description`, errors);
583
+ });
584
+ }
360
585
  function validateSafety(config, errors) {
361
- if (config.safety != null && !isPlainObject(config.safety)) {
362
- errors.push("safety must be an object");
586
+ const safety = config.review?.safety;
587
+ if (safety != null && !isPlainObject(safety)) {
588
+ errors.push("review.safety must be an object");
589
+ }
590
+ validateKnownKeys(safety, "review.safety", SAFETY_KEYS, errors);
591
+ validateStringArray(safety?.allowAuthors, "review.safety.allowAuthors", errors);
592
+ validateStringArray(safety?.blockedPaths, "review.safety.blockedPaths", errors);
593
+ validateStringArray(safety?.requiredLabels, "review.safety.requiredLabels", errors);
594
+ if (safety?.maxChangedFiles != null &&
595
+ (typeof safety.maxChangedFiles !== "number" ||
596
+ !Number.isInteger(safety.maxChangedFiles) ||
597
+ safety.maxChangedFiles < 0)) {
598
+ errors.push("review.safety.maxChangedFiles must be a non-negative integer");
363
599
  }
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");
600
+ }
601
+ function validatePromptObject(prompts, path, keys, errors) {
602
+ if (prompts == null)
603
+ return;
604
+ if (!isPlainObject(prompts)) {
605
+ errors.push(`${path} must be an object`);
606
+ return;
607
+ }
608
+ validateKnownKeys(prompts, path, keys, errors);
609
+ for (const [key, value] of Object.entries(prompts)) {
610
+ if (typeof value !== "string")
611
+ errors.push(`${path}.${key} must be a string`);
373
612
  }
374
613
  }
375
- async function validatePrompts(config, errors, directory) {
376
- if (config.prompts == null)
614
+ function validateTriage(config, errors, options) {
615
+ const triage = config.triage;
616
+ if (!triage)
377
617
  return;
378
- if (!isPlainObject(config.prompts)) {
379
- errors.push("prompts must be an object");
618
+ if (!isPlainObject(triage)) {
619
+ errors.push("triage must be an object");
380
620
  return;
381
621
  }
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`);
622
+ validateKnownKeys(triage, "triage", TRIAGE_KEYS, errors);
623
+ const automation = triage.automation;
624
+ const concurrency = triage.concurrency;
625
+ const safety = triage.safety;
626
+ if (!triage.account)
627
+ errors.push("triage.account is required");
628
+ validateString(triage.account, "triage.account", errors);
629
+ if (!triage.agents)
630
+ errors.push("triage.agents is required");
631
+ validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
632
+ if (Array.isArray(triage.agents)) {
633
+ validateResolvedAgentKeys(resolveAgents(config).triage ?? [], "triage.resolvedAgents", errors);
634
+ }
635
+ validateTriageCreator(triage.creator, "triage.creator", errors, options.modelCatalog);
636
+ if (automation?.create && !triage.creator)
637
+ errors.push("triage.creator is required when triage.automation.create is true");
638
+ if (automation != null && !isPlainObject(automation)) {
639
+ errors.push("triage.automation must be an object");
640
+ }
641
+ validateKnownKeys(automation, "triage.automation", TRIAGE_AUTOMATION_KEYS, errors);
642
+ validateBoolean(automation?.close, "triage.automation.close", errors);
643
+ validateBoolean(automation?.create, "triage.automation.create", errors);
644
+ validateBoolean(automation?.merge, "triage.automation.merge", errors);
645
+ validateBoolean(automation?.review, "triage.automation.review", errors);
646
+ validateStringArray(automation?.clear, "triage.automation.clear", errors);
647
+ if (automation?.review && !automation.create) {
648
+ errors.push("triage.automation.review requires triage.automation.create to be true");
649
+ }
650
+ if (automation?.merge && !automation.create) {
651
+ errors.push("triage.automation.merge requires triage.automation.create to be true");
652
+ }
653
+ validateKnownKeys(concurrency, "triage.concurrency", TRIAGE_CONCURRENCY_KEYS, errors);
654
+ if (concurrency?.runs != null &&
655
+ (typeof concurrency.runs !== "number" ||
656
+ !Number.isInteger(concurrency.runs) ||
657
+ concurrency.runs < 1)) {
658
+ errors.push("triage.concurrency.runs must be a positive integer");
659
+ }
660
+ validateTriageCategories(triage.categories, "triage.categories", errors);
661
+ validateKnownKeys(safety, "triage.safety", TRIAGE_SAFETY_KEYS, errors);
662
+ validateStringArray(safety?.allowAuthors, "triage.safety.allowAuthors", errors);
663
+ validateStringArray(safety?.allowMentionActors, "triage.safety.allowMentionActors", errors);
664
+ validateStringArray(safety?.allowMentionRoles, "triage.safety.allowMentionRoles", errors);
665
+ validateStringArray(safety?.blockedLabels, "triage.safety.blockedLabels", errors);
666
+ validateStringArray(safety?.requiredLabels, "triage.safety.requiredLabels", errors);
667
+ validateString(triage.output, "triage.output", errors);
668
+ validateString(triage.worktree, "triage.worktree", errors);
669
+ }
670
+ async function validatePrompts(config, errors, directory) {
671
+ validatePromptObject(config.review?.prompts, "review.prompts", REVIEW_PROMPT_KEYS, errors);
672
+ validatePromptObject(config.merge?.prompts, "merge.prompts", MERGE_PROMPT_KEYS, errors);
673
+ validatePromptObject(config.triage?.prompts, "triage.prompts", TRIAGE_PROMPT_KEYS, errors);
674
+ const promptEntries = [
675
+ ...Object.entries(config.review?.prompts ?? {}).map(([key, value]) => [`review.prompts.${key}`, value]),
676
+ ...Object.entries(config.merge?.prompts ?? {}).map(([key, value]) => [`merge.prompts.${key}`, value]),
677
+ ...Object.entries(config.triage?.prompts ?? {}).map(([key, value]) => [`triage.prompts.${key}`, value]),
678
+ ];
679
+ await Promise.all(promptEntries.map(async ([path, value]) => {
680
+ if (typeof value !== "string")
386
681
  return;
387
- }
388
682
  if (!directory)
389
683
  return;
390
684
  const fullPath = promptPath(directory, value);
@@ -392,17 +686,21 @@ async function validatePrompts(config, errors, directory) {
392
686
  await access(fullPath, constants.R_OK);
393
687
  }
394
688
  catch {
395
- errors.push(`prompts.${key} file is not readable: ${value}`);
689
+ errors.push(`${path} file is not readable: ${value}`);
396
690
  }
397
691
  }));
398
692
  }
399
693
  async function validateAuth(config, exec, errors) {
400
694
  const accounts = new Set();
401
- const agents = resolveAgents(config.agents);
695
+ const agents = resolveAgents(config);
402
696
  for (const reviewer of agents.reviewers)
403
697
  accounts.add(reviewer.account);
404
698
  if (agents.editor)
405
699
  accounts.add(agents.editor.account);
700
+ if (config.triage?.account)
701
+ accounts.add(config.triage.account);
702
+ if (agents.triageCreator?.account)
703
+ accounts.add(agents.triageCreator.account);
406
704
  await Promise.all([...accounts].filter(Boolean).map(async (account) => {
407
705
  try {
408
706
  await exec(`gh auth token${ghHostOption(config)} --user ${JSON.stringify(account)}`);
@@ -414,13 +712,35 @@ async function validateAuth(config, exec, errors) {
414
712
  }
415
713
  async function fetchPermissions(config, exec, account) {
416
714
  const token = (await exec(`gh auth token${ghHostOption(config)} --user ${JSON.stringify(account)}`)).trim();
417
- const raw = await exec(`GH_TOKEN=${JSON.stringify(token)} gh api${ghHostOption(config)} repos/${config.github?.owner}/${config.github?.repo} --jq .permissions`);
715
+ const raw = await exec(`gh api${ghHostOption(config)} repos/${config.github?.owner}/${config.github?.repo} --jq .permissions`, { env: { GH_TOKEN: token } });
418
716
  return JSON.parse(raw);
419
717
  }
718
+ async function validateWorktreeConfig(config, exec, options, errors) {
719
+ const agents = resolveAgents(config);
720
+ const checkEditor = Boolean(agents.editor && (options.requireEditor || options.requireWorktreeConfig));
721
+ const checkTriageCreator = Boolean(config.triage?.automation?.create &&
722
+ agents.triageCreator &&
723
+ (options.requireTriage || options.requireWorktreeConfig));
724
+ if (!checkEditor && !checkTriageCreator)
725
+ return;
726
+ if (!exec)
727
+ return;
728
+ const error = "git config extensions.worktreeConfig must be true when editor or triage PR creator is configured";
729
+ try {
730
+ const value = (await exec("git config --bool --get extensions.worktreeConfig"))
731
+ .trim()
732
+ .toLowerCase();
733
+ if (value !== "true")
734
+ errors.push(error);
735
+ }
736
+ catch {
737
+ errors.push(error);
738
+ }
739
+ }
420
740
  async function validateRepositoryPermissions(config, exec, errors, warnings) {
421
741
  if (!config.github?.owner || !config.github.repo)
422
742
  return;
423
- const agents = resolveAgents(config.agents);
743
+ const agents = resolveAgents(config);
424
744
  await Promise.all(agents.reviewers.map(async (reviewer) => {
425
745
  try {
426
746
  const permissions = await fetchPermissions(config, exec, reviewer.account);
@@ -432,6 +752,29 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
432
752
  warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
433
753
  }
434
754
  }));
755
+ if (config.triage?.account) {
756
+ try {
757
+ const permissions = await fetchPermissions(config, exec, config.triage.account);
758
+ if (!permissions.pull) {
759
+ errors.push(`GitHub account cannot read repository for issue triage: ${config.triage.account}`);
760
+ }
761
+ }
762
+ catch (error) {
763
+ warnings.push(`Could not validate repository permissions for GitHub account: ${config.triage.account} (${error.message})`);
764
+ }
765
+ }
766
+ if (agents.triageCreator?.account &&
767
+ agents.triageCreator.account !== config.triage?.account) {
768
+ try {
769
+ const permissions = await fetchPermissions(config, exec, agents.triageCreator.account);
770
+ if (!permissions.push) {
771
+ errors.push(`GitHub account cannot push to repository for triage PR creation: ${agents.triageCreator.account}`);
772
+ }
773
+ }
774
+ catch (error) {
775
+ warnings.push(`Could not validate repository permissions for GitHub account: ${agents.triageCreator.account} (${error.message})`);
776
+ }
777
+ }
435
778
  if (!agents.editor)
436
779
  return;
437
780
  try {
@@ -454,70 +797,41 @@ export async function validateConfig(config, options = {}) {
454
797
  validateKnownKeys(config, "config", CONFIG_KEYS, errors);
455
798
  validateString(config.$schema, "$schema", errors);
456
799
  validateString(config.language, "language", errors);
457
- if (!config.agents) {
458
- errors.push("agents is required");
800
+ if (config.agents != null && !isPlainObject(config.agents)) {
801
+ errors.push("agents must be an object");
459
802
  }
460
803
  else {
461
- if (!isPlainObject(config.agents)) {
462
- errors.push("agents must be an object");
804
+ validateKnownKeys(config.agents, "agents", AGENTS_KEYS, errors);
805
+ validatePermissionConfig(config.agents?.permissions, "agents.permissions", errors);
806
+ }
807
+ if ((options.requireReview ?? true) && !config.review) {
808
+ errors.push("review is required");
809
+ }
810
+ else if (config.review) {
811
+ if (!isPlainObject(config.review)) {
812
+ errors.push("review must be an object");
463
813
  }
464
814
  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
- }
815
+ validateKnownKeys(config.review, "review", REVIEW_KEYS, errors);
510
816
  }
511
- if (Array.isArray(config.agents.reviewers)) {
512
- validateResolvedReviewers(resolveAgents(config.agents).reviewers, "agents.resolvedReviewers", errors);
817
+ if (!config.review.agents)
818
+ errors.push("review.agents is required");
819
+ validateReviewerList(config.review.agents, "review.agents", errors, options.modelCatalog);
820
+ if (Array.isArray(config.review.agents)) {
821
+ validateResolvedReviewers(resolveAgents(config).reviewers, "review.resolvedAgents", errors);
513
822
  }
514
823
  }
824
+ if (options.requireTriage && !config.triage) {
825
+ errors.push("triage is required");
826
+ }
515
827
  validateMerge(config, errors, options);
828
+ validateReviewMerge(config, errors);
516
829
  validateAutomation(config, errors);
517
830
  validateClear(config, errors);
518
831
  validateChecks(config, errors);
519
832
  validateConcurrency(config, errors);
520
833
  validateSafety(config, errors);
834
+ validateTriage(config, errors, options);
521
835
  await validatePrompts(config, errors, options.directory);
522
836
  if (config.output != null && !isPlainObject(config.output)) {
523
837
  errors.push("output must be an object");
@@ -530,28 +844,9 @@ export async function validateConfig(config, options = {}) {
530
844
  errors.push("output.repairAttempts must be a non-negative integer");
531
845
  }
532
846
  }
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
- }
847
+ validateString(config.review?.output, "review.output", errors);
848
+ validateString(config.review?.worktree, "review.worktree", errors);
849
+ await validateWorktreeConfig(config, options.exec, options, errors);
555
850
  if (options.checkAuth && !errors.length) {
556
851
  if (!options.exec) {
557
852
  errors.push("validateConfig requires exec when checkAuth is true");