opencode-magi 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -10
- package/dist/commands.js +4 -0
- package/dist/config/output.js +11 -2
- package/dist/config/resolve.js +124 -26
- package/dist/config/validate.js +486 -191
- package/dist/config/worktree.js +19 -0
- package/dist/github/commands.js +349 -17
- package/dist/index.js +257 -27
- 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 +24 -4
- package/dist/orchestrator/report.js +15 -1
- package/dist/orchestrator/review-context.js +309 -0
- package/dist/orchestrator/review.js +78 -10
- package/dist/orchestrator/run-manager.js +418 -20
- package/dist/orchestrator/triage.js +1119 -0
- package/dist/permissions/editor.json +8 -1
- package/dist/prompts/compose.js +172 -15
- package/dist/prompts/contracts.js +119 -12
- package/dist/prompts/output.js +149 -14
- package/dist/prompts/templates/{close-reconsideration.md → review/close-reconsideration.md} +1 -2
- package/dist/prompts/templates/review/review.md +13 -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 +28 -27
- package/schema.json +234 -90
- package/dist/prompts/templates/rereview-close-reconsideration.md +0 -6
- package/dist/prompts/templates/review.md +0 -7
- /package/dist/prompts/templates/{ci-classification-after-edit.md → merge/ci-classification.md} +0 -0
- /package/dist/prompts/templates/{edit.md → merge/edit.md} +0 -0
- /package/dist/prompts/templates/{ci-classification.md → review/ci-classification.md} +0 -0
- /package/dist/prompts/templates/{finding-validation.md → review/finding-validation.md} +0 -0
- /package/dist/prompts/templates/{rereview.md → review/rereview.md} +0 -0
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { promisify } from "node:util";
|
|
|
7
7
|
import { MAGI_COMMANDS } from "./commands";
|
|
8
8
|
import { loadConfig, mergeMagiConfig } from "./config/load";
|
|
9
9
|
import { outputBaseDirs } from "./config/output";
|
|
10
|
+
import { worktreeBaseDirs } from "./config/worktree";
|
|
10
11
|
import { resolveRepository } from "./config/resolve";
|
|
11
12
|
import { validateConfig } from "./config/validate";
|
|
12
13
|
import { withGitHubApiRetry } from "./github/retry";
|
|
@@ -15,6 +16,7 @@ import { MagiRunManager } from "./orchestrator/run-manager";
|
|
|
15
16
|
const execAsync = promisify(nodeExec);
|
|
16
17
|
const GLOBAL_CONFIG_PATH = join(homedir(), ".config", "opencode", "magi.json");
|
|
17
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.";
|
|
18
20
|
function createExec(defaultCwd) {
|
|
19
21
|
return async (command, options) => {
|
|
20
22
|
const { stdout } = await execAsync(command, {
|
|
@@ -61,6 +63,16 @@ function parsePrToken(value) {
|
|
|
61
63
|
}
|
|
62
64
|
return pr;
|
|
63
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
|
+
}
|
|
64
76
|
export function parsePrs(value) {
|
|
65
77
|
const prs = value
|
|
66
78
|
.split(/[\s,]+/)
|
|
@@ -70,22 +82,164 @@ export function parsePrs(value) {
|
|
|
70
82
|
throw new Error("Specify one or more PR numbers or PR URLs.");
|
|
71
83
|
return prs;
|
|
72
84
|
}
|
|
73
|
-
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") {
|
|
74
95
|
const tokens = value.split(/[\s,]+/).filter(Boolean);
|
|
75
|
-
const
|
|
96
|
+
const configOverrides = {};
|
|
97
|
+
const prTokens = [];
|
|
98
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
99
|
+
const token = tokens[index];
|
|
76
100
|
if (token === "--dry-run") {
|
|
77
101
|
dryRun = true;
|
|
78
|
-
|
|
102
|
+
continue;
|
|
79
103
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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}.`);
|
|
83
227
|
}
|
|
84
228
|
function parseOptionalPr(value) {
|
|
85
229
|
if (!value?.trim())
|
|
86
230
|
return undefined;
|
|
87
231
|
return parsePrToken(value);
|
|
88
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
|
+
}
|
|
89
243
|
function clearFlag(value) {
|
|
90
244
|
return typeof value === "boolean" ? value : undefined;
|
|
91
245
|
}
|
|
@@ -128,6 +282,11 @@ function prMarkdownLink(repository, pr) {
|
|
|
128
282
|
const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/pull/${pr}`;
|
|
129
283
|
return `[#${pr}](${url})`;
|
|
130
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
|
+
}
|
|
131
290
|
function isPlainObject(value) {
|
|
132
291
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
133
292
|
}
|
|
@@ -189,7 +348,8 @@ export async function validateMagiConfigFiles(directory, options = {}) {
|
|
|
189
348
|
? withGitHubApiRetry(options.exec, mergedConfig.github?.apiRetryAttempts ?? 3)
|
|
190
349
|
: undefined,
|
|
191
350
|
modelCatalog: options.modelCatalog,
|
|
192
|
-
requireGithub: hasProjectConfig && Boolean(mergedConfig.agents
|
|
351
|
+
requireGithub: hasProjectConfig && Boolean(mergedConfig.review?.agents),
|
|
352
|
+
requireWorktreeConfig: true,
|
|
193
353
|
});
|
|
194
354
|
loadedFrom = existing.map((status) => status.path).join(", ");
|
|
195
355
|
errors.push(...validation.errors);
|
|
@@ -265,16 +425,20 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
265
425
|
},
|
|
266
426
|
tool: {
|
|
267
427
|
magi_merge: tool({
|
|
268
|
-
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(" "),
|
|
269
432
|
args: {
|
|
270
433
|
prs: tool.schema.string(),
|
|
271
434
|
dryRun: tool.schema.boolean().optional(),
|
|
272
435
|
},
|
|
273
436
|
async execute(args, context) {
|
|
274
|
-
const parsed = parseRunArguments(args.prs, args.dryRun ?? false);
|
|
437
|
+
const parsed = parseRunArguments(args.prs, args.dryRun ?? false, "merge");
|
|
275
438
|
const loaded = await loadConfig(directory);
|
|
276
|
-
const
|
|
277
|
-
const
|
|
439
|
+
const config = mergeMagiConfig(loaded.config, parsed.configOverrides);
|
|
440
|
+
const retryingExec = withGitHubApiRetry(exec, config.github?.apiRetryAttempts ?? 3);
|
|
441
|
+
const validation = await validateConfig(config, {
|
|
278
442
|
checkAuth: true,
|
|
279
443
|
directory,
|
|
280
444
|
exec: retryingExec,
|
|
@@ -283,9 +447,9 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
283
447
|
});
|
|
284
448
|
if (!validation.ok)
|
|
285
449
|
return JSON.stringify(validation, null, 2);
|
|
286
|
-
const repository = resolveRepository(
|
|
450
|
+
const repository = resolveRepository(config);
|
|
287
451
|
const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startMerge({
|
|
288
|
-
config
|
|
452
|
+
config,
|
|
289
453
|
dryRun: parsed.dryRun,
|
|
290
454
|
repository,
|
|
291
455
|
pr,
|
|
@@ -298,7 +462,10 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
298
462
|
},
|
|
299
463
|
}),
|
|
300
464
|
magi_review: tool({
|
|
301
|
-
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(" "),
|
|
302
469
|
args: {
|
|
303
470
|
prs: tool.schema.string(),
|
|
304
471
|
dryRun: tool.schema.boolean().optional(),
|
|
@@ -306,8 +473,9 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
306
473
|
async execute(args, context) {
|
|
307
474
|
const parsed = parseRunArguments(args.prs, args.dryRun ?? false);
|
|
308
475
|
const loaded = await loadConfig(directory);
|
|
309
|
-
const
|
|
310
|
-
const
|
|
476
|
+
const config = mergeMagiConfig(loaded.config, parsed.configOverrides);
|
|
477
|
+
const retryingExec = withGitHubApiRetry(exec, config.github?.apiRetryAttempts ?? 3);
|
|
478
|
+
const validation = await validateConfig(config, {
|
|
311
479
|
checkAuth: true,
|
|
312
480
|
directory,
|
|
313
481
|
exec: retryingExec,
|
|
@@ -315,9 +483,9 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
315
483
|
});
|
|
316
484
|
if (!validation.ok)
|
|
317
485
|
return JSON.stringify(validation, null, 2);
|
|
318
|
-
const repository = resolveRepository(
|
|
486
|
+
const repository = resolveRepository(config);
|
|
319
487
|
const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startReview({
|
|
320
|
-
config
|
|
488
|
+
config,
|
|
321
489
|
dryRun: parsed.dryRun,
|
|
322
490
|
repository,
|
|
323
491
|
pr,
|
|
@@ -329,11 +497,54 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
329
497
|
.join("\n");
|
|
330
498
|
},
|
|
331
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
|
+
}),
|
|
332
539
|
magi_status: tool({
|
|
333
|
-
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(" "),
|
|
334
544
|
args: {
|
|
335
545
|
runId: tool.schema.string().optional(),
|
|
336
546
|
pr: tool.schema.string().optional(),
|
|
547
|
+
issue: tool.schema.string().optional(),
|
|
337
548
|
block: tool.schema.boolean().optional(),
|
|
338
549
|
timeoutSeconds: tool.schema.number().optional(),
|
|
339
550
|
verbose: tool.schema.boolean().optional(),
|
|
@@ -341,8 +552,9 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
341
552
|
async execute(args) {
|
|
342
553
|
const states = await runManager.status({
|
|
343
554
|
block: args.block,
|
|
555
|
+
issue: parseOptionalIssue(args.issue),
|
|
344
556
|
outputDir: await configuredOutputDir(),
|
|
345
|
-
pr:
|
|
557
|
+
pr: parseOptionalPrs(args.pr),
|
|
346
558
|
runId: args.runId,
|
|
347
559
|
timeoutMs: args.timeoutSeconds == null
|
|
348
560
|
? undefined
|
|
@@ -354,20 +566,25 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
354
566
|
},
|
|
355
567
|
}),
|
|
356
568
|
magi_output: tool({
|
|
357
|
-
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(" "),
|
|
358
573
|
args: {
|
|
359
574
|
runId: tool.schema.string().optional(),
|
|
360
575
|
pr: tool.schema.string().optional(),
|
|
576
|
+
issue: tool.schema.string().optional(),
|
|
361
577
|
reviewer: tool.schema.string().optional(),
|
|
362
578
|
},
|
|
363
579
|
async execute(args) {
|
|
364
|
-
if (!args.runId && !args.pr)
|
|
365
|
-
return "Specify runId or
|
|
580
|
+
if (!args.runId && !args.pr && !args.issue)
|
|
581
|
+
return "Specify runId, pr, or issue.";
|
|
366
582
|
const outputDir = await configuredOutputDir();
|
|
367
583
|
if (outputDir)
|
|
368
584
|
await runManager.status({ outputDir });
|
|
369
585
|
return runManager.output({
|
|
370
586
|
outputDir,
|
|
587
|
+
issue: parseOptionalIssue(args.issue),
|
|
371
588
|
pr: parseOptionalPr(args.pr),
|
|
372
589
|
reviewer: args.reviewer,
|
|
373
590
|
runId: args.runId,
|
|
@@ -375,19 +592,25 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
375
592
|
},
|
|
376
593
|
}),
|
|
377
594
|
magi_cancel: tool({
|
|
378
|
-
description:
|
|
595
|
+
description: [
|
|
596
|
+
"Cancel a Magi background run by runId, PR, or issue.",
|
|
597
|
+
INTERNAL_FOLLOW_UP_TOOL_NOTE,
|
|
598
|
+
].join(" "),
|
|
379
599
|
args: {
|
|
380
600
|
runId: tool.schema.string().optional(),
|
|
381
601
|
pr: tool.schema.string().optional(),
|
|
602
|
+
issue: tool.schema.string().optional(),
|
|
382
603
|
},
|
|
383
604
|
async execute(args) {
|
|
384
|
-
if (!args.runId && !args.pr)
|
|
385
|
-
return "Specify runId or
|
|
605
|
+
if (!args.runId && !args.pr && !args.issue)
|
|
606
|
+
return "Specify runId, pr, or issue.";
|
|
386
607
|
const outputDir = await configuredOutputDir();
|
|
387
608
|
if (outputDir)
|
|
388
609
|
await runManager.status({ outputDir });
|
|
389
610
|
const pr = parseOptionalPr(args.pr);
|
|
611
|
+
const issue = parseOptionalIssue(args.issue);
|
|
390
612
|
const state = await runManager.cancel({
|
|
613
|
+
issue,
|
|
391
614
|
outputDir,
|
|
392
615
|
pr,
|
|
393
616
|
runId: args.runId,
|
|
@@ -395,7 +618,9 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
395
618
|
if (!state) {
|
|
396
619
|
return args.runId
|
|
397
620
|
? `Magi run not found: ${args.runId}`
|
|
398
|
-
:
|
|
621
|
+
: issue
|
|
622
|
+
? `Magi run not found for issue #${issue}`
|
|
623
|
+
: `Magi run not found for PR #${pr}`;
|
|
399
624
|
}
|
|
400
625
|
return runManager.formatStates([state]);
|
|
401
626
|
},
|
|
@@ -405,6 +630,7 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
405
630
|
args: {
|
|
406
631
|
runId: tool.schema.string().optional(),
|
|
407
632
|
pr: tool.schema.string().optional(),
|
|
633
|
+
issue: tool.schema.string().optional(),
|
|
408
634
|
branch: tool.schema.enum(["true", "false"]).optional(),
|
|
409
635
|
output: tool.schema.enum(["true", "false"]).optional(),
|
|
410
636
|
session: tool.schema.enum(["true", "false"]).optional(),
|
|
@@ -430,11 +656,15 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
430
656
|
};
|
|
431
657
|
return runManager.clear({
|
|
432
658
|
options,
|
|
659
|
+
issue: parseOptionalIssue(args.issue),
|
|
433
660
|
outputDir: loaded
|
|
434
661
|
? outputBaseDirs(directory, loaded.config)
|
|
435
662
|
: undefined,
|
|
436
663
|
pr: parseOptionalPr(args.pr),
|
|
437
664
|
runId: args.runId,
|
|
665
|
+
worktreeDir: loaded
|
|
666
|
+
? worktreeBaseDirs(directory, loaded.config)
|
|
667
|
+
: undefined,
|
|
438
668
|
});
|
|
439
669
|
},
|
|
440
670
|
}),
|
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";
|
|
@@ -109,7 +110,13 @@ async function runEditor(input, worktreePath, cycle, unresolvedThreads) {
|
|
|
109
110
|
await input.onProgress?.({ cycle, type: "editor_completed" });
|
|
110
111
|
if (!input.dryRun) {
|
|
111
112
|
if (result.value.mode === "EDITED") {
|
|
112
|
-
await
|
|
113
|
+
const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
|
|
114
|
+
const headOwner = meta.headRepositoryOwner?.login;
|
|
115
|
+
const headRepo = meta.headRepository?.name;
|
|
116
|
+
if (!headOwner || !headRepo) {
|
|
117
|
+
throw new Error("Pull request head repository is missing");
|
|
118
|
+
}
|
|
119
|
+
await pushHead(input.exec, input.repository, worktreePath, editor.account, { owner: headOwner, ref: meta.headRefName, repo: headRepo });
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
122
|
throwIfAborted(input.signal);
|
|
@@ -149,10 +156,16 @@ async function postRereviewOutput(input, reviewerKey, output) {
|
|
|
149
156
|
}
|
|
150
157
|
return replies[0] ?? "";
|
|
151
158
|
}
|
|
159
|
+
function parseRereviewOutputWithInlineTargets(text, targets) {
|
|
160
|
+
const output = parseRereviewOutput(text);
|
|
161
|
+
validateInlineCommentTargets(output.newFindings, targets, "newFindings");
|
|
162
|
+
return output;
|
|
163
|
+
}
|
|
152
164
|
async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionIds, ciFailureContext, options = {}) {
|
|
153
165
|
throwIfAborted(input.signal);
|
|
154
166
|
const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
|
|
155
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 }));
|
|
156
169
|
const artifactDir = outputDir(input);
|
|
157
170
|
let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
|
|
158
171
|
throwIfAborted(input.signal);
|
|
@@ -207,7 +220,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
|
|
|
207
220
|
}
|
|
208
221
|
},
|
|
209
222
|
options: reviewer.options,
|
|
210
|
-
parse:
|
|
223
|
+
parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
|
|
211
224
|
permission: reviewer.permission,
|
|
212
225
|
prompt,
|
|
213
226
|
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
@@ -290,7 +303,11 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
|
|
|
290
303
|
}
|
|
291
304
|
},
|
|
292
305
|
options: reviewer.options,
|
|
293
|
-
parse:
|
|
306
|
+
parse: (text) => {
|
|
307
|
+
const output = parseRereviewCloseReconsiderationOutput(text);
|
|
308
|
+
validateInlineCommentTargets(output.newFindings, inlineCommentTargets, "newFindings");
|
|
309
|
+
return output;
|
|
310
|
+
},
|
|
294
311
|
permission: reviewer.permission,
|
|
295
312
|
prompt,
|
|
296
313
|
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
@@ -353,6 +370,7 @@ async function finishMergeRun(input, result, reportInput) {
|
|
|
353
370
|
editorOutputs: reportInput.editorOutputs,
|
|
354
371
|
outputs: reportInput.outputs,
|
|
355
372
|
posted: reportInput.posted,
|
|
373
|
+
pr: input.pr,
|
|
356
374
|
repository: input.repository,
|
|
357
375
|
status: result.status,
|
|
358
376
|
});
|
|
@@ -536,6 +554,7 @@ export async function runMerge(input) {
|
|
|
536
554
|
editorOutputs: [],
|
|
537
555
|
outputs: {},
|
|
538
556
|
posted: {},
|
|
557
|
+
pr: input.pr,
|
|
539
558
|
repository: input.repository,
|
|
540
559
|
safety,
|
|
541
560
|
status: "safety_blocked",
|
|
@@ -565,6 +584,7 @@ export async function runMerge(input) {
|
|
|
565
584
|
...abortableInput,
|
|
566
585
|
allowAlreadyReviewed: true,
|
|
567
586
|
approvalPolicy: input.repository.merge.approvalPolicy,
|
|
587
|
+
enableReviewAutomation: false,
|
|
568
588
|
onProgress: (progress) => input.onProgress?.(progress),
|
|
569
589
|
runId: input.runId,
|
|
570
590
|
dryRun: input.dryRun,
|