opencode-magi 0.0.0-dev-20260520045400 → 0.0.0-dev-20260520064744

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/dist/commands.js CHANGED
@@ -3,6 +3,10 @@ export const MAGI_COMMANDS = {
3
3
  description: "Clear inactive Magi runs, sessions, worktrees, and outputs",
4
4
  template: "Call the `magi_clear` tool.",
5
5
  },
6
+ "magi:cancel": {
7
+ description: "Cancel a Magi background run",
8
+ template: [`Call the \`magi_cancel\` tool.`, "Selector: $ARGUMENTS"].join("\n"),
9
+ },
6
10
  "magi:merge": {
7
11
  description: "Review and merge pull requests with Magi",
8
12
  template: [`Call the \`magi_merge\` tool.`, "PR: $ARGUMENTS"].join("\n"),
@@ -15,6 +19,14 @@ export const MAGI_COMMANDS = {
15
19
  description: "Review pull requests with Magi",
16
20
  template: [`Call the \`magi_review\` tool.`, "PR: $ARGUMENTS"].join("\n"),
17
21
  },
22
+ "magi:output": {
23
+ description: "Show Magi run output artifacts",
24
+ template: [`Call the \`magi_output\` tool.`, "Selector: $ARGUMENTS"].join("\n"),
25
+ },
26
+ "magi:status": {
27
+ description: "Show Magi background run status",
28
+ template: [`Call the \`magi_status\` tool.`, "Selector: $ARGUMENTS"].join("\n"),
29
+ },
18
30
  "magi:validate": {
19
31
  description: "Validate Magi config",
20
32
  template: "Call the `magi_validate` tool.",
@@ -1,6 +1,14 @@
1
1
  import { mkdir, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ function normalizeRelatedPullRequestState(state) {
5
+ const normalized = state?.toUpperCase();
6
+ if (normalized === "MERGED")
7
+ return "MERGED";
8
+ if (normalized === "CLOSED")
9
+ return "CLOSED";
10
+ return "OPEN";
11
+ }
4
12
  const WORKTREE_CHECKOUT_RETRY_ATTEMPTS = 5;
5
13
  const WORKTREE_CHECKOUT_RETRY_DELAY_MS = 100;
6
14
  const worktreeCreateLocks = new Map();
@@ -157,9 +165,7 @@ export async function fetchRelatedPullRequests(exec, repository, issue) {
157
165
  continue;
158
166
  const state = source.mergedAt
159
167
  ? "MERGED"
160
- : source.state === "CLOSED"
161
- ? "CLOSED"
162
- : "OPEN";
168
+ : normalizeRelatedPullRequestState(source.state);
163
169
  prs.set(source.number, {
164
170
  author: source.author?.login ?? "",
165
171
  body: source.body,
@@ -170,25 +176,86 @@ export async function fetchRelatedPullRequests(exec, repository, issue) {
170
176
  url: source.url,
171
177
  });
172
178
  }
179
+ const searchQuery = `repo:${repoSlug(repository)} is:pr ${issue}`;
180
+ const searchRaw = await exec(`gh search prs ${shellQuote(searchQuery)} --json number,title,url,state,body,author --limit 10`).catch(() => "[]");
181
+ const searchData = JSON.parse(searchRaw);
182
+ const closingReference = new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#${issue}\\b`, "i");
183
+ for (const item of searchData) {
184
+ if (!closingReference.test(item.body ?? ""))
185
+ continue;
186
+ prs.set(item.number, {
187
+ author: item.author?.login ?? "",
188
+ body: item.body,
189
+ number: item.number,
190
+ state: normalizeRelatedPullRequestState(item.state),
191
+ title: item.title,
192
+ url: item.url,
193
+ });
194
+ }
173
195
  return [...prs.values()];
174
196
  }
197
+ function duplicateReferences(text) {
198
+ const refs = new Set();
199
+ const pattern = /duplicate(?:s)?\s+(?:of\s+)?#(\d+)/gi;
200
+ for (const match of text.matchAll(pattern))
201
+ refs.add(Number(match[1]));
202
+ return [...refs];
203
+ }
204
+ async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
205
+ const raw = await exec(`gh issue view ${number} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,state,body,createdAt`).catch(() => undefined);
206
+ if (!raw)
207
+ return undefined;
208
+ const data = JSON.parse(raw);
209
+ return { ...data, whyCandidate };
210
+ }
175
211
  export async function searchDuplicateIssues(exec, repository, issue, limit = 5) {
176
212
  const query = `${issue.title} repo:${repoSlug(repository)} is:issue -${issue.number}`;
213
+ const explicitCandidates = await Promise.all(duplicateReferences(issue.body)
214
+ .filter((number) => number !== issue.number)
215
+ .map((number) => fetchIssueCandidate(exec, repository, number, "Issue body explicitly references a duplicate target.")));
177
216
  const raw = await exec(`gh search issues ${shellQuote(query)} --json number,title,url,state,body --limit ${limit}`);
178
217
  const data = JSON.parse(raw);
179
- return data
218
+ const candidates = new Map();
219
+ for (const candidate of explicitCandidates) {
220
+ if (candidate)
221
+ candidates.set(candidate.number, candidate);
222
+ }
223
+ for (const item of data
180
224
  .filter((item) => item.number !== issue.number)
181
225
  .map((item) => ({
182
226
  ...item,
183
227
  whyCandidate: "GitHub issue search matched the title.",
184
- }));
228
+ }))) {
229
+ if (!candidates.has(item.number))
230
+ candidates.set(item.number, item);
231
+ }
232
+ return [...candidates.values()].slice(0, limit);
185
233
  }
186
234
  export async function postIssueComment(exec, repository, issue, account, body) {
187
235
  const token = await ghToken(exec, repository, account);
188
236
  const payloadPath = join(tmpdir(), `magi-issue-${process.pid}-${Date.now()}.json`);
189
237
  await writeFile(payloadPath, JSON.stringify({ body }));
190
238
  try {
191
- return await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/issues/${issue}/comments --method POST --input ${shellQuote(payloadPath)} --jq .html_url`, ghTokenEnv(token));
239
+ const raw = await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/issues/${issue}/comments --method POST --input ${shellQuote(payloadPath)} --jq '{id: .id, url: .html_url}'`, ghTokenEnv(token));
240
+ const data = JSON.parse(raw);
241
+ if (!data.id || !data.url)
242
+ throw new Error("GitHub issue comment response did not include id and url");
243
+ return { id: data.id, url: data.url };
244
+ }
245
+ finally {
246
+ await rm(payloadPath, { force: true });
247
+ }
248
+ }
249
+ export async function updateIssueComment(exec, repository, commentId, account, body) {
250
+ const token = await ghToken(exec, repository, account);
251
+ const payloadPath = join(tmpdir(), `magi-issue-comment-${process.pid}-${Date.now()}.json`);
252
+ await writeFile(payloadPath, JSON.stringify({ body }));
253
+ try {
254
+ const raw = await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/issues/comments/${commentId} --method PATCH --input ${shellQuote(payloadPath)} --jq '{id: .id, url: .html_url}'`, ghTokenEnv(token));
255
+ const data = JSON.parse(raw);
256
+ if (!data.id || !data.url)
257
+ throw new Error("GitHub issue comment response did not include id and url");
258
+ return { id: data.id, url: data.url };
192
259
  }
193
260
  finally {
194
261
  await rm(payloadPath, { force: true });
package/dist/index.js CHANGED
@@ -13,7 +13,6 @@ import { validateConfig } from "./config/validate";
13
13
  import { withGitHubApiRetry } from "./github/retry";
14
14
  import { mapPool } from "./orchestrator/pool";
15
15
  import { MagiRunManager } from "./orchestrator/run-manager";
16
- import { runTriage } from "./orchestrator/triage";
17
16
  const execAsync = promisify(nodeExec);
18
17
  const GLOBAL_CONFIG_PATH = join(homedir(), ".config", "opencode", "magi.json");
19
18
  const PROJECT_CONFIG_PATH = join(".opencode", "magi.json");
@@ -119,6 +118,11 @@ function parseOptionalPr(value) {
119
118
  return undefined;
120
119
  return parsePrToken(value);
121
120
  }
121
+ function parseOptionalIssue(value) {
122
+ if (!value?.trim())
123
+ return undefined;
124
+ return parseIssueToken(value);
125
+ }
122
126
  function clearFlag(value) {
123
127
  return typeof value === "boolean" ? value : undefined;
124
128
  }
@@ -161,6 +165,11 @@ function prMarkdownLink(repository, pr) {
161
165
  const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/pull/${pr}`;
162
166
  return `[#${pr}](${url})`;
163
167
  }
168
+ function issueMarkdownLink(repository, issue) {
169
+ const host = repository.github.host || "github.com";
170
+ const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/issues/${issue}`;
171
+ return `[#${issue}](${url})`;
172
+ }
164
173
  function isPlainObject(value) {
165
174
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
166
175
  }
@@ -391,27 +400,28 @@ export const MagiPlugin = async ({ client, directory }) => {
391
400
  const repository = resolveRepository(loaded.config);
392
401
  if (!repository.triage)
393
402
  return JSON.stringify({ errors: ["triage configuration is required"], ok: false }, null, 2);
394
- const results = await mapPool(parsed.issues, repository.triage.concurrency.runs, (issue) => runTriage({
395
- client: modelClient,
403
+ const states = await mapPool(parsed.issues, repository.triage.concurrency.runs, (issue) => runManager.startTriage({
396
404
  config: loaded.config,
397
- directory,
398
405
  dryRun: parsed.dryRun,
399
- exec: retryingExec,
400
406
  issue,
407
+ parentSessionId: context.sessionID,
401
408
  repository,
402
409
  signal: context.abort,
403
410
  }), { signal: context.abort });
404
- return results.map((result) => result.report).join("\n\n");
411
+ return states
412
+ .map((state) => `Started triaging ${issueMarkdownLink(repository, state.issue)}.`)
413
+ .join("\n");
405
414
  },
406
415
  }),
407
416
  magi_status: tool({
408
417
  description: [
409
- "Show Magi background run status. Optionally filter by runId or PR and wait for completion.",
418
+ "Show Magi background run status. Optionally filter by runId, PR, or issue and wait for completion.",
410
419
  INTERNAL_FOLLOW_UP_TOOL_NOTE,
411
420
  ].join(" "),
412
421
  args: {
413
422
  runId: tool.schema.string().optional(),
414
423
  pr: tool.schema.string().optional(),
424
+ issue: tool.schema.string().optional(),
415
425
  block: tool.schema.boolean().optional(),
416
426
  timeoutSeconds: tool.schema.number().optional(),
417
427
  verbose: tool.schema.boolean().optional(),
@@ -419,6 +429,7 @@ export const MagiPlugin = async ({ client, directory }) => {
419
429
  async execute(args) {
420
430
  const states = await runManager.status({
421
431
  block: args.block,
432
+ issue: parseOptionalIssue(args.issue),
422
433
  outputDir: await configuredOutputDir(),
423
434
  pr: parseOptionalPr(args.pr),
424
435
  runId: args.runId,
@@ -433,22 +444,24 @@ export const MagiPlugin = async ({ client, directory }) => {
433
444
  }),
434
445
  magi_output: tool({
435
446
  description: [
436
- "Show artifacts and details for a Magi background run by runId or PR, optionally for a single reviewer.",
447
+ "Show artifacts and details for a Magi background run by runId, PR, or issue, optionally for a single reviewer.",
437
448
  INTERNAL_FOLLOW_UP_TOOL_NOTE,
438
449
  ].join(" "),
439
450
  args: {
440
451
  runId: tool.schema.string().optional(),
441
452
  pr: tool.schema.string().optional(),
453
+ issue: tool.schema.string().optional(),
442
454
  reviewer: tool.schema.string().optional(),
443
455
  },
444
456
  async execute(args) {
445
- if (!args.runId && !args.pr)
446
- return "Specify runId or pr.";
457
+ if (!args.runId && !args.pr && !args.issue)
458
+ return "Specify runId, pr, or issue.";
447
459
  const outputDir = await configuredOutputDir();
448
460
  if (outputDir)
449
461
  await runManager.status({ outputDir });
450
462
  return runManager.output({
451
463
  outputDir,
464
+ issue: parseOptionalIssue(args.issue),
452
465
  pr: parseOptionalPr(args.pr),
453
466
  reviewer: args.reviewer,
454
467
  runId: args.runId,
@@ -456,19 +469,22 @@ export const MagiPlugin = async ({ client, directory }) => {
456
469
  },
457
470
  }),
458
471
  magi_cancel: tool({
459
- description: "Cancel a Magi background run by runId or PR.",
472
+ description: "Cancel a Magi background run by runId, PR, or issue.",
460
473
  args: {
461
474
  runId: tool.schema.string().optional(),
462
475
  pr: tool.schema.string().optional(),
476
+ issue: tool.schema.string().optional(),
463
477
  },
464
478
  async execute(args) {
465
- if (!args.runId && !args.pr)
466
- return "Specify runId or pr.";
479
+ if (!args.runId && !args.pr && !args.issue)
480
+ return "Specify runId, pr, or issue.";
467
481
  const outputDir = await configuredOutputDir();
468
482
  if (outputDir)
469
483
  await runManager.status({ outputDir });
470
484
  const pr = parseOptionalPr(args.pr);
485
+ const issue = parseOptionalIssue(args.issue);
471
486
  const state = await runManager.cancel({
487
+ issue,
472
488
  outputDir,
473
489
  pr,
474
490
  runId: args.runId,
@@ -476,7 +492,9 @@ export const MagiPlugin = async ({ client, directory }) => {
476
492
  if (!state) {
477
493
  return args.runId
478
494
  ? `Magi run not found: ${args.runId}`
479
- : `Magi run not found for PR #${pr}`;
495
+ : issue
496
+ ? `Magi run not found for issue #${issue}`
497
+ : `Magi run not found for PR #${pr}`;
480
498
  }
481
499
  return runManager.formatStates([state]);
482
500
  },
@@ -486,6 +504,7 @@ export const MagiPlugin = async ({ client, directory }) => {
486
504
  args: {
487
505
  runId: tool.schema.string().optional(),
488
506
  pr: tool.schema.string().optional(),
507
+ issue: tool.schema.string().optional(),
489
508
  branch: tool.schema.enum(["true", "false"]).optional(),
490
509
  output: tool.schema.enum(["true", "false"]).optional(),
491
510
  session: tool.schema.enum(["true", "false"]).optional(),
@@ -511,6 +530,7 @@ export const MagiPlugin = async ({ client, directory }) => {
511
530
  };
512
531
  return runManager.clear({
513
532
  options,
533
+ issue: parseOptionalIssue(args.issue),
514
534
  outputDir: loaded
515
535
  ? outputBaseDirs(directory, loaded.config)
516
536
  : undefined,
@@ -1,12 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, readdir, rm, rmdir, writeFile, } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
- import { outputBaseDirs, prRunOutputDir } from "../config/output";
4
+ import { issueRunOutputDir, outputBaseDirs, prRunOutputDir, } from "../config/output";
5
5
  import { worktreeBaseDirs } from "../config/worktree";
6
6
  import { removeBranch, removeWorktree, } from "../github/commands";
7
7
  import { withGitHubApiRetry } from "../github/retry";
8
8
  import { runMerge, } from "./merge";
9
9
  import { runReview } from "./review";
10
+ import { runTriage } from "./triage";
10
11
  const EVENT_LAST_UPDATE_THROTTLE_MS = 5_000;
11
12
  const DEFAULT_CLEAR_OPTIONS = {
12
13
  branch: true,
@@ -75,13 +76,28 @@ function prUrl(repository, pr) {
75
76
  const host = repository.github.host || "github.com";
76
77
  return `https://${host}/${repository.github.owner}/${repository.github.repo}/pull/${pr}`;
77
78
  }
79
+ function issueUrl(repository, issue) {
80
+ const host = repository.github.host || "github.com";
81
+ return `https://${host}/${repository.github.owner}/${repository.github.repo}/issues/${issue}`;
82
+ }
78
83
  function prMarkdownLink(state) {
79
84
  if (state.pr == null)
80
85
  return state.runId;
81
86
  return state.prUrl ? `[#${state.pr}](${state.prUrl})` : `#${state.pr}`;
82
87
  }
88
+ function issueMarkdownLink(state) {
89
+ if (state.issue == null)
90
+ return state.runId;
91
+ return state.issueUrl
92
+ ? `[#${state.issue}](${state.issueUrl})`
93
+ : `#${state.issue}`;
94
+ }
83
95
  function runLabel(state) {
84
- return state.pr == null ? state.runId : prMarkdownLink(state);
96
+ if (state.pr != null)
97
+ return prMarkdownLink(state);
98
+ if (state.issue != null)
99
+ return issueMarkdownLink(state);
100
+ return state.runId;
85
101
  }
86
102
  function reviewerCompletionText(input) {
87
103
  const reviewer = `**Reviewer ${input.reviewer}**`;
@@ -477,6 +493,55 @@ export class MagiRunManager {
477
493
  });
478
494
  return state;
479
495
  }
496
+ async startTriage(input) {
497
+ const runId = createRunId();
498
+ const outputDir = issueRunOutputDir({
499
+ config: input.config,
500
+ directory: this.input.directory,
501
+ issue: input.issue,
502
+ runId,
503
+ });
504
+ const createdAt = now();
505
+ const state = {
506
+ command: "triage",
507
+ createdAt,
508
+ dryRun: input.dryRun,
509
+ issue: input.issue,
510
+ issueUrl: issueUrl(input.repository, input.issue),
511
+ outputDir,
512
+ parentSessionId: input.parentSessionId,
513
+ phase: "queued",
514
+ repository: input.repository.alias,
515
+ reviewers: Object.fromEntries((input.repository.agents.triage ?? []).map((agent) => [
516
+ agent.key,
517
+ {
518
+ account: "",
519
+ repairAttempts: 0,
520
+ status: "pending",
521
+ toolCalls: 0,
522
+ },
523
+ ])),
524
+ runId,
525
+ status: "preparing",
526
+ updatedAt: createdAt,
527
+ };
528
+ this.active.set(runId, state);
529
+ this.runPaths.set(runId, join(outputDir, "state.json"));
530
+ for (const dir of outputBaseDirs(this.input.directory, input.config))
531
+ this.outputDirs.add(dir);
532
+ await this.persist(state);
533
+ await this.notify(state, `Started Magi triage for ${issueMarkdownLink(state)}.`);
534
+ const controller = new AbortController();
535
+ this.controllers.set(runId, controller);
536
+ void this.executeTriage({
537
+ ...input,
538
+ runId,
539
+ signal: controller.signal,
540
+ }).catch(async (error) => {
541
+ await this.failRun(runId, error);
542
+ });
543
+ return state;
544
+ }
480
545
  async status(input = {}) {
481
546
  const timeoutMs = Math.min(input.timeoutMs ?? 60_000, 600_000);
482
547
  const startedAt = Date.now();
@@ -1173,6 +1238,45 @@ export class MagiRunManager {
1173
1238
  this.active.delete(input.runId);
1174
1239
  this.controllers.delete(input.runId);
1175
1240
  }
1241
+ async executeTriage(input) {
1242
+ const state = this.active.get(input.runId);
1243
+ if (state) {
1244
+ state.status = "running";
1245
+ state.phase = "triaging";
1246
+ await this.persist(state);
1247
+ }
1248
+ const result = await runTriage({
1249
+ client: this.input.client,
1250
+ config: input.config,
1251
+ directory: this.input.directory,
1252
+ dryRun: input.dryRun,
1253
+ exec: withGitHubApiRetry(this.input.exec, input.config.github?.apiRetryAttempts ?? 3),
1254
+ issue: input.issue,
1255
+ repository: input.repository,
1256
+ runId: input.runId,
1257
+ signal: input.signal,
1258
+ });
1259
+ const completed = this.active.get(input.runId);
1260
+ if (!completed || completed.status === "cancelled")
1261
+ return;
1262
+ completed.status = result.result === "FAILED" ? "failed" : "completed";
1263
+ completed.phase = result.result;
1264
+ completed.completedAt = now();
1265
+ completed.verdict = result.result;
1266
+ completed.reportPath = join(completed.outputDir, "report.md");
1267
+ for (const agent of Object.values(completed.reviewers)) {
1268
+ if (agent.status === "pending")
1269
+ agent.status = "completed";
1270
+ }
1271
+ await this.persist(completed);
1272
+ await this.notify(completed, [
1273
+ `Finished triage for ${issueMarkdownLink(completed)}.`,
1274
+ "",
1275
+ result.report,
1276
+ ].join("\n"), { reply: true });
1277
+ this.active.delete(input.runId);
1278
+ this.controllers.delete(input.runId);
1279
+ }
1176
1280
  async applyReviewProgress(runId, progress) {
1177
1281
  const state = this.active.get(runId);
1178
1282
  if (!state)
@@ -1556,6 +1660,7 @@ export class MagiRunManager {
1556
1660
  : await this.listStates(input.outputDir);
1557
1661
  return states
1558
1662
  .filter((state) => input.command == null || state.command === input.command)
1663
+ .filter((state) => input.issue == null || state.issue === input.issue)
1559
1664
  .filter((state) => input.pr == null || state.pr === input.pr)
1560
1665
  .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1561
1666
  }
@@ -1569,6 +1674,8 @@ export class MagiRunManager {
1569
1674
  return input.runId;
1570
1675
  if (input.pr != null)
1571
1676
  return `PR #${input.pr}`;
1677
+ if (input.issue != null)
1678
+ return `issue #${input.issue}`;
1572
1679
  return "all runs";
1573
1680
  }
1574
1681
  absoluteOutputDir(dir) {