opencode-magi 0.5.0 → 0.6.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.
@@ -28,7 +28,7 @@ export function reviewerKey(reviewer, index) {
28
28
  return reviewer.id ?? `reviewer-${index + 1}`;
29
29
  }
30
30
  export function triageAgentKey(agent, index) {
31
- return agent.id ?? `triage-${index + 1}`;
31
+ return agent.id ?? `voter-${index + 1}`;
32
32
  }
33
33
  export function validateReviewerId(id) {
34
34
  return ID_PATTERN.test(id);
@@ -104,7 +104,7 @@ export function resolveAgents(config) {
104
104
  triageCreator: creator
105
105
  ? {
106
106
  ...creator,
107
- account: creator.account ?? config.triage?.account ?? "",
107
+ account: creator.account ?? "",
108
108
  permission: resolveTriageCreatorPermission(agents, creator),
109
109
  }
110
110
  : undefined,
@@ -179,7 +179,6 @@ export function resolveRepository(config) {
179
179
  requiredLabels: config.review?.safety?.requiredLabels ?? [],
180
180
  },
181
181
  triage: {
182
- account: config.triage?.account,
183
182
  automation: {
184
183
  clear: config.triage?.automation?.clear ?? ["triage"],
185
184
  close: config.triage?.automation?.close ?? false,
@@ -193,6 +192,7 @@ export function resolveRepository(config) {
193
192
  },
194
193
  output: config.triage?.output,
195
194
  prompts: config.triage?.prompts ?? {},
195
+ reporter: config.triage?.reporter,
196
196
  safety: {
197
197
  allowAuthors: config.triage?.safety?.allowAuthors ?? [],
198
198
  allowMentionActors: config.triage?.safety?.allowMentionActors ?? [],
@@ -40,6 +40,7 @@ const EDITOR_KEYS = new Set([
40
40
  "persona",
41
41
  ]);
42
42
  const TRIAGE_AGENT_KEYS = new Set([
43
+ "account",
43
44
  "id",
44
45
  "model",
45
46
  "options",
@@ -75,7 +76,6 @@ const MERGE_KEYS = new Set([
75
76
  "prompts",
76
77
  ]);
77
78
  const TRIAGE_KEYS = new Set([
78
- "account",
79
79
  "agents",
80
80
  "automation",
81
81
  "categories",
@@ -83,6 +83,7 @@ const TRIAGE_KEYS = new Set([
83
83
  "creator",
84
84
  "output",
85
85
  "prompts",
86
+ "reporter",
86
87
  "safety",
87
88
  "worktree",
88
89
  ]);
@@ -135,7 +136,6 @@ const MERGE_PROMPT_KEYS = new Set([
135
136
  "editGuidelines",
136
137
  ]);
137
138
  const TRIAGE_PROMPT_KEYS = new Set([
138
- "action",
139
139
  "acceptance",
140
140
  "category",
141
141
  "comment",
@@ -357,6 +357,9 @@ function validateTriageAgentList(agents, path, errors, catalog) {
357
357
  errors.push(`${path}[${index}].model is required`);
358
358
  validateString(agent.model, `${path}[${index}].model`, errors);
359
359
  validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
360
+ if (!agent.account)
361
+ errors.push(`${path}[${index}].account is required`);
362
+ validateString(agent.account, `${path}[${index}].account`, errors);
360
363
  validateString(agent.persona, `${path}[${index}].persona`, errors);
361
364
  if (agent.options != null && !isPlainObject(agent.options))
362
365
  errors.push(`${path}[${index}].options must be an object`);
@@ -383,12 +386,16 @@ function validateResolvedReviewers(reviewers, path, errors) {
383
386
  accounts.add(reviewer.account);
384
387
  }
385
388
  }
386
- function validateResolvedAgentKeys(agents, path, errors) {
389
+ function validateResolvedTriageAgents(agents, path, errors) {
387
390
  const keys = new Set();
391
+ const accounts = new Set();
388
392
  for (const agent of agents) {
389
393
  if (keys.has(agent.key))
390
394
  errors.push(`${path} has duplicate agent key: ${agent.key}`);
391
395
  keys.add(agent.key);
396
+ if (accounts.has(agent.account))
397
+ errors.push(`${path} has duplicate agent account: ${agent.account}`);
398
+ accounts.add(agent.account);
392
399
  }
393
400
  }
394
401
  function validateEditor(editor, path, errors, catalog) {
@@ -672,19 +679,26 @@ function validateTriage(config, errors, options) {
672
679
  validateKnownKeys(triage, "triage", TRIAGE_KEYS, errors);
673
680
  const automation = triage.automation;
674
681
  const concurrency = triage.concurrency;
682
+ const creator = triage.creator;
683
+ const reporter = typeof triage.reporter === "string" ? triage.reporter : undefined;
675
684
  const safety = triage.safety;
676
- if (!triage.account)
677
- errors.push("triage.account is required");
678
- validateString(triage.account, "triage.account", errors);
679
685
  if (!triage.agents)
680
686
  errors.push("triage.agents is required");
681
687
  validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
682
688
  if (Array.isArray(triage.agents)) {
683
- validateResolvedAgentKeys(resolveAgents(config).triage ?? [], "triage.resolvedAgents", errors);
689
+ const resolvedTriageAgents = resolveAgents(config).triage ?? [];
690
+ validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
691
+ if (reporter != null &&
692
+ !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
693
+ errors.push(`triage.reporter must match a triage agent key: ${reporter}`);
694
+ }
684
695
  }
685
- validateTriageCreator(triage.creator, "triage.creator", errors, options.modelCatalog);
686
- if (automation?.create && !triage.creator)
696
+ validateString(triage.reporter, "triage.reporter", errors);
697
+ validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog);
698
+ if (automation?.create && !creator)
687
699
  errors.push("triage.creator is required when triage.automation.create is true");
700
+ if (automation?.create && creator && !creator.account)
701
+ errors.push("triage.creator.account is required when triage.automation.create is true");
688
702
  if (automation != null && !isPlainObject(automation)) {
689
703
  errors.push("triage.automation must be an object");
690
704
  }
@@ -745,10 +759,10 @@ async function validateAuth(config, exec, errors) {
745
759
  const agents = resolveAgents(config);
746
760
  for (const reviewer of agents.reviewers)
747
761
  accounts.add(reviewer.account);
762
+ for (const agent of agents.triage ?? [])
763
+ accounts.add(agent.account);
748
764
  if (agents.editor)
749
765
  accounts.add(agents.editor.account);
750
- if (config.triage?.account)
751
- accounts.add(config.triage.account);
752
766
  if (agents.triageCreator?.account)
753
767
  accounts.add(agents.triageCreator.account);
754
768
  await Promise.all([...accounts].filter(Boolean).map(async (account) => {
@@ -802,19 +816,18 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
802
816
  warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
803
817
  }
804
818
  }));
805
- if (config.triage?.account) {
819
+ await Promise.all((agents.triage ?? []).map(async (agent) => {
806
820
  try {
807
- const permissions = await fetchPermissions(config, exec, config.triage.account);
821
+ const permissions = await fetchPermissions(config, exec, agent.account);
808
822
  if (!permissions.pull) {
809
- errors.push(`GitHub account cannot read repository for issue triage: ${config.triage.account}`);
823
+ errors.push(`GitHub account cannot read repository for issue triage: ${agent.account}`);
810
824
  }
811
825
  }
812
826
  catch (error) {
813
- warnings.push(`Could not validate repository permissions for GitHub account: ${config.triage.account} (${error.message})`);
827
+ warnings.push(`Could not validate repository permissions for GitHub account: ${agent.account} (${error.message})`);
814
828
  }
815
- }
816
- if (agents.triageCreator?.account &&
817
- agents.triageCreator.account !== config.triage?.account) {
829
+ }));
830
+ if (agents.triageCreator?.account) {
818
831
  try {
819
832
  const permissions = await fetchPermissions(config, exec, agents.triageCreator.account);
820
833
  if (!permissions.push) {
@@ -17,3 +17,9 @@ export function worktreeBaseDirs(directory, config = {}) {
17
17
  worktreeBaseDir(directory, config, "issue"),
18
18
  ];
19
19
  }
20
+ export function prRunWorktreeDir(input) {
21
+ return join(worktreeBaseDir(input.directory, input.config, "pr"), String(input.pr), input.runId);
22
+ }
23
+ export function issueRunWorktreeDir(input) {
24
+ return join(worktreeBaseDir(input.directory, input.config, "issue"), String(input.issue), input.runId);
25
+ }
@@ -20,6 +20,68 @@ function errorText(error) {
20
20
  .filter((item) => typeof item === "string")
21
21
  .join("\n");
22
22
  }
23
+ async function localCommitExists(exec, worktreePath, sha) {
24
+ try {
25
+ await exec(`git cat-file -e ${shellQuote(`${sha}^{commit}`)}`, {
26
+ cwd: worktreePath,
27
+ });
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function pullRequestCommitSource(input) {
35
+ if (input.source === "base") {
36
+ return {
37
+ owner: input.repository.github.owner,
38
+ refName: input.meta.baseRefName,
39
+ repo: input.repository.github.repo,
40
+ };
41
+ }
42
+ return {
43
+ owner: input.meta.headRepositoryOwner?.login ?? input.repository.github.owner,
44
+ refName: input.meta.headRefName,
45
+ repo: input.meta.headRepository?.name ?? input.repository.github.repo,
46
+ };
47
+ }
48
+ async function fetchPullRequestCommitSource(input) {
49
+ const commitSource = pullRequestCommitSource(input);
50
+ try {
51
+ await input.exec(`git fetch --no-tags ${shellQuote(repositoryGitUrl(input.repository, commitSource.owner, commitSource.repo))} ${shellQuote(`refs/heads/${commitSource.refName}`)}`, { cwd: input.worktreePath });
52
+ }
53
+ catch (error) {
54
+ throw new Error(`Could not fetch ${input.source} ref ${commitSource.refName} for #${input.meta.number}: ${errorText(error)}`);
55
+ }
56
+ }
57
+ export async function ensurePullRequestCommits(input) {
58
+ const missing = [];
59
+ for (const commit of input.commits) {
60
+ if (!(await localCommitExists(input.exec, input.worktreePath, commit.sha))) {
61
+ missing.push(commit);
62
+ }
63
+ }
64
+ for (const source of new Set(missing.map((commit) => commit.source))) {
65
+ await fetchPullRequestCommitSource({
66
+ exec: input.exec,
67
+ meta: input.meta,
68
+ repository: input.repository,
69
+ source,
70
+ worktreePath: input.worktreePath,
71
+ });
72
+ }
73
+ for (const commit of missing) {
74
+ if (await localCommitExists(input.exec, input.worktreePath, commit.sha)) {
75
+ continue;
76
+ }
77
+ const source = pullRequestCommitSource({
78
+ meta: input.meta,
79
+ repository: input.repository,
80
+ source: commit.source,
81
+ });
82
+ throw new Error(`${commit.label} commit ${commit.sha} is unavailable after fetching ${commit.source} ref ${source.refName}`);
83
+ }
84
+ }
23
85
  function isCheckoutConfigLockError(error) {
24
86
  const text = errorText(error);
25
87
  return (/could not lock config file/i.test(text) ||
@@ -330,16 +392,43 @@ export async function removeIssueLabels(exec, repository, issue, labels, account
330
392
  return removed;
331
393
  }
332
394
  export async function fetchPullRequestReviews(exec, repository, pr) {
333
- 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 } } } } } }`;
334
- 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}`);
335
- const data = JSON.parse(raw);
336
- return data.data.repository.pullRequest.reviews.nodes;
395
+ const query = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviews(first: 100, after: $cursor) { nodes { author { login } submittedAt state body commit { oid } comments(first: 100) { nodes { body path line startLine } } } pageInfo { hasNextPage endCursor } } } } }`;
396
+ const reviews = [];
397
+ let cursor;
398
+ for (;;) {
399
+ const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
400
+ 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}${cursorFlag}`);
401
+ const data = JSON.parse(raw);
402
+ const connection = data.data.repository.pullRequest.reviews;
403
+ reviews.push(...connection.nodes);
404
+ if (!connection.pageInfo?.hasNextPage)
405
+ break;
406
+ cursor = connection.pageInfo.endCursor;
407
+ if (!cursor)
408
+ throw new Error("GitHub reviews page was truncated");
409
+ }
410
+ return reviews.map((review) => ({
411
+ ...review,
412
+ comments: review.comments?.nodes ?? [],
413
+ }));
337
414
  }
338
415
  export async function fetchPullRequestCommits(exec, repository, pr) {
339
- const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { commits(first: 100) { nodes { commit { oid committedDate parents { totalCount } } } } } } }`;
340
- 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}`);
341
- const data = JSON.parse(raw);
342
- return data.data.repository.pullRequest.commits.nodes.map(({ commit }) => ({
416
+ const query = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { commits(first: 100, after: $cursor) { nodes { commit { oid committedDate parents { totalCount } } } pageInfo { hasNextPage endCursor } } } } }`;
417
+ const commits = [];
418
+ let cursor;
419
+ for (;;) {
420
+ const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
421
+ 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}${cursorFlag}`);
422
+ const data = JSON.parse(raw);
423
+ const connection = data.data.repository.pullRequest.commits;
424
+ commits.push(...connection.nodes);
425
+ if (!connection.pageInfo?.hasNextPage)
426
+ break;
427
+ cursor = connection.pageInfo.endCursor;
428
+ if (!cursor)
429
+ throw new Error("GitHub commits page was truncated");
430
+ }
431
+ return commits.map(({ commit }) => ({
343
432
  committedDate: commit.committedDate,
344
433
  oid: commit.oid,
345
434
  parentCount: commit.parents.totalCount,
@@ -369,38 +458,9 @@ export async function fetchPullRequestSafetyMeta(exec, repository, pr) {
369
458
  }
370
459
  return { author, changedFiles, files, labels };
371
460
  }
372
- export async function waitForChecks(exec, repository, pr, enabled = repository.checks.waitBeforeReview) {
373
- if (!enabled)
374
- return undefined;
375
- const report = {
376
- attempts: 0,
377
- excluded: [],
378
- failed: [],
379
- rerun: [],
380
- scopeInside: [],
381
- scopeOutsideRecovered: [],
382
- scopeOutsideUnresolved: [],
383
- };
384
- try {
385
- await watchChecks(exec, repository, pr);
386
- return report;
387
- }
388
- catch {
389
- report.failed = applyCheckExclusions({
390
- checks: await fetchFailedChecks(exec, repository, pr),
391
- excluded: report.excluded,
392
- patterns: repository.checks.exclude,
393
- });
394
- return report;
395
- }
396
- }
397
461
  export async function watchChecks(exec, repository, pr) {
398
462
  await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --watch`);
399
463
  }
400
- export async function fetchFailedChecks(exec, repository, pr) {
401
- const checks = await fetchPullRequestChecks(exec, repository, pr);
402
- return checks.filter((check) => isFailedCheck(check) || isCancelledCheck(check));
403
- }
404
464
  export function isCancelledCheck(check) {
405
465
  return check.bucket === "cancel" || check.state === "CANCELLED";
406
466
  }
@@ -472,9 +532,8 @@ export async function fetchMergeQueueRequirement(exec, repository, branch) {
472
532
  const rules = JSON.parse(raw);
473
533
  return rules.some((rule) => rule.type === "merge_queue");
474
534
  }
475
- export async function createWorktree(exec, repository, pr, root) {
476
- const worktreePath = join(root, `pr-${pr}`);
477
- const lockKey = `${repoSpecifier(repository)}:${root}`;
535
+ export async function createWorktree(exec, repository, pr, worktreePath) {
536
+ const lockKey = `${repoSpecifier(repository)}:${dirname(dirname(worktreePath))}`;
478
537
  return withWorktreeCreateLock(lockKey, async () => {
479
538
  let worktreeAdded = false;
480
539
  try {
@@ -591,6 +650,17 @@ export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_00
591
650
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
592
651
  }
593
652
  }
653
+ export async function waitForAutoMerge(exec, repository, pr, intervalMs = 30_000) {
654
+ for (;;) {
655
+ const status = await fetchPullRequestMergeStatus(exec, repository, pr);
656
+ if (status.state === "MERGED")
657
+ return "merged";
658
+ if (status.state !== "OPEN" || status.autoMergeRequest == null) {
659
+ return "dequeued";
660
+ }
661
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
662
+ }
663
+ }
594
664
  export async function closePullRequest(exec, repository, pr, account) {
595
665
  const token = await ghToken(exec, repository, account);
596
666
  return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
@@ -629,11 +699,49 @@ export async function configureGitIdentity(exec, worktreePath, identity) {
629
699
  }
630
700
  }
631
701
  export async function fetchUnresolvedThreads(exec, repository, pr, author) {
632
- const query = `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100) { nodes { id isResolved comments(first: 50) { nodes { databaseId author { login } path line body createdAt } } } } } } }`;
633
- 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}`);
634
- const data = JSON.parse(raw);
635
- const threads = data.data.repository.pullRequest.reviewThreads
636
- .nodes;
702
+ const threadQuery = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100, after: $cursor) { nodes { id isResolved comments(first: 100) { nodes { databaseId author { login } path line body createdAt } pageInfo { hasNextPage endCursor } } } pageInfo { hasNextPage endCursor } } } } }`;
703
+ const commentQuery = `query($threadId: ID!, $cursor: String) { node(id: $threadId) { ... on PullRequestReviewThread { comments(first: 100, after: $cursor) { nodes { databaseId author { login } path line body createdAt } pageInfo { hasNextPage endCursor } } } } }`;
704
+ const threads = [];
705
+ let cursor;
706
+ async function fetchRemainingComments(threadId, initialCursor) {
707
+ const comments = [];
708
+ let commentsCursor = initialCursor;
709
+ for (;;) {
710
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(commentQuery)} -F threadId=${shellQuote(threadId)} -F cursor=${shellQuote(commentsCursor)}`);
711
+ const data = JSON.parse(raw);
712
+ const connection = data.data.node?.comments;
713
+ if (!connection)
714
+ throw new Error("GitHub review thread comments were missing");
715
+ comments.push(...connection.nodes);
716
+ if (!connection.pageInfo?.hasNextPage)
717
+ break;
718
+ const nextCursor = connection.pageInfo.endCursor;
719
+ if (!nextCursor)
720
+ throw new Error("GitHub review thread comments page was truncated");
721
+ commentsCursor = nextCursor;
722
+ }
723
+ return comments;
724
+ }
725
+ for (;;) {
726
+ const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
727
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(threadQuery)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}${cursorFlag}`);
728
+ const data = JSON.parse(raw);
729
+ const connection = data.data.repository.pullRequest.reviewThreads;
730
+ for (const thread of connection.nodes) {
731
+ const commentsCursor = thread.comments.pageInfo?.endCursor;
732
+ if (thread.comments.pageInfo?.hasNextPage) {
733
+ if (!commentsCursor)
734
+ throw new Error("GitHub review thread comments page was truncated");
735
+ thread.comments.nodes.push(...(await fetchRemainingComments(thread.id, commentsCursor)));
736
+ }
737
+ }
738
+ threads.push(...connection.nodes);
739
+ if (!connection.pageInfo?.hasNextPage)
740
+ break;
741
+ cursor = connection.pageInfo.endCursor;
742
+ if (!cursor)
743
+ throw new Error("GitHub review threads page was truncated");
744
+ }
637
745
  return threads.flatMap((thread) => {
638
746
  if (thread.isResolved || !thread.comments.nodes.length)
639
747
  return [];
package/dist/index.js CHANGED
@@ -96,6 +96,7 @@ export function parseRunArguments(value, dryRun = false, command = "review") {
96
96
  const configOverrides = {};
97
97
  const prTokens = [];
98
98
  let sync = false;
99
+ let timeoutMs;
99
100
  for (let index = 0; index < tokens.length; index++) {
100
101
  const token = tokens[index];
101
102
  if (token === "--dry-run") {
@@ -110,6 +111,11 @@ export function parseRunArguments(value, dryRun = false, command = "review") {
110
111
  case "--language":
111
112
  setConfigOverride(configOverrides, ["language"], nextFlagValue(tokens, ++index, token));
112
113
  break;
114
+ case "--timeout":
115
+ timeoutMs =
116
+ parseIntegerFlag(nextFlagValue(tokens, ++index, token), token, 0) *
117
+ 1_000;
118
+ break;
113
119
  case "--merge":
114
120
  case "--no-merge":
115
121
  setConfigOverride(configOverrides, [command, "automation", "merge"], token === "--merge");
@@ -151,13 +157,20 @@ export function parseRunArguments(value, dryRun = false, command = "review") {
151
157
  prTokens.push(token);
152
158
  }
153
159
  }
154
- return { configOverrides, dryRun, prs: parsePrs(prTokens.join(" ")), sync };
160
+ return {
161
+ configOverrides,
162
+ dryRun,
163
+ prs: parsePrs(prTokens.join(" ")),
164
+ sync,
165
+ timeoutMs,
166
+ };
155
167
  }
156
168
  export function parseIssueRunArguments(value, dryRun = false) {
157
169
  const tokens = value.split(/[\s,]+/).filter(Boolean);
158
170
  const configOverrides = {};
159
171
  const issueTokens = [];
160
172
  let sync = false;
173
+ let timeoutMs;
161
174
  for (let index = 0; index < tokens.length; index++) {
162
175
  const token = tokens[index];
163
176
  if (token === "--dry-run") {
@@ -172,6 +185,11 @@ export function parseIssueRunArguments(value, dryRun = false) {
172
185
  case "--language":
173
186
  setConfigOverride(configOverrides, ["language"], nextFlagValue(tokens, ++index, token));
174
187
  break;
188
+ case "--timeout":
189
+ timeoutMs =
190
+ parseIntegerFlag(nextFlagValue(tokens, ++index, token), token, 0) *
191
+ 1_000;
192
+ break;
175
193
  case "--close":
176
194
  case "--no-close":
177
195
  setConfigOverride(configOverrides, ["triage", "automation", "close"], token === "--close");
@@ -210,6 +228,7 @@ export function parseIssueRunArguments(value, dryRun = false) {
210
228
  dryRun,
211
229
  issues: parseIssues(issueTokens.join(" ")),
212
230
  sync,
231
+ timeoutMs,
213
232
  };
214
233
  }
215
234
  function nextFlagValue(tokens, index, flag) {
@@ -306,6 +325,10 @@ function prMarkdownLink(repository, pr) {
306
325
  const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/pull/${pr}`;
307
326
  return `[#${pr}](${url})`;
308
327
  }
328
+ export function formatRunStartMessage(command, repository, pr) {
329
+ const action = command === "merge" ? "merge flow" : "reviewing";
330
+ return `Started ${action} ${prMarkdownLink(repository, pr)}.`;
331
+ }
309
332
  function issueMarkdownLink(repository, issue) {
310
333
  const host = repository.github.host || "github.com";
311
334
  const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/issues/${issue}`;
@@ -482,11 +505,12 @@ export const MagiPlugin = async ({ client, directory }) => {
482
505
  parentSessionId: context.sessionID,
483
506
  signal: context.abort,
484
507
  sync,
508
+ timeoutMs: parsed.timeoutMs,
485
509
  }), { signal: context.abort });
486
510
  if (sync)
487
511
  return syncResult(runManager, states);
488
512
  return states
489
- .map((state) => `Started reviewing ${prMarkdownLink(repository, state.pr)}.`)
513
+ .map((state) => formatRunStartMessage("merge", repository, state.pr))
490
514
  .join("\n");
491
515
  },
492
516
  }),
@@ -523,11 +547,12 @@ export const MagiPlugin = async ({ client, directory }) => {
523
547
  parentSessionId: context.sessionID,
524
548
  signal: context.abort,
525
549
  sync,
550
+ timeoutMs: parsed.timeoutMs,
526
551
  }), { signal: context.abort });
527
552
  if (sync)
528
553
  return syncResult(runManager, states);
529
554
  return states
530
- .map((state) => `Started reviewing ${prMarkdownLink(repository, state.pr)}.`)
555
+ .map((state) => formatRunStartMessage("review", repository, state.pr))
531
556
  .join("\n");
532
557
  },
533
558
  }),
@@ -567,6 +592,7 @@ export const MagiPlugin = async ({ client, directory }) => {
567
592
  repository,
568
593
  signal: context.abort,
569
594
  sync,
595
+ timeoutMs: parsed.timeoutMs,
570
596
  }), { signal: context.abort });
571
597
  if (sync)
572
598
  return syncResult(runManager, states);
@@ -176,7 +176,15 @@ function ciFailureContextForClassified(items, classified) {
176
176
  return "";
177
177
  return [
178
178
  "CI has scope-in failures that may be caused by this PR.",
179
- "Use this as a review hint; still inspect the PR diff before reporting findings.",
179
+ "Treat these failures as blocking review issues until the checks pass.",
180
+ [
181
+ "Do not approve this PR while this ci_failure_context is present.",
182
+ "Return CHANGES_REQUESTED and include a finding for each failing CI check.",
183
+ ].join(" "),
184
+ [
185
+ "Still inspect the PR diff before reporting findings.",
186
+ "If a CI failure does not map to an exact changed line, anchor the finding to the nearest responsible or first relevant changed line.",
187
+ ].join(" "),
180
188
  "",
181
189
  ...sections,
182
190
  ].join("\n\n");
@@ -343,6 +351,7 @@ async function classifyChecks(input) {
343
351
  }
344
352
  },
345
353
  options: reviewer.options,
354
+ parentSessionId: input.parentSessionId,
346
355
  parse: (text) => {
347
356
  const output = parseCiClassificationOutput(text);
348
357
  for (const check of output.checks) {
@@ -366,18 +375,20 @@ async function classifyChecks(input) {
366
375
  const rawPath = input.outputDir
367
376
  ? join(input.outputDir, `${reviewer.key}.ci-classification.raw.txt`)
368
377
  : undefined;
369
- const check = result.value.checks[0];
378
+ const checks = result.value.checks.map((check) => ({
379
+ classification: check.classification,
380
+ name: check.name,
381
+ reason: check.reason,
382
+ }));
370
383
  if (rawPath)
371
384
  await writeFile(rawPath, result.raw);
372
- run.classification = check?.classification;
385
+ run.checks = checks;
373
386
  run.rawPath = rawPath;
374
- run.reason = check?.reason;
375
387
  run.sessionId = result.sessionId;
376
388
  run.status = "completed";
377
389
  await input.onClassifierProgress?.({
378
- classification: check?.classification ?? "SCOPE_IN",
390
+ checks,
379
391
  rawPath,
380
- reason: check?.reason ?? "No classification reason was provided.",
381
392
  reviewer: reviewer.key,
382
393
  sessionId: result.sessionId,
383
394
  type: "classifier_completed",
@@ -392,22 +403,20 @@ async function classifyChecks(input) {
392
403
  reviewer: reviewer.key,
393
404
  type: "classifier_failed",
394
405
  });
395
- return { reviewer: reviewer.key, output: undefined };
406
+ throw error;
396
407
  }
397
408
  }, { signal: input.signal });
398
409
  const threshold = majorityThreshold(reviewers.length);
399
410
  return {
400
411
  classified: input.checks.map((item) => {
401
- const successfulVotes = votes.filter((vote) => vote.output);
402
- const checkVotes = successfulVotes.map((vote) => {
403
- const check = vote.output?.checks.find((output) => output.name === item.check.name);
412
+ const checkVotes = votes.map((vote) => {
413
+ const check = vote.output.checks.find((output) => output.name === item.check.name);
404
414
  return {
405
415
  classification: check?.classification ?? "SCOPE_IN",
406
416
  reason: check?.reason ?? "Missing classification; treated as scope-in.",
407
417
  reviewer: vote.reviewer,
408
418
  };
409
419
  });
410
- const failures = votes.filter((vote) => !vote.output);
411
420
  const scopeIn = checkVotes.filter((vote) => vote.classification === "SCOPE_IN");
412
421
  const scopeOut = checkVotes.filter((vote) => vote.classification === "SCOPE_OUT");
413
422
  const classification = scopeOut.length >= threshold
@@ -421,9 +430,6 @@ async function classifyChecks(input) {
421
430
  const reasons = checkVotes
422
431
  .filter((vote) => vote.classification === classification)
423
432
  .map((vote) => `${vote.reviewer}: ${vote.reason}`);
424
- for (const failure of failures) {
425
- reasons.push(`${failure.reviewer}: classifier failed; vote ignored`);
426
- }
427
433
  return {
428
434
  check: item.check,
429
435
  classification,
@@ -505,6 +511,7 @@ export async function waitForChecksWithClassification(input) {
505
511
  directory: input.directory,
506
512
  onClassifierProgress: input.onClassifierProgress,
507
513
  outputDir: input.outputDir,
514
+ parentSessionId: input.parentSessionId,
508
515
  pr: input.pr,
509
516
  repairAttempts: input.repairAttempts,
510
517
  repository: input.repository,