opencode-magi 0.0.0-dev-20260519011027
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.
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/commands.js +18 -0
- package/dist/config/load.js +62 -0
- package/dist/config/output.js +16 -0
- package/dist/config/resolve.js +113 -0
- package/dist/config/validate.js +580 -0
- package/dist/config/worktree.js +13 -0
- package/dist/github/commands.js +398 -0
- package/dist/github/retry.js +44 -0
- package/dist/index.js +540 -0
- package/dist/orchestrator/abort.js +9 -0
- package/dist/orchestrator/ci.js +568 -0
- package/dist/orchestrator/findings.js +66 -0
- package/dist/orchestrator/majority.js +48 -0
- package/dist/orchestrator/merge.js +836 -0
- package/dist/orchestrator/model.js +202 -0
- package/dist/orchestrator/pool.js +15 -0
- package/dist/orchestrator/report.js +168 -0
- package/dist/orchestrator/review.js +791 -0
- package/dist/orchestrator/run-manager.js +1670 -0
- package/dist/orchestrator/safety.js +44 -0
- package/dist/permissions/common.json +24 -0
- package/dist/permissions/editor.json +7 -0
- package/dist/prompts/compose.js +298 -0
- package/dist/prompts/contracts.js +189 -0
- package/dist/prompts/output.js +260 -0
- package/dist/prompts/templates/ci-classification-after-edit.md +16 -0
- package/dist/prompts/templates/ci-classification.md +9 -0
- package/dist/prompts/templates/close-reconsideration.md +6 -0
- package/dist/prompts/templates/edit.md +9 -0
- package/dist/prompts/templates/finding-validation.md +7 -0
- package/dist/prompts/templates/rereview-close-reconsideration.md +6 -0
- package/dist/prompts/templates/rereview.md +16 -0
- package/dist/prompts/templates/review.md +7 -0
- package/dist/types.js +1 -0
- package/package.json +67 -0
- package/schema.json +206 -0
|
@@ -0,0 +1,580 @@
|
|
|
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";
|
|
6
|
+
import schema from "../../schema.json" with { type: "json" };
|
|
7
|
+
import { resolveAgents, 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 CONFIG_KEYS = new Set([
|
|
13
|
+
"$schema",
|
|
14
|
+
"agents",
|
|
15
|
+
"automation",
|
|
16
|
+
"clear",
|
|
17
|
+
"checks",
|
|
18
|
+
"concurrency",
|
|
19
|
+
"github",
|
|
20
|
+
"language",
|
|
21
|
+
"merge",
|
|
22
|
+
"output",
|
|
23
|
+
"prompts",
|
|
24
|
+
"safety",
|
|
25
|
+
"worktree",
|
|
26
|
+
]);
|
|
27
|
+
const AGENTS_KEYS = new Set(["editor", "permissions", "reviewers"]);
|
|
28
|
+
const REVIEWER_KEYS = new Set([
|
|
29
|
+
"account",
|
|
30
|
+
"id",
|
|
31
|
+
"model",
|
|
32
|
+
"options",
|
|
33
|
+
"permission",
|
|
34
|
+
"persona",
|
|
35
|
+
]);
|
|
36
|
+
const EDITOR_KEYS = new Set([
|
|
37
|
+
"account",
|
|
38
|
+
"author",
|
|
39
|
+
"model",
|
|
40
|
+
"options",
|
|
41
|
+
"permission",
|
|
42
|
+
"persona",
|
|
43
|
+
]);
|
|
44
|
+
const AUTHOR_KEYS = new Set(["email", "name"]);
|
|
45
|
+
const GITHUB_KEYS = new Set(["apiRetryAttempts", "host", "owner", "repo"]);
|
|
46
|
+
const MERGE_KEYS = new Set([
|
|
47
|
+
"approvalPolicy",
|
|
48
|
+
"auto",
|
|
49
|
+
"deleteBranch",
|
|
50
|
+
"maxThreadResolutionCycles",
|
|
51
|
+
"mergeQueue",
|
|
52
|
+
"method",
|
|
53
|
+
]);
|
|
54
|
+
const CHECKS_KEYS = new Set([
|
|
55
|
+
"exclude",
|
|
56
|
+
"retryFailedJobs",
|
|
57
|
+
"waitAfterEdit",
|
|
58
|
+
"waitBeforeReview",
|
|
59
|
+
]);
|
|
60
|
+
const AUTOMATION_KEYS = new Set(["close", "merge"]);
|
|
61
|
+
const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
|
|
62
|
+
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(["dirs"]);
|
|
66
|
+
const WORKTREE_DIR_KEYS = new Set(["pr"]);
|
|
67
|
+
const SAFETY_KEYS = new Set([
|
|
68
|
+
"allowAuthors",
|
|
69
|
+
"blockedPaths",
|
|
70
|
+
"maxChangedFiles",
|
|
71
|
+
"requiredLabels",
|
|
72
|
+
]);
|
|
73
|
+
const PROMPT_KEYS = new Set([
|
|
74
|
+
"ciClassification",
|
|
75
|
+
"ciClassificationAfterEdit",
|
|
76
|
+
"closeReconsideration",
|
|
77
|
+
"edit",
|
|
78
|
+
"editGuidelines",
|
|
79
|
+
"findingValidation",
|
|
80
|
+
"report",
|
|
81
|
+
"rereview",
|
|
82
|
+
"rereviewCloseReconsideration",
|
|
83
|
+
"review",
|
|
84
|
+
"reviewGuidelines",
|
|
85
|
+
]);
|
|
86
|
+
function githubHost(config) {
|
|
87
|
+
return config.github?.host ?? "github.com";
|
|
88
|
+
}
|
|
89
|
+
function ghHostOption(config) {
|
|
90
|
+
const host = githubHost(config);
|
|
91
|
+
return host === "github.com" ? "" : ` --hostname ${JSON.stringify(host)}`;
|
|
92
|
+
}
|
|
93
|
+
function isPlainObject(value) {
|
|
94
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
95
|
+
}
|
|
96
|
+
function validateKnownKeys(value, path, keys, errors) {
|
|
97
|
+
if (!isPlainObject(value))
|
|
98
|
+
return;
|
|
99
|
+
for (const key of Object.keys(value)) {
|
|
100
|
+
if (!keys.has(key))
|
|
101
|
+
errors.push(`${path}.${key} is not supported`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function validateJsonSchema(config, errors) {
|
|
105
|
+
if (!validateSchema(config)) {
|
|
106
|
+
for (const error of validateSchema.errors ?? []) {
|
|
107
|
+
const path = error.instancePath || "config";
|
|
108
|
+
errors.push(`schema ${path}: ${error.message ?? "invalid value"}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function validateString(value, path, errors) {
|
|
113
|
+
if (value != null && typeof value !== "string") {
|
|
114
|
+
errors.push(`${path} must be a string`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function validateBoolean(value, path, errors) {
|
|
118
|
+
if (value != null && typeof value !== "boolean") {
|
|
119
|
+
errors.push(`${path} must be a boolean`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function validateBooleanObject(value, path, keys, errors) {
|
|
123
|
+
if (value != null && !isPlainObject(value)) {
|
|
124
|
+
errors.push(`${path} must be an object`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
validateKnownKeys(value, path, keys, errors);
|
|
128
|
+
if (!isPlainObject(value))
|
|
129
|
+
return;
|
|
130
|
+
for (const key of keys)
|
|
131
|
+
validateBoolean(value[key], `${path}.${key}`, errors);
|
|
132
|
+
}
|
|
133
|
+
function promptPath(directory, path) {
|
|
134
|
+
if (path === "~")
|
|
135
|
+
return homedir();
|
|
136
|
+
if (path.startsWith("~/"))
|
|
137
|
+
return join(homedir(), path.slice(2));
|
|
138
|
+
return isAbsolute(path) ? path : join(directory, path);
|
|
139
|
+
}
|
|
140
|
+
function isPermissionAction(value) {
|
|
141
|
+
return typeof value === "string" && PERMISSION_ACTIONS.has(value);
|
|
142
|
+
}
|
|
143
|
+
function validatePermissionConfig(permission, path, errors) {
|
|
144
|
+
if (permission == null)
|
|
145
|
+
return;
|
|
146
|
+
if (isPermissionAction(permission))
|
|
147
|
+
return;
|
|
148
|
+
if (!isPlainObject(permission)) {
|
|
149
|
+
errors.push(`${path} must be allow, ask, deny, or an object`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (const [key, value] of Object.entries(permission)) {
|
|
153
|
+
if (isPermissionAction(value))
|
|
154
|
+
continue;
|
|
155
|
+
if (!isPlainObject(value)) {
|
|
156
|
+
errors.push(`${path}.${key} must be allow, ask, deny, or an object`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
for (const [pattern, action] of Object.entries(value)) {
|
|
160
|
+
if (!isPermissionAction(action)) {
|
|
161
|
+
errors.push(`${path}.${key}.${pattern} must be allow, ask, or deny`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function validateModel(model, path, errors, catalog) {
|
|
167
|
+
if (!model)
|
|
168
|
+
return;
|
|
169
|
+
const slash = model.indexOf("/");
|
|
170
|
+
if (slash <= 0 || slash === model.length - 1) {
|
|
171
|
+
errors.push(`${path} must be a full OpenCode model ID in provider/model form`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!catalog)
|
|
175
|
+
return;
|
|
176
|
+
const providerId = model.slice(0, slash);
|
|
177
|
+
const modelId = model.slice(slash + 1);
|
|
178
|
+
const models = catalog[providerId];
|
|
179
|
+
if (!models) {
|
|
180
|
+
errors.push(`${path} uses unknown OpenCode provider: ${providerId}`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (!models.includes(modelId)) {
|
|
184
|
+
errors.push(`${path} uses unknown OpenCode model: ${model}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function validateReviewerList(reviewers, path, errors, catalog) {
|
|
188
|
+
if (reviewers == null)
|
|
189
|
+
return;
|
|
190
|
+
if (!Array.isArray(reviewers)) {
|
|
191
|
+
errors.push(`${path} must be an array`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (reviewers.length < 3)
|
|
195
|
+
errors.push(`${path} must contain at least 3 reviewers`);
|
|
196
|
+
if (reviewers.length % 2 === 0)
|
|
197
|
+
errors.push(`${path} must contain an odd number of reviewers`);
|
|
198
|
+
reviewers.forEach((reviewer, index) => {
|
|
199
|
+
if (!reviewer || typeof reviewer !== "object") {
|
|
200
|
+
errors.push(`${path}[${index}] must be an object`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
validateKnownKeys(reviewer, `${path}[${index}]`, REVIEWER_KEYS, errors);
|
|
204
|
+
if (!reviewer.model)
|
|
205
|
+
errors.push(`${path}[${index}].model is required`);
|
|
206
|
+
validateString(reviewer.model, `${path}[${index}].model`, errors);
|
|
207
|
+
validateModel(reviewer.model, `${path}[${index}].model`, errors, catalog);
|
|
208
|
+
if (!reviewer.account)
|
|
209
|
+
errors.push(`${path}[${index}].account is required`);
|
|
210
|
+
validateString(reviewer.account, `${path}[${index}].account`, errors);
|
|
211
|
+
validateString(reviewer.persona, `${path}[${index}].persona`, errors);
|
|
212
|
+
if (reviewer.options != null && !isPlainObject(reviewer.options))
|
|
213
|
+
errors.push(`${path}[${index}].options must be an object`);
|
|
214
|
+
validatePermissionConfig(reviewer.permission, `${path}[${index}].permission`, errors);
|
|
215
|
+
if (reviewer.id) {
|
|
216
|
+
if (!validateReviewerId(reviewer.id)) {
|
|
217
|
+
errors.push(`${path}[${index}].id may contain only letters, numbers, underscores, and hyphens`);
|
|
218
|
+
}
|
|
219
|
+
if (RESERVED_REVIEWER_KEYS.has(reviewer.id)) {
|
|
220
|
+
errors.push(`${path}[${index}].id is reserved: ${reviewer.id}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function validateResolvedReviewers(reviewers, path, errors) {
|
|
226
|
+
const keys = new Set();
|
|
227
|
+
const accounts = new Set();
|
|
228
|
+
for (const reviewer of reviewers) {
|
|
229
|
+
if (keys.has(reviewer.key))
|
|
230
|
+
errors.push(`${path} has duplicate reviewer key: ${reviewer.key}`);
|
|
231
|
+
keys.add(reviewer.key);
|
|
232
|
+
if (accounts.has(reviewer.account))
|
|
233
|
+
errors.push(`${path} has duplicate reviewer account: ${reviewer.account}`);
|
|
234
|
+
accounts.add(reviewer.account);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function validateMerge(config, errors, options) {
|
|
238
|
+
if (options.requireGithub ?? true) {
|
|
239
|
+
if (!config.github?.owner)
|
|
240
|
+
errors.push("github.owner is required");
|
|
241
|
+
if (!config.github?.repo)
|
|
242
|
+
errors.push("github.repo is required");
|
|
243
|
+
}
|
|
244
|
+
validateKnownKeys(config.github, "github", GITHUB_KEYS, errors);
|
|
245
|
+
validateString(config.github?.host, "github.host", errors);
|
|
246
|
+
validateString(config.github?.owner, "github.owner", errors);
|
|
247
|
+
validateString(config.github?.repo, "github.repo", errors);
|
|
248
|
+
if (config.github != null && !isPlainObject(config.github)) {
|
|
249
|
+
errors.push("github must be an object");
|
|
250
|
+
}
|
|
251
|
+
if (config.merge != null && !isPlainObject(config.merge)) {
|
|
252
|
+
errors.push("merge must be an object");
|
|
253
|
+
}
|
|
254
|
+
validateKnownKeys(config.merge, "merge", MERGE_KEYS, errors);
|
|
255
|
+
validateBoolean(config.merge?.auto, "merge.auto", errors);
|
|
256
|
+
validateBoolean(config.merge?.deleteBranch, "merge.deleteBranch", errors);
|
|
257
|
+
validateBoolean(config.merge?.mergeQueue, "merge.mergeQueue", errors);
|
|
258
|
+
if (config.github?.apiRetryAttempts != null &&
|
|
259
|
+
(typeof config.github.apiRetryAttempts !== "number" ||
|
|
260
|
+
!Number.isInteger(config.github.apiRetryAttempts) ||
|
|
261
|
+
config.github.apiRetryAttempts < 0)) {
|
|
262
|
+
errors.push("github.apiRetryAttempts must be a non-negative integer");
|
|
263
|
+
}
|
|
264
|
+
if (config.merge?.method != null &&
|
|
265
|
+
(typeof config.merge.method !== "string" ||
|
|
266
|
+
!["merge", "rebase", "squash"].includes(config.merge.method))) {
|
|
267
|
+
errors.push("merge.method must be merge, squash, or rebase");
|
|
268
|
+
}
|
|
269
|
+
if (config.merge?.approvalPolicy != null &&
|
|
270
|
+
(typeof config.merge.approvalPolicy !== "string" ||
|
|
271
|
+
!["majority", "unanimous"].includes(config.merge.approvalPolicy))) {
|
|
272
|
+
errors.push("merge.approvalPolicy must be majority or unanimous");
|
|
273
|
+
}
|
|
274
|
+
if (config.merge?.maxThreadResolutionCycles != null &&
|
|
275
|
+
(typeof config.merge.maxThreadResolutionCycles !== "number" ||
|
|
276
|
+
!Number.isInteger(config.merge.maxThreadResolutionCycles) ||
|
|
277
|
+
config.merge.maxThreadResolutionCycles < 0)) {
|
|
278
|
+
errors.push("merge.maxThreadResolutionCycles must be a non-negative integer");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function validateConcurrency(config, errors) {
|
|
282
|
+
if (config.concurrency != null && !isPlainObject(config.concurrency)) {
|
|
283
|
+
errors.push("concurrency must be an object");
|
|
284
|
+
}
|
|
285
|
+
validateKnownKeys(config.concurrency, "concurrency", CONCURRENCY_KEYS, errors);
|
|
286
|
+
if (config.concurrency?.runs != null) {
|
|
287
|
+
if (typeof config.concurrency.runs !== "number" ||
|
|
288
|
+
!Number.isInteger(config.concurrency.runs) ||
|
|
289
|
+
config.concurrency.runs < 1) {
|
|
290
|
+
errors.push("concurrency.runs must be a positive integer");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (config.concurrency?.reviewers != null) {
|
|
294
|
+
if (typeof config.concurrency.reviewers !== "number" ||
|
|
295
|
+
!Number.isInteger(config.concurrency.reviewers) ||
|
|
296
|
+
config.concurrency.reviewers < 1) {
|
|
297
|
+
errors.push("concurrency.reviewers must be a positive integer");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function validateAutomation(config, errors) {
|
|
302
|
+
if (config.automation != null && !isPlainObject(config.automation)) {
|
|
303
|
+
errors.push("automation must be an object");
|
|
304
|
+
}
|
|
305
|
+
validateKnownKeys(config.automation, "automation", AUTOMATION_KEYS, errors);
|
|
306
|
+
if (config.automation?.merge != null &&
|
|
307
|
+
typeof config.automation.merge !== "boolean") {
|
|
308
|
+
errors.push("automation.merge must be a boolean");
|
|
309
|
+
}
|
|
310
|
+
if (config.automation?.close != null &&
|
|
311
|
+
typeof config.automation.close !== "boolean") {
|
|
312
|
+
errors.push("automation.close must be a boolean");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function validateClear(config, errors) {
|
|
316
|
+
validateBooleanObject(config.clear, "clear", CLEAR_KEYS, errors);
|
|
317
|
+
}
|
|
318
|
+
function validateChecks(config, errors) {
|
|
319
|
+
if (config.checks != null && !isPlainObject(config.checks)) {
|
|
320
|
+
errors.push("checks must be an object");
|
|
321
|
+
}
|
|
322
|
+
validateKnownKeys(config.checks, "checks", CHECKS_KEYS, errors);
|
|
323
|
+
if (config.checks?.exclude != null) {
|
|
324
|
+
if (!Array.isArray(config.checks.exclude)) {
|
|
325
|
+
errors.push("checks.exclude must be an array");
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
config.checks.exclude.forEach((item, index) => {
|
|
329
|
+
if (typeof item !== "string")
|
|
330
|
+
errors.push(`checks.exclude[${index}] must be a string`);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (config.checks?.waitBeforeReview != null &&
|
|
335
|
+
typeof config.checks.waitBeforeReview !== "boolean") {
|
|
336
|
+
errors.push("checks.waitBeforeReview must be a boolean");
|
|
337
|
+
}
|
|
338
|
+
if (config.checks?.waitAfterEdit != null &&
|
|
339
|
+
typeof config.checks.waitAfterEdit !== "boolean") {
|
|
340
|
+
errors.push("checks.waitAfterEdit must be a boolean");
|
|
341
|
+
}
|
|
342
|
+
if (config.checks?.retryFailedJobs != null &&
|
|
343
|
+
(typeof config.checks.retryFailedJobs !== "number" ||
|
|
344
|
+
!Number.isInteger(config.checks.retryFailedJobs) ||
|
|
345
|
+
config.checks.retryFailedJobs < 0)) {
|
|
346
|
+
errors.push("checks.retryFailedJobs must be a non-negative integer");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function validateStringArray(value, path, errors) {
|
|
350
|
+
if (value == null)
|
|
351
|
+
return;
|
|
352
|
+
if (!Array.isArray(value)) {
|
|
353
|
+
errors.push(`${path} must be an array`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
value.forEach((item, index) => {
|
|
357
|
+
if (typeof item !== "string")
|
|
358
|
+
errors.push(`${path}[${index}] must be a string`);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function validateSafety(config, errors) {
|
|
362
|
+
if (config.safety != null && !isPlainObject(config.safety)) {
|
|
363
|
+
errors.push("safety must be an object");
|
|
364
|
+
}
|
|
365
|
+
validateKnownKeys(config.safety, "safety", SAFETY_KEYS, errors);
|
|
366
|
+
validateStringArray(config.safety?.allowAuthors, "safety.allowAuthors", errors);
|
|
367
|
+
validateStringArray(config.safety?.blockedPaths, "safety.blockedPaths", errors);
|
|
368
|
+
validateStringArray(config.safety?.requiredLabels, "safety.requiredLabels", errors);
|
|
369
|
+
if (config.safety?.maxChangedFiles != null &&
|
|
370
|
+
(typeof config.safety.maxChangedFiles !== "number" ||
|
|
371
|
+
!Number.isInteger(config.safety.maxChangedFiles) ||
|
|
372
|
+
config.safety.maxChangedFiles < 0)) {
|
|
373
|
+
errors.push("safety.maxChangedFiles must be a non-negative integer");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async function validatePrompts(config, errors, directory) {
|
|
377
|
+
if (config.prompts == null)
|
|
378
|
+
return;
|
|
379
|
+
if (!isPlainObject(config.prompts)) {
|
|
380
|
+
errors.push("prompts must be an object");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
validateKnownKeys(config.prompts, "prompts", PROMPT_KEYS, errors);
|
|
384
|
+
await Promise.all(Object.entries(config.prompts).map(async ([key, value]) => {
|
|
385
|
+
if (typeof value !== "string") {
|
|
386
|
+
errors.push(`prompts.${key} must be a string`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (!directory)
|
|
390
|
+
return;
|
|
391
|
+
const fullPath = promptPath(directory, value);
|
|
392
|
+
try {
|
|
393
|
+
await access(fullPath, constants.R_OK);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
errors.push(`prompts.${key} file is not readable: ${value}`);
|
|
397
|
+
}
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
async function validateAuth(config, exec, errors) {
|
|
401
|
+
const accounts = new Set();
|
|
402
|
+
const agents = resolveAgents(config.agents);
|
|
403
|
+
for (const reviewer of agents.reviewers)
|
|
404
|
+
accounts.add(reviewer.account);
|
|
405
|
+
if (agents.editor)
|
|
406
|
+
accounts.add(agents.editor.account);
|
|
407
|
+
await Promise.all([...accounts].filter(Boolean).map(async (account) => {
|
|
408
|
+
try {
|
|
409
|
+
await exec(`gh auth token${ghHostOption(config)} --user ${JSON.stringify(account)}`);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
errors.push(`GitHub account is not authenticated: ${account}`);
|
|
413
|
+
}
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
async function fetchPermissions(config, exec, account) {
|
|
417
|
+
const token = (await exec(`gh auth token${ghHostOption(config)} --user ${JSON.stringify(account)}`)).trim();
|
|
418
|
+
const raw = await exec(`GH_TOKEN=${JSON.stringify(token)} gh api${ghHostOption(config)} repos/${config.github?.owner}/${config.github?.repo} --jq .permissions`);
|
|
419
|
+
return JSON.parse(raw);
|
|
420
|
+
}
|
|
421
|
+
async function validateRepositoryPermissions(config, exec, errors, warnings) {
|
|
422
|
+
if (!config.github?.owner || !config.github.repo)
|
|
423
|
+
return;
|
|
424
|
+
const agents = resolveAgents(config.agents);
|
|
425
|
+
await Promise.all(agents.reviewers.map(async (reviewer) => {
|
|
426
|
+
try {
|
|
427
|
+
const permissions = await fetchPermissions(config, exec, reviewer.account);
|
|
428
|
+
if (!permissions.pull) {
|
|
429
|
+
errors.push(`GitHub account cannot read repository for PR review: ${reviewer.account}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
|
|
434
|
+
}
|
|
435
|
+
}));
|
|
436
|
+
if (!agents.editor)
|
|
437
|
+
return;
|
|
438
|
+
try {
|
|
439
|
+
const permissions = await fetchPermissions(config, exec, agents.editor.account);
|
|
440
|
+
if (!permissions.push) {
|
|
441
|
+
errors.push(`GitHub account cannot push to repository for editor operations: ${agents.editor.account}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
warnings.push(`Could not validate repository permissions for GitHub account: ${agents.editor.account} (${error.message})`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
export async function validateConfig(config, options = {}) {
|
|
449
|
+
const errors = [];
|
|
450
|
+
const warnings = [];
|
|
451
|
+
if (!config || typeof config !== "object")
|
|
452
|
+
errors.push("config must be an object");
|
|
453
|
+
if (config && typeof config === "object")
|
|
454
|
+
validateJsonSchema(config, errors);
|
|
455
|
+
validateKnownKeys(config, "config", CONFIG_KEYS, errors);
|
|
456
|
+
validateString(config.$schema, "$schema", errors);
|
|
457
|
+
validateString(config.language, "language", errors);
|
|
458
|
+
if (!config.agents) {
|
|
459
|
+
errors.push("agents is required");
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
if (!isPlainObject(config.agents)) {
|
|
463
|
+
errors.push("agents must be an object");
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
validateKnownKeys(config.agents, "agents", AGENTS_KEYS, errors);
|
|
467
|
+
}
|
|
468
|
+
validatePermissionConfig(config.agents.permissions, "agents.permissions", errors);
|
|
469
|
+
if (!config.agents.reviewers)
|
|
470
|
+
errors.push("agents.reviewers is required");
|
|
471
|
+
validateReviewerList(config.agents.reviewers, "agents.reviewers", errors, options.modelCatalog);
|
|
472
|
+
if (options.requireEditor && !config.agents.editor)
|
|
473
|
+
errors.push("agents.editor is required");
|
|
474
|
+
if (config.agents.editor) {
|
|
475
|
+
if (!config.agents.editor.model)
|
|
476
|
+
errors.push("agents.editor.model is required");
|
|
477
|
+
validateKnownKeys(config.agents.editor, "agents.editor", EDITOR_KEYS, errors);
|
|
478
|
+
validateString(config.agents.editor.model, "agents.editor.model", errors);
|
|
479
|
+
validateString(config.agents.editor.account, "agents.editor.account", errors);
|
|
480
|
+
validateString(config.agents.editor.persona, "agents.editor.persona", errors);
|
|
481
|
+
validateModel(config.agents.editor.model, "agents.editor.model", errors, options.modelCatalog);
|
|
482
|
+
if (!config.agents.editor.account)
|
|
483
|
+
errors.push("agents.editor.account is required");
|
|
484
|
+
if (config.agents.editor.options != null &&
|
|
485
|
+
!isPlainObject(config.agents.editor.options)) {
|
|
486
|
+
errors.push("agents.editor.options must be an object");
|
|
487
|
+
}
|
|
488
|
+
validatePermissionConfig(config.agents.editor.permission, "agents.editor.permission", errors);
|
|
489
|
+
const author = config.agents.editor.author;
|
|
490
|
+
if (!author || !isPlainObject(author)) {
|
|
491
|
+
if (author != null)
|
|
492
|
+
errors.push("agents.editor.author must be an object");
|
|
493
|
+
errors.push("agents.editor.author.name is required");
|
|
494
|
+
errors.push("agents.editor.author.email is required");
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
validateKnownKeys(author, "agents.editor.author", AUTHOR_KEYS, errors);
|
|
498
|
+
if (!author.name) {
|
|
499
|
+
errors.push("agents.editor.author.name is required");
|
|
500
|
+
}
|
|
501
|
+
else if (typeof author.name !== "string") {
|
|
502
|
+
errors.push("agents.editor.author.name must be a string");
|
|
503
|
+
}
|
|
504
|
+
if (!author.email) {
|
|
505
|
+
errors.push("agents.editor.author.email is required");
|
|
506
|
+
}
|
|
507
|
+
else if (typeof author.email !== "string") {
|
|
508
|
+
errors.push("agents.editor.author.email must be a string");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (Array.isArray(config.agents.reviewers)) {
|
|
513
|
+
validateResolvedReviewers(resolveAgents(config.agents).reviewers, "agents.resolvedReviewers", errors);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
validateMerge(config, errors, options);
|
|
517
|
+
validateAutomation(config, errors);
|
|
518
|
+
validateClear(config, errors);
|
|
519
|
+
validateChecks(config, errors);
|
|
520
|
+
validateConcurrency(config, errors);
|
|
521
|
+
validateSafety(config, errors);
|
|
522
|
+
await validatePrompts(config, errors, options.directory);
|
|
523
|
+
if (config.output != null && !isPlainObject(config.output)) {
|
|
524
|
+
errors.push("output must be an object");
|
|
525
|
+
}
|
|
526
|
+
validateKnownKeys(config.output, "output", OUTPUT_KEYS, errors);
|
|
527
|
+
if (config.output?.repairAttempts != null) {
|
|
528
|
+
if (typeof config.output.repairAttempts !== "number" ||
|
|
529
|
+
!Number.isInteger(config.output.repairAttempts) ||
|
|
530
|
+
config.output.repairAttempts < 0) {
|
|
531
|
+
errors.push("output.repairAttempts must be a non-negative integer");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (config.output?.dirs != null) {
|
|
535
|
+
if (!isPlainObject(config.output.dirs)) {
|
|
536
|
+
errors.push("output.dirs must be an object");
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
validateKnownKeys(config.output.dirs, "output.dirs", OUTPUT_DIR_KEYS, errors);
|
|
540
|
+
const dirs = config.output.dirs;
|
|
541
|
+
for (const key of OUTPUT_DIR_KEYS) {
|
|
542
|
+
const value = dirs[key];
|
|
543
|
+
if (value != null && typeof value !== "string") {
|
|
544
|
+
errors.push(`output.dirs.${key} must be a string`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (config.worktree != null && !isPlainObject(config.worktree)) {
|
|
550
|
+
errors.push("worktree must be an object");
|
|
551
|
+
}
|
|
552
|
+
validateKnownKeys(config.worktree, "worktree", WORKTREE_KEYS, errors);
|
|
553
|
+
if (config.worktree?.dirs != null) {
|
|
554
|
+
if (!isPlainObject(config.worktree.dirs)) {
|
|
555
|
+
errors.push("worktree.dirs must be an object");
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
validateKnownKeys(config.worktree.dirs, "worktree.dirs", WORKTREE_DIR_KEYS, errors);
|
|
559
|
+
const dirs = config.worktree.dirs;
|
|
560
|
+
for (const key of WORKTREE_DIR_KEYS) {
|
|
561
|
+
const value = dirs[key];
|
|
562
|
+
if (value != null && typeof value !== "string") {
|
|
563
|
+
errors.push(`worktree.dirs.${key} must be a string`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (options.checkAuth && !errors.length) {
|
|
569
|
+
if (!options.exec) {
|
|
570
|
+
errors.push("validateConfig requires exec when checkAuth is true");
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
await validateAuth(config, options.exec, errors);
|
|
574
|
+
if (!errors.length) {
|
|
575
|
+
await validateRepositoryPermissions(config, options.exec, errors, warnings);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return { errors, ok: errors.length === 0, warnings };
|
|
580
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { isAbsolute, join } from "node:path";
|
|
2
|
+
const DEFAULT_WORKTREE_DIRS = {
|
|
3
|
+
pr: ".magi/worktrees/pr",
|
|
4
|
+
};
|
|
5
|
+
function resolvePath(directory, path) {
|
|
6
|
+
return isAbsolute(path) ? path : join(directory, path);
|
|
7
|
+
}
|
|
8
|
+
export function worktreeBaseDir(directory, config, kind) {
|
|
9
|
+
return resolvePath(directory, config.worktree?.dirs?.[kind] ?? DEFAULT_WORKTREE_DIRS[kind]);
|
|
10
|
+
}
|
|
11
|
+
export function worktreeBaseDirs(directory, config = {}) {
|
|
12
|
+
return [worktreeBaseDir(directory, config, "pr")];
|
|
13
|
+
}
|