opencode-magi 0.0.0-dev-20260519144738 → 0.0.0-dev-20260520030110
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 +2 -0
- package/dist/commands.js +4 -0
- package/dist/config/output.js +11 -2
- package/dist/config/resolve.js +59 -0
- package/dist/config/validate.js +226 -2
- package/dist/config/worktree.js +8 -2
- package/dist/github/commands.js +93 -0
- package/dist/index.js +67 -0
- package/dist/orchestrator/majority.js +14 -0
- package/dist/orchestrator/triage.js +420 -0
- package/dist/prompts/compose.js +82 -1
- package/dist/prompts/contracts.js +48 -0
- package/dist/prompts/output.js +68 -0
- package/dist/prompts/templates/triage/action.md +5 -0
- package/dist/prompts/templates/triage/bug.md +7 -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-pr.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/feature.md +7 -0
- package/dist/prompts/templates/triage/kind.md +7 -0
- package/dist/prompts/templates/triage/question.md +5 -0
- package/dist/prompts/templates/triage/reconsider.md +5 -0
- package/package.json +1 -1
- package/schema.json +122 -1
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { validateConfig } from "./config/validate";
|
|
|
13
13
|
import { withGitHubApiRetry } from "./github/retry";
|
|
14
14
|
import { mapPool } from "./orchestrator/pool";
|
|
15
15
|
import { MagiRunManager } from "./orchestrator/run-manager";
|
|
16
|
+
import { runTriage } from "./orchestrator/triage";
|
|
16
17
|
const execAsync = promisify(nodeExec);
|
|
17
18
|
const GLOBAL_CONFIG_PATH = join(homedir(), ".config", "opencode", "magi.json");
|
|
18
19
|
const PROJECT_CONFIG_PATH = join(".opencode", "magi.json");
|
|
@@ -63,6 +64,16 @@ function parsePrToken(value) {
|
|
|
63
64
|
}
|
|
64
65
|
return pr;
|
|
65
66
|
}
|
|
67
|
+
function parseIssueToken(value) {
|
|
68
|
+
const trimmed = value.trim();
|
|
69
|
+
const issueUrl = trimmed.match(/(?:^|\/)issues\/(\d+)(?:[/?#].*)?$/);
|
|
70
|
+
const raw = issueUrl?.[1] ?? trimmed.replace(/^#/, "");
|
|
71
|
+
const issue = Number.parseInt(raw, 10);
|
|
72
|
+
if (!Number.isInteger(issue) || issue <= 0 || String(issue) !== raw) {
|
|
73
|
+
throw new Error("Specify one or more issue numbers or issue URLs.");
|
|
74
|
+
}
|
|
75
|
+
return issue;
|
|
76
|
+
}
|
|
66
77
|
export function parsePrs(value) {
|
|
67
78
|
const prs = value
|
|
68
79
|
.split(/[\s,]+/)
|
|
@@ -72,6 +83,15 @@ export function parsePrs(value) {
|
|
|
72
83
|
throw new Error("Specify one or more PR numbers or PR URLs.");
|
|
73
84
|
return prs;
|
|
74
85
|
}
|
|
86
|
+
export function parseIssues(value) {
|
|
87
|
+
const issues = value
|
|
88
|
+
.split(/[\s,]+/)
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.map(parseIssueToken);
|
|
91
|
+
if (!issues.length)
|
|
92
|
+
throw new Error("Specify one or more issue numbers or issue URLs.");
|
|
93
|
+
return issues;
|
|
94
|
+
}
|
|
75
95
|
export function parseRunArguments(value, dryRun = false) {
|
|
76
96
|
const tokens = value.split(/[\s,]+/).filter(Boolean);
|
|
77
97
|
const prTokens = tokens.filter((token) => {
|
|
@@ -83,6 +103,17 @@ export function parseRunArguments(value, dryRun = false) {
|
|
|
83
103
|
});
|
|
84
104
|
return { dryRun, prs: parsePrs(prTokens.join(" ")) };
|
|
85
105
|
}
|
|
106
|
+
export function parseIssueRunArguments(value, dryRun = false) {
|
|
107
|
+
const tokens = value.split(/[\s,]+/).filter(Boolean);
|
|
108
|
+
const issueTokens = tokens.filter((token) => {
|
|
109
|
+
if (token === "--dry-run") {
|
|
110
|
+
dryRun = true;
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
return { dryRun, issues: parseIssues(issueTokens.join(" ")) };
|
|
116
|
+
}
|
|
86
117
|
function parseOptionalPr(value) {
|
|
87
118
|
if (!value?.trim())
|
|
88
119
|
return undefined;
|
|
@@ -337,6 +368,42 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
337
368
|
.join("\n");
|
|
338
369
|
},
|
|
339
370
|
}),
|
|
371
|
+
magi_triage: tool({
|
|
372
|
+
description: "Triage one or more GitHub issues with configured Magi triage agents.",
|
|
373
|
+
args: {
|
|
374
|
+
issues: tool.schema.string(),
|
|
375
|
+
dryRun: tool.schema.boolean().optional(),
|
|
376
|
+
},
|
|
377
|
+
async execute(args, context) {
|
|
378
|
+
const parsed = parseIssueRunArguments(args.issues, args.dryRun ?? false);
|
|
379
|
+
const loaded = await loadConfig(directory);
|
|
380
|
+
const retryingExec = withGitHubApiRetry(exec, loaded.config.github?.apiRetryAttempts ?? 3);
|
|
381
|
+
const validation = await validateConfig(loaded.config, {
|
|
382
|
+
checkAuth: true,
|
|
383
|
+
directory,
|
|
384
|
+
exec: retryingExec,
|
|
385
|
+
modelCatalog: await modelCatalog(),
|
|
386
|
+
requireReview: false,
|
|
387
|
+
requireTriage: true,
|
|
388
|
+
});
|
|
389
|
+
if (!validation.ok)
|
|
390
|
+
return JSON.stringify(validation, null, 2);
|
|
391
|
+
const repository = resolveRepository(loaded.config);
|
|
392
|
+
if (!repository.triage)
|
|
393
|
+
return JSON.stringify({ errors: ["triage configuration is required"], ok: false }, null, 2);
|
|
394
|
+
const results = await mapPool(parsed.issues, repository.triage.concurrency.runs, (issue) => runTriage({
|
|
395
|
+
client: modelClient,
|
|
396
|
+
config: loaded.config,
|
|
397
|
+
directory,
|
|
398
|
+
dryRun: parsed.dryRun,
|
|
399
|
+
exec: retryingExec,
|
|
400
|
+
issue,
|
|
401
|
+
repository,
|
|
402
|
+
signal: context.abort,
|
|
403
|
+
}), { signal: context.abort });
|
|
404
|
+
return results.map((result) => result.report).join("\n\n");
|
|
405
|
+
},
|
|
406
|
+
}),
|
|
340
407
|
magi_status: tool({
|
|
341
408
|
description: [
|
|
342
409
|
"Show Magi background run status. Optionally filter by runId or PR and wait for completion.",
|
|
@@ -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");
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { issueRunOutputDir } from "../config/output";
|
|
4
|
+
import { worktreeBaseDir } from "../config/worktree";
|
|
5
|
+
import { closeIssue, closePullRequest, configureGitIdentity, createPullRequest, fetchIssue, fetchIssueComments, fetchRelatedPullRequests, postIssueComment, pushHead, removeIssueLabels, removeWorktree, searchDuplicateIssues, shellQuote, } from "../github/commands";
|
|
6
|
+
import { composeTriageBugPrompt, composeTriageCommentPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageFeaturePrompt, composeTriageKindPrompt, } from "../prompts/compose";
|
|
7
|
+
import { parseEditOutput, parseTriageBinaryOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, parseTriageKindOutput, } from "../prompts/output";
|
|
8
|
+
import { aggregateStringMajority, majorityThreshold } from "./majority";
|
|
9
|
+
import { runModelText, runModelWithRepair } from "./model";
|
|
10
|
+
const MARKER_PREFIX = "opencode-magi:triage";
|
|
11
|
+
const KIND_VOTES = ["ASK", "BUG", "FEATURE"];
|
|
12
|
+
const BINARY_VOTES = ["ASK", "NO", "YES"];
|
|
13
|
+
const DUPLICATE_VOTES = ["DUPLICATE", "NOT_DUPLICATE"];
|
|
14
|
+
const EXISTING_PR_VOTES = [
|
|
15
|
+
"RELATED_PR_DOES_NOT_HANDLE_ISSUE",
|
|
16
|
+
"RELATED_PR_HANDLES_ISSUE",
|
|
17
|
+
];
|
|
18
|
+
function marker(input) {
|
|
19
|
+
return `<!-- ${MARKER_PREFIX} v=1 issue=${input.issue} result=${input.result} action=${input.action} checkpoint=pending pr=${input.pr ?? "none"} processed=${(input.processed ?? []).join(",")} -->`;
|
|
20
|
+
}
|
|
21
|
+
export function parseTriageMarker(body) {
|
|
22
|
+
const match = body.match(/<!--\s*opencode-magi:triage\s+([^>]+?)\s*-->/);
|
|
23
|
+
if (!match)
|
|
24
|
+
return undefined;
|
|
25
|
+
const entries = Object.fromEntries(match[1]
|
|
26
|
+
.trim()
|
|
27
|
+
.split(/\s+/)
|
|
28
|
+
.map((part) => {
|
|
29
|
+
const index = part.indexOf("=");
|
|
30
|
+
return index === -1
|
|
31
|
+
? [part, ""]
|
|
32
|
+
: [part.slice(0, index), part.slice(index + 1)];
|
|
33
|
+
}));
|
|
34
|
+
const version = Number(entries.v);
|
|
35
|
+
if (version !== 1)
|
|
36
|
+
return undefined;
|
|
37
|
+
return {
|
|
38
|
+
action: entries.action,
|
|
39
|
+
checkpoint: entries.checkpoint ? Number(entries.checkpoint) : undefined,
|
|
40
|
+
issue: entries.issue ? Number(entries.issue) : undefined,
|
|
41
|
+
pr: entries.pr,
|
|
42
|
+
processed: entries.processed
|
|
43
|
+
? entries.processed.split(",").filter(Boolean).map(Number)
|
|
44
|
+
: [],
|
|
45
|
+
result: entries.result,
|
|
46
|
+
v: version,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function labelsContain(labels, targets) {
|
|
50
|
+
const set = new Set(labels.map((label) => label.toLowerCase()));
|
|
51
|
+
return targets.some((target) => set.has(target.toLowerCase()));
|
|
52
|
+
}
|
|
53
|
+
export function resolveIssueKind(issue, repository) {
|
|
54
|
+
const triage = repository.triage;
|
|
55
|
+
if (!triage)
|
|
56
|
+
throw new Error("triage configuration is required");
|
|
57
|
+
const bug = labelsContain(issue.labels, triage.kind.bug.label) ||
|
|
58
|
+
(issue.type != null && triage.kind.bug.type.includes(issue.type));
|
|
59
|
+
const feature = labelsContain(issue.labels, triage.kind.feature.label) ||
|
|
60
|
+
(issue.type != null && triage.kind.feature.type.includes(issue.type));
|
|
61
|
+
if (bug === feature)
|
|
62
|
+
return undefined;
|
|
63
|
+
return bug ? "BUG" : "FEATURE";
|
|
64
|
+
}
|
|
65
|
+
function issueContext(input) {
|
|
66
|
+
return JSON.stringify({
|
|
67
|
+
duplicateCandidates: input.relationship.duplicateCandidates,
|
|
68
|
+
issue: input.issue,
|
|
69
|
+
recentComments: input.relationship.comments.slice(-20),
|
|
70
|
+
relatedPullRequests: input.relationship.relatedPullRequests,
|
|
71
|
+
}, null, 2);
|
|
72
|
+
}
|
|
73
|
+
async function writeJson(path, value) {
|
|
74
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
75
|
+
}
|
|
76
|
+
async function runVote(input) {
|
|
77
|
+
const prompt = await input.prompt({
|
|
78
|
+
context: input.context,
|
|
79
|
+
directory: input.directory,
|
|
80
|
+
issue: input.issue,
|
|
81
|
+
repository: input.repository,
|
|
82
|
+
reviewer: input.agent,
|
|
83
|
+
});
|
|
84
|
+
const result = await runModelWithRepair({
|
|
85
|
+
client: input.client,
|
|
86
|
+
model: input.agent.model,
|
|
87
|
+
options: input.agent.options,
|
|
88
|
+
parse: input.parse,
|
|
89
|
+
permission: input.agent.permission,
|
|
90
|
+
prompt,
|
|
91
|
+
repairAttempts: 3,
|
|
92
|
+
schemaName: input.schemaName,
|
|
93
|
+
signal: input.signal,
|
|
94
|
+
title: `Magi triage ${input.schemaName} #${input.issue} (${input.agent.key})`,
|
|
95
|
+
});
|
|
96
|
+
return { ...result.value, raw: result.raw, sessionId: result.sessionId };
|
|
97
|
+
}
|
|
98
|
+
export function chooseDuplicateOutput(input) {
|
|
99
|
+
const candidates = new Set(input.candidateNumbers);
|
|
100
|
+
const threshold = majorityThreshold(input.outputs.length);
|
|
101
|
+
const counts = new Map();
|
|
102
|
+
for (const output of input.outputs) {
|
|
103
|
+
if (output.vote !== "DUPLICATE" ||
|
|
104
|
+
output.duplicateOf == null ||
|
|
105
|
+
!candidates.has(output.duplicateOf)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
counts.set(output.duplicateOf, (counts.get(output.duplicateOf) ?? 0) + 1);
|
|
109
|
+
}
|
|
110
|
+
const target = [...counts.entries()].find(([, count]) => count >= threshold)?.[0];
|
|
111
|
+
if (target == null)
|
|
112
|
+
return undefined;
|
|
113
|
+
return input.outputs.find((output) => output.vote === "DUPLICATE" && output.duplicateOf === target);
|
|
114
|
+
}
|
|
115
|
+
async function runDuplicateVote(input) {
|
|
116
|
+
const agents = input.input.repository.agents.triage;
|
|
117
|
+
if (!agents?.length)
|
|
118
|
+
throw new Error("triage.agents is required");
|
|
119
|
+
const outputs = await Promise.all(agents.map((agent) => runVote({
|
|
120
|
+
agent,
|
|
121
|
+
client: input.input.client,
|
|
122
|
+
context: input.context,
|
|
123
|
+
directory: input.input.directory,
|
|
124
|
+
issue: input.input.issue,
|
|
125
|
+
parse: parseTriageDuplicateOutput,
|
|
126
|
+
prompt: composeTriageDuplicatePrompt,
|
|
127
|
+
repository: input.input.repository,
|
|
128
|
+
schemaName: "triage duplicate",
|
|
129
|
+
signal: input.input.signal,
|
|
130
|
+
})));
|
|
131
|
+
const majority = aggregateStringMajority(outputs.map((output, index) => ({
|
|
132
|
+
reviewer: agents[index].key,
|
|
133
|
+
vote: output.vote,
|
|
134
|
+
})), DUPLICATE_VOTES);
|
|
135
|
+
await writeJson(join(input.outputDir, "duplicate-majority.json"), majority);
|
|
136
|
+
if (majority.vote !== "DUPLICATE")
|
|
137
|
+
return undefined;
|
|
138
|
+
return chooseDuplicateOutput({
|
|
139
|
+
candidateNumbers: input.candidateNumbers,
|
|
140
|
+
outputs,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async function runPhaseVote(input) {
|
|
144
|
+
const agents = input.input.repository.agents.triage;
|
|
145
|
+
if (!agents?.length)
|
|
146
|
+
throw new Error("triage.agents is required");
|
|
147
|
+
const outputs = await Promise.all(agents.map((agent) => runVote({
|
|
148
|
+
agent,
|
|
149
|
+
client: input.input.client,
|
|
150
|
+
context: input.context,
|
|
151
|
+
directory: input.input.directory,
|
|
152
|
+
issue: input.input.issue,
|
|
153
|
+
parse: input.parse,
|
|
154
|
+
prompt: input.prompt,
|
|
155
|
+
repository: input.input.repository,
|
|
156
|
+
schemaName: input.schemaName,
|
|
157
|
+
signal: input.input.signal,
|
|
158
|
+
})));
|
|
159
|
+
const majority = aggregateStringMajority(outputs.map((output, index) => ({
|
|
160
|
+
reviewer: agents[index].key,
|
|
161
|
+
vote: output.vote,
|
|
162
|
+
})), input.votes);
|
|
163
|
+
await writeJson(join(input.outputDir, `${input.phase}-majority.json`), majority);
|
|
164
|
+
return majority.vote;
|
|
165
|
+
}
|
|
166
|
+
async function relationshipScan(input, issue) {
|
|
167
|
+
const [comments, relatedPullRequests, duplicateCandidates] = await Promise.all([
|
|
168
|
+
fetchIssueComments(input.exec, input.repository, input.issue),
|
|
169
|
+
fetchRelatedPullRequests(input.exec, input.repository, input.issue),
|
|
170
|
+
searchDuplicateIssues(input.exec, input.repository, issue),
|
|
171
|
+
]);
|
|
172
|
+
const previousMarker = comments
|
|
173
|
+
.map((comment) => parseTriageMarker(comment.body))
|
|
174
|
+
.filter((item) => Boolean(item))
|
|
175
|
+
.at(-1);
|
|
176
|
+
return { comments, duplicateCandidates, previousMarker, relatedPullRequests };
|
|
177
|
+
}
|
|
178
|
+
function safetyBlocked(input, issue, hasMarker) {
|
|
179
|
+
const triage = input.repository.triage;
|
|
180
|
+
if (!triage)
|
|
181
|
+
throw new Error("triage configuration is required");
|
|
182
|
+
const safety = triage.safety;
|
|
183
|
+
if (issue.state === "CLOSED")
|
|
184
|
+
return "issue is closed";
|
|
185
|
+
if (!hasMarker && safety.requiredLabels.length) {
|
|
186
|
+
const missing = safety.requiredLabels.filter((label) => !labelsContain(issue.labels, [label]));
|
|
187
|
+
if (missing.length)
|
|
188
|
+
return `missing required labels: ${missing.join(", ")}`;
|
|
189
|
+
}
|
|
190
|
+
if (safety.blockedLabels.some((label) => labelsContain(issue.labels, [label]))) {
|
|
191
|
+
return "issue has a blocked label";
|
|
192
|
+
}
|
|
193
|
+
if (safety.allowAuthors.length &&
|
|
194
|
+
!safety.allowAuthors.includes(issue.author)) {
|
|
195
|
+
return `issue author is not allowed: ${issue.author}`;
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
async function createImplementationPr(input) {
|
|
200
|
+
const creator = input.input.repository.agents.triageCreator;
|
|
201
|
+
if (!creator)
|
|
202
|
+
return undefined;
|
|
203
|
+
const branch = `magi/issue-${input.issue.number}-${Date.now().toString(36)}`;
|
|
204
|
+
const worktreePath = join(worktreeBaseDir(input.input.directory, input.input.config, "issue"), `issue-${input.issue.number}`);
|
|
205
|
+
await mkdir(dirname(worktreePath), { recursive: true });
|
|
206
|
+
await input.input.exec(`git worktree add -b ${shellQuote(branch)} ${shellQuote(worktreePath)}`);
|
|
207
|
+
try {
|
|
208
|
+
await configureGitIdentity(input.input.exec, worktreePath, creator.author);
|
|
209
|
+
const prompt = `Implement issue #${input.issue.number}.\n\n${input.context}\n\nReturn the edit output contract.`;
|
|
210
|
+
const result = await runModelWithRepair({
|
|
211
|
+
client: input.input.client,
|
|
212
|
+
model: creator.model,
|
|
213
|
+
options: creator.options,
|
|
214
|
+
parse: parseEditOutput,
|
|
215
|
+
permission: creator.permission,
|
|
216
|
+
prompt,
|
|
217
|
+
repairAttempts: 3,
|
|
218
|
+
schemaName: "edit",
|
|
219
|
+
signal: input.input.signal,
|
|
220
|
+
title: `Magi triage create PR #${input.issue.number}`,
|
|
221
|
+
});
|
|
222
|
+
await writeJson(join(input.outputDir, "create-pr.json"), result.value);
|
|
223
|
+
if (result.value.mode !== "EDITED")
|
|
224
|
+
return undefined;
|
|
225
|
+
await pushHead(input.input.exec, input.input.repository, worktreePath, creator.account, {
|
|
226
|
+
owner: input.input.repository.github.owner,
|
|
227
|
+
ref: branch,
|
|
228
|
+
repo: input.input.repository.github.repo,
|
|
229
|
+
});
|
|
230
|
+
return createPullRequest(input.input.exec, input.input.repository, creator.account, {
|
|
231
|
+
body: `Closes #${input.issue.number}`,
|
|
232
|
+
head: branch,
|
|
233
|
+
title: `fix: address issue #${input.issue.number}`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await removeWorktree(input.input.exec, worktreePath).catch(() => undefined);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
export async function runTriage(input) {
|
|
241
|
+
const triage = input.repository.triage;
|
|
242
|
+
if (!triage?.account)
|
|
243
|
+
throw new Error("triage.account is required");
|
|
244
|
+
const agents = input.repository.agents.triage;
|
|
245
|
+
if (!agents?.length)
|
|
246
|
+
throw new Error("triage.agents is required");
|
|
247
|
+
const runId = input.runId ?? `run-${Date.now().toString(36)}`;
|
|
248
|
+
const outputDir = issueRunOutputDir({
|
|
249
|
+
config: input.config,
|
|
250
|
+
directory: input.directory,
|
|
251
|
+
issue: input.issue,
|
|
252
|
+
runId,
|
|
253
|
+
});
|
|
254
|
+
await mkdir(outputDir, { recursive: true });
|
|
255
|
+
const issue = await fetchIssue(input.exec, input.repository, input.issue);
|
|
256
|
+
const relationship = await relationshipScan(input, issue);
|
|
257
|
+
const block = safetyBlocked(input, issue, Boolean(relationship.previousMarker));
|
|
258
|
+
await writeJson(join(outputDir, "issue.json"), issue);
|
|
259
|
+
await writeJson(join(outputDir, "relationship-summary.json"), relationship);
|
|
260
|
+
if (block) {
|
|
261
|
+
const report = `Magi triage blocked for #${input.issue}: ${block}`;
|
|
262
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
263
|
+
return { issue: input.issue, outputDir, report, result: "FAILED" };
|
|
264
|
+
}
|
|
265
|
+
const context = issueContext({ issue, relationship });
|
|
266
|
+
if (relationship.relatedPullRequests.length) {
|
|
267
|
+
const vote = await runPhaseVote({
|
|
268
|
+
context,
|
|
269
|
+
input,
|
|
270
|
+
outputDir,
|
|
271
|
+
parse: parseTriageExistingPrOutput,
|
|
272
|
+
phase: "existing-pr",
|
|
273
|
+
prompt: composeTriageExistingPrPrompt,
|
|
274
|
+
schemaName: "triage existing PR",
|
|
275
|
+
votes: EXISTING_PR_VOTES,
|
|
276
|
+
});
|
|
277
|
+
if (vote === "RELATED_PR_HANDLES_ISSUE") {
|
|
278
|
+
const merged = relationship.relatedPullRequests.some((pr) => pr.state === "MERGED");
|
|
279
|
+
if (merged && triage.automation.close) {
|
|
280
|
+
const body = `@${issue.author} Magi found a related merged pull request that appears to resolve this issue.\n\n${marker({ action: "CLOSE", issue: issue.number, result: "RESOLVED_BY_MERGED_PR" })}`;
|
|
281
|
+
if (!input.dryRun) {
|
|
282
|
+
await postIssueComment(input.exec, input.repository, issue.number, triage.account, body);
|
|
283
|
+
await closeIssue(input.exec, input.repository, issue.number, triage.account);
|
|
284
|
+
await removeIssueLabels(input.exec, input.repository, issue.number, triage.automation.clear, triage.account);
|
|
285
|
+
}
|
|
286
|
+
const report = `Magi triage closed #${issue.number} because a related PR was merged.`;
|
|
287
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
288
|
+
return {
|
|
289
|
+
issue: issue.number,
|
|
290
|
+
outputDir,
|
|
291
|
+
report,
|
|
292
|
+
result: "BUG_ACCEPTED",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (!input.dryRun) {
|
|
296
|
+
await removeIssueLabels(input.exec, input.repository, issue.number, triage.automation.clear, triage.account);
|
|
297
|
+
}
|
|
298
|
+
const report = `Magi triage cleared #${issue.number} because a related PR handles it.`;
|
|
299
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
300
|
+
return { issue: issue.number, outputDir, report, result: "CLEAR_ONLY" };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (relationship.duplicateCandidates.length) {
|
|
304
|
+
const duplicate = await runDuplicateVote({
|
|
305
|
+
candidateNumbers: relationship.duplicateCandidates.map((candidate) => candidate.number),
|
|
306
|
+
context,
|
|
307
|
+
input,
|
|
308
|
+
outputDir,
|
|
309
|
+
});
|
|
310
|
+
if (duplicate) {
|
|
311
|
+
const body = `@${issue.author} Magi triage found this issue duplicates #${duplicate.duplicateOf}.\n\nReason: ${duplicate.reason}\n\n${marker({ action: "CLOSE", issue: issue.number, result: "DUPLICATE" })}`;
|
|
312
|
+
if (!input.dryRun) {
|
|
313
|
+
await postIssueComment(input.exec, input.repository, issue.number, triage.account, body);
|
|
314
|
+
}
|
|
315
|
+
if (!input.dryRun && triage.automation.close) {
|
|
316
|
+
for (const pr of relationship.relatedPullRequests.filter((pr) => pr.state === "OPEN")) {
|
|
317
|
+
await closePullRequest(input.exec, input.repository, pr.number, triage.account);
|
|
318
|
+
}
|
|
319
|
+
await closeIssue(input.exec, input.repository, issue.number, triage.account);
|
|
320
|
+
}
|
|
321
|
+
if (!input.dryRun) {
|
|
322
|
+
await removeIssueLabels(input.exec, input.repository, issue.number, triage.automation.clear, triage.account);
|
|
323
|
+
}
|
|
324
|
+
const report = `Magi triage marked #${issue.number} duplicate of #${duplicate.duplicateOf}.`;
|
|
325
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
326
|
+
return { issue: issue.number, outputDir, report, result: "DUPLICATE" };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const kind = resolveIssueKind(issue, input.repository) ??
|
|
330
|
+
(await runPhaseVote({
|
|
331
|
+
context,
|
|
332
|
+
input,
|
|
333
|
+
outputDir,
|
|
334
|
+
parse: parseTriageKindOutput,
|
|
335
|
+
phase: "kind",
|
|
336
|
+
prompt: composeTriageKindPrompt,
|
|
337
|
+
schemaName: "triage kind",
|
|
338
|
+
votes: KIND_VOTES,
|
|
339
|
+
})) ??
|
|
340
|
+
"ASK";
|
|
341
|
+
let result = "ASK";
|
|
342
|
+
if (kind === "BUG") {
|
|
343
|
+
const vote = await runPhaseVote({
|
|
344
|
+
context,
|
|
345
|
+
input,
|
|
346
|
+
outputDir,
|
|
347
|
+
parse: parseTriageBinaryOutput,
|
|
348
|
+
phase: "bug",
|
|
349
|
+
prompt: composeTriageBugPrompt,
|
|
350
|
+
schemaName: "triage bug",
|
|
351
|
+
votes: BINARY_VOTES,
|
|
352
|
+
});
|
|
353
|
+
result =
|
|
354
|
+
vote === "YES" ? "BUG_ACCEPTED" : vote === "NO" ? "BUG_REJECTED" : "ASK";
|
|
355
|
+
}
|
|
356
|
+
if (kind === "FEATURE") {
|
|
357
|
+
const vote = await runPhaseVote({
|
|
358
|
+
context,
|
|
359
|
+
input,
|
|
360
|
+
outputDir,
|
|
361
|
+
parse: parseTriageBinaryOutput,
|
|
362
|
+
phase: "feature",
|
|
363
|
+
prompt: composeTriageFeaturePrompt,
|
|
364
|
+
schemaName: "triage feature",
|
|
365
|
+
votes: BINARY_VOTES,
|
|
366
|
+
});
|
|
367
|
+
result =
|
|
368
|
+
vote === "YES"
|
|
369
|
+
? "FEATURE_ACCEPTED"
|
|
370
|
+
: vote === "NO"
|
|
371
|
+
? "FEATURE_REJECTED"
|
|
372
|
+
: "ASK";
|
|
373
|
+
}
|
|
374
|
+
const needsClose = triage.automation.close &&
|
|
375
|
+
(result === "BUG_REJECTED" || result === "FEATURE_REJECTED");
|
|
376
|
+
const needsPr = triage.automation.pr &&
|
|
377
|
+
(result === "BUG_ACCEPTED" || result === "FEATURE_ACCEPTED");
|
|
378
|
+
const commentContext = `Result: ${result}\n\n${context}`;
|
|
379
|
+
const commentPrompt = composeTriageCommentPrompt({
|
|
380
|
+
author: issue.author,
|
|
381
|
+
context: commentContext,
|
|
382
|
+
directory: input.directory,
|
|
383
|
+
issue: issue.number,
|
|
384
|
+
repository: input.repository,
|
|
385
|
+
});
|
|
386
|
+
const comment = (await runModelText({
|
|
387
|
+
allowEmpty: false,
|
|
388
|
+
client: input.client,
|
|
389
|
+
model: agents[0].model,
|
|
390
|
+
options: agents[0].options,
|
|
391
|
+
permission: agents[0].permission,
|
|
392
|
+
prompt: commentPrompt,
|
|
393
|
+
signal: input.signal,
|
|
394
|
+
title: `Magi triage comment #${issue.number}`,
|
|
395
|
+
})).raw + `\n\n${marker({ action: result, issue: issue.number, result })}`;
|
|
396
|
+
let prUrl;
|
|
397
|
+
if (!input.dryRun) {
|
|
398
|
+
await postIssueComment(input.exec, input.repository, issue.number, triage.account, comment);
|
|
399
|
+
if (needsClose) {
|
|
400
|
+
for (const pr of relationship.relatedPullRequests.filter((pr) => pr.state === "OPEN")) {
|
|
401
|
+
await closePullRequest(input.exec, input.repository, pr.number, triage.account);
|
|
402
|
+
}
|
|
403
|
+
await closeIssue(input.exec, input.repository, issue.number, triage.account);
|
|
404
|
+
}
|
|
405
|
+
if (needsPr)
|
|
406
|
+
prUrl = await createImplementationPr({ context, input, issue, outputDir });
|
|
407
|
+
if (result !== "ASK") {
|
|
408
|
+
await removeIssueLabels(input.exec, input.repository, issue.number, triage.automation.clear, triage.account);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const report = [
|
|
412
|
+
`Magi triage result for #${issue.number}: ${result}`,
|
|
413
|
+
prUrl ? `Created PR: ${prUrl}` : undefined,
|
|
414
|
+
input.dryRun ? "Dry run: no GitHub mutations were performed." : undefined,
|
|
415
|
+
]
|
|
416
|
+
.filter(Boolean)
|
|
417
|
+
.join("\n");
|
|
418
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
419
|
+
return { issue: issue.number, outputDir, report, result };
|
|
420
|
+
}
|
package/dist/prompts/compose.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
|
-
import { ciClassificationAfterEditOutputContract, ciClassificationOutputContract, closeReconsiderationOutputContract, editOutputContract, findingValidationOutputContract, rereviewCloseReconsiderationOutputContract, rereviewOutputContract, reviewOutputContract, } from "./contracts";
|
|
4
|
+
import { ciClassificationAfterEditOutputContract, ciClassificationOutputContract, closeReconsiderationOutputContract, editOutputContract, findingValidationOutputContract, rereviewCloseReconsiderationOutputContract, rereviewOutputContract, reviewOutputContract, triageCommentClassificationOutputContract, triageDuplicateOutputContract, triageVoteOutputContract, } from "./contracts";
|
|
5
5
|
async function readOptionalPrompt(directory, path, values = {}) {
|
|
6
6
|
if (!path)
|
|
7
7
|
return "";
|
|
@@ -65,6 +65,13 @@ function editValues(input) {
|
|
|
65
65
|
worktreePath: input.worktreePath,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
+
function triageValues(input) {
|
|
69
|
+
return {
|
|
70
|
+
...repositoryValues(input.repository),
|
|
71
|
+
context: input.context,
|
|
72
|
+
issue: String(input.issue),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
68
75
|
function personaBlock(persona) {
|
|
69
76
|
return persona ? `<persona>\n${persona}\n</persona>` : "";
|
|
70
77
|
}
|
|
@@ -292,3 +299,77 @@ export async function composeCiClassificationAfterEditPrompt(input) {
|
|
|
292
299
|
.filter(Boolean)
|
|
293
300
|
.join("\n\n");
|
|
294
301
|
}
|
|
302
|
+
async function composeTriageVotePrompt(input) {
|
|
303
|
+
const values = triageValues(input);
|
|
304
|
+
const task = await taskBlock({
|
|
305
|
+
builtin: `triage/${input.builtin}`,
|
|
306
|
+
customPath: input.customPath,
|
|
307
|
+
directory: input.directory,
|
|
308
|
+
values,
|
|
309
|
+
});
|
|
310
|
+
return [
|
|
311
|
+
task,
|
|
312
|
+
languageBlock(input.repository.language),
|
|
313
|
+
personaBlock(input.reviewer.persona),
|
|
314
|
+
input.outputContract,
|
|
315
|
+
]
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
.join("\n\n");
|
|
318
|
+
}
|
|
319
|
+
export function composeTriageCommentPrompt(input) {
|
|
320
|
+
return [
|
|
321
|
+
`<task>\nCompose one concise GitHub issue comment for issue #${input.issue}. Mention @${input.author}. Use the context below and do not include markdown fences.\n</task>`,
|
|
322
|
+
languageBlock(input.repository.language),
|
|
323
|
+
`<context>\n${input.context}\n</context>`,
|
|
324
|
+
]
|
|
325
|
+
.filter(Boolean)
|
|
326
|
+
.join("\n\n");
|
|
327
|
+
}
|
|
328
|
+
export async function composeTriageExistingPrPrompt(input) {
|
|
329
|
+
return composeTriageVotePrompt({
|
|
330
|
+
...input,
|
|
331
|
+
builtin: "existing-pr",
|
|
332
|
+
customPath: input.repository.triage?.prompts.existingPr,
|
|
333
|
+
outputContract: triageVoteOutputContract('"RELATED_PR_HANDLES_ISSUE" | "RELATED_PR_DOES_NOT_HANDLE_ISSUE"'),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
export async function composeTriageDuplicatePrompt(input) {
|
|
337
|
+
return composeTriageVotePrompt({
|
|
338
|
+
...input,
|
|
339
|
+
builtin: "duplicate",
|
|
340
|
+
customPath: input.repository.triage?.prompts.duplicate,
|
|
341
|
+
outputContract: triageDuplicateOutputContract,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
export async function composeTriageKindPrompt(input) {
|
|
345
|
+
return composeTriageVotePrompt({
|
|
346
|
+
...input,
|
|
347
|
+
builtin: "kind",
|
|
348
|
+
customPath: input.repository.triage?.prompts.kind,
|
|
349
|
+
outputContract: triageVoteOutputContract('"BUG" | "FEATURE" | "ASK"'),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
export async function composeTriageBugPrompt(input) {
|
|
353
|
+
return composeTriageVotePrompt({
|
|
354
|
+
...input,
|
|
355
|
+
builtin: "bug",
|
|
356
|
+
customPath: input.repository.triage?.prompts.bug,
|
|
357
|
+
outputContract: triageVoteOutputContract('"YES" | "NO" | "ASK"'),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
export async function composeTriageFeaturePrompt(input) {
|
|
361
|
+
return composeTriageVotePrompt({
|
|
362
|
+
...input,
|
|
363
|
+
builtin: "feature",
|
|
364
|
+
customPath: input.repository.triage?.prompts.feature,
|
|
365
|
+
outputContract: triageVoteOutputContract('"YES" | "NO" | "ASK"'),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
export async function composeTriageCommentClassificationPrompt(input) {
|
|
369
|
+
return composeTriageVotePrompt({
|
|
370
|
+
...input,
|
|
371
|
+
builtin: "comment-classification",
|
|
372
|
+
customPath: input.repository.triage?.prompts.commentClassification,
|
|
373
|
+
outputContract: triageCommentClassificationOutputContract,
|
|
374
|
+
});
|
|
375
|
+
}
|