opencode-magi 0.5.0 → 0.6.1

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,16 +136,13 @@ 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
- "comment",
142
141
  "commentClassification",
143
142
  "create",
144
143
  "createGuidelines",
145
144
  "duplicate",
146
145
  "existingPr",
147
- "question",
148
146
  "reconsider",
149
147
  ]);
150
148
  function githubHost(config) {
@@ -357,6 +355,9 @@ function validateTriageAgentList(agents, path, errors, catalog) {
357
355
  errors.push(`${path}[${index}].model is required`);
358
356
  validateString(agent.model, `${path}[${index}].model`, errors);
359
357
  validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
358
+ if (!agent.account)
359
+ errors.push(`${path}[${index}].account is required`);
360
+ validateString(agent.account, `${path}[${index}].account`, errors);
360
361
  validateString(agent.persona, `${path}[${index}].persona`, errors);
361
362
  if (agent.options != null && !isPlainObject(agent.options))
362
363
  errors.push(`${path}[${index}].options must be an object`);
@@ -383,12 +384,16 @@ function validateResolvedReviewers(reviewers, path, errors) {
383
384
  accounts.add(reviewer.account);
384
385
  }
385
386
  }
386
- function validateResolvedAgentKeys(agents, path, errors) {
387
+ function validateResolvedTriageAgents(agents, path, errors) {
387
388
  const keys = new Set();
389
+ const accounts = new Set();
388
390
  for (const agent of agents) {
389
391
  if (keys.has(agent.key))
390
392
  errors.push(`${path} has duplicate agent key: ${agent.key}`);
391
393
  keys.add(agent.key);
394
+ if (accounts.has(agent.account))
395
+ errors.push(`${path} has duplicate agent account: ${agent.account}`);
396
+ accounts.add(agent.account);
392
397
  }
393
398
  }
394
399
  function validateEditor(editor, path, errors, catalog) {
@@ -672,19 +677,26 @@ function validateTriage(config, errors, options) {
672
677
  validateKnownKeys(triage, "triage", TRIAGE_KEYS, errors);
673
678
  const automation = triage.automation;
674
679
  const concurrency = triage.concurrency;
680
+ const creator = triage.creator;
681
+ const reporter = typeof triage.reporter === "string" ? triage.reporter : undefined;
675
682
  const safety = triage.safety;
676
- if (!triage.account)
677
- errors.push("triage.account is required");
678
- validateString(triage.account, "triage.account", errors);
679
683
  if (!triage.agents)
680
684
  errors.push("triage.agents is required");
681
685
  validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
682
686
  if (Array.isArray(triage.agents)) {
683
- validateResolvedAgentKeys(resolveAgents(config).triage ?? [], "triage.resolvedAgents", errors);
687
+ const resolvedTriageAgents = resolveAgents(config).triage ?? [];
688
+ validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
689
+ if (reporter != null &&
690
+ !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
691
+ errors.push(`triage.reporter must match a triage agent key: ${reporter}`);
692
+ }
684
693
  }
685
- validateTriageCreator(triage.creator, "triage.creator", errors, options.modelCatalog);
686
- if (automation?.create && !triage.creator)
694
+ validateString(triage.reporter, "triage.reporter", errors);
695
+ validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog);
696
+ if (automation?.create && !creator)
687
697
  errors.push("triage.creator is required when triage.automation.create is true");
698
+ if (automation?.create && creator && !creator.account)
699
+ errors.push("triage.creator.account is required when triage.automation.create is true");
688
700
  if (automation != null && !isPlainObject(automation)) {
689
701
  errors.push("triage.automation must be an object");
690
702
  }
@@ -745,10 +757,10 @@ async function validateAuth(config, exec, errors) {
745
757
  const agents = resolveAgents(config);
746
758
  for (const reviewer of agents.reviewers)
747
759
  accounts.add(reviewer.account);
760
+ for (const agent of agents.triage ?? [])
761
+ accounts.add(agent.account);
748
762
  if (agents.editor)
749
763
  accounts.add(agents.editor.account);
750
- if (config.triage?.account)
751
- accounts.add(config.triage.account);
752
764
  if (agents.triageCreator?.account)
753
765
  accounts.add(agents.triageCreator.account);
754
766
  await Promise.all([...accounts].filter(Boolean).map(async (account) => {
@@ -802,19 +814,18 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
802
814
  warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
803
815
  }
804
816
  }));
805
- if (config.triage?.account) {
817
+ await Promise.all((agents.triage ?? []).map(async (agent) => {
806
818
  try {
807
- const permissions = await fetchPermissions(config, exec, config.triage.account);
819
+ const permissions = await fetchPermissions(config, exec, agent.account);
808
820
  if (!permissions.pull) {
809
- errors.push(`GitHub account cannot read repository for issue triage: ${config.triage.account}`);
821
+ errors.push(`GitHub account cannot read repository for issue triage: ${agent.account}`);
810
822
  }
811
823
  }
812
824
  catch (error) {
813
- warnings.push(`Could not validate repository permissions for GitHub account: ${config.triage.account} (${error.message})`);
825
+ warnings.push(`Could not validate repository permissions for GitHub account: ${agent.account} (${error.message})`);
814
826
  }
815
- }
816
- if (agents.triageCreator?.account &&
817
- agents.triageCreator.account !== config.triage?.account) {
827
+ }));
828
+ if (agents.triageCreator?.account) {
818
829
  try {
819
830
  const permissions = await fetchPermissions(config, exec, agents.triageCreator.account);
820
831
  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,82 @@ function errorText(error) {
20
20
  .filter((item) => typeof item === "string")
21
21
  .join("\n");
22
22
  }
23
+ function isIssueTypeUnavailableText(text) {
24
+ return (/cannot query field ["']?issueType["']? on type ["']?Issue["']?/i.test(text) ||
25
+ /field ["']?issueType["']?.*(does not exist|doesn't exist|is not defined|not found).*type ["']?Issue["']?/i.test(text) ||
26
+ /undefinedField.*issueType/i.test(text) ||
27
+ /issueType.*unsupported field|unsupported field.*issueType/i.test(text));
28
+ }
29
+ function isIssueTypeUnavailableError(error) {
30
+ return isIssueTypeUnavailableText(errorText(error));
31
+ }
32
+ function isIssueTypeUnavailableGraphqlResponse(data) {
33
+ return (data.errors?.some((error) => isIssueTypeUnavailableText([error.message, error.type]
34
+ .filter((item) => typeof item === "string")
35
+ .join("\n"))) ?? false);
36
+ }
37
+ async function localCommitExists(exec, worktreePath, sha) {
38
+ try {
39
+ await exec(`git cat-file -e ${shellQuote(`${sha}^{commit}`)}`, {
40
+ cwd: worktreePath,
41
+ });
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ function pullRequestCommitSource(input) {
49
+ if (input.source === "base") {
50
+ return {
51
+ owner: input.repository.github.owner,
52
+ refName: input.meta.baseRefName,
53
+ repo: input.repository.github.repo,
54
+ };
55
+ }
56
+ return {
57
+ owner: input.meta.headRepositoryOwner?.login ?? input.repository.github.owner,
58
+ refName: input.meta.headRefName,
59
+ repo: input.meta.headRepository?.name ?? input.repository.github.repo,
60
+ };
61
+ }
62
+ async function fetchPullRequestCommitSource(input) {
63
+ const commitSource = pullRequestCommitSource(input);
64
+ try {
65
+ await input.exec(`git fetch --no-tags ${shellQuote(repositoryGitUrl(input.repository, commitSource.owner, commitSource.repo))} ${shellQuote(`refs/heads/${commitSource.refName}`)}`, { cwd: input.worktreePath });
66
+ }
67
+ catch (error) {
68
+ throw new Error(`Could not fetch ${input.source} ref ${commitSource.refName} for #${input.meta.number}: ${errorText(error)}`);
69
+ }
70
+ }
71
+ export async function ensurePullRequestCommits(input) {
72
+ const missing = [];
73
+ for (const commit of input.commits) {
74
+ if (!(await localCommitExists(input.exec, input.worktreePath, commit.sha))) {
75
+ missing.push(commit);
76
+ }
77
+ }
78
+ for (const source of new Set(missing.map((commit) => commit.source))) {
79
+ await fetchPullRequestCommitSource({
80
+ exec: input.exec,
81
+ meta: input.meta,
82
+ repository: input.repository,
83
+ source,
84
+ worktreePath: input.worktreePath,
85
+ });
86
+ }
87
+ for (const commit of missing) {
88
+ if (await localCommitExists(input.exec, input.worktreePath, commit.sha)) {
89
+ continue;
90
+ }
91
+ const source = pullRequestCommitSource({
92
+ meta: input.meta,
93
+ repository: input.repository,
94
+ source: commit.source,
95
+ });
96
+ throw new Error(`${commit.label} commit ${commit.sha} is unavailable after fetching ${commit.source} ref ${source.refName}`);
97
+ }
98
+ }
23
99
  function isCheckoutConfigLockError(error) {
24
100
  const text = errorText(error);
25
101
  return (/could not lock config file/i.test(text) ||
@@ -122,26 +198,34 @@ export async function fetchPullRequestClosingIssues(exec, repository, pr) {
122
198
  }
123
199
  export async function fetchIssue(exec, repository, issue) {
124
200
  const query = `query($owner: String!, $repo: String!, $issue: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { number title body url state author { login } labels(first: 100) { nodes { name } } issueType { name } } } }`;
201
+ let raw;
125
202
  try {
126
- 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}`);
127
- const data = JSON.parse(raw);
128
- const graphqlIssue = data.data?.repository?.issue;
129
- if (!graphqlIssue)
130
- throw new Error(`Could not fetch issue #${issue}`);
131
- return {
132
- author: graphqlIssue.author?.login ?? "",
133
- body: graphqlIssue.body ?? "",
134
- labels: graphqlIssue.labels?.nodes?.map((label) => label.name) ?? [],
135
- number: graphqlIssue.number,
136
- state: graphqlIssue.state,
137
- title: graphqlIssue.title,
138
- type: graphqlIssue.issueType?.name,
139
- url: graphqlIssue.url,
140
- };
203
+ 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}`);
141
204
  }
142
- catch {
143
- return fetchIssueWithCli(exec, repository, issue);
205
+ catch (error) {
206
+ if (isIssueTypeUnavailableError(error)) {
207
+ return fetchIssueWithCli(exec, repository, issue);
208
+ }
209
+ throw error;
144
210
  }
211
+ const data = JSON.parse(raw);
212
+ const graphqlIssue = data.data?.repository?.issue;
213
+ if (!graphqlIssue) {
214
+ if (isIssueTypeUnavailableGraphqlResponse(data)) {
215
+ return fetchIssueWithCli(exec, repository, issue);
216
+ }
217
+ throw new Error(`Could not fetch issue #${issue}`);
218
+ }
219
+ return {
220
+ author: graphqlIssue.author?.login ?? "",
221
+ body: graphqlIssue.body ?? "",
222
+ labels: graphqlIssue.labels?.nodes?.map((label) => label.name) ?? [],
223
+ number: graphqlIssue.number,
224
+ state: graphqlIssue.state,
225
+ title: graphqlIssue.title,
226
+ type: graphqlIssue.issueType?.name,
227
+ url: graphqlIssue.url,
228
+ };
145
229
  }
146
230
  async function fetchIssueWithCli(exec, repository, issue) {
147
231
  const raw = await exec(`gh issue view ${issue} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,body,url,state,author,labels`);
@@ -330,16 +414,43 @@ export async function removeIssueLabels(exec, repository, issue, labels, account
330
414
  return removed;
331
415
  }
332
416
  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;
417
+ 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 } } } } }`;
418
+ const reviews = [];
419
+ let cursor;
420
+ for (;;) {
421
+ const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
422
+ 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}`);
423
+ const data = JSON.parse(raw);
424
+ const connection = data.data.repository.pullRequest.reviews;
425
+ reviews.push(...connection.nodes);
426
+ if (!connection.pageInfo?.hasNextPage)
427
+ break;
428
+ cursor = connection.pageInfo.endCursor;
429
+ if (!cursor)
430
+ throw new Error("GitHub reviews page was truncated");
431
+ }
432
+ return reviews.map((review) => ({
433
+ ...review,
434
+ comments: review.comments?.nodes ?? [],
435
+ }));
337
436
  }
338
437
  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 }) => ({
438
+ 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 } } } } }`;
439
+ const commits = [];
440
+ let cursor;
441
+ for (;;) {
442
+ const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
443
+ 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}`);
444
+ const data = JSON.parse(raw);
445
+ const connection = data.data.repository.pullRequest.commits;
446
+ commits.push(...connection.nodes);
447
+ if (!connection.pageInfo?.hasNextPage)
448
+ break;
449
+ cursor = connection.pageInfo.endCursor;
450
+ if (!cursor)
451
+ throw new Error("GitHub commits page was truncated");
452
+ }
453
+ return commits.map(({ commit }) => ({
343
454
  committedDate: commit.committedDate,
344
455
  oid: commit.oid,
345
456
  parentCount: commit.parents.totalCount,
@@ -369,37 +480,9 @@ export async function fetchPullRequestSafetyMeta(exec, repository, pr) {
369
480
  }
370
481
  return { author, changedFiles, files, labels };
371
482
  }
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
- export async function watchChecks(exec, repository, pr) {
398
- await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --watch`);
399
- }
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));
483
+ export async function watchChecks(exec, repository, pr, options = {}) {
484
+ const requiredFlag = options.requiredOnly ? " --required" : "";
485
+ await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --watch${requiredFlag}`);
403
486
  }
404
487
  export function isCancelledCheck(check) {
405
488
  return check.bucket === "cancel" || check.state === "CANCELLED";
@@ -409,8 +492,9 @@ export function isFailedCheck(check) {
409
492
  }
410
493
  export async function fetchPullRequestChecks(exec, repository, pr, options = {}) {
411
494
  let raw;
495
+ const requiredFlag = options.requiredOnly ? " --required" : "";
412
496
  try {
413
- raw = await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json name,state,bucket,link,workflow`);
497
+ raw = await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json name,state,bucket,link,workflow${requiredFlag}`);
414
498
  }
415
499
  catch (error) {
416
500
  if (options.tolerateMissingChecks &&
@@ -472,9 +556,8 @@ export async function fetchMergeQueueRequirement(exec, repository, branch) {
472
556
  const rules = JSON.parse(raw);
473
557
  return rules.some((rule) => rule.type === "merge_queue");
474
558
  }
475
- export async function createWorktree(exec, repository, pr, root) {
476
- const worktreePath = join(root, `pr-${pr}`);
477
- const lockKey = `${repoSpecifier(repository)}:${root}`;
559
+ export async function createWorktree(exec, repository, pr, worktreePath) {
560
+ const lockKey = `${repoSpecifier(repository)}:${dirname(dirname(worktreePath))}`;
478
561
  return withWorktreeCreateLock(lockKey, async () => {
479
562
  let worktreeAdded = false;
480
563
  try {
@@ -591,6 +674,17 @@ export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_00
591
674
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
592
675
  }
593
676
  }
677
+ export async function waitForAutoMerge(exec, repository, pr, intervalMs = 30_000) {
678
+ for (;;) {
679
+ const status = await fetchPullRequestMergeStatus(exec, repository, pr);
680
+ if (status.state === "MERGED")
681
+ return "merged";
682
+ if (status.state !== "OPEN" || status.autoMergeRequest == null) {
683
+ return "dequeued";
684
+ }
685
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
686
+ }
687
+ }
594
688
  export async function closePullRequest(exec, repository, pr, account) {
595
689
  const token = await ghToken(exec, repository, account);
596
690
  return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
@@ -629,11 +723,49 @@ export async function configureGitIdentity(exec, worktreePath, identity) {
629
723
  }
630
724
  }
631
725
  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;
726
+ 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 } } } } }`;
727
+ 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 } } } } }`;
728
+ const threads = [];
729
+ let cursor;
730
+ async function fetchRemainingComments(threadId, initialCursor) {
731
+ const comments = [];
732
+ let commentsCursor = initialCursor;
733
+ for (;;) {
734
+ const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(commentQuery)} -F threadId=${shellQuote(threadId)} -F cursor=${shellQuote(commentsCursor)}`);
735
+ const data = JSON.parse(raw);
736
+ const connection = data.data.node?.comments;
737
+ if (!connection)
738
+ throw new Error("GitHub review thread comments were missing");
739
+ comments.push(...connection.nodes);
740
+ if (!connection.pageInfo?.hasNextPage)
741
+ break;
742
+ const nextCursor = connection.pageInfo.endCursor;
743
+ if (!nextCursor)
744
+ throw new Error("GitHub review thread comments page was truncated");
745
+ commentsCursor = nextCursor;
746
+ }
747
+ return comments;
748
+ }
749
+ for (;;) {
750
+ const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
751
+ 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}`);
752
+ const data = JSON.parse(raw);
753
+ const connection = data.data.repository.pullRequest.reviewThreads;
754
+ for (const thread of connection.nodes) {
755
+ const commentsCursor = thread.comments.pageInfo?.endCursor;
756
+ if (thread.comments.pageInfo?.hasNextPage) {
757
+ if (!commentsCursor)
758
+ throw new Error("GitHub review thread comments page was truncated");
759
+ thread.comments.nodes.push(...(await fetchRemainingComments(thread.id, commentsCursor)));
760
+ }
761
+ }
762
+ threads.push(...connection.nodes);
763
+ if (!connection.pageInfo?.hasNextPage)
764
+ break;
765
+ cursor = connection.pageInfo.endCursor;
766
+ if (!cursor)
767
+ throw new Error("GitHub review threads page was truncated");
768
+ }
637
769
  return threads.flatMap((thread) => {
638
770
  if (thread.isResolved || !thread.comments.nodes.length)
639
771
  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);