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