opencode-magi 0.9.0 → 0.11.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 (126) hide show
  1. package/README.ja.md +254 -0
  2. package/README.md +37 -34
  3. package/dist/commands.js +11 -9
  4. package/dist/config/index.js +2 -0
  5. package/dist/config/resolve.js +120 -248
  6. package/dist/config/validate.js +103 -1100
  7. package/dist/constant.js +135 -0
  8. package/dist/graphql/index.generated.js +125 -0
  9. package/dist/graphql/index.js +49 -0
  10. package/dist/index.js +12 -800
  11. package/dist/magi.js +357 -0
  12. package/dist/permissions/common.json +22 -22
  13. package/dist/permissions/editor.json +12 -12
  14. package/dist/prompts/index.js +98 -0
  15. package/dist/prompts/merge/ci-classification/output-contract.md +22 -0
  16. package/dist/prompts/merge/ci-classification/task.md +10 -0
  17. package/dist/prompts/merge/ci-classification/validate.json +20 -0
  18. package/dist/prompts/merge/conflict/output-contract.md +12 -0
  19. package/dist/prompts/merge/conflict/task.md +9 -0
  20. package/dist/prompts/merge/conflict/validate.json +4 -0
  21. package/dist/prompts/merge/edit/output-contract.md +26 -0
  22. package/dist/prompts/merge/edit/task.md +10 -0
  23. package/dist/prompts/merge/edit/validate.json +60 -0
  24. package/dist/prompts/review/ci-classification/output-contract.md +21 -0
  25. package/dist/prompts/review/ci-classification/task.md +7 -0
  26. package/dist/prompts/review/ci-classification/validate.json +20 -0
  27. package/dist/prompts/review/close-reconsideration/output-contract.md +21 -0
  28. package/dist/prompts/review/close-reconsideration/task.md +6 -0
  29. package/dist/prompts/review/close-reconsideration/validate.json +44 -0
  30. package/dist/prompts/review/comment/output-contract.md +11 -0
  31. package/dist/prompts/review/comment/task.md +3 -0
  32. package/dist/prompts/review/comment/validate.json +8 -0
  33. package/dist/prompts/review/finding-validation/output-contract.md +25 -0
  34. package/dist/prompts/{templates/review/finding-validation.md → review/finding-validation/task.md} +2 -3
  35. package/dist/prompts/review/finding-validation/validate.json +21 -0
  36. package/dist/prompts/review/rereview/output-contract.md +30 -0
  37. package/dist/prompts/{templates/review/rereview.md → review/rereview/task.md} +8 -11
  38. package/dist/prompts/review/rereview/validate.json +87 -0
  39. package/dist/prompts/review/review/output-contract.md +25 -0
  40. package/dist/prompts/{templates/review/review.md → review/review/task.md} +2 -5
  41. package/dist/prompts/review/review/validate.json +54 -0
  42. package/dist/prompts/triage/acceptance/output-contract.md +16 -0
  43. package/dist/prompts/triage/acceptance/validate.json +23 -0
  44. package/dist/prompts/triage/category/output-contract.md +16 -0
  45. package/dist/prompts/triage/category/validate.json +23 -0
  46. package/dist/prompts/triage/comment-classification/output-contract.md +15 -0
  47. package/dist/prompts/triage/comment-classification/validate.json +28 -0
  48. package/dist/prompts/triage/create/output-contract.md +28 -0
  49. package/dist/prompts/triage/create/validate.json +73 -0
  50. package/dist/prompts/triage/deplicate/output-contract.md +16 -0
  51. package/dist/prompts/triage/deplicate/validate.json +20 -0
  52. package/dist/prompts/triage/existing/output-contract.md +16 -0
  53. package/dist/prompts/triage/existing/validate.json +13 -0
  54. package/dist/prompts/triage/reconsider/output-contract.md +16 -0
  55. package/dist/prompts/triage/reconsider/validate.json +23 -0
  56. package/dist/prompts/triage/signal/output-contract.md +19 -0
  57. package/dist/prompts/triage/signal/validate.json +18 -0
  58. package/dist/tools/clear/index.js +21 -0
  59. package/dist/tools/index.js +12 -0
  60. package/dist/tools/merge/action.js +47 -0
  61. package/dist/tools/merge/context.js +118 -0
  62. package/dist/tools/merge/editor.js +264 -0
  63. package/dist/tools/merge/index.js +151 -0
  64. package/dist/tools/merge/index.type.js +1 -0
  65. package/dist/tools/merge/merge.js +42 -0
  66. package/dist/tools/merge/report.js +73 -0
  67. package/dist/tools/review/action.js +429 -0
  68. package/dist/tools/review/check.js +295 -0
  69. package/dist/tools/review/context.js +258 -0
  70. package/dist/tools/review/index.js +104 -0
  71. package/dist/tools/review/index.type.js +1 -0
  72. package/dist/tools/review/report.js +143 -0
  73. package/dist/tools/review/review.js +145 -0
  74. package/dist/tools/review/reviewer.js +409 -0
  75. package/dist/tools/triage/index.js +16 -0
  76. package/dist/tools/validate/index.js +28 -0
  77. package/dist/utils/array.js +6 -0
  78. package/dist/utils/assertion.js +38 -0
  79. package/dist/utils/exec.js +14 -0
  80. package/dist/utils/fs.js +24 -0
  81. package/dist/utils/function.js +37 -0
  82. package/dist/utils/github.js +63 -0
  83. package/dist/utils/index.js +10 -0
  84. package/dist/utils/index.type.js +1 -0
  85. package/dist/utils/object.js +21 -0
  86. package/dist/utils/opencode.js +25 -0
  87. package/dist/utils/string.js +38 -0
  88. package/dist/utils/worker.js +30 -0
  89. package/package.json +20 -8
  90. package/schema.json +9 -7
  91. package/dist/config/load.js +0 -62
  92. package/dist/config/output.js +0 -25
  93. package/dist/config/worktree.js +0 -25
  94. package/dist/github/commands.js +0 -905
  95. package/dist/github/retry.js +0 -44
  96. package/dist/orchestrator/abort.js +0 -9
  97. package/dist/orchestrator/ci.js +0 -579
  98. package/dist/orchestrator/findings.js +0 -88
  99. package/dist/orchestrator/inline-comments.js +0 -73
  100. package/dist/orchestrator/majority.js +0 -62
  101. package/dist/orchestrator/merge.js +0 -1260
  102. package/dist/orchestrator/model.js +0 -216
  103. package/dist/orchestrator/pool.js +0 -15
  104. package/dist/orchestrator/report.js +0 -175
  105. package/dist/orchestrator/review-context.js +0 -342
  106. package/dist/orchestrator/review.js +0 -1478
  107. package/dist/orchestrator/run-manager.js +0 -2132
  108. package/dist/orchestrator/safety.js +0 -44
  109. package/dist/orchestrator/triage.js +0 -1370
  110. package/dist/prompts/compose.js +0 -473
  111. package/dist/prompts/contracts.js +0 -300
  112. package/dist/prompts/output.js +0 -401
  113. package/dist/prompts/templates/merge/ci-classification.md +0 -16
  114. package/dist/prompts/templates/merge/conflict.md +0 -10
  115. package/dist/prompts/templates/merge/edit.md +0 -15
  116. package/dist/prompts/templates/review/ci-classification.md +0 -9
  117. package/dist/prompts/templates/review/close-reconsideration.md +0 -6
  118. /package/dist/{types.js → config/index.type.js} +0 -0
  119. /package/dist/prompts/{templates/triage/acceptance.md → triage/acceptance/task.md} +0 -0
  120. /package/dist/prompts/{templates/triage/category.md → triage/category/task.md} +0 -0
  121. /package/dist/prompts/{templates/triage/comment-classification.md → triage/comment-classification/task.md} +0 -0
  122. /package/dist/prompts/{templates/triage/create.md → triage/create/task.md} +0 -0
  123. /package/dist/prompts/{templates/triage/duplicate.md → triage/deplicate/task.md} +0 -0
  124. /package/dist/prompts/{templates/triage/existing-pr.md → triage/existing/task.md} +0 -0
  125. /package/dist/prompts/{templates/triage/reconsider.md → triage/reconsider/task.md} +0 -0
  126. /package/dist/prompts/{templates/triage/signal.md → triage/signal/task.md} +0 -0
@@ -1,1117 +1,120 @@
1
1
  import { Ajv2020 } from "ajv/dist/2020";
2
- import { constants } from "node:fs";
3
- import { access } from "node:fs/promises";
4
- import { homedir } from "node:os";
5
- import { isAbsolute, join } from "node:path";
2
+ import { command, createExecWithGitHubApiRetry, filterEmpty } from "@/utils";
6
3
  import schema from "../../schema.json" with { type: "json" };
7
- import { resolveAgents, reviewerKey, triageAgentKey, validateReviewerId, } from "./resolve";
8
- const RESERVED_REVIEWER_KEYS = new Set(["editor", "orchestrator", "system"]);
9
- const PERMISSION_ACTIONS = new Set(["allow", "ask", "deny"]);
10
- const AJV = new Ajv2020({ allErrors: true, strict: false });
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"]);
14
- const CONFIG_KEYS = new Set([
15
- "$schema",
16
- "agents",
17
- "clear",
18
- "github",
19
- "language",
20
- "merge",
21
- "output",
22
- "review",
23
- "triage",
24
- ]);
25
- const AGENTS_KEYS = new Set(["permissions"]);
26
- const REVIEWER_KEYS = new Set([
27
- "account",
28
- "id",
29
- "model",
30
- "permissions",
31
- "persona",
32
- ]);
33
- const EDITOR_KEYS = new Set([
34
- "account",
35
- "author",
36
- "model",
37
- "permissions",
38
- "persona",
39
- ]);
40
- const TRIAGE_AGENT_KEYS = new Set([
41
- "account",
42
- "id",
43
- "model",
44
- "permissions",
45
- "persona",
46
- ]);
47
- const TRIAGE_CREATOR_KEYS = new Set([
48
- "account",
49
- "author",
50
- "model",
51
- "permissions",
52
- "persona",
53
- ]);
54
- const AUTHOR_KEYS = new Set(["email", "name"]);
55
- const GITHUB_KEYS = new Set(["apiRetryAttempts", "host", "owner", "repo"]);
56
- const REVIEW_KEYS = new Set([
57
- "account",
58
- "automation",
59
- "checks",
60
- "concurrency",
61
- "merge",
62
- "mode",
63
- "output",
64
- "prompts",
65
- "reviewers",
66
- "safety",
67
- "worktree",
68
- ]);
69
- const MERGE_KEYS = new Set([
70
- "automation",
71
- "checks",
72
- "editor",
73
- "maxThreadResolutionCycles",
74
- "prompts",
75
- ]);
76
- const TRIAGE_KEYS = new Set([
77
- "automation",
78
- "categories",
79
- "concurrency",
80
- "creator",
81
- "output",
82
- "prompts",
83
- "reporter",
84
- "safety",
85
- "signals",
86
- "voters",
87
- "worktree",
88
- ]);
89
- const REVIEW_MERGE_KEYS = new Set([
90
- "approvalPolicy",
91
- "auto",
92
- "deleteBranch",
93
- "method",
94
- "queue",
95
- ]);
96
- const REVIEW_CHECKS_KEYS = new Set(["exclude", "retryFailedJobs", "wait"]);
97
- const MERGE_CHECKS_KEYS = new Set(["wait"]);
98
- const AUTOMATION_KEYS = new Set(["close", "merge"]);
99
- const MERGE_AUTOMATION_KEYS = new Set(["close", "conflict", "merge"]);
100
- const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
101
- const CONCURRENCY_KEYS = new Set(["reviewers", "runs"]);
102
- const OUTPUT_KEYS = new Set(["repairAttempts"]);
103
- const TRIAGE_AUTOMATION_KEYS = new Set([
104
- "close",
105
- "create",
106
- "label",
107
- "merge",
108
- "review",
109
- ]);
110
- const TRIAGE_CATEGORY_KEYS = new Set(["description", "id", "labels", "types"]);
111
- const TRIAGE_LABEL_RULE_KEYS = new Set(["add", "remove", "when"]);
112
- const TRIAGE_LABEL_RULE_WHEN_KEYS = new Set([
113
- "category",
114
- "disposition",
115
- "signals",
116
- ]);
117
- const TRIAGE_CONCURRENCY_KEYS = new Set(["runs"]);
118
- const TRIAGE_SAFETY_KEYS = new Set([
119
- "allowAuthors",
120
- "allowMentionActors",
121
- "allowMentionRoles",
122
- "blockedLabels",
123
- "requiredLabels",
124
- ]);
125
- const TRIAGE_SIGNAL_KEYS = new Set(["description", "id"]);
126
- const TRIAGE_DISPOSITIONS = new Set([
127
- "accepted",
128
- "rejected",
129
- "invalid",
130
- "duplicate",
131
- "already_handled",
132
- "needs_category",
133
- "needs_acceptance",
134
- "blocked",
135
- "failed",
136
- ]);
137
- const SAFETY_KEYS = new Set([
138
- "allowAuthors",
139
- "blockedPaths",
140
- "maxChangedFiles",
141
- "requiredLabels",
142
- ]);
143
- const REVIEW_PROMPT_KEYS = new Set([
144
- "ciClassification",
145
- "closeReconsideration",
146
- "findingValidation",
147
- "rereview",
148
- "review",
149
- "reviewGuidelines",
150
- ]);
151
- const MERGE_PROMPT_KEYS = new Set([
152
- "ciClassification",
153
- "edit",
154
- "editGuidelines",
155
- ]);
156
- const TRIAGE_PROMPT_KEYS = new Set([
157
- "acceptance",
158
- "category",
159
- "commentClassification",
160
- "create",
161
- "createGuidelines",
162
- "duplicate",
163
- "existingPr",
164
- "reconsider",
165
- ]);
166
- const MODEL_CANDIDATE_KEYS = new Set(["id", "options"]);
167
- function githubHost(config) {
168
- return config.github?.host ?? "github.com";
169
- }
170
- function ghHostOption(config) {
171
- const host = githubHost(config);
172
- return host === "github.com" ? "" : ` --hostname ${JSON.stringify(host)}`;
173
- }
174
- function isPlainObject(value) {
175
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
176
- }
177
- function expandAgentRefUse(value, path, refs, refsInvalid, errors) {
178
- if (!isPlainObject(value) || !Object.hasOwn(value, "ref"))
179
- return value;
180
- const use = { ...value };
181
- const ref = use.ref;
182
- delete use.ref;
183
- if (typeof ref !== "string") {
184
- errors.push(`${path}.ref must be a string`);
185
- return use;
186
- }
187
- if (refsInvalid) {
188
- errors.push(`agents.refs must be an object to resolve ${path}.ref`);
189
- return use;
190
- }
191
- const preset = refs?.[ref];
192
- if (preset == null) {
193
- errors.push(`${path}.ref references unknown agents.refs preset: ${ref}`);
194
- return use;
195
- }
196
- if (!isPlainObject(preset)) {
197
- errors.push(`agents.refs.${ref} must be an object when referenced by ${path}.ref`);
198
- return use;
199
- }
200
- const presetFields = { ...preset };
201
- delete presetFields.ref;
202
- return { ...presetFields, ...use };
203
- }
204
- function expandAgentRefs(config, errors) {
205
- if (!config || typeof config !== "object")
206
- return;
207
- const magiConfig = config;
208
- const agents = magiConfig.agents;
209
- const refsValue = isPlainObject(agents) ? agents.refs : undefined;
210
- const refsInvalid = refsValue != null && !isPlainObject(refsValue);
211
- const refs = isPlainObject(refsValue) ? refsValue : undefined;
212
- if (Array.isArray(magiConfig.review?.reviewers)) {
213
- magiConfig.review.reviewers = magiConfig.review.reviewers.map((agent, index) => expandAgentRefUse(agent, `review.reviewers[${index}]`, refs, refsInvalid, errors));
214
- }
215
- if (isPlainObject(magiConfig.merge?.editor)) {
216
- magiConfig.merge.editor = expandAgentRefUse(magiConfig.merge.editor, "merge.editor", refs, refsInvalid, errors);
217
- }
218
- if (Array.isArray(magiConfig.triage?.voters)) {
219
- magiConfig.triage.voters = magiConfig.triage.voters.map((agent, index) => expandAgentRefUse(agent, `triage.voters[${index}]`, refs, refsInvalid, errors));
220
- }
221
- if (isPlainObject(magiConfig.triage?.creator)) {
222
- magiConfig.triage.creator = expandAgentRefUse(magiConfig.triage.creator, "triage.creator", refs, refsInvalid, errors);
223
- }
224
- if (isPlainObject(magiConfig.agents))
225
- delete magiConfig.agents.refs;
226
- }
227
- function validateKnownKeys(value, path, keys, errors) {
228
- if (!isPlainObject(value))
229
- return;
230
- for (const key of Object.keys(value)) {
231
- if (!keys.has(key))
232
- errors.push(`${path}.${key} is not supported`);
233
- }
234
- }
235
- function validateJsonSchema(config, errors) {
236
- if (!validateSchema(config)) {
237
- for (const error of validateSchema.errors ?? []) {
238
- const path = error.instancePath || "config";
239
- errors.push(`schema ${path}: ${error.message ?? "invalid value"}`);
240
- }
241
- }
242
- }
243
- function validateString(value, path, errors) {
244
- if (value != null && typeof value !== "string") {
245
- errors.push(`${path} must be a string`);
246
- }
247
- }
248
- function validateBoolean(value, path, errors) {
249
- if (value != null && typeof value !== "boolean") {
250
- errors.push(`${path} must be a boolean`);
251
- }
252
- }
253
- function validateBooleanObject(value, path, keys, errors) {
254
- if (value != null && !isPlainObject(value)) {
255
- errors.push(`${path} must be an object`);
256
- return;
257
- }
258
- validateKnownKeys(value, path, keys, errors);
259
- if (!isPlainObject(value))
260
- return;
261
- for (const key of keys)
262
- validateBoolean(value[key], `${path}.${key}`, errors);
263
- }
264
- function promptPath(directory, path) {
265
- if (path === "~")
266
- return homedir();
267
- if (path.startsWith("~/"))
268
- return join(homedir(), path.slice(2));
269
- return isAbsolute(path) ? path : join(directory, path);
270
- }
271
- function isPermissionAction(value) {
272
- return typeof value === "string" && PERMISSION_ACTIONS.has(value);
273
- }
274
- function validatePermissionConfig(permission, path, errors) {
275
- if (permission == null)
276
- return;
277
- if (isPermissionAction(permission))
278
- return;
279
- if (!isPlainObject(permission)) {
280
- errors.push(`${path} must be allow, ask, deny, or an object`);
281
- return;
282
- }
283
- for (const [key, value] of Object.entries(permission)) {
284
- if (isPermissionAction(value))
285
- continue;
286
- if (!isPlainObject(value)) {
287
- errors.push(`${path}.${key} must be allow, ask, deny, or an object`);
288
- continue;
289
- }
290
- for (const [pattern, action] of Object.entries(value)) {
291
- if (!isPermissionAction(action)) {
292
- errors.push(`${path}.${key}.${pattern} must be allow, ask, or deny`);
293
- }
294
- }
295
- }
296
- }
297
- function modelValidationError(model, path, catalog) {
298
- const slash = model.indexOf("/");
299
- if (slash <= 0 || slash === model.length - 1)
300
- return `${path} must be a full OpenCode model ID in provider/model form`;
301
- if (!catalog)
302
- return undefined;
303
- const providerId = model.slice(0, slash);
304
- const modelId = model.slice(slash + 1);
305
- const models = catalog[providerId];
306
- if (!models)
307
- return `${path} uses unknown OpenCode provider: ${providerId}`;
308
- if (!models.includes(modelId))
309
- return `${path} uses unknown OpenCode model: ${model}`;
310
- return undefined;
311
- }
312
- function validateModelId(model, path, errors, catalog) {
313
- const error = modelValidationError(model, path, catalog);
314
- if (error) {
315
- errors.push(error);
316
- return false;
317
- }
318
- return true;
319
- }
320
- function readModelCandidate(value, path, errors) {
321
- if (typeof value === "string")
322
- return { id: value };
323
- if (!isPlainObject(value)) {
324
- errors.push(`${path} must be a string or an object`);
325
- return undefined;
326
- }
327
- validateKnownKeys(value, path, MODEL_CANDIDATE_KEYS, errors);
328
- if (typeof value.id !== "string") {
329
- errors.push(`${path}.id must be a string`);
330
- return undefined;
331
- }
332
- if (value.options != null && !isPlainObject(value.options)) {
333
- errors.push(`${path}.options must be an object`);
334
- return undefined;
335
- }
336
- return { id: value.id, options: value.options };
337
- }
338
- function validateAndNormalizeModel(target, path, errors, catalog) {
339
- const model = target.model;
340
- if (typeof model === "string") {
341
- validateModelId(model, path, errors, catalog);
342
- return;
343
- }
344
- if (isPlainObject(model)) {
345
- const candidate = readModelCandidate(model, path, errors);
346
- if (candidate &&
347
- validateModelId(candidate.id, `${path}.id`, errors, catalog)) {
348
- target.model = candidate.id;
349
- if (candidate.options)
350
- target.options = candidate.options;
351
- else
352
- delete target.options;
353
- }
354
- return;
355
- }
356
- if (!Array.isArray(model)) {
357
- if (model != null)
358
- errors.push(`${path} must be a string, an object, or an array`);
359
- return;
360
- }
361
- if (!model.length) {
362
- errors.push(`${path} must contain at least one model candidate`);
363
- return;
364
- }
365
- if (!catalog) {
366
- errors.push(`${path} requires an OpenCode model catalog`);
367
- return;
368
- }
369
- const candidateErrors = [];
370
- for (const [index, value] of model.entries()) {
371
- const candidatePath = `${path}[${index}]`;
372
- const candidate = readModelCandidate(value, candidatePath, errors);
373
- if (!candidate)
374
- continue;
375
- const idPath = isPlainObject(value) ? `${candidatePath}.id` : candidatePath;
376
- const error = modelValidationError(candidate.id, idPath, catalog);
377
- if (!error) {
378
- target.model = candidate.id;
379
- if (candidate.options)
380
- target.options = candidate.options;
381
- else
382
- delete target.options;
383
- return;
384
- }
385
- candidateErrors.push(error);
386
- }
387
- errors.push(`${path} must contain at least one usable OpenCode model candidate${candidateErrors.length ? ` (${candidateErrors.join("; ")})` : ""}`);
388
- }
389
- function validateReviewerList(reviewers, path, errors, catalog, mode = "single") {
390
- if (reviewers == null)
391
- return;
392
- if (!Array.isArray(reviewers)) {
393
- errors.push(`${path} must be an array`);
394
- return;
395
- }
396
- if (reviewers.length < 3)
397
- errors.push(`${path} must contain at least 3 reviewers`);
398
- if (reviewers.length % 2 === 0)
399
- errors.push(`${path} must contain an odd number of reviewers`);
400
- reviewers.forEach((reviewer, index) => {
401
- if (!reviewer || typeof reviewer !== "object") {
402
- errors.push(`${path}[${index}] must be an object`);
403
- return;
404
- }
405
- validateKnownKeys(reviewer, `${path}[${index}]`, REVIEWER_KEYS, errors);
406
- if (!reviewer.model)
407
- errors.push(`${path}[${index}].model is required`);
408
- validateAndNormalizeModel(reviewer, `${path}[${index}].model`, errors, catalog);
409
- if (mode === "multi" && !reviewer.account)
410
- errors.push(`${path}[${index}].account is required`);
411
- validateString(reviewer.account, `${path}[${index}].account`, errors);
412
- validateString(reviewer.persona, `${path}[${index}].persona`, errors);
413
- validatePermissionConfig(reviewer.permissions, `${path}[${index}].permissions`, errors);
414
- if (reviewer.id) {
415
- if (!validateReviewerId(reviewer.id)) {
416
- errors.push(`${path}[${index}].id may contain only letters, numbers, underscores, and hyphens`);
417
- }
418
- if (RESERVED_REVIEWER_KEYS.has(reviewer.id)) {
419
- errors.push(`${path}[${index}].id is reserved: ${reviewer.id}`);
420
- }
421
- }
422
- });
423
- }
424
- function validateTriageAgentList(voters, path, errors, catalog) {
425
- if (voters == null)
426
- return;
427
- if (!Array.isArray(voters)) {
428
- errors.push(`${path} must be an array`);
429
- return;
430
- }
431
- if (voters.length < 3)
432
- errors.push(`${path} must contain at least 3 voters`);
433
- if (voters.length % 2 === 0)
434
- errors.push(`${path} must contain an odd number of voters`);
435
- voters.forEach((agent, index) => {
436
- if (!agent || typeof agent !== "object") {
437
- errors.push(`${path}[${index}] must be an object`);
438
- return;
439
- }
440
- validateKnownKeys(agent, `${path}[${index}]`, TRIAGE_AGENT_KEYS, errors);
441
- if (!agent.model)
442
- errors.push(`${path}[${index}].model is required`);
443
- validateAndNormalizeModel(agent, `${path}[${index}].model`, errors, catalog);
444
- if (!agent.account)
445
- errors.push(`${path}[${index}].account is required`);
446
- validateString(agent.account, `${path}[${index}].account`, errors);
447
- validateString(agent.persona, `${path}[${index}].persona`, errors);
448
- validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
449
- if (agent.id) {
450
- if (!validateReviewerId(agent.id)) {
451
- errors.push(`${path}[${index}].id may contain only letters, numbers, underscores, and hyphens`);
452
- }
453
- if (RESERVED_REVIEWER_KEYS.has(agent.id)) {
454
- errors.push(`${path}[${index}].id is reserved: ${agent.id}`);
455
- }
456
- }
457
- });
458
- }
459
- function validateResolvedReviewers(reviewers, path, errors, mode = "single") {
460
- const keys = new Set();
461
- const accounts = new Set();
462
- for (const reviewer of reviewers) {
463
- if (keys.has(reviewer.key))
464
- errors.push(`${path} has duplicate reviewer key: ${reviewer.key}`);
465
- keys.add(reviewer.key);
466
- if (mode === "multi" && accounts.has(reviewer.account))
467
- errors.push(`${path} has duplicate reviewer account: ${reviewer.account}`);
468
- accounts.add(reviewer.account);
469
- }
470
- }
471
- function reviewMode(config) {
472
- return config.review?.mode === "multi" ? "multi" : "single";
473
- }
474
- function validateReviewIdentity(config, errors) {
475
- const mode = config.review?.mode;
476
- if (mode != null && mode !== "multi" && mode !== "single") {
477
- errors.push("review.mode must be multi or single");
478
- }
479
- validateString(config.review?.account, "review.account", errors);
480
- if ((mode == null || mode === "single") && !config.review?.account) {
481
- errors.push("review.account is required when review.mode is single");
482
- }
483
- }
484
- function validateResolvedTriageAgents(agents, path, errors) {
485
- const keys = new Set();
486
- const accounts = new Set();
487
- for (const agent of agents) {
488
- if (keys.has(agent.key))
489
- errors.push(`${path} has duplicate agent key: ${agent.key}`);
490
- keys.add(agent.key);
491
- if (accounts.has(agent.account))
492
- errors.push(`${path} has duplicate agent account: ${agent.account}`);
493
- accounts.add(agent.account);
494
- }
495
- }
496
- function validateEditor(editor, path, errors, catalog) {
497
- if (!editor)
498
- return;
499
- if (!isPlainObject(editor)) {
500
- errors.push(`${path} must be an object`);
501
- return;
502
- }
503
- if (!editor.model)
504
- errors.push(`${path}.model is required`);
505
- validateKnownKeys(editor, path, EDITOR_KEYS, errors);
506
- validateAndNormalizeModel(editor, `${path}.model`, errors, catalog);
507
- validateString(editor.account, `${path}.account`, errors);
508
- validateString(editor.persona, `${path}.persona`, errors);
509
- if (!editor.account)
510
- errors.push(`${path}.account is required`);
511
- validatePermissionConfig(editor.permissions, `${path}.permissions`, errors);
512
- const author = editor.author;
513
- if (!author || !isPlainObject(author)) {
514
- if (author != null)
515
- errors.push(`${path}.author must be an object`);
516
- errors.push(`${path}.author.name is required`);
517
- errors.push(`${path}.author.email is required`);
4
+ function required(required, value, name) {
5
+ return required && !value ? `${name} is required` : undefined;
6
+ }
7
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
8
+ const validateSchema = ajv.compile(schema);
9
+ function schemaErrors(config) {
10
+ if (validateSchema(config))
11
+ return [];
12
+ return (validateSchema.errors ?? []).map((e) => `schema ${e.instancePath || "config"}: ${e.message ?? "invalid value"}`);
13
+ }
14
+ function requiredErrors(config, { creator = false, editor = false, github = true, reviewers = false, voters = false, } = {}) {
15
+ creator ||= !!config.triage.creator;
16
+ editor ||= !!config.merge.editor;
17
+ reviewers ||= !!config.review.reviewers;
18
+ voters ||= !!config.triage.voters;
19
+ const errors = [
20
+ required(github, config.github.owner, "github.owner"),
21
+ required(github, config.github.repo, "github.repo"),
22
+ required(reviewers, config.review.reviewers, "review.reviewers"),
23
+ ...(config.review.reviewers ?? []).map((reviewer, index) => required(reviewers || !!reviewer, reviewer.model, `review.reviewers[${index}].model`)),
24
+ required(editor, config.merge.editor, "merge.editor"),
25
+ required(editor, config.merge.editor?.model, "merge.editor.model"),
26
+ required(editor, config.merge.editor?.author, "merge.editor.author"),
27
+ required(voters, config.triage.voters, "triage.voters"),
28
+ ...(config.triage.voters ?? []).map((voter, index) => required(voters || !!voter, voter.model, `triage.voters[${index}].model`)),
29
+ required(creator, config.triage.creator, "triage.creator"),
30
+ required(creator, config.triage.creator?.model, "triage.creator.model"),
31
+ required(creator, config.triage.creator?.author, "triage.creator.author"),
32
+ ];
33
+ if (config.mode === "single") {
34
+ errors.push(required(true, config.account, "account"));
518
35
  }
519
36
  else {
520
- validateKnownKeys(author, `${path}.author`, AUTHOR_KEYS, errors);
521
- if (!author.name) {
522
- errors.push(`${path}.author.name is required`);
523
- }
524
- else if (typeof author.name !== "string") {
525
- errors.push(`${path}.author.name must be a string`);
526
- }
527
- if (!author.email) {
528
- errors.push(`${path}.author.email is required`);
529
- }
530
- else if (typeof author.email !== "string") {
531
- errors.push(`${path}.author.email must be a string`);
532
- }
533
- }
534
- }
535
- function validateTriageCreator(creator, path, errors, catalog) {
536
- if (!creator)
537
- return;
538
- if (!isPlainObject(creator)) {
539
- errors.push(`${path} must be an object`);
540
- return;
541
- }
542
- validateKnownKeys(creator, path, TRIAGE_CREATOR_KEYS, errors);
543
- if (!creator.model)
544
- errors.push(`${path}.model is required`);
545
- validateString(creator.account, `${path}.account`, errors);
546
- validateAndNormalizeModel(creator, `${path}.model`, errors, catalog);
547
- validateString(creator.persona, `${path}.persona`, errors);
548
- validatePermissionConfig(creator.permissions, `${path}.permissions`, errors);
549
- const author = creator.author;
550
- if (!author || !isPlainObject(author)) {
551
- if (author != null)
552
- errors.push(`${path}.author must be an object`);
553
- errors.push(`${path}.author.name is required`);
554
- errors.push(`${path}.author.email is required`);
555
- return;
556
- }
557
- validateKnownKeys(author, `${path}.author`, AUTHOR_KEYS, errors);
558
- if (!author.name)
559
- errors.push(`${path}.author.name is required`);
560
- validateString(author.name, `${path}.author.name`, errors);
561
- if (!author.email)
562
- errors.push(`${path}.author.email is required`);
563
- validateString(author.email, `${path}.author.email`, errors);
564
- }
565
- function validateMerge(config, errors, options) {
566
- const merge = config.merge;
567
- if (options.requireGithub ?? true) {
568
- if (!config.github?.owner)
569
- errors.push("github.owner is required");
570
- if (!config.github?.repo)
571
- errors.push("github.repo is required");
572
- }
573
- validateKnownKeys(config.github, "github", GITHUB_KEYS, errors);
574
- validateString(config.github?.host, "github.host", errors);
575
- validateString(config.github?.owner, "github.owner", errors);
576
- validateString(config.github?.repo, "github.repo", errors);
577
- if (config.github != null && !isPlainObject(config.github)) {
578
- errors.push("github must be an object");
579
- }
580
- if (config.github?.apiRetryAttempts != null &&
581
- (typeof config.github.apiRetryAttempts !== "number" ||
582
- !Number.isInteger(config.github.apiRetryAttempts) ||
583
- config.github.apiRetryAttempts < 0)) {
584
- errors.push("github.apiRetryAttempts must be a non-negative integer");
585
- }
586
- if (merge != null && !isPlainObject(merge)) {
587
- errors.push("merge must be an object");
588
- }
589
- validateKnownKeys(merge, "merge", MERGE_KEYS, errors);
590
- validateBooleanObject(merge?.automation, "merge.automation", MERGE_AUTOMATION_KEYS, errors);
591
- const checks = merge?.checks;
592
- validateKnownKeys(checks, "merge.checks", MERGE_CHECKS_KEYS, errors);
593
- validateBoolean(checks?.wait, "merge.checks.wait", errors);
594
- validateEditor(merge?.editor, "merge.editor", errors, options.modelCatalog);
595
- if (merge?.maxThreadResolutionCycles != null &&
596
- (typeof merge.maxThreadResolutionCycles !== "number" ||
597
- !Number.isInteger(merge.maxThreadResolutionCycles) ||
598
- merge.maxThreadResolutionCycles < 0)) {
599
- errors.push("merge.maxThreadResolutionCycles must be a non-negative integer");
600
- }
601
- if (options.requireEditor && !merge?.editor)
602
- errors.push("merge.editor is required");
603
- }
604
- function validateReviewMerge(config, errors) {
605
- const merge = config.review?.merge;
606
- if (merge != null && !isPlainObject(merge)) {
607
- errors.push("review.merge must be an object");
608
- }
609
- validateKnownKeys(merge, "review.merge", REVIEW_MERGE_KEYS, errors);
610
- validateBoolean(merge?.auto, "review.merge.auto", errors);
611
- validateBoolean(merge?.deleteBranch, "review.merge.deleteBranch", errors);
612
- validateBoolean(merge?.queue, "review.merge.queue", errors);
613
- if (merge?.method != null &&
614
- (typeof merge.method !== "string" ||
615
- !["merge", "rebase", "squash"].includes(merge.method))) {
616
- errors.push("review.merge.method must be merge, squash, or rebase");
617
- }
618
- if (merge?.approvalPolicy != null &&
619
- (typeof merge.approvalPolicy !== "string" ||
620
- !["majority", "unanimous"].includes(merge.approvalPolicy))) {
621
- errors.push("review.merge.approvalPolicy must be majority or unanimous");
622
- }
623
- }
624
- function validateConcurrency(config, errors) {
625
- const concurrency = config.review?.concurrency;
626
- if (concurrency != null && !isPlainObject(concurrency)) {
627
- errors.push("review.concurrency must be an object");
628
- }
629
- validateKnownKeys(concurrency, "review.concurrency", CONCURRENCY_KEYS, errors);
630
- if (concurrency?.runs != null) {
631
- if (typeof concurrency.runs !== "number" ||
632
- !Number.isInteger(concurrency.runs) ||
633
- concurrency.runs < 1) {
634
- errors.push("review.concurrency.runs must be a positive integer");
635
- }
636
- }
637
- if (concurrency?.reviewers != null) {
638
- if (typeof concurrency.reviewers !== "number" ||
639
- !Number.isInteger(concurrency.reviewers) ||
640
- concurrency.reviewers < 1) {
641
- errors.push("review.concurrency.reviewers must be a positive integer");
642
- }
643
- }
644
- }
645
- function validateAutomation(config, errors) {
646
- validateBooleanObject(config.review?.automation, "review.automation", AUTOMATION_KEYS, errors);
647
- }
648
- function validateClear(config, errors) {
649
- validateBooleanObject(config.clear, "clear", CLEAR_KEYS, errors);
650
- }
651
- function validateChecks(config, errors) {
652
- const checks = config.review?.checks;
653
- if (checks != null && !isPlainObject(checks)) {
654
- errors.push("review.checks must be an object");
655
- }
656
- validateKnownKeys(checks, "review.checks", REVIEW_CHECKS_KEYS, errors);
657
- if (checks?.exclude != null) {
658
- if (!Array.isArray(checks.exclude)) {
659
- errors.push("review.checks.exclude must be an array");
660
- }
661
- else {
662
- checks.exclude.forEach((item, index) => {
663
- if (typeof item !== "string")
664
- errors.push(`review.checks.exclude[${index}] must be a string`);
665
- });
666
- }
667
- }
668
- if (checks?.wait != null && typeof checks.wait !== "boolean") {
669
- errors.push("review.checks.wait must be a boolean");
670
- }
671
- if (checks?.retryFailedJobs != null &&
672
- (typeof checks.retryFailedJobs !== "number" ||
673
- !Number.isInteger(checks.retryFailedJobs) ||
674
- checks.retryFailedJobs < 0)) {
675
- errors.push("review.checks.retryFailedJobs must be a non-negative integer");
676
- }
677
- }
678
- function validateStringArray(value, path, errors) {
679
- if (value == null)
680
- return;
681
- if (!Array.isArray(value)) {
682
- errors.push(`${path} must be an array`);
683
- return;
684
- }
685
- value.forEach((item, index) => {
686
- if (typeof item !== "string")
687
- errors.push(`${path}[${index}] must be a string`);
688
- });
689
- }
690
- function validateTriageCategories(categories, path, errors) {
691
- if (categories == null)
692
- return;
693
- if (!Array.isArray(categories)) {
694
- errors.push(`${path} must be an array`);
695
- return;
696
- }
697
- const ids = new Set();
698
- categories.forEach((item, index) => {
699
- const itemPath = `${path}[${index}]`;
700
- if (!isPlainObject(item)) {
701
- errors.push(`${itemPath} must be an object`);
702
- return;
703
- }
704
- const category = item;
705
- validateKnownKeys(category, itemPath, TRIAGE_CATEGORY_KEYS, errors);
706
- if (!category.id) {
707
- errors.push(`${itemPath}.id is required`);
708
- }
709
- else if (typeof category.id !== "string") {
710
- errors.push(`${itemPath}.id must be a string`);
711
- }
712
- else if (!TRIAGE_CATEGORY_ID_PATTERN.test(category.id)) {
713
- errors.push(`${itemPath}.id must match /^[A-Za-z0-9_-]+$/`);
714
- }
715
- else if (RESERVED_TRIAGE_CATEGORY_IDS.has(category.id)) {
716
- errors.push(`${itemPath}.id is reserved: ${category.id}`);
717
- }
718
- else if (ids.has(category.id)) {
719
- errors.push(`${itemPath}.id must be unique`);
720
- }
721
- else {
722
- ids.add(category.id);
723
- }
724
- validateStringArray(category.labels, `${itemPath}.labels`, errors);
725
- validateStringArray(category.types, `${itemPath}.types`, errors);
726
- validateString(category.description, `${itemPath}.description`, errors);
727
- });
728
- }
729
- function validateTriageSignals(signals, path, errors) {
730
- if (signals == null)
731
- return;
732
- if (!Array.isArray(signals)) {
733
- errors.push(`${path} must be an array`);
734
- return;
735
- }
736
- const ids = new Set();
737
- signals.forEach((item, index) => {
738
- const itemPath = `${path}[${index}]`;
739
- if (!isPlainObject(item)) {
740
- errors.push(`${itemPath} must be an object`);
741
- return;
742
- }
743
- const signal = item;
744
- validateKnownKeys(signal, itemPath, TRIAGE_SIGNAL_KEYS, errors);
745
- if (!signal.id) {
746
- errors.push(`${itemPath}.id is required`);
747
- }
748
- else if (typeof signal.id !== "string") {
749
- errors.push(`${itemPath}.id must be a string`);
750
- }
751
- else if (!TRIAGE_CATEGORY_ID_PATTERN.test(signal.id)) {
752
- errors.push(`${itemPath}.id must match /^[A-Za-z0-9_-]+$/`);
753
- }
754
- else if (ids.has(signal.id)) {
755
- errors.push(`${itemPath}.id must be unique`);
756
- }
757
- else {
758
- ids.add(signal.id);
759
- }
760
- if (!signal.description) {
761
- errors.push(`${itemPath}.description is required`);
762
- }
763
- else {
764
- validateString(signal.description, `${itemPath}.description`, errors);
765
- }
37
+ errors.push(...(config.review.reviewers ?? []).map((reviewer, index) => required(reviewers, reviewer.account, `review.reviewers[${index}].account`)));
38
+ errors.push(required(editor, config.merge.editor?.account, "merge.editor.account"));
39
+ errors.push(...(config.triage.voters ?? []).map((voter, index) => required(voters, voter.account, `triage.voters[${index}].account`)));
40
+ errors.push(required(creator, config.triage.creator?.account, "triage.creator.account"));
41
+ }
42
+ return filterEmpty(errors);
43
+ }
44
+ function duplicateErrors(values, message) {
45
+ const seen = new Set();
46
+ return values.flatMap((value) => {
47
+ if (seen.has(value))
48
+ return [message(value)];
49
+ seen.add(value);
50
+ return [];
766
51
  });
767
52
  }
768
- function validateTriageLabelRules(rules, path, errors) {
769
- if (rules == null)
770
- return;
771
- if (!Array.isArray(rules)) {
772
- errors.push(`${path} must be an array`);
773
- return;
774
- }
775
- rules.forEach((item, index) => {
776
- const itemPath = `${path}[${index}]`;
777
- if (!isPlainObject(item)) {
778
- errors.push(`${itemPath} must be an object`);
779
- return;
780
- }
781
- const rule = item;
782
- validateKnownKeys(rule, itemPath, TRIAGE_LABEL_RULE_KEYS, errors);
783
- validateStringArray(rule.add, `${itemPath}.add`, errors);
784
- validateStringArray(rule.remove, `${itemPath}.remove`, errors);
785
- if (!isPlainObject(rule.when)) {
786
- errors.push(`${itemPath}.when must be an object`);
787
- return;
788
- }
789
- validateKnownKeys(rule.when, `${itemPath}.when`, TRIAGE_LABEL_RULE_WHEN_KEYS, errors);
790
- if (!Object.keys(rule.when).length) {
791
- errors.push(`${itemPath}.when must not be empty`);
792
- }
793
- if (rule.when.disposition != null &&
794
- (typeof rule.when.disposition !== "string" ||
795
- !TRIAGE_DISPOSITIONS.has(rule.when.disposition))) {
796
- errors.push(`${itemPath}.when.disposition must be a triage disposition`);
797
- }
798
- validateString(rule.when.category, `${itemPath}.when.category`, errors);
799
- validateStringArray(rule.when.signals, `${itemPath}.when.signals`, errors);
800
- });
801
- }
802
- function validateSafety(config, errors) {
803
- const safety = config.review?.safety;
804
- if (safety != null && !isPlainObject(safety)) {
805
- errors.push("review.safety must be an object");
806
- }
807
- validateKnownKeys(safety, "review.safety", SAFETY_KEYS, errors);
808
- validateStringArray(safety?.allowAuthors, "review.safety.allowAuthors", errors);
809
- validateStringArray(safety?.blockedPaths, "review.safety.blockedPaths", errors);
810
- validateStringArray(safety?.requiredLabels, "review.safety.requiredLabels", errors);
811
- if (safety?.maxChangedFiles != null &&
812
- (typeof safety.maxChangedFiles !== "number" ||
813
- !Number.isInteger(safety.maxChangedFiles) ||
814
- safety.maxChangedFiles < 0)) {
815
- errors.push("review.safety.maxChangedFiles must be a non-negative integer");
816
- }
817
- }
818
- function validatePromptObject(prompts, path, keys, errors) {
819
- if (prompts == null)
820
- return;
821
- if (!isPlainObject(prompts)) {
822
- errors.push(`${path} must be an object`);
823
- return;
824
- }
825
- validateKnownKeys(prompts, path, keys, errors);
826
- for (const [key, value] of Object.entries(prompts)) {
827
- if (typeof value !== "string")
828
- errors.push(`${path}.${key} must be a string`);
829
- }
830
- }
831
- function validateTriage(config, errors, options) {
832
- const triage = config.triage;
833
- if (!triage)
834
- return;
835
- if (!isPlainObject(triage)) {
836
- errors.push("triage must be an object");
837
- return;
838
- }
839
- validateKnownKeys(triage, "triage", TRIAGE_KEYS, errors);
840
- const automation = triage.automation;
841
- const concurrency = triage.concurrency;
842
- const creator = triage.creator;
843
- const reporter = typeof triage.reporter === "string" ? triage.reporter : undefined;
844
- const safety = triage.safety;
845
- if (!triage.voters)
846
- errors.push("triage.voters is required");
847
- validateTriageAgentList(triage.voters, "triage.voters", errors, options.modelCatalog);
848
- if (Array.isArray(triage.voters)) {
849
- const resolvedTriageAgents = triage.voters.map((agent, index) => ({
850
- account: agent && typeof agent === "object" && typeof agent.account === "string"
851
- ? agent.account
852
- : "",
853
- key: agent && typeof agent === "object" ? triageAgentKey(agent, index) : "",
854
- }));
855
- validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
856
- if (reporter != null &&
857
- !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
858
- errors.push(`triage.reporter must match a triage voter key: ${reporter}`);
859
- }
860
- }
861
- validateString(triage.reporter, "triage.reporter", errors);
862
- validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog);
863
- if (automation?.create && !creator)
864
- errors.push("triage.creator is required when triage.automation.create is true");
865
- if (automation?.create && creator && !creator.account)
866
- errors.push("triage.creator.account is required when triage.automation.create is true");
867
- if (automation != null && !isPlainObject(automation)) {
868
- errors.push("triage.automation must be an object");
869
- }
870
- validateKnownKeys(automation, "triage.automation", TRIAGE_AUTOMATION_KEYS, errors);
871
- validateBoolean(automation?.close, "triage.automation.close", errors);
872
- validateBoolean(automation?.create, "triage.automation.create", errors);
873
- validateBoolean(automation?.merge, "triage.automation.merge", errors);
874
- validateBoolean(automation?.review, "triage.automation.review", errors);
875
- validateTriageLabelRules(automation?.label, "triage.automation.label", errors);
876
- if (automation?.review && !automation.create) {
877
- errors.push("triage.automation.review requires triage.automation.create to be true");
878
- }
879
- if (automation?.merge && !automation.create) {
880
- errors.push("triage.automation.merge requires triage.automation.create to be true");
881
- }
882
- validateKnownKeys(concurrency, "triage.concurrency", TRIAGE_CONCURRENCY_KEYS, errors);
883
- if (concurrency?.runs != null &&
884
- (typeof concurrency.runs !== "number" ||
885
- !Number.isInteger(concurrency.runs) ||
886
- concurrency.runs < 1)) {
887
- errors.push("triage.concurrency.runs must be a positive integer");
888
- }
889
- validateTriageCategories(triage.categories, "triage.categories", errors);
890
- validateTriageSignals(triage.signals, "triage.signals", errors);
891
- validateKnownKeys(safety, "triage.safety", TRIAGE_SAFETY_KEYS, errors);
892
- validateStringArray(safety?.allowAuthors, "triage.safety.allowAuthors", errors);
893
- validateStringArray(safety?.allowMentionActors, "triage.safety.allowMentionActors", errors);
894
- validateStringArray(safety?.allowMentionRoles, "triage.safety.allowMentionRoles", errors);
895
- validateStringArray(safety?.blockedLabels, "triage.safety.blockedLabels", errors);
896
- validateStringArray(safety?.requiredLabels, "triage.safety.requiredLabels", errors);
897
- validateString(triage.output, "triage.output", errors);
898
- validateString(triage.worktree, "triage.worktree", errors);
899
- }
900
- async function validatePrompts(config, errors, directory) {
901
- validatePromptObject(config.review?.prompts, "review.prompts", REVIEW_PROMPT_KEYS, errors);
902
- validatePromptObject(config.merge?.prompts, "merge.prompts", MERGE_PROMPT_KEYS, errors);
903
- validatePromptObject(config.triage?.prompts, "triage.prompts", TRIAGE_PROMPT_KEYS, errors);
904
- const promptEntries = [
905
- ...Object.entries(config.review?.prompts ?? {}).map(([key, value]) => [`review.prompts.${key}`, value]),
906
- ...Object.entries(config.merge?.prompts ?? {}).map(([key, value]) => [`merge.prompts.${key}`, value]),
907
- ...Object.entries(config.triage?.prompts ?? {}).map(([key, value]) => [`triage.prompts.${key}`, value]),
53
+ function agentGroupErrors(agents, path) {
54
+ if (!agents)
55
+ return [];
56
+ return [
57
+ ...(agents.length % 2 === 0
58
+ ? [`${path} must contain an odd number of agents`]
59
+ : []),
60
+ ...duplicateErrors(agents.map(({ id }) => id), (value) => `${path} has duplicate id: ${value}`),
908
61
  ];
909
- await Promise.all(promptEntries.map(async ([path, value]) => {
910
- if (typeof value !== "string")
911
- return;
912
- if (!directory)
913
- return;
914
- const fullPath = promptPath(directory, value);
915
- try {
916
- await access(fullPath, constants.R_OK);
917
- }
918
- catch {
919
- errors.push(`${path} file is not readable: ${value}`);
920
- }
921
- }));
922
- }
923
- async function validateAuth(config, exec, errors) {
924
- const accounts = new Set();
925
- const agents = resolveAgents(config);
926
- if (reviewMode(config) === "single") {
927
- if (config.review?.account)
928
- accounts.add(config.review.account);
929
- }
930
- else {
931
- for (const reviewer of agents.reviewers)
932
- accounts.add(reviewer.account);
933
- }
934
- for (const agent of agents.triage ?? [])
935
- accounts.add(agent.account);
936
- if (agents.editor)
937
- accounts.add(agents.editor.account);
938
- if (agents.triageCreator?.account)
939
- accounts.add(agents.triageCreator.account);
940
- await Promise.all([...accounts].filter(Boolean).map(async (account) => {
941
- try {
942
- await exec(`gh auth token${ghHostOption(config)} --user ${JSON.stringify(account)}`);
943
- }
944
- catch {
945
- errors.push(`GitHub account is not authenticated: ${account}`);
946
- }
947
- }));
948
62
  }
949
- async function fetchPermissions(config, exec, account) {
950
- const token = (await exec(`gh auth token${ghHostOption(config)} --user ${JSON.stringify(account)}`)).trim();
951
- const raw = await exec(`gh api${ghHostOption(config)} repos/${config.github?.owner}/${config.github?.repo} --jq .permissions`, { env: { GH_TOKEN: token } });
952
- return JSON.parse(raw);
63
+ function groupErrors(config) {
64
+ const reviewerIds = new Set((config.review.reviewers ?? []).map(({ id }) => id));
65
+ const voterIds = new Set((config.triage.voters ?? []).map(({ id }) => id));
66
+ return [
67
+ ...agentGroupErrors(config.review.reviewers, "review.reviewers"),
68
+ ...(config.review.operator && !reviewerIds.has(config.review.operator)
69
+ ? [`review.operator must match a configured review reviewer id`]
70
+ : []),
71
+ ...agentGroupErrors(config.triage.voters, "triage.voters"),
72
+ ...(config.triage.operator && !voterIds.has(config.triage.operator)
73
+ ? [`triage.operator must match a configured triage voter id`]
74
+ : []),
75
+ ];
953
76
  }
954
- async function validateWorktreeConfig(config, exec, options, errors) {
955
- const checkEditor = Boolean(config.merge?.editor &&
956
- (options.requireEditor || options.requireWorktreeConfig));
957
- const checkTriageCreator = Boolean(config.triage?.automation?.create &&
958
- config.triage?.creator &&
959
- (options.requireTriage || options.requireWorktreeConfig));
960
- if (!checkEditor && !checkTriageCreator)
961
- return;
962
- if (!exec)
963
- return;
964
- const error = "git config extensions.worktreeConfig must be true when editor or triage PR creator is configured";
77
+ async function authError(config, exec, account) {
965
78
  try {
966
- const value = (await exec("git config --bool --get extensions.worktreeConfig"))
967
- .trim()
968
- .toLowerCase();
969
- if (value !== "true")
970
- errors.push(error);
79
+ await createExecWithGitHubApiRetry(exec, config.github.retryApiAttempts)(command("gh", "auth", "token", "--user", JSON.stringify(account)));
80
+ return undefined;
971
81
  }
972
82
  catch {
973
- errors.push(error);
974
- }
975
- }
976
- async function validateRepositoryPermissions(config, exec, errors, warnings) {
977
- if (!config.github?.owner || !config.github.repo)
978
- return;
979
- const agents = resolveAgents(config);
980
- const reviewAccounts = reviewMode(config) === "single"
981
- ? config.review?.account
982
- ? [config.review.account]
983
- : []
984
- : agents.reviewers.map((reviewer) => reviewer.account);
985
- await Promise.all(reviewAccounts.map(async (account) => {
986
- try {
987
- const permissions = await fetchPermissions(config, exec, account);
988
- if (!permissions.pull) {
989
- errors.push(`GitHub account cannot read repository for PR review: ${account}`);
990
- }
991
- }
992
- catch (error) {
993
- warnings.push(`Could not validate repository permissions for GitHub account: ${account} (${error.message})`);
994
- }
995
- }));
996
- await Promise.all((agents.triage ?? []).map(async (agent) => {
997
- try {
998
- const permissions = await fetchPermissions(config, exec, agent.account);
999
- if (!permissions.pull) {
1000
- errors.push(`GitHub account cannot read repository for issue triage: ${agent.account}`);
1001
- }
1002
- }
1003
- catch (error) {
1004
- warnings.push(`Could not validate repository permissions for GitHub account: ${agent.account} (${error.message})`);
1005
- }
1006
- }));
1007
- if (agents.triageCreator?.account) {
1008
- try {
1009
- const permissions = await fetchPermissions(config, exec, agents.triageCreator.account);
1010
- if (!permissions.push) {
1011
- errors.push(`GitHub account cannot push to repository for triage PR creation: ${agents.triageCreator.account}`);
1012
- }
1013
- }
1014
- catch (error) {
1015
- warnings.push(`Could not validate repository permissions for GitHub account: ${agents.triageCreator.account} (${error.message})`);
1016
- }
1017
- }
1018
- if (!agents.editor)
1019
- return;
1020
- try {
1021
- const permissions = await fetchPermissions(config, exec, agents.editor.account);
1022
- if (!permissions.push) {
1023
- errors.push(`GitHub account cannot push to repository for editor operations: ${agents.editor.account}`);
1024
- }
1025
- }
1026
- catch (error) {
1027
- warnings.push(`Could not validate repository permissions for GitHub account: ${agents.editor.account} (${error.message})`);
83
+ return `Account is not authenticated: ${account}`;
1028
84
  }
1029
85
  }
1030
- export async function validateConfig(config, options = {}) {
1031
- const errors = [];
1032
- const warnings = [];
1033
- if (!config || typeof config !== "object")
1034
- errors.push("config must be an object");
1035
- if (options.requireModelCatalog && !options.modelCatalog) {
1036
- errors.push("OpenCode model catalog could not be loaded");
1037
- }
1038
- expandAgentRefs(config, errors);
1039
- if (config && typeof config === "object")
1040
- validateJsonSchema(config, errors);
1041
- validateKnownKeys(config, "config", CONFIG_KEYS, errors);
1042
- validateString(config.$schema, "$schema", errors);
1043
- validateString(config.language, "language", errors);
1044
- if (config.agents != null && !isPlainObject(config.agents)) {
1045
- errors.push("agents must be an object");
86
+ async function authErrors(config, exec) {
87
+ if (config.mode === "single") {
88
+ return filterEmpty([await authError(config, exec, config.account)]);
1046
89
  }
1047
90
  else {
1048
- validateKnownKeys(config.agents, "agents", AGENTS_KEYS, errors);
1049
- validatePermissionConfig(config.agents?.permissions, "agents.permissions", errors);
1050
- }
1051
- if ((options.requireReview ?? true) && !config.review) {
1052
- errors.push("review is required");
1053
- }
1054
- else if (config.review) {
1055
- const mode = reviewMode(config);
1056
- if (!isPlainObject(config.review)) {
1057
- errors.push("review must be an object");
1058
- }
1059
- else {
1060
- validateKnownKeys(config.review, "review", REVIEW_KEYS, errors);
1061
- }
1062
- validateReviewIdentity(config, errors);
1063
- if (!config.review.reviewers)
1064
- errors.push("review.reviewers is required");
1065
- validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog, mode);
1066
- if (Array.isArray(config.review.reviewers)) {
1067
- validateResolvedReviewers(config.review.reviewers.map((reviewer, index) => ({
1068
- account: reviewer &&
1069
- typeof reviewer === "object" &&
1070
- typeof reviewer.account === "string"
1071
- ? reviewer.account
1072
- : "",
1073
- key: reviewer && typeof reviewer === "object"
1074
- ? reviewerKey(reviewer, index)
1075
- : "",
1076
- })), "review.resolvedReviewers", errors, mode);
1077
- }
1078
- }
1079
- if (options.requireTriage && !config.triage) {
1080
- errors.push("triage is required");
1081
- }
1082
- validateMerge(config, errors, options);
1083
- validateReviewMerge(config, errors);
1084
- validateAutomation(config, errors);
1085
- validateClear(config, errors);
1086
- validateChecks(config, errors);
1087
- validateConcurrency(config, errors);
1088
- validateSafety(config, errors);
1089
- validateTriage(config, errors, options);
1090
- await validatePrompts(config, errors, options.directory);
1091
- if (config.output != null && !isPlainObject(config.output)) {
1092
- errors.push("output must be an object");
1093
- }
1094
- validateKnownKeys(config.output, "output", OUTPUT_KEYS, errors);
1095
- if (config.output?.repairAttempts != null) {
1096
- if (typeof config.output.repairAttempts !== "number" ||
1097
- !Number.isInteger(config.output.repairAttempts) ||
1098
- config.output.repairAttempts < 0) {
1099
- errors.push("output.repairAttempts must be a non-negative integer");
1100
- }
1101
- }
1102
- validateString(config.review?.output, "review.output", errors);
1103
- validateString(config.review?.worktree, "review.worktree", errors);
1104
- await validateWorktreeConfig(config, options.exec, options, errors);
1105
- if (options.checkAuth && !errors.length) {
1106
- if (!options.exec) {
1107
- errors.push("validateConfig requires exec when checkAuth is true");
1108
- }
1109
- else {
1110
- await validateAuth(config, options.exec, errors);
1111
- if (!errors.length) {
1112
- await validateRepositoryPermissions(config, options.exec, errors, warnings);
1113
- }
1114
- }
1115
- }
1116
- return { errors, ok: errors.length === 0, warnings };
91
+ const accounts = {
92
+ ...Object.fromEntries(config.review.reviewers?.map(({ account }, index) => [
93
+ account,
94
+ `review.reviewers[${index}]`,
95
+ ]) ?? []),
96
+ ...Object.fromEntries(config.triage.voters?.map(({ account }, index) => [
97
+ account,
98
+ `triage.voters[${index}]`,
99
+ ]) ?? []),
100
+ };
101
+ if (config.merge.editor)
102
+ accounts[config.merge.editor.account] = "merge.editor";
103
+ if (config.triage.creator)
104
+ accounts[config.triage.creator.account] = "triage.creator";
105
+ return filterEmpty([
106
+ ...duplicateErrors(Object.keys(accounts), (value) => `${accounts[value]} has duplicate account: ${value}`),
107
+ ...(await Promise.all(Object.keys(accounts).map((account) => authError(config, exec, account)))),
108
+ ]);
109
+ }
110
+ }
111
+ export async function validateConfig(config, { exec, require } = {}) {
112
+ const errors = [
113
+ ...schemaErrors(config),
114
+ ...requiredErrors(config, require),
115
+ ...groupErrors(config),
116
+ ];
117
+ if (!errors.length && exec)
118
+ errors.push(...(await authErrors(config, exec)));
119
+ return errors;
1117
120
  }