opencode-magi 0.0.0-dev-20260519144738 → 0.0.0-dev-20260520030110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -149,6 +149,8 @@ Run commands from OpenCode.
149
149
  /magi:review --dry-run 123
150
150
  /magi:merge 123
151
151
  /magi:merge --dry-run 123
152
+ /magi:triage 47 48
153
+ /magi:triage --dry-run 47
152
154
  /magi:clear
153
155
  ```
154
156
 
package/dist/commands.js CHANGED
@@ -7,6 +7,10 @@ export const MAGI_COMMANDS = {
7
7
  description: "Review and merge pull requests with Magi",
8
8
  template: [`Call the \`magi_merge\` tool.`, "PR: $ARGUMENTS"].join("\n"),
9
9
  },
10
+ "magi:triage": {
11
+ description: "Triage GitHub issues with Magi",
12
+ template: [`Call the \`magi_triage\` tool.`, "Issue: $ARGUMENTS"].join("\n"),
13
+ },
10
14
  "magi:review": {
11
15
  description: "Review pull requests with Magi",
12
16
  template: [`Call the \`magi_review\` tool.`, "PR: $ARGUMENTS"].join("\n"),
@@ -1,16 +1,25 @@
1
1
  import { isAbsolute, join } from "node:path";
2
2
  const DEFAULT_OUTPUT_DIRS = {
3
+ issue: ".magi/runs/issue",
3
4
  pr: ".magi/runs/pr",
4
5
  };
5
6
  function resolvePath(directory, path) {
6
7
  return isAbsolute(path) ? path : join(directory, path);
7
8
  }
8
9
  export function outputBaseDir(directory, config, kind) {
9
- return resolvePath(directory, config.review?.output ?? DEFAULT_OUTPUT_DIRS[kind]);
10
+ return resolvePath(directory, kind === "issue"
11
+ ? (config.triage?.output ?? DEFAULT_OUTPUT_DIRS[kind])
12
+ : (config.review?.output ?? DEFAULT_OUTPUT_DIRS[kind]));
10
13
  }
11
14
  export function outputBaseDirs(directory, config) {
12
- return [outputBaseDir(directory, config, "pr")];
15
+ return [
16
+ outputBaseDir(directory, config, "pr"),
17
+ outputBaseDir(directory, config, "issue"),
18
+ ];
13
19
  }
14
20
  export function prRunOutputDir(input) {
15
21
  return join(outputBaseDir(input.directory, input.config, "pr"), String(input.pr), ...(input.runId ? [input.runId] : []));
16
22
  }
23
+ export function issueRunOutputDir(input) {
24
+ return join(outputBaseDir(input.directory, input.config, "issue"), String(input.issue), ...(input.runId ? [input.runId] : []));
25
+ }
@@ -7,6 +7,9 @@ const DEFAULT_EDITOR_PERMISSION = mergePermissions(DEFAULT_COMMON_PERMISSION, ed
7
7
  export function reviewerKey(reviewer, index) {
8
8
  return reviewer.id ?? `reviewer-${index + 1}`;
9
9
  }
10
+ export function triageAgentKey(agent, index) {
11
+ return agent.id ?? `triage-${index + 1}`;
12
+ }
10
13
  export function validateReviewerId(id) {
11
14
  return ID_PATTERN.test(id);
12
15
  }
@@ -49,9 +52,16 @@ export function resolveReviewerPermission(agents, reviewer) {
49
52
  export function resolveEditorPermission(agents, editor) {
50
53
  return mergePermissions(mergePermissions(DEFAULT_EDITOR_PERMISSION, agents.permissions), editor.permissions);
51
54
  }
55
+ export function resolveTriageAgentPermission(agents, agent) {
56
+ return mergePermissions(mergePermissions(DEFAULT_REVIEWER_PERMISSION, agents.permissions), agent.permissions);
57
+ }
58
+ export function resolveTriageCreatorPermission(agents, creator) {
59
+ return mergePermissions(mergePermissions(DEFAULT_EDITOR_PERMISSION, agents.permissions), creator.permissions);
60
+ }
52
61
  export function resolveAgents(config) {
53
62
  const agents = config.agents ?? {};
54
63
  const editor = config.merge?.editor;
64
+ const creator = config.triage?.creator;
55
65
  return {
56
66
  editor: editor
57
67
  ? {
@@ -65,6 +75,19 @@ export function resolveAgents(config) {
65
75
  index,
66
76
  permission: resolveReviewerPermission(agents, reviewer),
67
77
  })),
78
+ triage: (config.triage?.agents ?? []).map((agent, index) => ({
79
+ ...agent,
80
+ key: triageAgentKey(agent, index),
81
+ index,
82
+ permission: resolveTriageAgentPermission(agents, agent),
83
+ })),
84
+ triageCreator: creator
85
+ ? {
86
+ ...creator,
87
+ account: creator.account ?? config.triage?.account ?? "",
88
+ permission: resolveTriageCreatorPermission(agents, creator),
89
+ }
90
+ : undefined,
68
91
  };
69
92
  }
70
93
  export function resolveRepository(config) {
@@ -127,5 +150,41 @@ export function resolveRepository(config) {
127
150
  maxChangedFiles: config.review?.safety?.maxChangedFiles,
128
151
  requiredLabels: config.review?.safety?.requiredLabels ?? [],
129
152
  },
153
+ triage: {
154
+ account: config.triage?.account,
155
+ automation: {
156
+ clear: config.triage?.automation?.clear ?? ["triage"],
157
+ close: config.triage?.automation?.close ?? false,
158
+ pr: config.triage?.automation?.pr ?? false,
159
+ },
160
+ concurrency: {
161
+ runs: config.triage?.concurrency?.runs ?? 3,
162
+ },
163
+ kind: {
164
+ bug: {
165
+ label: config.triage?.kind?.bug?.label ?? ["bug"],
166
+ type: config.triage?.kind?.bug?.type ?? ["Bug"],
167
+ },
168
+ feature: {
169
+ label: config.triage?.kind?.feature?.label ?? ["enhancement"],
170
+ type: config.triage?.kind?.feature?.type ?? ["Feature"],
171
+ },
172
+ },
173
+ output: config.triage?.output,
174
+ prompts: config.triage?.prompts ?? {},
175
+ safety: {
176
+ allowAuthors: config.triage?.safety?.allowAuthors ?? [],
177
+ allowMentionActors: config.triage?.safety?.allowMentionActors ?? [],
178
+ allowMentionRoles: config.triage?.safety?.allowMentionRoles ?? [
179
+ "AUTHOR",
180
+ "OWNER",
181
+ "MEMBER",
182
+ "COLLABORATOR",
183
+ ],
184
+ blockedLabels: config.triage?.safety?.blockedLabels ?? [],
185
+ requiredLabels: config.triage?.safety?.requiredLabels ?? ["triage"],
186
+ },
187
+ worktree: config.triage?.worktree,
188
+ },
130
189
  };
131
190
  }
@@ -18,6 +18,7 @@ const CONFIG_KEYS = new Set([
18
18
  "merge",
19
19
  "output",
20
20
  "review",
21
+ "triage",
21
22
  ]);
22
23
  const AGENTS_KEYS = new Set(["permissions"]);
23
24
  const REVIEWER_KEYS = new Set([
@@ -36,6 +37,21 @@ const EDITOR_KEYS = new Set([
36
37
  "permissions",
37
38
  "persona",
38
39
  ]);
40
+ const TRIAGE_AGENT_KEYS = new Set([
41
+ "id",
42
+ "model",
43
+ "options",
44
+ "permissions",
45
+ "persona",
46
+ ]);
47
+ const TRIAGE_CREATOR_KEYS = new Set([
48
+ "account",
49
+ "author",
50
+ "model",
51
+ "options",
52
+ "permissions",
53
+ "persona",
54
+ ]);
39
55
  const AUTHOR_KEYS = new Set(["email", "name"]);
40
56
  const GITHUB_KEYS = new Set(["apiRetryAttempts", "host", "owner", "repo"]);
41
57
  const REVIEW_KEYS = new Set([
@@ -56,6 +72,18 @@ const MERGE_KEYS = new Set([
56
72
  "maxThreadResolutionCycles",
57
73
  "prompts",
58
74
  ]);
75
+ const TRIAGE_KEYS = new Set([
76
+ "account",
77
+ "agents",
78
+ "automation",
79
+ "concurrency",
80
+ "creator",
81
+ "kind",
82
+ "output",
83
+ "prompts",
84
+ "safety",
85
+ "worktree",
86
+ ]);
59
87
  const REVIEW_MERGE_KEYS = new Set([
60
88
  "approvalPolicy",
61
89
  "auto",
@@ -69,6 +97,17 @@ const AUTOMATION_KEYS = new Set(["close", "merge"]);
69
97
  const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
70
98
  const CONCURRENCY_KEYS = new Set(["reviewers", "runs"]);
71
99
  const OUTPUT_KEYS = new Set(["repairAttempts"]);
100
+ const TRIAGE_AUTOMATION_KEYS = new Set(["clear", "close", "pr"]);
101
+ const TRIAGE_CONCURRENCY_KEYS = new Set(["runs"]);
102
+ const TRIAGE_KIND_KEYS = new Set(["bug", "feature"]);
103
+ const TRIAGE_KIND_RULE_KEYS = new Set(["label", "type"]);
104
+ const TRIAGE_SAFETY_KEYS = new Set([
105
+ "allowAuthors",
106
+ "allowMentionActors",
107
+ "allowMentionRoles",
108
+ "blockedLabels",
109
+ "requiredLabels",
110
+ ]);
72
111
  const SAFETY_KEYS = new Set([
73
112
  "allowAuthors",
74
113
  "blockedPaths",
@@ -88,6 +127,19 @@ const MERGE_PROMPT_KEYS = new Set([
88
127
  "edit",
89
128
  "editGuidelines",
90
129
  ]);
130
+ const TRIAGE_PROMPT_KEYS = new Set([
131
+ "action",
132
+ "bug",
133
+ "comment",
134
+ "commentClassification",
135
+ "createPr",
136
+ "duplicate",
137
+ "existingPr",
138
+ "feature",
139
+ "kind",
140
+ "question",
141
+ "reconsider",
142
+ ]);
91
143
  function githubHost(config) {
92
144
  return config.github?.host ?? "github.com";
93
145
  }
@@ -227,6 +279,41 @@ function validateReviewerList(reviewers, path, errors, catalog) {
227
279
  }
228
280
  });
229
281
  }
282
+ function validateTriageAgentList(agents, path, errors, catalog) {
283
+ if (agents == null)
284
+ return;
285
+ if (!Array.isArray(agents)) {
286
+ errors.push(`${path} must be an array`);
287
+ return;
288
+ }
289
+ if (agents.length < 3)
290
+ errors.push(`${path} must contain at least 3 agents`);
291
+ if (agents.length % 2 === 0)
292
+ errors.push(`${path} must contain an odd number of agents`);
293
+ agents.forEach((agent, index) => {
294
+ if (!agent || typeof agent !== "object") {
295
+ errors.push(`${path}[${index}] must be an object`);
296
+ return;
297
+ }
298
+ validateKnownKeys(agent, `${path}[${index}]`, TRIAGE_AGENT_KEYS, errors);
299
+ if (!agent.model)
300
+ errors.push(`${path}[${index}].model is required`);
301
+ validateString(agent.model, `${path}[${index}].model`, errors);
302
+ validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
303
+ validateString(agent.persona, `${path}[${index}].persona`, errors);
304
+ if (agent.options != null && !isPlainObject(agent.options))
305
+ errors.push(`${path}[${index}].options must be an object`);
306
+ validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
307
+ if (agent.id) {
308
+ if (!validateReviewerId(agent.id)) {
309
+ errors.push(`${path}[${index}].id may contain only letters, numbers, underscores, and hyphens`);
310
+ }
311
+ if (RESERVED_REVIEWER_KEYS.has(agent.id)) {
312
+ errors.push(`${path}[${index}].id is reserved: ${agent.id}`);
313
+ }
314
+ }
315
+ });
316
+ }
230
317
  function validateResolvedReviewers(reviewers, path, errors) {
231
318
  const keys = new Set();
232
319
  const accounts = new Set();
@@ -239,6 +326,14 @@ function validateResolvedReviewers(reviewers, path, errors) {
239
326
  accounts.add(reviewer.account);
240
327
  }
241
328
  }
329
+ function validateResolvedAgentKeys(agents, path, errors) {
330
+ const keys = new Set();
331
+ for (const agent of agents) {
332
+ if (keys.has(agent.key))
333
+ errors.push(`${path} has duplicate agent key: ${agent.key}`);
334
+ keys.add(agent.key);
335
+ }
336
+ }
242
337
  function validateEditor(editor, path, errors, catalog) {
243
338
  if (!editor)
244
339
  return;
@@ -282,6 +377,40 @@ function validateEditor(editor, path, errors, catalog) {
282
377
  }
283
378
  }
284
379
  }
380
+ function validateTriageCreator(creator, path, errors, catalog) {
381
+ if (!creator)
382
+ return;
383
+ if (!isPlainObject(creator)) {
384
+ errors.push(`${path} must be an object`);
385
+ return;
386
+ }
387
+ validateKnownKeys(creator, path, TRIAGE_CREATOR_KEYS, errors);
388
+ if (!creator.model)
389
+ errors.push(`${path}.model is required`);
390
+ validateString(creator.account, `${path}.account`, errors);
391
+ validateString(creator.model, `${path}.model`, errors);
392
+ validateString(creator.persona, `${path}.persona`, errors);
393
+ validateModel(creator.model, `${path}.model`, errors, catalog);
394
+ if (creator.options != null && !isPlainObject(creator.options)) {
395
+ errors.push(`${path}.options must be an object`);
396
+ }
397
+ validatePermissionConfig(creator.permissions, `${path}.permissions`, errors);
398
+ const author = creator.author;
399
+ if (!author || !isPlainObject(author)) {
400
+ if (author != null)
401
+ errors.push(`${path}.author must be an object`);
402
+ errors.push(`${path}.author.name is required`);
403
+ errors.push(`${path}.author.email is required`);
404
+ return;
405
+ }
406
+ validateKnownKeys(author, `${path}.author`, AUTHOR_KEYS, errors);
407
+ if (!author.name)
408
+ errors.push(`${path}.author.name is required`);
409
+ validateString(author.name, `${path}.author.name`, errors);
410
+ if (!author.email)
411
+ errors.push(`${path}.author.email is required`);
412
+ validateString(author.email, `${path}.author.email`, errors);
413
+ }
285
414
  function validateMerge(config, errors, options) {
286
415
  const merge = config.merge;
287
416
  if (options.requireGithub ?? true) {
@@ -407,6 +536,17 @@ function validateStringArray(value, path, errors) {
407
536
  errors.push(`${path}[${index}] must be a string`);
408
537
  });
409
538
  }
539
+ function validateStringArrayObject(value, path, keys, errors) {
540
+ if (value == null)
541
+ return;
542
+ if (!isPlainObject(value)) {
543
+ errors.push(`${path} must be an object`);
544
+ return;
545
+ }
546
+ validateKnownKeys(value, path, keys, errors);
547
+ for (const key of keys)
548
+ validateStringArray(value[key], `${path}.${key}`, errors);
549
+ }
410
550
  function validateSafety(config, errors) {
411
551
  const safety = config.review?.safety;
412
552
  if (safety != null && !isPlainObject(safety)) {
@@ -436,12 +576,65 @@ function validatePromptObject(prompts, path, keys, errors) {
436
576
  errors.push(`${path}.${key} must be a string`);
437
577
  }
438
578
  }
579
+ function validateTriage(config, errors, options) {
580
+ const triage = config.triage;
581
+ if (!triage)
582
+ return;
583
+ if (!isPlainObject(triage)) {
584
+ errors.push("triage must be an object");
585
+ return;
586
+ }
587
+ validateKnownKeys(triage, "triage", TRIAGE_KEYS, errors);
588
+ const automation = triage.automation;
589
+ const concurrency = triage.concurrency;
590
+ const kind = triage.kind;
591
+ const safety = triage.safety;
592
+ if (!triage.account)
593
+ errors.push("triage.account is required");
594
+ validateString(triage.account, "triage.account", errors);
595
+ if (!triage.agents)
596
+ errors.push("triage.agents is required");
597
+ validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
598
+ if (Array.isArray(triage.agents)) {
599
+ validateResolvedAgentKeys(resolveAgents(config).triage ?? [], "triage.resolvedAgents", errors);
600
+ }
601
+ validateTriageCreator(triage.creator, "triage.creator", errors, options.modelCatalog);
602
+ if (automation?.pr && !triage.creator)
603
+ errors.push("triage.creator is required when triage.automation.pr is true");
604
+ if (automation != null && !isPlainObject(automation)) {
605
+ errors.push("triage.automation must be an object");
606
+ }
607
+ validateKnownKeys(automation, "triage.automation", TRIAGE_AUTOMATION_KEYS, errors);
608
+ validateBoolean(automation?.close, "triage.automation.close", errors);
609
+ validateBoolean(automation?.pr, "triage.automation.pr", errors);
610
+ validateStringArray(automation?.clear, "triage.automation.clear", errors);
611
+ validateKnownKeys(concurrency, "triage.concurrency", TRIAGE_CONCURRENCY_KEYS, errors);
612
+ if (concurrency?.runs != null &&
613
+ (typeof concurrency.runs !== "number" ||
614
+ !Number.isInteger(concurrency.runs) ||
615
+ concurrency.runs < 1)) {
616
+ errors.push("triage.concurrency.runs must be a positive integer");
617
+ }
618
+ validateKnownKeys(kind, "triage.kind", TRIAGE_KIND_KEYS, errors);
619
+ validateStringArrayObject(kind?.bug, "triage.kind.bug", TRIAGE_KIND_RULE_KEYS, errors);
620
+ validateStringArrayObject(kind?.feature, "triage.kind.feature", TRIAGE_KIND_RULE_KEYS, errors);
621
+ validateKnownKeys(safety, "triage.safety", TRIAGE_SAFETY_KEYS, errors);
622
+ validateStringArray(safety?.allowAuthors, "triage.safety.allowAuthors", errors);
623
+ validateStringArray(safety?.allowMentionActors, "triage.safety.allowMentionActors", errors);
624
+ validateStringArray(safety?.allowMentionRoles, "triage.safety.allowMentionRoles", errors);
625
+ validateStringArray(safety?.blockedLabels, "triage.safety.blockedLabels", errors);
626
+ validateStringArray(safety?.requiredLabels, "triage.safety.requiredLabels", errors);
627
+ validateString(triage.output, "triage.output", errors);
628
+ validateString(triage.worktree, "triage.worktree", errors);
629
+ }
439
630
  async function validatePrompts(config, errors, directory) {
440
631
  validatePromptObject(config.review?.prompts, "review.prompts", REVIEW_PROMPT_KEYS, errors);
441
632
  validatePromptObject(config.merge?.prompts, "merge.prompts", MERGE_PROMPT_KEYS, errors);
633
+ validatePromptObject(config.triage?.prompts, "triage.prompts", TRIAGE_PROMPT_KEYS, errors);
442
634
  const promptEntries = [
443
635
  ...Object.entries(config.review?.prompts ?? {}).map(([key, value]) => [`review.prompts.${key}`, value]),
444
636
  ...Object.entries(config.merge?.prompts ?? {}).map(([key, value]) => [`merge.prompts.${key}`, value]),
637
+ ...Object.entries(config.triage?.prompts ?? {}).map(([key, value]) => [`triage.prompts.${key}`, value]),
445
638
  ];
446
639
  await Promise.all(promptEntries.map(async ([path, value]) => {
447
640
  if (typeof value !== "string")
@@ -464,6 +657,10 @@ async function validateAuth(config, exec, errors) {
464
657
  accounts.add(reviewer.account);
465
658
  if (agents.editor)
466
659
  accounts.add(agents.editor.account);
660
+ if (config.triage?.account)
661
+ accounts.add(config.triage.account);
662
+ if (agents.triageCreator?.account)
663
+ accounts.add(agents.triageCreator.account);
467
664
  await Promise.all([...accounts].filter(Boolean).map(async (account) => {
468
665
  try {
469
666
  await exec(`gh auth token${ghHostOption(config)} --user ${JSON.stringify(account)}`);
@@ -493,6 +690,29 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
493
690
  warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
494
691
  }
495
692
  }));
693
+ if (config.triage?.account) {
694
+ try {
695
+ const permissions = await fetchPermissions(config, exec, config.triage.account);
696
+ if (!permissions.pull) {
697
+ errors.push(`GitHub account cannot read repository for issue triage: ${config.triage.account}`);
698
+ }
699
+ }
700
+ catch (error) {
701
+ warnings.push(`Could not validate repository permissions for GitHub account: ${config.triage.account} (${error.message})`);
702
+ }
703
+ }
704
+ if (agents.triageCreator?.account &&
705
+ agents.triageCreator.account !== config.triage?.account) {
706
+ try {
707
+ const permissions = await fetchPermissions(config, exec, agents.triageCreator.account);
708
+ if (!permissions.push) {
709
+ errors.push(`GitHub account cannot push to repository for triage PR creation: ${agents.triageCreator.account}`);
710
+ }
711
+ }
712
+ catch (error) {
713
+ warnings.push(`Could not validate repository permissions for GitHub account: ${agents.triageCreator.account} (${error.message})`);
714
+ }
715
+ }
496
716
  if (!agents.editor)
497
717
  return;
498
718
  try {
@@ -522,10 +742,10 @@ export async function validateConfig(config, options = {}) {
522
742
  validateKnownKeys(config.agents, "agents", AGENTS_KEYS, errors);
523
743
  validatePermissionConfig(config.agents?.permissions, "agents.permissions", errors);
524
744
  }
525
- if (!config.review) {
745
+ if ((options.requireReview ?? true) && !config.review) {
526
746
  errors.push("review is required");
527
747
  }
528
- else {
748
+ else if (config.review) {
529
749
  if (!isPlainObject(config.review)) {
530
750
  errors.push("review must be an object");
531
751
  }
@@ -539,6 +759,9 @@ export async function validateConfig(config, options = {}) {
539
759
  validateResolvedReviewers(resolveAgents(config).reviewers, "review.resolvedAgents", errors);
540
760
  }
541
761
  }
762
+ if (options.requireTriage && !config.triage) {
763
+ errors.push("triage is required");
764
+ }
542
765
  validateMerge(config, errors, options);
543
766
  validateReviewMerge(config, errors);
544
767
  validateAutomation(config, errors);
@@ -546,6 +769,7 @@ export async function validateConfig(config, options = {}) {
546
769
  validateChecks(config, errors);
547
770
  validateConcurrency(config, errors);
548
771
  validateSafety(config, errors);
772
+ validateTriage(config, errors, options);
549
773
  await validatePrompts(config, errors, options.directory);
550
774
  if (config.output != null && !isPlainObject(config.output)) {
551
775
  errors.push("output must be an object");
@@ -1,13 +1,19 @@
1
1
  import { isAbsolute, join } from "node:path";
2
2
  const DEFAULT_WORKTREE_DIRS = {
3
+ issue: ".magi/worktrees/issue",
3
4
  pr: ".magi/worktrees/pr",
4
5
  };
5
6
  function resolvePath(directory, path) {
6
7
  return isAbsolute(path) ? path : join(directory, path);
7
8
  }
8
9
  export function worktreeBaseDir(directory, config, kind) {
9
- return resolvePath(directory, config.review?.worktree ?? DEFAULT_WORKTREE_DIRS[kind]);
10
+ return resolvePath(directory, kind === "issue"
11
+ ? (config.triage?.worktree ?? DEFAULT_WORKTREE_DIRS[kind])
12
+ : (config.review?.worktree ?? DEFAULT_WORKTREE_DIRS[kind]));
10
13
  }
11
14
  export function worktreeBaseDirs(directory, config = {}) {
12
- return [worktreeBaseDir(directory, config, "pr")];
15
+ return [
16
+ worktreeBaseDir(directory, config, "pr"),
17
+ worktreeBaseDir(directory, config, "issue"),
18
+ ];
13
19
  }
@@ -97,6 +97,94 @@ export async function fetchPullRequest(exec, repository, pr) {
97
97
  const json = await exec(`gh pr view ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,isDraft,baseRefOid,headRefOid,baseRefName,headRefName,headRepository,headRepositoryOwner`);
98
98
  return JSON.parse(json);
99
99
  }
100
+ export async function fetchIssue(exec, repository, issue) {
101
+ const raw = await exec(`gh issue view ${issue} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,body,url,state,author,labels,type`);
102
+ const data = JSON.parse(raw);
103
+ return {
104
+ author: data.author?.login ?? "",
105
+ body: data.body ?? "",
106
+ labels: data.labels?.map((label) => label.name) ?? [],
107
+ number: data.number,
108
+ state: data.state,
109
+ title: data.title,
110
+ type: data.type?.name,
111
+ url: data.url,
112
+ };
113
+ }
114
+ export async function fetchIssueComments(exec, repository, issue, limit = 50) {
115
+ const query = `query($owner: String!, $repo: String!, $issue: Int!, $limit: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { comments(last: $limit) { nodes { databaseId author { login } authorAssociation body createdAt url } } } } }`;
116
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F issue=${issue} -F limit=${limit}`);
117
+ const data = JSON.parse(raw);
118
+ return (data.data?.repository?.issue?.comments?.nodes?.map((comment) => ({
119
+ author: comment.author?.login ?? "",
120
+ authorAssociation: comment.authorAssociation,
121
+ body: comment.body ?? "",
122
+ createdAt: comment.createdAt,
123
+ id: comment.databaseId,
124
+ url: comment.url,
125
+ })) ?? []);
126
+ }
127
+ export async function fetchRelatedPullRequests(exec, repository, issue) {
128
+ const query = `query($owner: String!, $repo: String!, $issue: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { timelineItems(first: 50, itemTypes: [CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) { nodes { __typename ... on ConnectedEvent { subject { __typename ... on PullRequest { number title url state mergedAt body author { login } } } } ... on CrossReferencedEvent { source { __typename ... on PullRequest { number title url state mergedAt body author { login } } } } } } } } } }`;
129
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F issue=${issue}`);
130
+ const data = JSON.parse(raw);
131
+ const prs = new Map();
132
+ for (const node of data.data?.repository?.issue?.timelineItems?.nodes ?? []) {
133
+ const source = (node.subject ?? node.source);
134
+ if (!source?.number || !source.url)
135
+ continue;
136
+ const state = source.mergedAt
137
+ ? "MERGED"
138
+ : source.state === "CLOSED"
139
+ ? "CLOSED"
140
+ : "OPEN";
141
+ prs.set(source.number, {
142
+ author: source.author?.login ?? "",
143
+ body: source.body,
144
+ mergedAt: source.mergedAt,
145
+ number: source.number,
146
+ state,
147
+ title: source.title ?? `PR #${source.number}`,
148
+ url: source.url,
149
+ });
150
+ }
151
+ return [...prs.values()];
152
+ }
153
+ export async function searchDuplicateIssues(exec, repository, issue, limit = 5) {
154
+ const query = `${issue.title} repo:${repoSlug(repository)} is:issue -${issue.number}`;
155
+ const raw = await exec(`gh search issues ${shellQuote(query)} --json number,title,url,state,body --limit ${limit}`);
156
+ const data = JSON.parse(raw);
157
+ return data
158
+ .filter((item) => item.number !== issue.number)
159
+ .map((item) => ({
160
+ ...item,
161
+ whyCandidate: "GitHub issue search matched the title.",
162
+ }));
163
+ }
164
+ export async function postIssueComment(exec, repository, issue, account, body) {
165
+ const token = await ghToken(exec, repository, account);
166
+ const payloadPath = join(tmpdir(), `magi-issue-${process.pid}-${Date.now()}.json`);
167
+ await writeFile(payloadPath, JSON.stringify({ body }));
168
+ try {
169
+ 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));
170
+ }
171
+ finally {
172
+ await rm(payloadPath, { force: true });
173
+ }
174
+ }
175
+ export async function closeIssue(exec, repository, issue, account) {
176
+ const token = await ghToken(exec, repository, account);
177
+ return exec(`gh issue close ${issue} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
178
+ }
179
+ export async function removeIssueLabels(exec, repository, issue, labels, account) {
180
+ const token = await ghToken(exec, repository, account);
181
+ const removed = [];
182
+ for (const label of labels) {
183
+ await exec(`gh issue edit ${issue} --repo ${shellQuote(repoSpecifier(repository))} --remove-label ${shellQuote(label)}`, ghTokenEnv(token));
184
+ removed.push(label);
185
+ }
186
+ return removed;
187
+ }
100
188
  export async function fetchPullRequestReviews(exec, repository, pr) {
101
189
  const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviews(first: 100) { nodes { author { login } submittedAt state body commit { oid } } } } } }`;
102
190
  const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}`);
@@ -366,6 +454,11 @@ export async function pushHead(exec, repository, worktreePath, account, head) {
366
454
  },
367
455
  });
368
456
  }
457
+ export async function createPullRequest(exec, repository, account, input) {
458
+ const token = await ghToken(exec, repository, account);
459
+ const baseFlag = input.base ? ` --base ${shellQuote(input.base)}` : "";
460
+ return exec(`gh pr create --repo ${shellQuote(repoSpecifier(repository))} --head ${shellQuote(input.head)}${baseFlag} --title ${shellQuote(input.title)} --body ${shellQuote(input.body)}`, ghTokenEnv(token));
461
+ }
369
462
  export async function configureGitIdentity(exec, worktreePath, identity) {
370
463
  if (identity.name) {
371
464
  await exec(`git config --worktree user.name ${shellQuote(identity.name)}`, {