opencode-magi 0.2.0 → 0.4.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 +341 -3
- package/dist/config/worktree.js +8 -2
- package/dist/github/commands.js +381 -19
- 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 +79 -0
- package/dist/orchestrator/majority.js +14 -0
- package/dist/orchestrator/merge.js +108 -34
- package/dist/orchestrator/report.js +25 -7
- package/dist/orchestrator/review-context.js +309 -0
- package/dist/orchestrator/review.js +122 -14
- 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 +163 -1
- package/dist/prompts/contracts.js +131 -18
- package/dist/prompts/output.js +173 -22
- package/dist/prompts/templates/merge/edit.md +12 -5
- 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 +162 -5
|
@@ -0,0 +1,1119 @@
|
|
|
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 { assignIssue, closeIssue, closePullRequest, configureGitIdentity, createPullRequest, fetchIssue, fetchIssueComments, fetchRelatedPullRequests, postIssueComment, pushHead, removeIssueLabels, removeWorktree, searchDuplicateIssues, shellQuote, updateIssueComment, } from "../github/commands";
|
|
6
|
+
import { composeTriageAcceptancePrompt, composeTriageActionPrompt, composeTriageCategoryPrompt, composeTriageCommentClassificationPrompt, composeTriageCommentPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageQuestionPrompt, composeTriageReconsiderPrompt, } from "../prompts/compose";
|
|
7
|
+
import { parseTriageActionOutput, parseTriageBinaryOutput, parseTriageCategoryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, } from "../prompts/output";
|
|
8
|
+
import { aggregateStringMajority, majorityThreshold } from "./majority";
|
|
9
|
+
import { runModelText, runModelWithRepair, } from "./model";
|
|
10
|
+
const MARKER_PREFIX = "opencode-magi:triage";
|
|
11
|
+
const BINARY_VOTES = ["ASK", "NO", "YES"];
|
|
12
|
+
const DUPLICATE_VOTES = ["DUPLICATE", "NOT_DUPLICATE"];
|
|
13
|
+
const EXISTING_PR_VOTES = [
|
|
14
|
+
"RELATED_PR_DOES_NOT_HANDLE_ISSUE",
|
|
15
|
+
"RELATED_PR_HANDLES_ISSUE",
|
|
16
|
+
];
|
|
17
|
+
const RECONSIDERATION_CLASSES = new Set([
|
|
18
|
+
"CLARIFICATION",
|
|
19
|
+
"NEW_EVIDENCE",
|
|
20
|
+
"OBJECTION",
|
|
21
|
+
]);
|
|
22
|
+
function marker(input) {
|
|
23
|
+
const askReason = input.decision.askReason
|
|
24
|
+
? ` askReason=${input.decision.askReason}`
|
|
25
|
+
: "";
|
|
26
|
+
return `<!-- ${MARKER_PREFIX} v=2 issue=${input.issue} category=${input.decision.category ?? "none"} disposition=${input.decision.disposition}${askReason} action=${input.action} checkpoint=${input.checkpoint ?? "pending"} pr=${input.pr ?? "none"} processed=${(input.processed ?? []).join(",")} -->`;
|
|
27
|
+
}
|
|
28
|
+
export function parseTriageMarker(body) {
|
|
29
|
+
const match = body.match(/<!--\s*opencode-magi:triage\s+([^>]+?)\s*-->/);
|
|
30
|
+
if (!match)
|
|
31
|
+
return undefined;
|
|
32
|
+
const entries = Object.fromEntries(match[1]
|
|
33
|
+
.trim()
|
|
34
|
+
.split(/\s+/)
|
|
35
|
+
.map((part) => {
|
|
36
|
+
const index = part.indexOf("=");
|
|
37
|
+
return index === -1
|
|
38
|
+
? [part, ""]
|
|
39
|
+
: [part.slice(0, index), part.slice(index + 1)];
|
|
40
|
+
}));
|
|
41
|
+
const version = Number(entries.v);
|
|
42
|
+
if (version !== 1 && version !== 2)
|
|
43
|
+
return undefined;
|
|
44
|
+
return {
|
|
45
|
+
action: entries.action,
|
|
46
|
+
askReason: entries.askReason === "acceptance_unclear" ||
|
|
47
|
+
entries.askReason === "category_unclear"
|
|
48
|
+
? entries.askReason
|
|
49
|
+
: undefined,
|
|
50
|
+
category: entries.category === "none" ? null : entries.category || undefined,
|
|
51
|
+
checkpoint: entries.checkpoint && Number.isFinite(Number(entries.checkpoint))
|
|
52
|
+
? Number(entries.checkpoint)
|
|
53
|
+
: undefined,
|
|
54
|
+
disposition: entries.disposition === "accepted" ||
|
|
55
|
+
entries.disposition === "rejected" ||
|
|
56
|
+
entries.disposition === "ask" ||
|
|
57
|
+
entries.disposition === "duplicate" ||
|
|
58
|
+
entries.disposition === "clear_only" ||
|
|
59
|
+
entries.disposition === "failed"
|
|
60
|
+
? entries.disposition
|
|
61
|
+
: undefined,
|
|
62
|
+
issue: entries.issue ? Number(entries.issue) : undefined,
|
|
63
|
+
pr: entries.pr,
|
|
64
|
+
processed: entries.processed
|
|
65
|
+
? entries.processed.split(",").filter(Boolean).map(Number)
|
|
66
|
+
: [],
|
|
67
|
+
result: entries.result,
|
|
68
|
+
v: version,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function labelsContain(labels, targets) {
|
|
72
|
+
const set = new Set(labels.map((label) => label.toLowerCase()));
|
|
73
|
+
return targets.some((target) => set.has(target.toLowerCase()));
|
|
74
|
+
}
|
|
75
|
+
function existingClearLabels(issue, labels) {
|
|
76
|
+
const existing = new Set(issue.labels.map((label) => label.toLowerCase()));
|
|
77
|
+
return labels.filter((label) => existing.has(label.toLowerCase()));
|
|
78
|
+
}
|
|
79
|
+
export function resolveIssueCategory(issue, repository) {
|
|
80
|
+
const triage = repository.triage;
|
|
81
|
+
if (!triage)
|
|
82
|
+
throw new Error("triage configuration is required");
|
|
83
|
+
const matches = triage.categories.filter((category) => labelsContain(issue.labels, category.labels) ||
|
|
84
|
+
(issue.type != null && category.types.includes(issue.type)));
|
|
85
|
+
if (matches.length !== 1)
|
|
86
|
+
return undefined;
|
|
87
|
+
return matches[0].id;
|
|
88
|
+
}
|
|
89
|
+
function issueContext(input) {
|
|
90
|
+
return JSON.stringify({
|
|
91
|
+
duplicateCandidates: input.relationship.duplicateCandidates,
|
|
92
|
+
issue: input.issue,
|
|
93
|
+
recentComments: input.relationship.comments.slice(-20),
|
|
94
|
+
reconsideration: input.reconsideration,
|
|
95
|
+
relatedPullRequests: input.relationship.relatedPullRequests,
|
|
96
|
+
}, null, 2);
|
|
97
|
+
}
|
|
98
|
+
async function writeJson(path, value) {
|
|
99
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
100
|
+
}
|
|
101
|
+
async function emitProgress(input, progress) {
|
|
102
|
+
await input.onProgress?.(progress);
|
|
103
|
+
}
|
|
104
|
+
async function emitTriageModelProgress(input) {
|
|
105
|
+
if (input.progress.type === "session_created") {
|
|
106
|
+
await emitProgress(input.run, {
|
|
107
|
+
options: input.progress.options,
|
|
108
|
+
phase: input.phase,
|
|
109
|
+
reviewer: input.reviewer,
|
|
110
|
+
sessionId: input.progress.sessionId,
|
|
111
|
+
type: "triage_agent_session",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (input.progress.type === "repair") {
|
|
115
|
+
await emitProgress(input.run, {
|
|
116
|
+
phase: input.phase,
|
|
117
|
+
reviewer: input.reviewer,
|
|
118
|
+
type: "triage_agent_repair",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (input.progress.type === "response") {
|
|
122
|
+
await emitProgress(input.run, {
|
|
123
|
+
phase: input.phase,
|
|
124
|
+
reviewer: input.reviewer,
|
|
125
|
+
sessionId: input.progress.sessionId,
|
|
126
|
+
type: "triage_agent_response",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function runVote(input) {
|
|
131
|
+
const prompt = await input.prompt({
|
|
132
|
+
context: input.context,
|
|
133
|
+
directory: input.directory,
|
|
134
|
+
issue: input.issue,
|
|
135
|
+
repository: input.repository,
|
|
136
|
+
reviewer: input.agent,
|
|
137
|
+
});
|
|
138
|
+
await emitProgress(input.run, {
|
|
139
|
+
phase: input.phase,
|
|
140
|
+
reviewer: input.agent.key,
|
|
141
|
+
type: "triage_agent_started",
|
|
142
|
+
});
|
|
143
|
+
let result;
|
|
144
|
+
try {
|
|
145
|
+
result = await runModelWithRepair({
|
|
146
|
+
client: input.client,
|
|
147
|
+
model: input.agent.model,
|
|
148
|
+
onProgress: (progress) => emitTriageModelProgress({
|
|
149
|
+
phase: input.phase,
|
|
150
|
+
progress,
|
|
151
|
+
reviewer: input.agent.key,
|
|
152
|
+
run: input.run,
|
|
153
|
+
}),
|
|
154
|
+
options: input.agent.options,
|
|
155
|
+
parse: input.parse,
|
|
156
|
+
permission: input.agent.permission,
|
|
157
|
+
prompt,
|
|
158
|
+
repairAttempts: 3,
|
|
159
|
+
schemaName: input.schemaName,
|
|
160
|
+
signal: input.signal,
|
|
161
|
+
title: `Magi triage ${input.schemaName} #${input.issue} (${input.agent.key})`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
await emitProgress(input.run, {
|
|
166
|
+
error: error instanceof Error ? error.message : String(error),
|
|
167
|
+
phase: input.phase,
|
|
168
|
+
reviewer: input.agent.key,
|
|
169
|
+
type: "triage_agent_failed",
|
|
170
|
+
});
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
await emitProgress(input.run, {
|
|
174
|
+
phase: input.phase,
|
|
175
|
+
reviewer: input.agent.key,
|
|
176
|
+
sessionId: result.sessionId,
|
|
177
|
+
type: "triage_agent_completed",
|
|
178
|
+
vote: result.value.vote,
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
...result.value,
|
|
182
|
+
promptText: prompt,
|
|
183
|
+
raw: result.raw,
|
|
184
|
+
sessionId: result.sessionId,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
async function writeVoteArtifacts(input) {
|
|
188
|
+
const base = join(input.outputDir, `${input.reviewer}.${input.phase}`);
|
|
189
|
+
await writeFile(`${base}.prompt.txt`, `${input.output.promptText}\n`);
|
|
190
|
+
await writeFile(`${base}.raw.txt`, `${input.output.raw}\n`);
|
|
191
|
+
await writeJson(`${base}.json`, {
|
|
192
|
+
reason: input.output.reason,
|
|
193
|
+
vote: input.output.vote,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
export function chooseDuplicateOutput(input) {
|
|
197
|
+
const candidates = new Set(input.candidateNumbers);
|
|
198
|
+
const threshold = majorityThreshold(input.outputs.length);
|
|
199
|
+
const counts = new Map();
|
|
200
|
+
for (const output of input.outputs) {
|
|
201
|
+
if (output.vote !== "DUPLICATE" ||
|
|
202
|
+
output.duplicateOf == null ||
|
|
203
|
+
!candidates.has(output.duplicateOf)) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
counts.set(output.duplicateOf, (counts.get(output.duplicateOf) ?? 0) + 1);
|
|
207
|
+
}
|
|
208
|
+
const target = [...counts.entries()].find(([, count]) => count >= threshold)?.[0];
|
|
209
|
+
if (target == null)
|
|
210
|
+
return undefined;
|
|
211
|
+
return input.outputs.find((output) => output.vote === "DUPLICATE" && output.duplicateOf === target);
|
|
212
|
+
}
|
|
213
|
+
async function runDuplicateVote(input) {
|
|
214
|
+
const agents = input.input.repository.agents.triage;
|
|
215
|
+
if (!agents?.length)
|
|
216
|
+
throw new Error("triage.agents is required");
|
|
217
|
+
await emitProgress(input.input, { phase: "duplicate", type: "phase" });
|
|
218
|
+
const outputs = await Promise.all(agents.map((agent) => runVote({
|
|
219
|
+
agent,
|
|
220
|
+
client: input.input.client,
|
|
221
|
+
context: input.context,
|
|
222
|
+
directory: input.input.directory,
|
|
223
|
+
issue: input.input.issue,
|
|
224
|
+
parse: parseTriageDuplicateOutput,
|
|
225
|
+
phase: "duplicate",
|
|
226
|
+
prompt: composeTriageDuplicatePrompt,
|
|
227
|
+
repository: input.input.repository,
|
|
228
|
+
run: input.input,
|
|
229
|
+
schemaName: "triage duplicate",
|
|
230
|
+
signal: input.input.signal,
|
|
231
|
+
})));
|
|
232
|
+
const majority = aggregateStringMajority(outputs.map((output, index) => ({
|
|
233
|
+
reviewer: agents[index].key,
|
|
234
|
+
vote: output.vote,
|
|
235
|
+
})), DUPLICATE_VOTES);
|
|
236
|
+
await Promise.all(outputs.map((output, index) => writeVoteArtifacts({
|
|
237
|
+
output,
|
|
238
|
+
outputDir: input.outputDir,
|
|
239
|
+
phase: "duplicate",
|
|
240
|
+
reviewer: agents[index].key,
|
|
241
|
+
})));
|
|
242
|
+
await Promise.all(outputs.map((output, index) => writeJson(join(input.outputDir, `${agents[index].key}.duplicate.json`), {
|
|
243
|
+
duplicateOf: output.duplicateOf,
|
|
244
|
+
reason: output.reason,
|
|
245
|
+
vote: output.vote,
|
|
246
|
+
})));
|
|
247
|
+
await writeJson(join(input.outputDir, "duplicate-majority.json"), majority);
|
|
248
|
+
if (majority.vote !== "DUPLICATE")
|
|
249
|
+
return undefined;
|
|
250
|
+
return chooseDuplicateOutput({
|
|
251
|
+
candidateNumbers: input.candidateNumbers,
|
|
252
|
+
outputs,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async function runPhaseVote(input) {
|
|
256
|
+
const agents = input.input.repository.agents.triage;
|
|
257
|
+
if (!agents?.length)
|
|
258
|
+
throw new Error("triage.agents is required");
|
|
259
|
+
await emitProgress(input.input, { phase: input.phase, type: "phase" });
|
|
260
|
+
const outputs = await Promise.all(agents.map((agent) => runVote({
|
|
261
|
+
agent,
|
|
262
|
+
client: input.input.client,
|
|
263
|
+
context: input.context,
|
|
264
|
+
directory: input.input.directory,
|
|
265
|
+
issue: input.input.issue,
|
|
266
|
+
parse: input.parse,
|
|
267
|
+
phase: input.phase,
|
|
268
|
+
prompt: input.prompt,
|
|
269
|
+
repository: input.input.repository,
|
|
270
|
+
run: input.input,
|
|
271
|
+
schemaName: input.schemaName,
|
|
272
|
+
signal: input.input.signal,
|
|
273
|
+
})));
|
|
274
|
+
const majority = aggregateStringMajority(outputs.map((output, index) => ({
|
|
275
|
+
reviewer: agents[index].key,
|
|
276
|
+
vote: output.vote,
|
|
277
|
+
})), input.votes);
|
|
278
|
+
await Promise.all(outputs.map((output, index) => writeVoteArtifacts({
|
|
279
|
+
output,
|
|
280
|
+
outputDir: input.outputDir,
|
|
281
|
+
phase: input.phase,
|
|
282
|
+
reviewer: agents[index].key,
|
|
283
|
+
})));
|
|
284
|
+
await writeJson(join(input.outputDir, `${input.phase}-majority.json`), majority);
|
|
285
|
+
return majority.vote;
|
|
286
|
+
}
|
|
287
|
+
async function relationshipScan(input, issue) {
|
|
288
|
+
const [comments, relatedPullRequests, duplicateCandidates] = await Promise.all([
|
|
289
|
+
fetchIssueComments(input.exec, input.repository, input.issue),
|
|
290
|
+
fetchRelatedPullRequests(input.exec, input.repository, input.issue),
|
|
291
|
+
searchDuplicateIssues(input.exec, input.repository, issue),
|
|
292
|
+
]);
|
|
293
|
+
const markers = comments
|
|
294
|
+
.filter((comment) => comment.author === input.repository.triage?.account)
|
|
295
|
+
.map((comment) => {
|
|
296
|
+
const parsed = parseTriageMarker(comment.body);
|
|
297
|
+
return parsed ? { ...parsed, commentId: comment.id } : undefined;
|
|
298
|
+
})
|
|
299
|
+
.filter(Boolean);
|
|
300
|
+
const previousMarker = markers.at(-1);
|
|
301
|
+
const mentionReplies = previousMarker
|
|
302
|
+
? eligibleMentionReplies({
|
|
303
|
+
account: input.repository.triage?.account ?? "",
|
|
304
|
+
comments,
|
|
305
|
+
marker: previousMarker,
|
|
306
|
+
processed: previousMarker.processed,
|
|
307
|
+
repository: input.repository,
|
|
308
|
+
})
|
|
309
|
+
: [];
|
|
310
|
+
return {
|
|
311
|
+
comments,
|
|
312
|
+
duplicateCandidates,
|
|
313
|
+
mentionReplies,
|
|
314
|
+
previousMarker,
|
|
315
|
+
relatedPullRequests,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function mentionsAccount(body, account) {
|
|
319
|
+
return new RegExp(`@${account.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i").test(body);
|
|
320
|
+
}
|
|
321
|
+
function markerCheckpoint(marker) {
|
|
322
|
+
return marker.checkpoint ?? marker.commentId;
|
|
323
|
+
}
|
|
324
|
+
function markerPr(marker) {
|
|
325
|
+
const pr = Number(marker.pr);
|
|
326
|
+
return Number.isInteger(pr) && pr > 0 ? pr : undefined;
|
|
327
|
+
}
|
|
328
|
+
function pullRequestNumberFromUrl(url) {
|
|
329
|
+
const match = url.match(/\/pull\/(\d+)(?:\D|$)/);
|
|
330
|
+
const number = match ? Number(match[1]) : undefined;
|
|
331
|
+
return number && Number.isInteger(number) ? number : undefined;
|
|
332
|
+
}
|
|
333
|
+
export function mentionAllowed(comment, repository) {
|
|
334
|
+
const safety = repository.triage?.safety;
|
|
335
|
+
if (!safety)
|
|
336
|
+
return false;
|
|
337
|
+
const actorAllowed = safety.allowMentionActors.length
|
|
338
|
+
? safety.allowMentionActors.includes(comment.author)
|
|
339
|
+
: false;
|
|
340
|
+
const roleAllowed = safety.allowMentionRoles.length
|
|
341
|
+
? safety.allowMentionRoles.includes(comment.authorAssociation ?? "")
|
|
342
|
+
: false;
|
|
343
|
+
return safety.allowMentionActors.length || safety.allowMentionRoles.length
|
|
344
|
+
? actorAllowed || roleAllowed
|
|
345
|
+
: true;
|
|
346
|
+
}
|
|
347
|
+
export function eligibleMentionReplies(input) {
|
|
348
|
+
const checkpoint = markerCheckpoint(input.marker);
|
|
349
|
+
const processed = new Set(input.processed);
|
|
350
|
+
return input.comments.filter((comment) => {
|
|
351
|
+
if (checkpoint != null && comment.id <= checkpoint)
|
|
352
|
+
return false;
|
|
353
|
+
if (processed.has(comment.id))
|
|
354
|
+
return false;
|
|
355
|
+
if (!mentionsAccount(comment.body, input.account))
|
|
356
|
+
return false;
|
|
357
|
+
return mentionAllowed(comment, input.repository);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
function finalResultFromMarker(marker) {
|
|
361
|
+
if (marker.disposition) {
|
|
362
|
+
return {
|
|
363
|
+
askReason: marker.askReason,
|
|
364
|
+
category: marker.category ?? null,
|
|
365
|
+
disposition: marker.disposition,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
switch (marker.result) {
|
|
369
|
+
case "BUG_ACCEPTED":
|
|
370
|
+
case "RESOLVED_BY_MERGED_PR":
|
|
371
|
+
return { category: "bug", disposition: "accepted" };
|
|
372
|
+
case "BUG_REJECTED":
|
|
373
|
+
return { category: "bug", disposition: "rejected" };
|
|
374
|
+
case "FEATURE_ACCEPTED":
|
|
375
|
+
return { category: "feature", disposition: "accepted" };
|
|
376
|
+
case "FEATURE_REJECTED":
|
|
377
|
+
return { category: "feature", disposition: "rejected" };
|
|
378
|
+
case "ASK":
|
|
379
|
+
return {
|
|
380
|
+
askReason: "acceptance_unclear",
|
|
381
|
+
category: null,
|
|
382
|
+
disposition: "ask",
|
|
383
|
+
};
|
|
384
|
+
case "CLEAR_ONLY":
|
|
385
|
+
return { category: null, disposition: "clear_only" };
|
|
386
|
+
case "DUPLICATE":
|
|
387
|
+
return { category: null, disposition: "duplicate" };
|
|
388
|
+
default:
|
|
389
|
+
return { category: null, disposition: "failed" };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function decisionText(decision) {
|
|
393
|
+
return JSON.stringify(decision);
|
|
394
|
+
}
|
|
395
|
+
function actionPlan(input) {
|
|
396
|
+
if (input.result.disposition === "clear_only") {
|
|
397
|
+
return {
|
|
398
|
+
action: "CLEAR_ONLY",
|
|
399
|
+
allowedActions: ["CLEAR_ONLY"],
|
|
400
|
+
clearLabels: true,
|
|
401
|
+
closeIssue: false,
|
|
402
|
+
createPr: false,
|
|
403
|
+
postComment: false,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (input.result.disposition === "ask") {
|
|
407
|
+
return {
|
|
408
|
+
action: "ASK",
|
|
409
|
+
allowedActions: ["ASK"],
|
|
410
|
+
clearLabels: false,
|
|
411
|
+
closeIssue: false,
|
|
412
|
+
createPr: false,
|
|
413
|
+
postComment: true,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const closeIssue = input.triage.automation.close &&
|
|
417
|
+
(input.result.disposition === "rejected" ||
|
|
418
|
+
input.result.disposition === "duplicate");
|
|
419
|
+
const createPr = input.triage.automation.create && input.result.disposition === "accepted";
|
|
420
|
+
return {
|
|
421
|
+
action: closeIssue ? "CLOSE" : createPr ? "PR" : "COMMENT",
|
|
422
|
+
allowedActions: [closeIssue ? "CLOSE" : createPr ? "PR" : "COMMENT"],
|
|
423
|
+
clearLabels: true,
|
|
424
|
+
closeIssue,
|
|
425
|
+
createPr,
|
|
426
|
+
postComment: true,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function previousAutomationPlan(input) {
|
|
430
|
+
const base = actionPlan({ result: input.result, triage: input.triage });
|
|
431
|
+
const clearLabels = base.clearLabels &&
|
|
432
|
+
existingClearLabels(input.issue, input.triage.automation.clear).length > 0;
|
|
433
|
+
const closeIssue = input.marker.action === "CLOSE" &&
|
|
434
|
+
base.closeIssue &&
|
|
435
|
+
input.issue.state === "OPEN";
|
|
436
|
+
const createPr = input.marker.action === "PR" &&
|
|
437
|
+
base.createPr &&
|
|
438
|
+
!markerPr(input.marker) &&
|
|
439
|
+
!input.relationship.relatedPullRequests.length;
|
|
440
|
+
if (!clearLabels && !closeIssue && !createPr)
|
|
441
|
+
return undefined;
|
|
442
|
+
const action = closeIssue ? "CLOSE" : createPr ? "PR" : "CLEAR_ONLY";
|
|
443
|
+
return {
|
|
444
|
+
...base,
|
|
445
|
+
action,
|
|
446
|
+
allowedActions: [action],
|
|
447
|
+
clearLabels,
|
|
448
|
+
closeIssue,
|
|
449
|
+
createPr,
|
|
450
|
+
postComment: false,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
async function runActionPrompt(input) {
|
|
454
|
+
const agent = input.input.repository.agents.triage?.[0];
|
|
455
|
+
if (!agent)
|
|
456
|
+
throw new Error("triage.agents is required");
|
|
457
|
+
const context = JSON.stringify({
|
|
458
|
+
allowedActions: input.plan.allowedActions,
|
|
459
|
+
deterministicPlan: input.plan,
|
|
460
|
+
result: input.result,
|
|
461
|
+
triageContext: input.context,
|
|
462
|
+
}, null, 2);
|
|
463
|
+
const prompt = await composeTriageActionPrompt({
|
|
464
|
+
context,
|
|
465
|
+
directory: input.input.directory,
|
|
466
|
+
issue: input.input.issue,
|
|
467
|
+
repository: input.input.repository,
|
|
468
|
+
reviewer: agent,
|
|
469
|
+
});
|
|
470
|
+
const result = await runModelWithRepair({
|
|
471
|
+
client: input.input.client,
|
|
472
|
+
model: agent.model,
|
|
473
|
+
options: agent.options,
|
|
474
|
+
parse: parseTriageActionOutput,
|
|
475
|
+
permission: agent.permission,
|
|
476
|
+
prompt,
|
|
477
|
+
repairAttempts: 3,
|
|
478
|
+
schemaName: "triage action",
|
|
479
|
+
signal: input.input.signal,
|
|
480
|
+
title: `Magi triage action #${input.input.issue}`,
|
|
481
|
+
});
|
|
482
|
+
await writeJson(join(input.outputDir, "action.json"), {
|
|
483
|
+
model: result.value,
|
|
484
|
+
plan: input.plan,
|
|
485
|
+
});
|
|
486
|
+
return result.value;
|
|
487
|
+
}
|
|
488
|
+
async function classifyMentionReplies(input) {
|
|
489
|
+
const agent = input.input.repository.agents.triage?.[0];
|
|
490
|
+
if (!agent)
|
|
491
|
+
throw new Error("triage.agents is required");
|
|
492
|
+
const prompt = await composeTriageCommentClassificationPrompt({
|
|
493
|
+
context: JSON.stringify({ context: input.context, mentionReplies: input.replies }, null, 2),
|
|
494
|
+
directory: input.input.directory,
|
|
495
|
+
issue: input.input.issue,
|
|
496
|
+
repository: input.input.repository,
|
|
497
|
+
reviewer: agent,
|
|
498
|
+
});
|
|
499
|
+
const result = await runModelWithRepair({
|
|
500
|
+
client: input.input.client,
|
|
501
|
+
model: agent.model,
|
|
502
|
+
options: agent.options,
|
|
503
|
+
parse: parseTriageCommentClassificationOutput,
|
|
504
|
+
permission: agent.permission,
|
|
505
|
+
prompt,
|
|
506
|
+
repairAttempts: 3,
|
|
507
|
+
schemaName: "triage comment classification",
|
|
508
|
+
signal: input.input.signal,
|
|
509
|
+
title: `Magi triage comment classification #${input.input.issue}`,
|
|
510
|
+
});
|
|
511
|
+
await writeJson(join(input.outputDir, "comment-classification.json"), result.value);
|
|
512
|
+
return result.value;
|
|
513
|
+
}
|
|
514
|
+
async function runReconsiderationVote(input) {
|
|
515
|
+
return runPhaseVote({
|
|
516
|
+
context: input.context,
|
|
517
|
+
input: input.input,
|
|
518
|
+
outputDir: input.outputDir,
|
|
519
|
+
parse: parseTriageBinaryOutput,
|
|
520
|
+
phase: "reconsider",
|
|
521
|
+
prompt: composeTriageReconsiderPrompt,
|
|
522
|
+
schemaName: "triage reconsider",
|
|
523
|
+
votes: BINARY_VOTES,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
async function composeResultComment(input) {
|
|
527
|
+
const agents = input.input.repository.agents.triage;
|
|
528
|
+
if (!agents?.length)
|
|
529
|
+
throw new Error("triage.agents is required");
|
|
530
|
+
if (input.result.disposition === "ask" &&
|
|
531
|
+
input.result.askReason === "category_unclear") {
|
|
532
|
+
const language = input.input.repository.language?.toLowerCase() ?? "";
|
|
533
|
+
const body = language.includes("ja") || language.includes("japanese")
|
|
534
|
+
? `@${input.issue.author} 現在の説明だけでは、何をすべきか判断できません。\n\n期待する動作、実際の動作、必要な理由、関連する例・ログ・スクリーンショットなどを追記してください。`
|
|
535
|
+
: `@${input.issue.author} I can't determine what should be done from the current description.\n\nPlease add more detail, such as the expected behavior, the actual behavior, the reason this is needed, or any relevant examples, logs, or screenshots.`;
|
|
536
|
+
const comment = `${body}\n\n${marker({
|
|
537
|
+
action: input.action,
|
|
538
|
+
checkpoint: "pending",
|
|
539
|
+
decision: input.result,
|
|
540
|
+
issue: input.issue.number,
|
|
541
|
+
processed: input.processed,
|
|
542
|
+
})}`;
|
|
543
|
+
await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
|
|
544
|
+
return comment;
|
|
545
|
+
}
|
|
546
|
+
const prompt = await (input.result.disposition === "ask"
|
|
547
|
+
? composeTriageQuestionPrompt
|
|
548
|
+
: composeTriageCommentPrompt)({
|
|
549
|
+
author: input.issue.author,
|
|
550
|
+
context: input.context,
|
|
551
|
+
directory: input.input.directory,
|
|
552
|
+
issue: input.issue.number,
|
|
553
|
+
repository: input.input.repository,
|
|
554
|
+
});
|
|
555
|
+
const comment = (await runModelText({
|
|
556
|
+
allowEmpty: false,
|
|
557
|
+
client: input.input.client,
|
|
558
|
+
model: agents[0].model,
|
|
559
|
+
options: agents[0].options,
|
|
560
|
+
permission: agents[0].permission,
|
|
561
|
+
prompt,
|
|
562
|
+
signal: input.input.signal,
|
|
563
|
+
title: `Magi triage comment #${input.issue.number}`,
|
|
564
|
+
})).raw +
|
|
565
|
+
`\n\n${marker({
|
|
566
|
+
action: input.action,
|
|
567
|
+
checkpoint: "pending",
|
|
568
|
+
decision: input.result,
|
|
569
|
+
issue: input.issue.number,
|
|
570
|
+
processed: input.processed,
|
|
571
|
+
})}`;
|
|
572
|
+
await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
|
|
573
|
+
return comment;
|
|
574
|
+
}
|
|
575
|
+
async function postMarkedIssueComment(input) {
|
|
576
|
+
const posted = await postIssueComment(input.exec, input.repository, input.issue, input.account, input.body);
|
|
577
|
+
const body = input.body.replace("checkpoint=pending", `checkpoint=${posted.id}`);
|
|
578
|
+
const updated = body === input.body
|
|
579
|
+
? posted
|
|
580
|
+
: await updateIssueComment(input.exec, input.repository, posted.id, input.account, body);
|
|
581
|
+
await writeJson(join(input.outputDir, "posted.json"), updated);
|
|
582
|
+
return updated;
|
|
583
|
+
}
|
|
584
|
+
async function persistProcessedMarker(input) {
|
|
585
|
+
if (!input.marker.commentId)
|
|
586
|
+
return;
|
|
587
|
+
const previousComment = input.comments.find((comment) => comment.id === input.marker.commentId);
|
|
588
|
+
if (!previousComment)
|
|
589
|
+
return;
|
|
590
|
+
const updatedMarker = marker({
|
|
591
|
+
action: input.marker.action ?? input.marker.result ?? "ASK",
|
|
592
|
+
checkpoint: markerCheckpoint(input.marker),
|
|
593
|
+
decision: finalResultFromMarker(input.marker),
|
|
594
|
+
issue: input.issue.number,
|
|
595
|
+
pr: input.pr ?? markerPr(input.marker),
|
|
596
|
+
processed: input.processed,
|
|
597
|
+
});
|
|
598
|
+
const body = previousComment.body.replace(/<!--\s*opencode-magi:triage\s+[^>]+?\s*-->/, updatedMarker);
|
|
599
|
+
if (body === previousComment.body)
|
|
600
|
+
return;
|
|
601
|
+
const updated = await updateIssueComment(input.exec, input.repository, input.marker.commentId, input.account, body);
|
|
602
|
+
await writeJson(join(input.outputDir, "processed.json"), {
|
|
603
|
+
processed: input.processed,
|
|
604
|
+
updated,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
async function finishWithResult(input) {
|
|
608
|
+
const triage = input.input.repository.triage;
|
|
609
|
+
if (!triage)
|
|
610
|
+
throw new Error("triage configuration is required");
|
|
611
|
+
const plan = input.plan ?? actionPlan({ result: input.result, triage });
|
|
612
|
+
await emitProgress(input.input, {
|
|
613
|
+
action: plan.action,
|
|
614
|
+
result: input.result,
|
|
615
|
+
type: "decision",
|
|
616
|
+
});
|
|
617
|
+
await runActionPrompt({
|
|
618
|
+
context: input.context,
|
|
619
|
+
input: input.input,
|
|
620
|
+
outputDir: input.outputDir,
|
|
621
|
+
plan,
|
|
622
|
+
result: input.result,
|
|
623
|
+
});
|
|
624
|
+
let prUrl;
|
|
625
|
+
const comment = plan.postComment
|
|
626
|
+
? await composeResultComment({
|
|
627
|
+
action: plan.action,
|
|
628
|
+
context: `Result: ${decisionText(input.result)}\nAction: ${plan.action}\n\n${input.context}`,
|
|
629
|
+
input: input.input,
|
|
630
|
+
issue: input.issue,
|
|
631
|
+
outputDir: input.outputDir,
|
|
632
|
+
processed: input.processed,
|
|
633
|
+
result: input.result,
|
|
634
|
+
})
|
|
635
|
+
: undefined;
|
|
636
|
+
if (!input.input.dryRun) {
|
|
637
|
+
if (comment) {
|
|
638
|
+
await emitProgress(input.input, { type: "comment_posting" });
|
|
639
|
+
const posted = await postMarkedIssueComment({
|
|
640
|
+
account: triage.account ?? "",
|
|
641
|
+
body: comment,
|
|
642
|
+
exec: input.input.exec,
|
|
643
|
+
issue: input.issue.number,
|
|
644
|
+
outputDir: input.outputDir,
|
|
645
|
+
repository: input.input.repository,
|
|
646
|
+
});
|
|
647
|
+
await emitProgress(input.input, {
|
|
648
|
+
type: "comment_posted",
|
|
649
|
+
url: posted.url,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
if (plan.clearLabels) {
|
|
653
|
+
const clearLabels = existingClearLabels(input.issue, triage.automation.clear);
|
|
654
|
+
if (clearLabels.length) {
|
|
655
|
+
await removeIssueLabels(input.input.exec, input.input.repository, input.issue.number, clearLabels, triage.account ?? "");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (plan.closeIssue) {
|
|
659
|
+
const closedPrs = [];
|
|
660
|
+
for (const pr of input.relationship.relatedPullRequests.filter((pr) => pr.state === "OPEN")) {
|
|
661
|
+
await closePullRequest(input.input.exec, input.input.repository, pr.number, triage.account ?? "");
|
|
662
|
+
closedPrs.push(pr.number);
|
|
663
|
+
}
|
|
664
|
+
if (closedPrs.length)
|
|
665
|
+
await writeJson(join(input.outputDir, "closed-prs.json"), closedPrs);
|
|
666
|
+
await closeIssue(input.input.exec, input.input.repository, input.issue.number, triage.account ?? "");
|
|
667
|
+
}
|
|
668
|
+
if (plan.createPr) {
|
|
669
|
+
prUrl = await createImplementationPr({
|
|
670
|
+
context: input.context,
|
|
671
|
+
input: input.input,
|
|
672
|
+
issue: input.issue,
|
|
673
|
+
outputDir: input.outputDir,
|
|
674
|
+
});
|
|
675
|
+
if (prUrl) {
|
|
676
|
+
await writeJson(join(input.outputDir, "pr.json"), { url: prUrl });
|
|
677
|
+
await emitProgress(input.input, { type: "pr_created", url: prUrl });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (input.previousMarker && prUrl) {
|
|
681
|
+
await persistProcessedMarker({
|
|
682
|
+
account: triage.account ?? "",
|
|
683
|
+
comments: input.relationship.comments,
|
|
684
|
+
exec: input.input.exec,
|
|
685
|
+
issue: input.issue,
|
|
686
|
+
marker: input.previousMarker,
|
|
687
|
+
outputDir: input.outputDir,
|
|
688
|
+
pr: pullRequestNumberFromUrl(prUrl),
|
|
689
|
+
processed: input.processed ?? input.previousMarker.processed,
|
|
690
|
+
repository: input.input.repository,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const report = [
|
|
695
|
+
`Magi triage result for #${input.issue.number}: ${decisionText(input.result)}`,
|
|
696
|
+
prUrl ? `Created PR: ${prUrl}` : undefined,
|
|
697
|
+
input.input.dryRun
|
|
698
|
+
? "Dry run: no GitHub mutations were performed."
|
|
699
|
+
: undefined,
|
|
700
|
+
]
|
|
701
|
+
.filter(Boolean)
|
|
702
|
+
.join("\n");
|
|
703
|
+
await writeFile(join(input.outputDir, "report.md"), `${report}\n`);
|
|
704
|
+
return {
|
|
705
|
+
issue: input.issue.number,
|
|
706
|
+
outputDir: input.outputDir,
|
|
707
|
+
prUrl,
|
|
708
|
+
report,
|
|
709
|
+
result: input.result,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
function safetyBlocked(input, issue, hasMarker) {
|
|
713
|
+
const triage = input.repository.triage;
|
|
714
|
+
if (!triage)
|
|
715
|
+
throw new Error("triage configuration is required");
|
|
716
|
+
const safety = triage.safety;
|
|
717
|
+
if (issue.state === "CLOSED")
|
|
718
|
+
return "issue is closed";
|
|
719
|
+
if (!hasMarker && safety.requiredLabels.length) {
|
|
720
|
+
const missing = safety.requiredLabels.filter((label) => !labelsContain(issue.labels, [label]));
|
|
721
|
+
if (missing.length)
|
|
722
|
+
return `missing required labels: ${missing.join(", ")}`;
|
|
723
|
+
}
|
|
724
|
+
if (safety.blockedLabels.some((label) => labelsContain(issue.labels, [label]))) {
|
|
725
|
+
return "issue has a blocked label";
|
|
726
|
+
}
|
|
727
|
+
if (safety.allowAuthors.length &&
|
|
728
|
+
!safety.allowAuthors.includes(issue.author)) {
|
|
729
|
+
return `issue author is not allowed: ${issue.author}`;
|
|
730
|
+
}
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
async function createImplementationPr(input) {
|
|
734
|
+
const creator = input.input.repository.agents.triageCreator;
|
|
735
|
+
if (!creator)
|
|
736
|
+
return undefined;
|
|
737
|
+
const triage = input.input.repository.triage;
|
|
738
|
+
if (!triage?.account)
|
|
739
|
+
throw new Error("triage.account is required");
|
|
740
|
+
await emitProgress(input.input, { type: "pr_creation_started" });
|
|
741
|
+
await emitProgress(input.input, { type: "triage_creator_started" });
|
|
742
|
+
try {
|
|
743
|
+
await assignIssue(input.input.exec, input.input.repository, input.issue.number, triage.account);
|
|
744
|
+
const branch = `magi/issue-${input.issue.number}-${Date.now().toString(36)}`;
|
|
745
|
+
const worktreePath = join(worktreeBaseDir(input.input.directory, input.input.config, "issue"), `issue-${input.issue.number}`);
|
|
746
|
+
await mkdir(dirname(worktreePath), { recursive: true });
|
|
747
|
+
await input.input.exec(`git worktree add -b ${shellQuote(branch)} ${shellQuote(worktreePath)}`);
|
|
748
|
+
await emitProgress(input.input, {
|
|
749
|
+
branch,
|
|
750
|
+
type: "worktree_created",
|
|
751
|
+
worktreePath,
|
|
752
|
+
});
|
|
753
|
+
try {
|
|
754
|
+
await configureGitIdentity(input.input.exec, worktreePath, creator.author);
|
|
755
|
+
const prompt = await composeTriageCreatePrPrompt({
|
|
756
|
+
context: input.context,
|
|
757
|
+
directory: input.input.directory,
|
|
758
|
+
issue: input.issue.number,
|
|
759
|
+
repository: input.input.repository,
|
|
760
|
+
worktreePath,
|
|
761
|
+
});
|
|
762
|
+
const result = await runModelWithRepair({
|
|
763
|
+
client: input.input.client,
|
|
764
|
+
model: creator.model,
|
|
765
|
+
onProgress: async (progress) => {
|
|
766
|
+
if (progress.type === "session_created") {
|
|
767
|
+
await emitProgress(input.input, {
|
|
768
|
+
options: progress.options,
|
|
769
|
+
sessionId: progress.sessionId,
|
|
770
|
+
type: "triage_creator_session",
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
if (progress.type === "repair") {
|
|
774
|
+
await emitProgress(input.input, { type: "triage_creator_repair" });
|
|
775
|
+
}
|
|
776
|
+
if (progress.type === "response") {
|
|
777
|
+
await emitProgress(input.input, {
|
|
778
|
+
sessionId: progress.sessionId,
|
|
779
|
+
type: "triage_creator_response",
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
options: creator.options,
|
|
784
|
+
parse: parseTriageCreatePrOutput,
|
|
785
|
+
permission: creator.permission,
|
|
786
|
+
prompt,
|
|
787
|
+
repairAttempts: 3,
|
|
788
|
+
schemaName: "triage create PR",
|
|
789
|
+
signal: input.input.signal,
|
|
790
|
+
title: `Magi triage create PR #${input.issue.number}`,
|
|
791
|
+
});
|
|
792
|
+
await emitProgress(input.input, {
|
|
793
|
+
sessionId: result.sessionId,
|
|
794
|
+
type: "triage_creator_completed",
|
|
795
|
+
});
|
|
796
|
+
await writeJson(join(input.outputDir, "create-pr.json"), result.value);
|
|
797
|
+
if (result.value.mode !== "EDITED")
|
|
798
|
+
return undefined;
|
|
799
|
+
const pullRequest = result.value.pullRequest;
|
|
800
|
+
if (!pullRequest)
|
|
801
|
+
throw new Error("EDITED requires pullRequest");
|
|
802
|
+
await pushHead(input.input.exec, input.input.repository, worktreePath, creator.account, {
|
|
803
|
+
owner: input.input.repository.github.owner,
|
|
804
|
+
ref: branch,
|
|
805
|
+
repo: input.input.repository.github.repo,
|
|
806
|
+
});
|
|
807
|
+
return createPullRequest(input.input.exec, input.input.repository, creator.account, {
|
|
808
|
+
body: pullRequest.body,
|
|
809
|
+
head: branch,
|
|
810
|
+
title: pullRequest.title,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
finally {
|
|
814
|
+
await removeWorktree(input.input.exec, worktreePath).catch(() => undefined);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
await emitProgress(input.input, {
|
|
819
|
+
error: error instanceof Error ? error.message : String(error),
|
|
820
|
+
type: "triage_creator_failed",
|
|
821
|
+
});
|
|
822
|
+
throw error;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
export async function runTriage(input) {
|
|
826
|
+
const triage = input.repository.triage;
|
|
827
|
+
if (!triage?.account)
|
|
828
|
+
throw new Error("triage.account is required");
|
|
829
|
+
const agents = input.repository.agents.triage;
|
|
830
|
+
if (!agents?.length)
|
|
831
|
+
throw new Error("triage.agents is required");
|
|
832
|
+
const runId = input.runId ?? `run-${Date.now().toString(36)}`;
|
|
833
|
+
const outputDir = issueRunOutputDir({
|
|
834
|
+
config: input.config,
|
|
835
|
+
directory: input.directory,
|
|
836
|
+
issue: input.issue,
|
|
837
|
+
runId,
|
|
838
|
+
});
|
|
839
|
+
await mkdir(outputDir, { recursive: true });
|
|
840
|
+
await emitProgress(input, { phase: "fetching issue", type: "phase" });
|
|
841
|
+
const issue = await fetchIssue(input.exec, input.repository, input.issue);
|
|
842
|
+
await emitProgress(input, {
|
|
843
|
+
phase: "scanning issue relationships",
|
|
844
|
+
type: "phase",
|
|
845
|
+
});
|
|
846
|
+
const relationship = await relationshipScan(input, issue);
|
|
847
|
+
const block = safetyBlocked(input, issue, Boolean(relationship.previousMarker));
|
|
848
|
+
await writeJson(join(outputDir, "issue.json"), issue);
|
|
849
|
+
await writeJson(join(outputDir, "comments.json"), relationship.comments);
|
|
850
|
+
await writeJson(join(outputDir, "relationship-summary.json"), relationship);
|
|
851
|
+
if (relationship.previousMarker)
|
|
852
|
+
await writeJson(join(outputDir, "previous-triage.json"), relationship.previousMarker);
|
|
853
|
+
if (block) {
|
|
854
|
+
const report = `Magi triage blocked for #${input.issue}: ${block}`;
|
|
855
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
856
|
+
return {
|
|
857
|
+
issue: input.issue,
|
|
858
|
+
outputDir,
|
|
859
|
+
report,
|
|
860
|
+
result: { category: null, disposition: "failed" },
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
let context = issueContext({ issue, relationship });
|
|
864
|
+
await writeFile(join(outputDir, "context.md"), `${context}\n`);
|
|
865
|
+
await emitProgress(input, { phase: "triaging", type: "phase" });
|
|
866
|
+
let processed = relationship.previousMarker?.processed ?? [];
|
|
867
|
+
let result;
|
|
868
|
+
if (relationship.previousMarker) {
|
|
869
|
+
if (!relationship.mentionReplies.length) {
|
|
870
|
+
const result = finalResultFromMarker(relationship.previousMarker);
|
|
871
|
+
const plan = previousAutomationPlan({
|
|
872
|
+
issue,
|
|
873
|
+
marker: relationship.previousMarker,
|
|
874
|
+
relationship,
|
|
875
|
+
result,
|
|
876
|
+
triage,
|
|
877
|
+
});
|
|
878
|
+
if (plan) {
|
|
879
|
+
return finishWithResult({
|
|
880
|
+
context,
|
|
881
|
+
input,
|
|
882
|
+
issue,
|
|
883
|
+
outputDir,
|
|
884
|
+
plan,
|
|
885
|
+
previousMarker: relationship.previousMarker,
|
|
886
|
+
processed,
|
|
887
|
+
relationship,
|
|
888
|
+
result,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
const report = `Magi triage skipped #${issue.number} because no eligible mention replies were found for reconsideration.`;
|
|
892
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
893
|
+
return { issue: issue.number, outputDir, report, result };
|
|
894
|
+
}
|
|
895
|
+
const classifications = await classifyMentionReplies({
|
|
896
|
+
context,
|
|
897
|
+
input,
|
|
898
|
+
outputDir,
|
|
899
|
+
replies: relationship.mentionReplies,
|
|
900
|
+
});
|
|
901
|
+
const triggeringComments = relationship.mentionReplies.filter((comment) => classifications.comments.some((item) => item.commentId === comment.id &&
|
|
902
|
+
RECONSIDERATION_CLASSES.has(item.classification)));
|
|
903
|
+
processed = [
|
|
904
|
+
...new Set([
|
|
905
|
+
...processed,
|
|
906
|
+
...classifications.comments.map((comment) => comment.commentId),
|
|
907
|
+
]),
|
|
908
|
+
];
|
|
909
|
+
if (!triggeringComments.length) {
|
|
910
|
+
if (!input.dryRun) {
|
|
911
|
+
await persistProcessedMarker({
|
|
912
|
+
account: triage.account,
|
|
913
|
+
comments: relationship.comments,
|
|
914
|
+
exec: input.exec,
|
|
915
|
+
issue,
|
|
916
|
+
marker: relationship.previousMarker,
|
|
917
|
+
outputDir,
|
|
918
|
+
processed,
|
|
919
|
+
repository: input.repository,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
const result = finalResultFromMarker(relationship.previousMarker);
|
|
923
|
+
const report = `Magi triage skipped #${issue.number} because allowed mention replies did not request reconsideration.`;
|
|
924
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
925
|
+
return { issue: issue.number, outputDir, report, result };
|
|
926
|
+
}
|
|
927
|
+
context = issueContext({
|
|
928
|
+
issue,
|
|
929
|
+
relationship,
|
|
930
|
+
reconsideration: {
|
|
931
|
+
classifications,
|
|
932
|
+
previousMarker: relationship.previousMarker,
|
|
933
|
+
triggeringComments,
|
|
934
|
+
},
|
|
935
|
+
});
|
|
936
|
+
await writeFile(join(outputDir, "context.md"), `${context}\n`);
|
|
937
|
+
const vote = await runReconsiderationVote({ context, input, outputDir });
|
|
938
|
+
const previous = finalResultFromMarker(relationship.previousMarker);
|
|
939
|
+
result =
|
|
940
|
+
vote === "YES"
|
|
941
|
+
? { category: previous.category, disposition: "accepted" }
|
|
942
|
+
: vote === "NO"
|
|
943
|
+
? { category: previous.category, disposition: "rejected" }
|
|
944
|
+
: {
|
|
945
|
+
askReason: "acceptance_unclear",
|
|
946
|
+
category: previous.category,
|
|
947
|
+
disposition: "ask",
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
if (!result && relationship.relatedPullRequests.length) {
|
|
951
|
+
const vote = await runPhaseVote({
|
|
952
|
+
context,
|
|
953
|
+
input,
|
|
954
|
+
outputDir,
|
|
955
|
+
parse: parseTriageExistingPrOutput,
|
|
956
|
+
phase: "existing-pr",
|
|
957
|
+
prompt: composeTriageExistingPrPrompt,
|
|
958
|
+
schemaName: "triage existing PR",
|
|
959
|
+
votes: EXISTING_PR_VOTES,
|
|
960
|
+
});
|
|
961
|
+
if (vote === "RELATED_PR_HANDLES_ISSUE") {
|
|
962
|
+
const merged = relationship.relatedPullRequests.some((pr) => pr.state === "MERGED");
|
|
963
|
+
if (merged && triage.automation.close) {
|
|
964
|
+
const relatedPrDecision = {
|
|
965
|
+
category: resolveIssueCategory(issue, input.repository) ?? null,
|
|
966
|
+
disposition: "accepted",
|
|
967
|
+
};
|
|
968
|
+
const plan = {
|
|
969
|
+
action: "CLOSE",
|
|
970
|
+
allowedActions: ["CLOSE"],
|
|
971
|
+
clearLabels: true,
|
|
972
|
+
closeIssue: true,
|
|
973
|
+
createPr: false,
|
|
974
|
+
postComment: true,
|
|
975
|
+
};
|
|
976
|
+
await emitProgress(input, {
|
|
977
|
+
action: plan.action,
|
|
978
|
+
result: relatedPrDecision,
|
|
979
|
+
type: "decision",
|
|
980
|
+
});
|
|
981
|
+
await runActionPrompt({
|
|
982
|
+
context,
|
|
983
|
+
input,
|
|
984
|
+
outputDir,
|
|
985
|
+
plan,
|
|
986
|
+
result: relatedPrDecision,
|
|
987
|
+
});
|
|
988
|
+
const body = await composeResultComment({
|
|
989
|
+
action: "CLOSE",
|
|
990
|
+
context: `Result: ${decisionText(relatedPrDecision)}\nAction: CLOSE\n\n${context}`,
|
|
991
|
+
input,
|
|
992
|
+
issue,
|
|
993
|
+
outputDir,
|
|
994
|
+
processed,
|
|
995
|
+
result: relatedPrDecision,
|
|
996
|
+
});
|
|
997
|
+
if (!input.dryRun) {
|
|
998
|
+
await emitProgress(input, { type: "comment_posting" });
|
|
999
|
+
const posted = await postMarkedIssueComment({
|
|
1000
|
+
account: triage.account,
|
|
1001
|
+
body,
|
|
1002
|
+
exec: input.exec,
|
|
1003
|
+
issue: issue.number,
|
|
1004
|
+
outputDir,
|
|
1005
|
+
repository: input.repository,
|
|
1006
|
+
});
|
|
1007
|
+
await emitProgress(input, { type: "comment_posted", url: posted.url });
|
|
1008
|
+
const clearLabels = existingClearLabels(issue, triage.automation.clear);
|
|
1009
|
+
if (clearLabels.length) {
|
|
1010
|
+
await removeIssueLabels(input.exec, input.repository, issue.number, clearLabels, triage.account);
|
|
1011
|
+
}
|
|
1012
|
+
const closedPrs = [];
|
|
1013
|
+
for (const pr of relationship.relatedPullRequests.filter((pr) => pr.state === "OPEN")) {
|
|
1014
|
+
await closePullRequest(input.exec, input.repository, pr.number, triage.account);
|
|
1015
|
+
closedPrs.push(pr.number);
|
|
1016
|
+
}
|
|
1017
|
+
if (closedPrs.length)
|
|
1018
|
+
await writeJson(join(outputDir, "closed-prs.json"), closedPrs);
|
|
1019
|
+
await closeIssue(input.exec, input.repository, issue.number, triage.account);
|
|
1020
|
+
}
|
|
1021
|
+
const report = `Magi triage closed #${issue.number} because a related PR was merged.`;
|
|
1022
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
1023
|
+
return {
|
|
1024
|
+
issue: issue.number,
|
|
1025
|
+
outputDir,
|
|
1026
|
+
report,
|
|
1027
|
+
result: relatedPrDecision,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
return finishWithResult({
|
|
1031
|
+
context,
|
|
1032
|
+
input,
|
|
1033
|
+
issue,
|
|
1034
|
+
outputDir,
|
|
1035
|
+
processed,
|
|
1036
|
+
relationship,
|
|
1037
|
+
result: { category: null, disposition: "clear_only" },
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (!result && relationship.duplicateCandidates.length) {
|
|
1042
|
+
const duplicate = await runDuplicateVote({
|
|
1043
|
+
candidateNumbers: relationship.duplicateCandidates.map((candidate) => candidate.number),
|
|
1044
|
+
context,
|
|
1045
|
+
input,
|
|
1046
|
+
outputDir,
|
|
1047
|
+
});
|
|
1048
|
+
if (duplicate) {
|
|
1049
|
+
context = `${context}\n\nDuplicate decision: ${JSON.stringify(duplicate)}`;
|
|
1050
|
+
result = { category: null, disposition: "duplicate" };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (!result) {
|
|
1054
|
+
const resolvedCategory = resolveIssueCategory(issue, input.repository);
|
|
1055
|
+
await writeJson(join(outputDir, "category-resolution.json"), {
|
|
1056
|
+
category: resolvedCategory,
|
|
1057
|
+
source: resolvedCategory ? "config" : "vote",
|
|
1058
|
+
});
|
|
1059
|
+
const category = resolvedCategory ??
|
|
1060
|
+
(await runPhaseVote({
|
|
1061
|
+
context,
|
|
1062
|
+
input,
|
|
1063
|
+
outputDir,
|
|
1064
|
+
parse: (text) => parseTriageCategoryOutput(text, triage.categories.map((item) => item.id)),
|
|
1065
|
+
phase: "category",
|
|
1066
|
+
prompt: composeTriageCategoryPrompt,
|
|
1067
|
+
schemaName: "triage category",
|
|
1068
|
+
votes: ["ASK", ...triage.categories.map((item) => item.id)],
|
|
1069
|
+
})) ??
|
|
1070
|
+
"ASK";
|
|
1071
|
+
if (category === "ASK") {
|
|
1072
|
+
result = {
|
|
1073
|
+
askReason: "category_unclear",
|
|
1074
|
+
category: null,
|
|
1075
|
+
disposition: "ask",
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
const categoryConfig = triage.categories.find((item) => item.id === category);
|
|
1080
|
+
const voteContext = JSON.stringify({
|
|
1081
|
+
category: categoryConfig,
|
|
1082
|
+
triageContext: context,
|
|
1083
|
+
}, null, 2);
|
|
1084
|
+
const vote = await runPhaseVote({
|
|
1085
|
+
context: voteContext,
|
|
1086
|
+
input,
|
|
1087
|
+
outputDir,
|
|
1088
|
+
parse: parseTriageBinaryOutput,
|
|
1089
|
+
phase: "acceptance",
|
|
1090
|
+
prompt: composeTriageAcceptancePrompt,
|
|
1091
|
+
schemaName: "triage acceptance",
|
|
1092
|
+
votes: BINARY_VOTES,
|
|
1093
|
+
});
|
|
1094
|
+
result =
|
|
1095
|
+
vote === "YES"
|
|
1096
|
+
? { category, disposition: "accepted" }
|
|
1097
|
+
: vote === "NO"
|
|
1098
|
+
? { category, disposition: "rejected" }
|
|
1099
|
+
: {
|
|
1100
|
+
askReason: "acceptance_unclear",
|
|
1101
|
+
category,
|
|
1102
|
+
disposition: "ask",
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return finishWithResult({
|
|
1107
|
+
context,
|
|
1108
|
+
input,
|
|
1109
|
+
issue,
|
|
1110
|
+
outputDir,
|
|
1111
|
+
processed,
|
|
1112
|
+
relationship,
|
|
1113
|
+
result: result ?? {
|
|
1114
|
+
askReason: "acceptance_unclear",
|
|
1115
|
+
category: null,
|
|
1116
|
+
disposition: "ask",
|
|
1117
|
+
},
|
|
1118
|
+
});
|
|
1119
|
+
}
|