opencode-magi 0.10.0 → 0.12.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.
Files changed (126) hide show
  1. package/README.ja.md +254 -0
  2. package/README.md +31 -26
  3. package/dist/commands.js +11 -9
  4. package/dist/config/index.js +2 -0
  5. package/dist/config/resolve.js +120 -246
  6. package/dist/config/validate.js +98 -1110
  7. package/dist/constant.js +131 -0
  8. package/dist/graphql/index.generated.js +125 -0
  9. package/dist/graphql/index.js +49 -0
  10. package/dist/index.js +12 -800
  11. package/dist/magi.js +357 -0
  12. package/dist/permissions/common.json +22 -22
  13. package/dist/permissions/editor.json +12 -12
  14. package/dist/prompts/index.js +98 -0
  15. package/dist/prompts/merge/ci-classification/output-contract.md +22 -0
  16. package/dist/prompts/merge/ci-classification/task.md +10 -0
  17. package/dist/prompts/merge/ci-classification/validate.json +20 -0
  18. package/dist/prompts/merge/conflict/output-contract.md +12 -0
  19. package/dist/prompts/merge/conflict/task.md +9 -0
  20. package/dist/prompts/merge/conflict/validate.json +4 -0
  21. package/dist/prompts/merge/edit/output-contract.md +26 -0
  22. package/dist/prompts/merge/edit/task.md +10 -0
  23. package/dist/prompts/merge/edit/validate.json +60 -0
  24. package/dist/prompts/review/ci-classification/output-contract.md +21 -0
  25. package/dist/prompts/review/ci-classification/task.md +7 -0
  26. package/dist/prompts/review/ci-classification/validate.json +20 -0
  27. package/dist/prompts/review/close-reconsideration/output-contract.md +21 -0
  28. package/dist/prompts/review/close-reconsideration/task.md +6 -0
  29. package/dist/prompts/review/close-reconsideration/validate.json +44 -0
  30. package/dist/prompts/review/comment/output-contract.md +11 -0
  31. package/dist/prompts/review/comment/task.md +3 -0
  32. package/dist/prompts/review/comment/validate.json +8 -0
  33. package/dist/prompts/review/finding-validation/output-contract.md +25 -0
  34. package/dist/prompts/{templates/review/finding-validation.md → review/finding-validation/task.md} +2 -3
  35. package/dist/prompts/review/finding-validation/validate.json +21 -0
  36. package/dist/prompts/review/rereview/output-contract.md +30 -0
  37. package/dist/prompts/{templates/review/rereview.md → review/rereview/task.md} +8 -11
  38. package/dist/prompts/review/rereview/validate.json +87 -0
  39. package/dist/prompts/review/review/output-contract.md +25 -0
  40. package/dist/prompts/{templates/review/review.md → review/review/task.md} +2 -5
  41. package/dist/prompts/review/review/validate.json +54 -0
  42. package/dist/prompts/triage/acceptance/output-contract.md +16 -0
  43. package/dist/prompts/triage/acceptance/validate.json +23 -0
  44. package/dist/prompts/triage/category/output-contract.md +16 -0
  45. package/dist/prompts/triage/category/validate.json +23 -0
  46. package/dist/prompts/triage/comment-classification/output-contract.md +15 -0
  47. package/dist/prompts/triage/comment-classification/validate.json +28 -0
  48. package/dist/prompts/triage/create/output-contract.md +28 -0
  49. package/dist/prompts/triage/create/validate.json +73 -0
  50. package/dist/prompts/triage/deplicate/output-contract.md +16 -0
  51. package/dist/prompts/triage/deplicate/validate.json +20 -0
  52. package/dist/prompts/triage/existing/output-contract.md +16 -0
  53. package/dist/prompts/triage/existing/validate.json +13 -0
  54. package/dist/prompts/triage/reconsider/output-contract.md +16 -0
  55. package/dist/prompts/triage/reconsider/validate.json +23 -0
  56. package/dist/prompts/triage/signal/output-contract.md +19 -0
  57. package/dist/prompts/triage/signal/validate.json +18 -0
  58. package/dist/tools/clear/index.js +21 -0
  59. package/dist/tools/index.js +12 -0
  60. package/dist/tools/merge/action.js +47 -0
  61. package/dist/tools/merge/context.js +116 -0
  62. package/dist/tools/merge/editor.js +256 -0
  63. package/dist/tools/merge/index.js +151 -0
  64. package/dist/tools/merge/index.type.js +1 -0
  65. package/dist/tools/merge/merge.js +42 -0
  66. package/dist/tools/merge/report.js +73 -0
  67. package/dist/tools/review/action.js +459 -0
  68. package/dist/tools/review/check.js +335 -0
  69. package/dist/tools/review/context.js +254 -0
  70. package/dist/tools/review/index.js +104 -0
  71. package/dist/tools/review/index.type.js +1 -0
  72. package/dist/tools/review/report.js +143 -0
  73. package/dist/tools/review/review.js +146 -0
  74. package/dist/tools/review/reviewer.js +409 -0
  75. package/dist/tools/triage/index.js +16 -0
  76. package/dist/tools/validate/index.js +28 -0
  77. package/dist/utils/array.js +6 -0
  78. package/dist/utils/assertion.js +38 -0
  79. package/dist/utils/exec.js +14 -0
  80. package/dist/utils/fs.js +24 -0
  81. package/dist/utils/function.js +46 -0
  82. package/dist/utils/github.js +63 -0
  83. package/dist/utils/index.js +10 -0
  84. package/dist/utils/index.type.js +1 -0
  85. package/dist/utils/object.js +21 -0
  86. package/dist/utils/opencode.js +25 -0
  87. package/dist/utils/string.js +38 -0
  88. package/dist/utils/worker.js +30 -0
  89. package/package.json +36 -23
  90. package/schema.json +116 -20
  91. package/dist/config/load.js +0 -62
  92. package/dist/config/output.js +0 -25
  93. package/dist/config/worktree.js +0 -25
  94. package/dist/github/commands.js +0 -905
  95. package/dist/github/retry.js +0 -44
  96. package/dist/orchestrator/abort.js +0 -9
  97. package/dist/orchestrator/ci.js +0 -579
  98. package/dist/orchestrator/findings.js +0 -88
  99. package/dist/orchestrator/inline-comments.js +0 -73
  100. package/dist/orchestrator/majority.js +0 -62
  101. package/dist/orchestrator/merge.js +0 -1260
  102. package/dist/orchestrator/model.js +0 -216
  103. package/dist/orchestrator/pool.js +0 -15
  104. package/dist/orchestrator/report.js +0 -175
  105. package/dist/orchestrator/review-context.js +0 -342
  106. package/dist/orchestrator/review.js +0 -1480
  107. package/dist/orchestrator/run-manager.js +0 -2132
  108. package/dist/orchestrator/safety.js +0 -44
  109. package/dist/orchestrator/triage.js +0 -1370
  110. package/dist/prompts/compose.js +0 -473
  111. package/dist/prompts/contracts.js +0 -300
  112. package/dist/prompts/output.js +0 -401
  113. package/dist/prompts/templates/merge/ci-classification.md +0 -16
  114. package/dist/prompts/templates/merge/conflict.md +0 -10
  115. package/dist/prompts/templates/merge/edit.md +0 -15
  116. package/dist/prompts/templates/review/ci-classification.md +0 -9
  117. package/dist/prompts/templates/review/close-reconsideration.md +0 -6
  118. /package/dist/{types.js → config/index.type.js} +0 -0
  119. /package/dist/prompts/{templates/triage/acceptance.md → triage/acceptance/task.md} +0 -0
  120. /package/dist/prompts/{templates/triage/category.md → triage/category/task.md} +0 -0
  121. /package/dist/prompts/{templates/triage/comment-classification.md → triage/comment-classification/task.md} +0 -0
  122. /package/dist/prompts/{templates/triage/create.md → triage/create/task.md} +0 -0
  123. /package/dist/prompts/{templates/triage/duplicate.md → triage/deplicate/task.md} +0 -0
  124. /package/dist/prompts/{templates/triage/existing-pr.md → triage/existing/task.md} +0 -0
  125. /package/dist/prompts/{templates/triage/reconsider.md → triage/reconsider/task.md} +0 -0
  126. /package/dist/prompts/{templates/triage/signal.md → triage/signal/task.md} +0 -0
@@ -1,1370 +0,0 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
- import { issueRunOutputDir } from "../config/output";
4
- import { issueRunWorktreeDir } from "../config/worktree";
5
- import { addIssueLabels, assignIssue, closeIssue, closePullRequest, configureGitIdentity, createPullRequest, fetchIssue, fetchIssueComments, fetchRelatedPullRequests, postIssueComment, pushHead, removeIssueLabels, removeWorktree, searchDuplicateIssues, shellQuote, updateIssueComment, } from "../github/commands";
6
- import { composeTriageAcceptancePrompt, composeTriageCategoryPrompt, composeTriageCommentClassificationPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageReconsiderPrompt, composeTriageSignalPrompt, } from "../prompts/compose";
7
- import { parseTriageBinaryOutput, parseTriageCategoryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, parseTriageSignalOutput, } from "../prompts/output";
8
- import { aggregateStringMajority, majorityThreshold } from "./majority";
9
- import { runModelWithRepair, } from "./model";
10
- const MARKER_PREFIX = "opencode-magi:triage";
11
- const BINARY_VOTES = ["ASK", "NO", "YES"];
12
- const ACCEPTANCE_VOTES = ["ASK", "INVALID", "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
- const RECONSIDERATION_CLASSES = new Set([
19
- "CLARIFICATION",
20
- "NEW_EVIDENCE",
21
- "OBJECTION",
22
- ]);
23
- function marker(input) {
24
- return `<!-- ${MARKER_PREFIX} v=2 issue=${input.issue} category=${input.decision.category ?? "none"} disposition=${input.decision.disposition} signals=${input.decision.signals.join(",")} action=${input.action} checkpoint=${input.checkpoint ?? "pending"} pr=${input.pr ?? "none"} processed=${(input.processed ?? []).join(",")} -->`;
25
- }
26
- function markerDisposition(input) {
27
- switch (input.disposition) {
28
- case "accepted":
29
- case "rejected":
30
- case "invalid":
31
- case "duplicate":
32
- case "already_handled":
33
- case "needs_category":
34
- case "needs_acceptance":
35
- case "blocked":
36
- case "failed":
37
- return input.disposition;
38
- case "ask":
39
- return input.askReason === "category_unclear"
40
- ? "needs_category"
41
- : "needs_acceptance";
42
- case "clear_only":
43
- return "already_handled";
44
- default:
45
- return undefined;
46
- }
47
- }
48
- export function parseTriageMarker(body) {
49
- const match = body.match(/<!--\s*opencode-magi:triage\s+([^>]+?)\s*-->/);
50
- if (!match)
51
- return undefined;
52
- const entries = Object.fromEntries(match[1]
53
- .trim()
54
- .split(/\s+/)
55
- .map((part) => {
56
- const index = part.indexOf("=");
57
- return index === -1
58
- ? [part, ""]
59
- : [part.slice(0, index), part.slice(index + 1)];
60
- }));
61
- const version = Number(entries.v);
62
- if (version !== 1 && version !== 2)
63
- return undefined;
64
- const askReason = entries.askReason === "acceptance_unclear" ||
65
- entries.askReason === "category_unclear"
66
- ? entries.askReason
67
- : undefined;
68
- return {
69
- action: entries.action,
70
- askReason,
71
- category: entries.category === "none" ? null : entries.category || undefined,
72
- checkpoint: entries.checkpoint && Number.isFinite(Number(entries.checkpoint))
73
- ? Number(entries.checkpoint)
74
- : undefined,
75
- disposition: markerDisposition({
76
- askReason,
77
- disposition: entries.disposition,
78
- }),
79
- issue: entries.issue ? Number(entries.issue) : undefined,
80
- pr: entries.pr,
81
- processed: entries.processed
82
- ? entries.processed.split(",").filter(Boolean).map(Number)
83
- : [],
84
- result: entries.result,
85
- signals: entries.signals ? entries.signals.split(",").filter(Boolean) : [],
86
- v: version,
87
- };
88
- }
89
- function labelsContain(labels, targets) {
90
- const set = new Set(labels.map((label) => label.toLowerCase()));
91
- return targets.some((target) => set.has(target.toLowerCase()));
92
- }
93
- function addLabel(labels, label) {
94
- const key = label.toLowerCase();
95
- if (!labels.has(key))
96
- labels.set(key, label);
97
- }
98
- function labelRuleMatches(rule, result) {
99
- const when = rule.when;
100
- if (when.disposition && when.disposition !== result.disposition)
101
- return false;
102
- if (when.category && when.category !== result.category)
103
- return false;
104
- if (when.signals?.length) {
105
- const signals = new Set(result.signals);
106
- if (when.signals.some((signal) => !signals.has(signal)))
107
- return false;
108
- }
109
- return true;
110
- }
111
- export function triageLabelChanges(input) {
112
- const add = new Map();
113
- const remove = new Map();
114
- for (const rule of input.rules) {
115
- if (!labelRuleMatches(rule, input.result))
116
- continue;
117
- for (const label of rule.add ?? [])
118
- addLabel(add, label);
119
- for (const label of rule.remove ?? [])
120
- addLabel(remove, label);
121
- }
122
- for (const key of add.keys())
123
- remove.delete(key);
124
- const existing = new Set(input.issueLabels.map((label) => label.toLowerCase()));
125
- return {
126
- add: [...add]
127
- .filter(([key]) => !existing.has(key))
128
- .map(([, label]) => label),
129
- remove: [...remove]
130
- .filter(([key]) => existing.has(key))
131
- .map(([, label]) => label),
132
- };
133
- }
134
- export function resolveIssueCategory(issue, repository) {
135
- const triage = repository.triage;
136
- if (!triage)
137
- throw new Error("triage configuration is required");
138
- const matches = triage.categories.filter((category) => labelsContain(issue.labels, category.labels) ||
139
- (issue.type != null && category.types.includes(issue.type)));
140
- if (matches.length !== 1)
141
- return undefined;
142
- return matches[0].id;
143
- }
144
- function issueContext(input) {
145
- return JSON.stringify({
146
- duplicateCandidates: input.relationship.duplicateCandidates,
147
- issue: input.issue,
148
- recentComments: input.relationship.comments.slice(-20),
149
- reconsideration: input.reconsideration,
150
- relatedPullRequests: input.relationship.relatedPullRequests,
151
- }, null, 2);
152
- }
153
- async function writeJson(path, value) {
154
- await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
155
- }
156
- async function emitProgress(input, progress) {
157
- await input.onProgress?.(progress);
158
- }
159
- async function emitTriageModelProgress(input) {
160
- if (input.progress.type === "session_created") {
161
- await emitProgress(input.run, {
162
- options: input.progress.options,
163
- phase: input.phase,
164
- sessionId: input.progress.sessionId,
165
- type: "triage_agent_session",
166
- voter: input.voter,
167
- });
168
- }
169
- if (input.progress.type === "repair") {
170
- await emitProgress(input.run, {
171
- phase: input.phase,
172
- type: "triage_agent_repair",
173
- voter: input.voter,
174
- });
175
- }
176
- if (input.progress.type === "response") {
177
- await emitProgress(input.run, {
178
- phase: input.phase,
179
- sessionId: input.progress.sessionId,
180
- type: "triage_agent_response",
181
- voter: input.voter,
182
- });
183
- }
184
- }
185
- async function runVote(input) {
186
- const prompt = input.promptText ??
187
- (await input.prompt({
188
- context: input.context,
189
- directory: input.directory,
190
- issue: input.issue,
191
- repository: input.repository,
192
- voter: input.agent,
193
- }));
194
- await emitProgress(input.run, {
195
- phase: input.phase,
196
- type: "triage_agent_started",
197
- voter: input.agent.key,
198
- });
199
- let result;
200
- try {
201
- result = await runModelWithRepair({
202
- client: input.client,
203
- model: input.agent.model,
204
- onProgress: (progress) => emitTriageModelProgress({
205
- phase: input.phase,
206
- progress,
207
- run: input.run,
208
- voter: input.agent.key,
209
- }),
210
- options: input.agent.options,
211
- parentSessionId: input.run.parentSessionId,
212
- parse: input.parse,
213
- permission: input.agent.permission,
214
- prompt,
215
- repairAttempts: input.run.config.output?.repairAttempts ?? 3,
216
- schemaName: input.schemaName,
217
- signal: input.signal,
218
- title: `Magi triage ${input.schemaName} #${input.issue} (${input.agent.key})`,
219
- });
220
- }
221
- catch (error) {
222
- await emitProgress(input.run, {
223
- error: error instanceof Error ? error.message : String(error),
224
- phase: input.phase,
225
- type: "triage_agent_failed",
226
- voter: input.agent.key,
227
- });
228
- throw error;
229
- }
230
- await emitProgress(input.run, {
231
- phase: input.phase,
232
- sessionId: result.sessionId,
233
- type: "triage_agent_completed",
234
- voter: input.agent.key,
235
- vote: result.value.vote,
236
- });
237
- return {
238
- ...result.value,
239
- promptText: prompt,
240
- raw: result.raw,
241
- sessionId: result.sessionId,
242
- voter: input.agent.key,
243
- };
244
- }
245
- async function writeVoteArtifacts(input) {
246
- const base = join(input.outputDir, `${input.voter}.${input.phase}`);
247
- await writeFile(`${base}.prompt.txt`, `${input.output.promptText}\n`);
248
- await writeFile(`${base}.raw.txt`, `${input.output.raw}\n`);
249
- await writeJson(`${base}.json`, {
250
- body: input.output.body,
251
- reason: input.output.reason,
252
- vote: input.output.vote,
253
- });
254
- }
255
- export function chooseDuplicateOutput(input) {
256
- const candidates = new Set(input.candidateNumbers);
257
- const threshold = majorityThreshold(input.outputs.length);
258
- const counts = new Map();
259
- for (const output of input.outputs) {
260
- if (output.vote !== "DUPLICATE" ||
261
- output.duplicateOf == null ||
262
- !candidates.has(output.duplicateOf)) {
263
- continue;
264
- }
265
- counts.set(output.duplicateOf, (counts.get(output.duplicateOf) ?? 0) + 1);
266
- }
267
- const target = [...counts.entries()].find(([, count]) => count >= threshold)?.[0];
268
- if (target == null)
269
- return undefined;
270
- return input.outputs.find((output) => output.vote === "DUPLICATE" && output.duplicateOf === target);
271
- }
272
- async function runDuplicateVote(input) {
273
- const agents = input.input.repository.agents.triage;
274
- if (!agents?.length)
275
- throw new Error("triage.voters is required");
276
- await emitProgress(input.input, { phase: "duplicate", type: "phase" });
277
- const outputs = await Promise.all(agents.map((agent) => runVote({
278
- agent,
279
- client: input.input.client,
280
- context: input.context,
281
- directory: input.input.directory,
282
- issue: input.input.issue,
283
- parse: parseTriageDuplicateOutput,
284
- phase: "duplicate",
285
- prompt: composeTriageDuplicatePrompt,
286
- repository: input.input.repository,
287
- run: input.input,
288
- schemaName: "triage duplicate",
289
- signal: input.input.signal,
290
- })));
291
- const majority = aggregateStringMajority(outputs.map((output, index) => ({
292
- voter: agents[index].key,
293
- vote: output.vote,
294
- })), DUPLICATE_VOTES);
295
- await Promise.all(outputs.map((output, index) => writeVoteArtifacts({
296
- output,
297
- outputDir: input.outputDir,
298
- phase: "duplicate",
299
- voter: agents[index].key,
300
- })));
301
- await Promise.all(outputs.map((output, index) => writeJson(join(input.outputDir, `${agents[index].key}.duplicate.json`), {
302
- duplicateOf: output.duplicateOf,
303
- reason: output.reason,
304
- vote: output.vote,
305
- })));
306
- await writeJson(join(input.outputDir, "duplicate-majority.json"), majority);
307
- if (majority.vote !== "DUPLICATE")
308
- return undefined;
309
- return chooseDuplicateOutput({
310
- candidateNumbers: input.candidateNumbers,
311
- outputs,
312
- });
313
- }
314
- async function runPhaseVote(input) {
315
- const agents = input.input.repository.agents.triage;
316
- if (!agents?.length)
317
- throw new Error("triage.voters is required");
318
- await emitProgress(input.input, { phase: input.phase, type: "phase" });
319
- const promptTexts = await Promise.all(agents.map((agent) => input.prompt({
320
- context: input.context,
321
- directory: input.input.directory,
322
- issue: input.input.issue,
323
- repository: input.input.repository,
324
- voter: agent,
325
- })));
326
- const outputs = await Promise.all(agents.map((agent, index) => runVote({
327
- agent,
328
- client: input.input.client,
329
- context: input.context,
330
- directory: input.input.directory,
331
- issue: input.input.issue,
332
- parse: input.parse,
333
- phase: input.phase,
334
- prompt: input.prompt,
335
- promptText: promptTexts[index],
336
- repository: input.input.repository,
337
- run: input.input,
338
- schemaName: input.schemaName,
339
- signal: input.input.signal,
340
- })));
341
- const majority = aggregateStringMajority(outputs.map((output, index) => ({
342
- voter: agents[index].key,
343
- vote: output.vote,
344
- })), input.votes);
345
- await Promise.all(outputs.map((output, index) => writeVoteArtifacts({
346
- output,
347
- outputDir: input.outputDir,
348
- phase: input.phase,
349
- voter: agents[index].key,
350
- })));
351
- await writeJson(join(input.outputDir, `${input.phase}-majority.json`), majority);
352
- return {
353
- outputs,
354
- reason: chooseDecisionReason({
355
- outputs,
356
- threshold: majority.threshold,
357
- vote: majority.vote,
358
- voters: majority.vote ? majority.voters[majority.vote] : undefined,
359
- }),
360
- vote: majority.vote,
361
- };
362
- }
363
- async function runSignalVote(input) {
364
- const agents = input.input.repository.agents.triage;
365
- const signals = input.input.repository.triage?.signals ?? [];
366
- if (!agents?.length)
367
- throw new Error("triage.voters is required");
368
- if (!signals.length)
369
- return [];
370
- await emitProgress(input.input, { phase: "signal", type: "phase" });
371
- const signalIds = signals.map((signal) => signal.id);
372
- const promptTexts = await Promise.all(agents.map((agent) => composeTriageSignalPrompt({
373
- context: input.context,
374
- directory: input.input.directory,
375
- issue: input.input.issue,
376
- repository: input.input.repository,
377
- voter: agent,
378
- })));
379
- const outputs = await Promise.all(agents.map(async (agent, index) => {
380
- const prompt = promptTexts[index];
381
- await emitProgress(input.input, {
382
- phase: "signal",
383
- type: "triage_agent_started",
384
- voter: agent.key,
385
- });
386
- const result = await runModelWithRepair({
387
- client: input.input.client,
388
- model: agent.model,
389
- onProgress: (progress) => emitTriageModelProgress({
390
- phase: "signal",
391
- progress,
392
- run: input.input,
393
- voter: agent.key,
394
- }),
395
- options: agent.options,
396
- parentSessionId: input.input.parentSessionId,
397
- parse: (text) => parseTriageSignalOutput(text, signalIds),
398
- permission: agent.permission,
399
- prompt,
400
- repairAttempts: input.input.config.output?.repairAttempts ?? 3,
401
- schemaName: "triage signal",
402
- signal: input.input.signal,
403
- title: `Magi triage signal #${input.input.issue} (${agent.key})`,
404
- });
405
- await emitProgress(input.input, {
406
- phase: "signal",
407
- sessionId: result.sessionId,
408
- type: "triage_agent_completed",
409
- voter: agent.key,
410
- vote: result.value.signals.map((signal) => signal.id).join(",") || "none",
411
- });
412
- return {
413
- ...result.value,
414
- promptText: prompt,
415
- raw: result.raw,
416
- sessionId: result.sessionId,
417
- voter: agent.key,
418
- };
419
- }));
420
- const threshold = majorityThreshold(outputs.length);
421
- const counts = new Map();
422
- for (const output of outputs) {
423
- for (const id of new Set(output.signals.map((signal) => signal.id))) {
424
- counts.set(id, (counts.get(id) ?? 0) + 1);
425
- }
426
- }
427
- const selected = signalIds.filter((id) => (counts.get(id) ?? 0) >= threshold);
428
- await Promise.all(outputs.map((output) => {
429
- const base = join(input.outputDir, `${output.voter}.signal`);
430
- return Promise.all([
431
- writeFile(`${base}.prompt.txt`, `${output.promptText}\n`),
432
- writeFile(`${base}.raw.txt`, `${output.raw}\n`),
433
- writeJson(`${base}.json`, { signals: output.signals }),
434
- ]);
435
- }));
436
- await writeJson(join(input.outputDir, "signal-majority.json"), {
437
- counts: Object.fromEntries(counts),
438
- selected,
439
- threshold,
440
- });
441
- return selected;
442
- }
443
- async function relationshipScan(input, issue) {
444
- const [comments, relatedPullRequests, duplicateCandidates] = await Promise.all([
445
- fetchIssueComments(input.exec, input.repository, input.issue),
446
- fetchRelatedPullRequests(input.exec, input.repository, input.issue),
447
- searchDuplicateIssues(input.exec, input.repository, issue),
448
- ]);
449
- const triageAccounts = new Set((input.repository.agents.triage ?? []).map((agent) => agent.account));
450
- const markers = comments
451
- .filter((comment) => triageAccounts.has(comment.author))
452
- .map((comment) => {
453
- const parsed = parseTriageMarker(comment.body);
454
- return parsed
455
- ? { ...parsed, account: comment.author, commentId: comment.id }
456
- : undefined;
457
- })
458
- .filter(Boolean);
459
- const previousMarker = markers.at(-1);
460
- const mentionReplies = previousMarker
461
- ? eligibleMentionReplies({
462
- account: previousMarker.account ?? "",
463
- comments,
464
- marker: previousMarker,
465
- processed: previousMarker.processed,
466
- repository: input.repository,
467
- })
468
- : [];
469
- return {
470
- comments,
471
- duplicateCandidates,
472
- mentionReplies,
473
- previousMarker,
474
- relatedPullRequests,
475
- };
476
- }
477
- function mentionsAccount(body, account) {
478
- return new RegExp(`@${account.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i").test(body);
479
- }
480
- function markerCheckpoint(marker) {
481
- return marker.checkpoint ?? marker.commentId;
482
- }
483
- function markerPr(marker) {
484
- const pr = Number(marker.pr);
485
- return Number.isInteger(pr) && pr > 0 ? pr : undefined;
486
- }
487
- function pullRequestNumberFromUrl(url) {
488
- const match = url.match(/\/pull\/(\d+)(?:\D|$)/);
489
- const number = match ? Number(match[1]) : undefined;
490
- return number && Number.isInteger(number) ? number : undefined;
491
- }
492
- export function mentionAllowed(comment, repository) {
493
- const safety = repository.triage?.safety;
494
- if (!safety)
495
- return false;
496
- const actorAllowed = safety.allowMentionActors.length
497
- ? safety.allowMentionActors.includes(comment.author)
498
- : false;
499
- const roleAllowed = safety.allowMentionRoles.length
500
- ? safety.allowMentionRoles.includes(comment.authorAssociation ?? "")
501
- : false;
502
- return safety.allowMentionActors.length || safety.allowMentionRoles.length
503
- ? actorAllowed || roleAllowed
504
- : true;
505
- }
506
- export function eligibleMentionReplies(input) {
507
- const checkpoint = markerCheckpoint(input.marker);
508
- const processed = new Set(input.processed);
509
- return input.comments.filter((comment) => {
510
- if (checkpoint != null && comment.id <= checkpoint)
511
- return false;
512
- if (processed.has(comment.id))
513
- return false;
514
- if (!mentionsAccount(comment.body, input.account))
515
- return false;
516
- return mentionAllowed(comment, input.repository);
517
- });
518
- }
519
- function finalResultFromMarker(marker) {
520
- if (marker.disposition) {
521
- return {
522
- category: marker.category ?? null,
523
- disposition: marker.disposition,
524
- signals: marker.signals,
525
- };
526
- }
527
- switch (marker.result) {
528
- case "BUG_ACCEPTED":
529
- case "RESOLVED_BY_MERGED_PR":
530
- return { category: "bug", disposition: "accepted", signals: [] };
531
- case "BUG_REJECTED":
532
- return { category: "bug", disposition: "rejected", signals: [] };
533
- case "FEATURE_ACCEPTED":
534
- return { category: "feature", disposition: "accepted", signals: [] };
535
- case "FEATURE_REJECTED":
536
- return { category: "feature", disposition: "rejected", signals: [] };
537
- case "ASK":
538
- return {
539
- category: null,
540
- disposition: "needs_acceptance",
541
- signals: [],
542
- };
543
- case "CLEAR_ONLY":
544
- return { category: null, disposition: "already_handled", signals: [] };
545
- case "DUPLICATE":
546
- return { category: null, disposition: "duplicate", signals: [] };
547
- default:
548
- return { category: null, disposition: "failed", signals: [] };
549
- }
550
- }
551
- function decisionText(decision) {
552
- return JSON.stringify(decision);
553
- }
554
- function actionPlan(input) {
555
- if (input.result.disposition === "already_handled") {
556
- return {
557
- action: "CLEAR_ONLY",
558
- allowedActions: ["CLEAR_ONLY"],
559
- clearLabels: true,
560
- closeIssue: false,
561
- createPr: false,
562
- postComment: false,
563
- };
564
- }
565
- if (input.result.disposition === "needs_category" ||
566
- input.result.disposition === "needs_acceptance") {
567
- return {
568
- action: "ASK",
569
- allowedActions: ["ASK"],
570
- clearLabels: true,
571
- closeIssue: false,
572
- createPr: false,
573
- postComment: true,
574
- };
575
- }
576
- const closeIssue = input.triage.automation.close &&
577
- (input.result.disposition === "rejected" ||
578
- input.result.disposition === "invalid" ||
579
- input.result.disposition === "duplicate");
580
- const createPr = input.triage.automation.create && input.result.disposition === "accepted";
581
- return {
582
- action: closeIssue ? "CLOSE" : createPr ? "PR" : "COMMENT",
583
- allowedActions: [closeIssue ? "CLOSE" : createPr ? "PR" : "COMMENT"],
584
- clearLabels: true,
585
- closeIssue,
586
- createPr,
587
- postComment: true,
588
- };
589
- }
590
- function previousAutomationPlan(input) {
591
- const base = actionPlan({ result: input.result, triage: input.triage });
592
- const labelChanges = triageLabelChanges({
593
- issueLabels: input.issue.labels,
594
- result: input.result,
595
- rules: input.triage.automation.label,
596
- });
597
- const clearLabels = base.clearLabels &&
598
- (labelChanges.add.length > 0 || labelChanges.remove.length > 0);
599
- const closeIssue = input.marker.action === "CLOSE" &&
600
- base.closeIssue &&
601
- input.issue.state === "OPEN";
602
- const createPr = input.marker.action === "PR" &&
603
- base.createPr &&
604
- !markerPr(input.marker) &&
605
- !input.relationship.relatedPullRequests.length;
606
- if (!clearLabels && !closeIssue && !createPr)
607
- return undefined;
608
- const action = closeIssue ? "CLOSE" : createPr ? "PR" : "CLEAR_ONLY";
609
- return {
610
- ...base,
611
- action,
612
- allowedActions: [action],
613
- clearLabels,
614
- closeIssue,
615
- createPr,
616
- postComment: false,
617
- };
618
- }
619
- async function classifyMentionReplies(input) {
620
- const agent = triageReporter(input.input.repository, input.input.issue);
621
- const prompt = await composeTriageCommentClassificationPrompt({
622
- context: JSON.stringify({ context: input.context, mentionReplies: input.replies }, null, 2),
623
- directory: input.input.directory,
624
- issue: input.input.issue,
625
- repository: input.input.repository,
626
- voter: agent,
627
- });
628
- const result = await runModelWithRepair({
629
- client: input.input.client,
630
- model: agent.model,
631
- onProgress: async (progress) => {
632
- if (progress.type !== "session_created")
633
- return;
634
- await emitProgress(input.input, {
635
- agent: agent.key,
636
- key: `triage:comment-classification:${agent.key}:${progress.sessionId}`,
637
- options: progress.options,
638
- sessionId: progress.sessionId,
639
- type: "triage_session",
640
- });
641
- },
642
- options: agent.options,
643
- parentSessionId: input.input.parentSessionId,
644
- parse: parseTriageCommentClassificationOutput,
645
- permission: agent.permission,
646
- prompt,
647
- repairAttempts: input.input.config.output?.repairAttempts ?? 3,
648
- schemaName: "triage comment classification",
649
- signal: input.input.signal,
650
- title: `Magi triage comment classification #${input.input.issue} (${agent.key})`,
651
- });
652
- await writeJson(join(input.outputDir, "comment-classification.json"), result.value);
653
- return result.value;
654
- }
655
- async function runReconsiderationVote(input) {
656
- return runPhaseVote({
657
- context: input.context,
658
- input: input.input,
659
- outputDir: input.outputDir,
660
- parse: parseTriageBinaryOutput,
661
- phase: "reconsider",
662
- prompt: composeTriageReconsiderPrompt,
663
- schemaName: "triage reconsider",
664
- votes: BINARY_VOTES,
665
- });
666
- }
667
- function triageReporter(repository, issue) {
668
- const agents = repository.agents.triage ?? [];
669
- if (!agents.length)
670
- throw new Error("triage.voters is required");
671
- const configured = repository.triage?.reporter;
672
- const reporter = configured
673
- ? agents.find((agent) => agent.key === configured)
674
- : agents[Math.abs(issue) % agents.length];
675
- if (!reporter)
676
- throw new Error(`Unknown triage reporter: ${configured}`);
677
- return reporter;
678
- }
679
- function decisionCommentBody(input) {
680
- const reason = input.reason?.trim();
681
- return reason
682
- ? reason
683
- : decisionCommentFallback({ action: input.action, result: input.result });
684
- }
685
- function decisionCommentFallback(input) {
686
- if (input.result.disposition === "accepted") {
687
- const category = input.result.category
688
- ? `${input.result.category} issue`
689
- : "issue";
690
- return input.action === "PR"
691
- ? `Magi accepted this ${category} and will prepare an implementation pull request.`
692
- : `Magi accepted this ${category}.`;
693
- }
694
- if (input.result.disposition === "rejected") {
695
- const category = input.result.category
696
- ? `${input.result.category} issue`
697
- : "issue";
698
- return `Magi does not plan to act on this ${category}.`;
699
- }
700
- if (input.result.disposition === "duplicate") {
701
- return "Magi marked this issue as a duplicate.";
702
- }
703
- if (input.result.disposition === "invalid") {
704
- return "Magi marked this issue as invalid or not actionable.";
705
- }
706
- return "Magi completed triage for this issue.";
707
- }
708
- function agentForKey(repository, key) {
709
- const agent = repository.agents.triage?.find((item) => item.key === key);
710
- if (!agent)
711
- throw new Error(`Unknown triage voter: ${key}`);
712
- return agent;
713
- }
714
- function askOutputs(outputs) {
715
- return (outputs ?? []).filter((output) => output.vote === "ASK");
716
- }
717
- function chooseDecisionReason(input) {
718
- if (!input.vote)
719
- return undefined;
720
- const canonicalVoter = input.voters?.[input.threshold - 1];
721
- const canonicalReason = input.outputs?.find((output) => output.voter === canonicalVoter && output.vote === input.vote);
722
- return (canonicalReason?.reason ??
723
- input.outputs?.find((output) => output.vote === input.vote)?.reason);
724
- }
725
- async function postMarkedIssueComment(input) {
726
- const posted = await postIssueComment(input.exec, input.repository, input.issue, input.account, input.body);
727
- const body = input.body.replace("checkpoint=pending", `checkpoint=${posted.id}`);
728
- const updated = body === input.body
729
- ? posted
730
- : await updateIssueComment(input.exec, input.repository, posted.id, input.account, body);
731
- await writeJson(join(input.outputDir, `posted-${updated.id}.json`), {
732
- account: input.account,
733
- ...updated,
734
- });
735
- return updated;
736
- }
737
- async function postPlainIssueComment(input) {
738
- const posted = await postIssueComment(input.exec, input.repository, input.issue, input.account, input.body);
739
- await writeJson(join(input.outputDir, `posted-${posted.id}.json`), {
740
- account: input.account,
741
- ...posted,
742
- });
743
- return posted;
744
- }
745
- async function persistProcessedMarker(input) {
746
- if (!input.marker.commentId)
747
- return;
748
- const previousComment = input.comments.find((comment) => comment.id === input.marker.commentId);
749
- if (!previousComment)
750
- return;
751
- const updatedMarker = marker({
752
- action: input.marker.action ?? input.marker.result ?? "ASK",
753
- checkpoint: markerCheckpoint(input.marker),
754
- decision: finalResultFromMarker(input.marker),
755
- issue: input.issue.number,
756
- pr: input.pr ?? markerPr(input.marker),
757
- processed: input.processed,
758
- });
759
- const body = previousComment.body.replace(/<!--\s*opencode-magi:triage\s+[^>]+?\s*-->/, updatedMarker);
760
- if (body === previousComment.body)
761
- return;
762
- const updated = await updateIssueComment(input.exec, input.repository, input.marker.commentId, input.account, body);
763
- await writeJson(join(input.outputDir, "processed.json"), {
764
- processed: input.processed,
765
- updated,
766
- });
767
- }
768
- async function postAskComments(input) {
769
- const urls = [];
770
- for (const output of askOutputs(input.outputs)) {
771
- const agent = agentForKey(input.repository, output.voter);
772
- const body = input.mark
773
- ? `${output.body}\n\n${marker({
774
- action: input.action,
775
- checkpoint: "pending",
776
- decision: input.result,
777
- issue: input.issue.number,
778
- processed: input.processed,
779
- })}`
780
- : output.body;
781
- if (!body?.trim())
782
- continue;
783
- await writeFile(join(input.outputDir, `${agent.key}.ask-comment.md`), `${body}\n`);
784
- if (input.dryRun) {
785
- urls.push(`dry-run:would-comment:${agent.key}`);
786
- continue;
787
- }
788
- await emitProgress(input.run, { type: "comment_posting" });
789
- const posted = input.mark
790
- ? await postMarkedIssueComment({
791
- account: agent.account,
792
- body,
793
- exec: input.exec,
794
- issue: input.issue.number,
795
- outputDir: input.outputDir,
796
- repository: input.repository,
797
- })
798
- : await postPlainIssueComment({
799
- account: agent.account,
800
- body,
801
- exec: input.exec,
802
- issue: input.issue.number,
803
- outputDir: input.outputDir,
804
- repository: input.repository,
805
- });
806
- urls.push(posted.url);
807
- await emitProgress(input.run, { type: "comment_posted", url: posted.url });
808
- }
809
- return urls;
810
- }
811
- async function finishWithResult(input) {
812
- const triage = input.input.repository.triage;
813
- if (!triage)
814
- throw new Error("triage configuration is required");
815
- const plan = input.plan ?? actionPlan({ result: input.result, triage });
816
- await emitProgress(input.input, {
817
- action: plan.action,
818
- result: input.result,
819
- type: "decision",
820
- });
821
- let prUrl;
822
- const reporter = triageReporter(input.input.repository, input.issue.number);
823
- const comment = plan.postComment &&
824
- input.result.disposition !== "needs_category" &&
825
- input.result.disposition !== "needs_acceptance"
826
- ? `${decisionCommentBody({
827
- action: plan.action,
828
- reason: input.commentReason,
829
- result: input.result,
830
- })}\n\n${marker({
831
- action: plan.action,
832
- checkpoint: "pending",
833
- decision: input.result,
834
- issue: input.issue.number,
835
- processed: input.processed,
836
- })}`
837
- : undefined;
838
- if (comment) {
839
- await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
840
- }
841
- const labelChanges = plan.clearLabels
842
- ? triageLabelChanges({
843
- issueLabels: input.issue.labels,
844
- result: input.result,
845
- rules: triage.automation.label,
846
- })
847
- : { add: [], remove: [] };
848
- if (plan.clearLabels) {
849
- await writeJson(join(input.outputDir, "label-changes.json"), labelChanges);
850
- }
851
- if ((input.result.disposition === "needs_category" ||
852
- input.result.disposition === "needs_acceptance") &&
853
- input.askOutputs) {
854
- await postAskComments({
855
- action: plan.action,
856
- dryRun: input.input.dryRun,
857
- exec: input.input.exec,
858
- issue: input.issue,
859
- mark: input.markAskComments ?? false,
860
- outputs: input.askOutputs,
861
- outputDir: input.outputDir,
862
- processed: input.processed,
863
- repository: input.input.repository,
864
- result: input.result,
865
- run: input.input,
866
- });
867
- }
868
- if (!input.input.dryRun) {
869
- if (comment) {
870
- await emitProgress(input.input, { type: "comment_posting" });
871
- const posted = await postMarkedIssueComment({
872
- account: reporter.account,
873
- body: comment,
874
- exec: input.input.exec,
875
- issue: input.issue.number,
876
- outputDir: input.outputDir,
877
- repository: input.input.repository,
878
- });
879
- await emitProgress(input.input, {
880
- type: "comment_posted",
881
- url: posted.url,
882
- });
883
- }
884
- if (plan.clearLabels) {
885
- if (labelChanges.remove.length) {
886
- await removeIssueLabels(input.input.exec, input.input.repository, input.issue.number, labelChanges.remove, reporter.account);
887
- }
888
- if (labelChanges.add.length) {
889
- await addIssueLabels(input.input.exec, input.input.repository, input.issue.number, labelChanges.add, reporter.account);
890
- }
891
- }
892
- if (plan.closeIssue) {
893
- const closedPrs = [];
894
- for (const pr of input.relationship.relatedPullRequests.filter((pr) => pr.state === "OPEN")) {
895
- await closePullRequest(input.input.exec, input.input.repository, pr.number, reporter.account);
896
- closedPrs.push(pr.number);
897
- }
898
- if (closedPrs.length)
899
- await writeJson(join(input.outputDir, "closed-prs.json"), closedPrs);
900
- await closeIssue(input.input.exec, input.input.repository, input.issue.number, reporter.account);
901
- }
902
- if (plan.createPr) {
903
- prUrl = await createImplementationPr({
904
- context: input.context,
905
- input: input.input,
906
- issue: input.issue,
907
- outputDir: input.outputDir,
908
- runId: input.runId,
909
- });
910
- if (prUrl) {
911
- await writeJson(join(input.outputDir, "pr.json"), { url: prUrl });
912
- await emitProgress(input.input, { type: "pr_created", url: prUrl });
913
- }
914
- }
915
- if (input.previousMarker && prUrl) {
916
- await persistProcessedMarker({
917
- account: input.previousMarker.account ?? reporter.account,
918
- comments: input.relationship.comments,
919
- exec: input.input.exec,
920
- issue: input.issue,
921
- marker: input.previousMarker,
922
- outputDir: input.outputDir,
923
- pr: pullRequestNumberFromUrl(prUrl),
924
- processed: input.processed ?? input.previousMarker.processed,
925
- repository: input.input.repository,
926
- });
927
- }
928
- }
929
- const report = [
930
- `Magi triage result for #${input.issue.number}: ${decisionText(input.result)}`,
931
- prUrl ? `Created PR: ${prUrl}` : undefined,
932
- input.input.dryRun
933
- ? "Dry run: no GitHub mutations were performed."
934
- : undefined,
935
- ]
936
- .filter(Boolean)
937
- .join("\n");
938
- await writeFile(join(input.outputDir, "report.md"), `${report}\n`);
939
- return {
940
- issue: input.issue.number,
941
- outputDir: input.outputDir,
942
- prUrl,
943
- report,
944
- result: input.result,
945
- };
946
- }
947
- function safetyBlocked(input, issue, hasMarker) {
948
- const triage = input.repository.triage;
949
- if (!triage)
950
- throw new Error("triage configuration is required");
951
- const safety = triage.safety;
952
- if (issue.state === "CLOSED")
953
- return "issue is closed";
954
- if (!hasMarker && safety.requiredLabels.length) {
955
- const missing = safety.requiredLabels.filter((label) => !labelsContain(issue.labels, [label]));
956
- if (missing.length)
957
- return `missing required labels: ${missing.join(", ")}`;
958
- }
959
- if (safety.blockedLabels.some((label) => labelsContain(issue.labels, [label]))) {
960
- return "issue has a blocked label";
961
- }
962
- if (safety.allowAuthors.length &&
963
- !safety.allowAuthors.includes(issue.author)) {
964
- return `issue author is not allowed: ${issue.author}`;
965
- }
966
- return undefined;
967
- }
968
- async function createImplementationPr(input) {
969
- const creator = input.input.repository.agents.triageCreator;
970
- if (!creator)
971
- return undefined;
972
- await emitProgress(input.input, { type: "pr_creation_started" });
973
- await emitProgress(input.input, { type: "triage_creator_started" });
974
- try {
975
- await assignIssue(input.input.exec, input.input.repository, input.issue.number, creator.account);
976
- const branch = `magi/issue-${input.issue.number}-${Date.now().toString(36)}`;
977
- const worktreePath = issueRunWorktreeDir({
978
- config: input.input.config,
979
- directory: input.input.directory,
980
- issue: input.issue.number,
981
- runId: input.runId,
982
- });
983
- await mkdir(dirname(worktreePath), { recursive: true });
984
- await input.input.exec(`git worktree add -b ${shellQuote(branch)} ${shellQuote(worktreePath)}`);
985
- await emitProgress(input.input, {
986
- branch,
987
- type: "worktree_created",
988
- worktreePath,
989
- });
990
- try {
991
- await configureGitIdentity(input.input.exec, worktreePath, creator.author);
992
- const prompt = await composeTriageCreatePrPrompt({
993
- context: input.context,
994
- directory: input.input.directory,
995
- issue: input.issue.number,
996
- repository: input.input.repository,
997
- worktreePath,
998
- });
999
- const result = await runModelWithRepair({
1000
- client: input.input.client,
1001
- model: creator.model,
1002
- onProgress: async (progress) => {
1003
- if (progress.type === "session_created") {
1004
- await emitProgress(input.input, {
1005
- options: progress.options,
1006
- sessionId: progress.sessionId,
1007
- type: "triage_creator_session",
1008
- });
1009
- }
1010
- if (progress.type === "repair") {
1011
- await emitProgress(input.input, { type: "triage_creator_repair" });
1012
- }
1013
- if (progress.type === "response") {
1014
- await emitProgress(input.input, {
1015
- sessionId: progress.sessionId,
1016
- type: "triage_creator_response",
1017
- });
1018
- }
1019
- },
1020
- options: creator.options,
1021
- parentSessionId: input.input.parentSessionId,
1022
- parse: parseTriageCreatePrOutput,
1023
- permission: creator.permission,
1024
- prompt,
1025
- repairAttempts: input.input.config.output?.repairAttempts ?? 3,
1026
- schemaName: "triage create PR",
1027
- signal: input.input.signal,
1028
- title: `Magi triage create PR #${input.issue.number}`,
1029
- });
1030
- await emitProgress(input.input, {
1031
- sessionId: result.sessionId,
1032
- type: "triage_creator_completed",
1033
- });
1034
- await writeJson(join(input.outputDir, "create-pr.json"), result.value);
1035
- if (result.value.mode !== "EDITED")
1036
- return undefined;
1037
- const pullRequest = result.value.pullRequest;
1038
- if (!pullRequest)
1039
- throw new Error("EDITED requires pullRequest");
1040
- await pushHead(input.input.exec, input.input.repository, worktreePath, creator.account, {
1041
- owner: input.input.repository.github.owner,
1042
- ref: branch,
1043
- repo: input.input.repository.github.repo,
1044
- });
1045
- return createPullRequest(input.input.exec, input.input.repository, creator.account, {
1046
- body: pullRequest.body,
1047
- head: branch,
1048
- title: pullRequest.title,
1049
- });
1050
- }
1051
- finally {
1052
- await removeWorktree(input.input.exec, worktreePath).catch(() => undefined);
1053
- }
1054
- }
1055
- catch (error) {
1056
- await emitProgress(input.input, {
1057
- error: error instanceof Error ? error.message : String(error),
1058
- type: "triage_creator_failed",
1059
- });
1060
- throw error;
1061
- }
1062
- }
1063
- export async function runTriage(input) {
1064
- const triage = input.repository.triage;
1065
- if (!triage)
1066
- throw new Error("triage configuration is required");
1067
- const agents = input.repository.agents.triage;
1068
- if (!agents?.length)
1069
- throw new Error("triage.voters is required");
1070
- const runId = input.runId ?? `run-${Date.now().toString(36)}`;
1071
- const outputDir = issueRunOutputDir({
1072
- config: input.config,
1073
- directory: input.directory,
1074
- issue: input.issue,
1075
- runId,
1076
- });
1077
- await mkdir(outputDir, { recursive: true });
1078
- await emitProgress(input, { phase: "fetching issue", type: "phase" });
1079
- const issue = await fetchIssue(input.exec, input.repository, input.issue);
1080
- await emitProgress(input, {
1081
- phase: "scanning issue relationships",
1082
- type: "phase",
1083
- });
1084
- const relationship = await relationshipScan(input, issue);
1085
- const block = safetyBlocked(input, issue, Boolean(relationship.previousMarker));
1086
- await writeJson(join(outputDir, "issue.json"), issue);
1087
- await writeJson(join(outputDir, "comments.json"), relationship.comments);
1088
- await writeJson(join(outputDir, "relationship-summary.json"), relationship);
1089
- if (relationship.previousMarker)
1090
- await writeJson(join(outputDir, "previous-triage.json"), relationship.previousMarker);
1091
- if (block) {
1092
- const report = `Magi triage blocked for #${input.issue}: ${block}`;
1093
- await writeFile(join(outputDir, "report.md"), `${report}\n`);
1094
- return {
1095
- issue: input.issue,
1096
- outputDir,
1097
- report,
1098
- result: { category: null, disposition: "blocked", signals: [] },
1099
- };
1100
- }
1101
- let context = issueContext({ issue, relationship });
1102
- await writeFile(join(outputDir, "context.md"), `${context}\n`);
1103
- await emitProgress(input, { phase: "triaging", type: "phase" });
1104
- let processed = relationship.previousMarker?.processed ?? [];
1105
- let result;
1106
- let askCommentOutputs;
1107
- let commentReason;
1108
- let markAskComments = false;
1109
- if (relationship.previousMarker) {
1110
- if (!relationship.mentionReplies.length) {
1111
- const result = finalResultFromMarker(relationship.previousMarker);
1112
- const plan = previousAutomationPlan({
1113
- issue,
1114
- marker: relationship.previousMarker,
1115
- relationship,
1116
- result,
1117
- triage,
1118
- });
1119
- if (plan) {
1120
- return finishWithResult({
1121
- context,
1122
- input,
1123
- issue,
1124
- outputDir,
1125
- plan,
1126
- previousMarker: relationship.previousMarker,
1127
- processed,
1128
- relationship,
1129
- result,
1130
- runId,
1131
- });
1132
- }
1133
- const report = `Magi triage skipped #${issue.number} because no eligible mention replies were found for reconsideration.`;
1134
- await writeFile(join(outputDir, "report.md"), `${report}\n`);
1135
- return { issue: issue.number, outputDir, report, result };
1136
- }
1137
- const classifications = await classifyMentionReplies({
1138
- context,
1139
- input,
1140
- outputDir,
1141
- replies: relationship.mentionReplies,
1142
- });
1143
- const triggeringComments = relationship.mentionReplies.filter((comment) => classifications.comments.some((item) => item.commentId === comment.id &&
1144
- RECONSIDERATION_CLASSES.has(item.classification)));
1145
- processed = [
1146
- ...new Set([
1147
- ...processed,
1148
- ...classifications.comments.map((comment) => comment.commentId),
1149
- ]),
1150
- ];
1151
- if (!triggeringComments.length) {
1152
- if (!input.dryRun) {
1153
- await persistProcessedMarker({
1154
- account: relationship.previousMarker.account ?? "",
1155
- comments: relationship.comments,
1156
- exec: input.exec,
1157
- issue,
1158
- marker: relationship.previousMarker,
1159
- outputDir,
1160
- processed,
1161
- repository: input.repository,
1162
- });
1163
- }
1164
- const result = finalResultFromMarker(relationship.previousMarker);
1165
- const report = `Magi triage skipped #${issue.number} because allowed mention replies did not request reconsideration.`;
1166
- await writeFile(join(outputDir, "report.md"), `${report}\n`);
1167
- return { issue: issue.number, outputDir, report, result };
1168
- }
1169
- context = issueContext({
1170
- issue,
1171
- relationship,
1172
- reconsideration: {
1173
- classifications,
1174
- previousMarker: relationship.previousMarker,
1175
- triggeringComments,
1176
- },
1177
- });
1178
- await writeFile(join(outputDir, "context.md"), `${context}\n`);
1179
- const previous = finalResultFromMarker(relationship.previousMarker);
1180
- if (previous.disposition !== "needs_acceptance") {
1181
- const reconsideration = await runReconsiderationVote({
1182
- context,
1183
- input,
1184
- outputDir,
1185
- });
1186
- commentReason = reconsideration.reason;
1187
- result =
1188
- reconsideration.vote === "YES"
1189
- ? {
1190
- category: previous.category,
1191
- disposition: "accepted",
1192
- signals: [],
1193
- }
1194
- : reconsideration.vote === "NO"
1195
- ? {
1196
- category: previous.category,
1197
- disposition: "rejected",
1198
- signals: [],
1199
- }
1200
- : {
1201
- category: previous.category,
1202
- disposition: "needs_acceptance",
1203
- signals: [],
1204
- };
1205
- if (result.disposition === "needs_acceptance") {
1206
- askCommentOutputs = askOutputs(reconsideration.outputs);
1207
- markAskComments = true;
1208
- }
1209
- }
1210
- }
1211
- if (!result && relationship.relatedPullRequests.length) {
1212
- const existingPr = await runPhaseVote({
1213
- context,
1214
- input,
1215
- outputDir,
1216
- parse: parseTriageExistingPrOutput,
1217
- phase: "existing-pr",
1218
- prompt: composeTriageExistingPrPrompt,
1219
- schemaName: "triage existing PR",
1220
- votes: EXISTING_PR_VOTES,
1221
- });
1222
- if (existingPr.vote === "RELATED_PR_HANDLES_ISSUE") {
1223
- const merged = relationship.relatedPullRequests.some((pr) => pr.state === "MERGED");
1224
- if (merged && triage.automation.close) {
1225
- const relatedPrDecision = {
1226
- category: resolveIssueCategory(issue, input.repository) ?? null,
1227
- disposition: "accepted",
1228
- signals: [],
1229
- };
1230
- const plan = {
1231
- action: "CLOSE",
1232
- allowedActions: ["CLOSE"],
1233
- clearLabels: true,
1234
- closeIssue: true,
1235
- createPr: false,
1236
- postComment: true,
1237
- };
1238
- return finishWithResult({
1239
- commentReason: existingPr.reason,
1240
- context,
1241
- input,
1242
- issue,
1243
- outputDir,
1244
- plan,
1245
- processed,
1246
- relationship,
1247
- result: relatedPrDecision,
1248
- runId,
1249
- });
1250
- }
1251
- return finishWithResult({
1252
- context,
1253
- input,
1254
- issue,
1255
- outputDir,
1256
- processed,
1257
- relationship,
1258
- result: { category: null, disposition: "already_handled", signals: [] },
1259
- runId,
1260
- });
1261
- }
1262
- }
1263
- if (!result && relationship.duplicateCandidates.length) {
1264
- const duplicate = await runDuplicateVote({
1265
- candidateNumbers: relationship.duplicateCandidates.map((candidate) => candidate.number),
1266
- context,
1267
- input,
1268
- outputDir,
1269
- });
1270
- if (duplicate) {
1271
- context = `${context}\n\nDuplicate decision: ${JSON.stringify(duplicate)}`;
1272
- commentReason = duplicate.reason;
1273
- result = { category: null, disposition: "duplicate", signals: [] };
1274
- }
1275
- }
1276
- if (!result) {
1277
- const resolvedCategory = resolveIssueCategory(issue, input.repository);
1278
- await writeJson(join(outputDir, "category-resolution.json"), {
1279
- category: resolvedCategory,
1280
- source: resolvedCategory ? "config" : "vote",
1281
- });
1282
- const categoryVote = resolvedCategory
1283
- ? undefined
1284
- : await runPhaseVote({
1285
- context,
1286
- input,
1287
- outputDir,
1288
- parse: (text) => parseTriageCategoryOutput(text, triage.categories.map((item) => item.id)),
1289
- phase: "category",
1290
- prompt: composeTriageCategoryPrompt,
1291
- schemaName: "triage category",
1292
- votes: ["ASK", ...triage.categories.map((item) => item.id)],
1293
- });
1294
- const category = resolvedCategory ?? categoryVote?.vote ?? "ASK";
1295
- if (category === "ASK") {
1296
- result = {
1297
- category: null,
1298
- disposition: "needs_category",
1299
- signals: [],
1300
- };
1301
- askCommentOutputs = askOutputs(categoryVote?.outputs);
1302
- markAskComments = false;
1303
- }
1304
- else {
1305
- const categoryConfig = triage.categories.find((item) => item.id === category);
1306
- const voteContext = JSON.stringify({
1307
- category: categoryConfig,
1308
- triageContext: context,
1309
- }, null, 2);
1310
- const acceptance = await runPhaseVote({
1311
- context: voteContext,
1312
- input,
1313
- outputDir,
1314
- parse: parseTriageBinaryOutput,
1315
- phase: "acceptance",
1316
- prompt: composeTriageAcceptancePrompt,
1317
- schemaName: "triage acceptance",
1318
- votes: ACCEPTANCE_VOTES,
1319
- });
1320
- commentReason = acceptance.reason;
1321
- result =
1322
- acceptance.vote === "YES"
1323
- ? { category, disposition: "accepted", signals: [] }
1324
- : acceptance.vote === "NO"
1325
- ? { category, disposition: "rejected", signals: [] }
1326
- : acceptance.vote === "INVALID"
1327
- ? { category, disposition: "invalid", signals: [] }
1328
- : {
1329
- category,
1330
- disposition: "needs_acceptance",
1331
- signals: [],
1332
- };
1333
- if (result.disposition === "needs_acceptance") {
1334
- askCommentOutputs = askOutputs(acceptance.outputs);
1335
- markAskComments = true;
1336
- }
1337
- }
1338
- }
1339
- if (result &&
1340
- result.disposition !== "blocked" &&
1341
- result.disposition !== "failed" &&
1342
- triage.signals.length) {
1343
- const signalContext = `${context}\n\nFinal triage result: ${JSON.stringify(result)}`;
1344
- result = {
1345
- ...result,
1346
- signals: await runSignalVote({
1347
- context: signalContext,
1348
- input,
1349
- outputDir,
1350
- }),
1351
- };
1352
- }
1353
- return finishWithResult({
1354
- askOutputs: askCommentOutputs,
1355
- commentReason,
1356
- context,
1357
- input,
1358
- issue,
1359
- markAskComments,
1360
- outputDir,
1361
- processed,
1362
- relationship,
1363
- result: result ?? {
1364
- category: null,
1365
- disposition: "needs_acceptance",
1366
- signals: [],
1367
- },
1368
- runId,
1369
- });
1370
- }