@wardby/cli 0.4.1 → 0.5.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.
Files changed (221) hide show
  1. package/.env.example +8 -1
  2. package/README.md +3 -1
  3. package/deploy/local/docker-compose.quickstart-coding.yml +27 -0
  4. package/dist/claude-coding-worker/driver.d.ts +2 -0
  5. package/dist/claude-coding-worker/driver.js +19 -3
  6. package/dist/claude-coding-worker/sdk.js +1 -1
  7. package/dist/cli.js +41 -22
  8. package/dist/coding/continuation-wording.d.ts +10 -0
  9. package/dist/coding/continuation-wording.js +13 -0
  10. package/dist/coding/docker-preflight.d.ts +12 -0
  11. package/dist/coding/docker-preflight.js +40 -0
  12. package/dist/coding/local-git.d.ts +22 -0
  13. package/dist/coding/local-git.js +102 -0
  14. package/dist/coding/local-repo-status.d.ts +7 -0
  15. package/dist/coding/local-repo-status.js +56 -0
  16. package/dist/coding/local-repo.d.ts +17 -0
  17. package/dist/coding/local-repo.js +65 -0
  18. package/dist/coding/observability.d.ts +1 -1
  19. package/dist/coding/observability.js +1 -0
  20. package/dist/coding/profile.d.ts +6 -0
  21. package/dist/coding/profile.js +13 -4
  22. package/dist/coding/protocol.d.ts +32 -7
  23. package/dist/coding/protocol.js +66 -4
  24. package/dist/coding-worker/artifact.d.ts +1 -0
  25. package/dist/coding-worker/errors.js +2 -0
  26. package/dist/config/providers.d.ts +13 -0
  27. package/dist/config/providers.js +21 -9
  28. package/dist/core/attribution.d.ts +13 -0
  29. package/dist/core/attribution.js +30 -0
  30. package/dist/core/budget-groups.d.ts +3 -1
  31. package/dist/core/budget-groups.js +53 -5
  32. package/dist/core/ci-context.d.ts +8 -0
  33. package/dist/core/ci-context.js +59 -0
  34. package/dist/core/delegation-siblings.d.ts +22 -0
  35. package/dist/core/delegation-siblings.js +40 -0
  36. package/dist/core/dispatch.d.ts +28 -4
  37. package/dist/core/dispatch.js +57 -15
  38. package/dist/core/engine-native.d.ts +16 -0
  39. package/dist/core/engine-native.js +49 -2
  40. package/dist/core/host-events.d.ts +25 -2
  41. package/dist/core/host-events.js +361 -19
  42. package/dist/core/host-status.d.ts +8 -3
  43. package/dist/core/host-status.js +38 -7
  44. package/dist/core/issue-events.d.ts +2 -6
  45. package/dist/core/issue-events.js +13 -19
  46. package/dist/core/pull-request-state-sync.d.ts +17 -0
  47. package/dist/core/pull-request-state-sync.js +53 -0
  48. package/dist/core/reconciler.d.ts +10 -1
  49. package/dist/core/reconciler.js +15 -2
  50. package/dist/core/related-pull-requests.d.ts +95 -0
  51. package/dist/core/related-pull-requests.js +338 -0
  52. package/dist/core/repo-access.d.ts +5 -1
  53. package/dist/core/repo-access.js +17 -1
  54. package/dist/core/review-fix-ledger.d.ts +20 -0
  55. package/dist/core/review-fix-ledger.js +41 -0
  56. package/dist/core/review-fix.d.ts +60 -0
  57. package/dist/core/review-fix.js +155 -0
  58. package/dist/core/review-host-tools.d.ts +13 -2
  59. package/dist/core/review-host-tools.js +72 -11
  60. package/dist/core/runner.d.ts +2 -2
  61. package/dist/core/runner.js +379 -184
  62. package/dist/core/serial-gate.d.ts +11 -0
  63. package/dist/core/serial-gate.js +19 -0
  64. package/dist/core/timing.d.ts +7 -0
  65. package/dist/core/timing.js +7 -0
  66. package/dist/generated/prisma/browser.d.ts +23 -0
  67. package/dist/generated/prisma/client.d.ts +23 -0
  68. package/dist/generated/prisma/commonInputTypes.d.ts +22 -0
  69. package/dist/generated/prisma/internal/class.d.ts +33 -0
  70. package/dist/generated/prisma/internal/class.js +4 -4
  71. package/dist/generated/prisma/internal/prismaNamespace.d.ts +274 -1
  72. package/dist/generated/prisma/internal/prismaNamespace.js +47 -2
  73. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +48 -0
  74. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +47 -2
  75. package/dist/generated/prisma/models/Agent.d.ts +422 -1
  76. package/dist/generated/prisma/models/AgentRepository.d.ts +120 -2
  77. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +42 -1
  78. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  79. package/dist/generated/prisma/models/DeferredReview.d.ts +1336 -0
  80. package/dist/generated/prisma/models/DeferredReview.js +1 -0
  81. package/dist/generated/prisma/models/LocalPullRequest.d.ts +1384 -0
  82. package/dist/generated/prisma/models/LocalPullRequest.js +1 -0
  83. package/dist/generated/prisma/models/LocalReview.d.ts +1315 -0
  84. package/dist/generated/prisma/models/LocalReview.js +1 -0
  85. package/dist/generated/prisma/models/Run.d.ts +223 -0
  86. package/dist/generated/prisma/models/RunHostCheck.d.ts +148 -1
  87. package/dist/generated/prisma/models.d.ts +3 -0
  88. package/dist/help-index.json +486 -37
  89. package/dist/import/neutral-schema.d.ts +2 -2
  90. package/dist/mcp/auth/access.d.ts +3 -1
  91. package/dist/mcp/auth/host-account-cli.js +2 -2
  92. package/dist/mcp/auth/repo-authorization.d.ts +6 -7
  93. package/dist/mcp/auth/repo-authorization.js +21 -0
  94. package/dist/mcp/index.js +4 -1
  95. package/dist/mcp/tools/agents.js +46 -4
  96. package/dist/mcp/tools/host-accounts.js +2 -2
  97. package/dist/mcp/tools/repositories.js +71 -12
  98. package/dist/mcp/tools/runs.js +23 -0
  99. package/dist/mcp/tools/trigger.js +153 -24
  100. package/dist/providers/engine/types.d.ts +11 -0
  101. package/dist/providers/executor/composition.js +12 -7
  102. package/dist/providers/executor/container.d.ts +40 -3
  103. package/dist/providers/executor/container.js +140 -9
  104. package/dist/providers/executor/types.d.ts +1 -1
  105. package/dist/providers/jobs/fake-kubernetes-api.d.ts +3 -2
  106. package/dist/providers/jobs/fake-kubernetes-api.js +3 -0
  107. package/dist/providers/jobs/kubernetes-api.d.ts +3 -1
  108. package/dist/providers/jobs/kubernetes-client.d.ts +2 -1
  109. package/dist/providers/jobs/kubernetes-client.js +3 -0
  110. package/dist/providers/jobs/kubernetes-isolation.d.ts +7 -0
  111. package/dist/providers/jobs/kubernetes-isolation.js +8 -3
  112. package/dist/providers/jobs/kubernetes-preflight.d.ts +8 -0
  113. package/dist/providers/jobs/kubernetes-preflight.js +7 -0
  114. package/dist/providers/jobs/kubernetes-quota.d.ts +31 -0
  115. package/dist/providers/jobs/kubernetes-quota.js +110 -0
  116. package/dist/providers/jobs/kubernetes.d.ts +9 -0
  117. package/dist/providers/jobs/kubernetes.js +50 -0
  118. package/dist/providers/jobs/types.d.ts +7 -0
  119. package/dist/providers/review-host/ci.d.ts +7 -0
  120. package/dist/providers/review-host/ci.js +20 -0
  121. package/dist/providers/review-host/github-events.d.ts +7 -0
  122. package/dist/providers/review-host/github-events.js +31 -7
  123. package/dist/providers/review-host/github.d.ts +19 -2
  124. package/dist/providers/review-host/github.js +145 -2
  125. package/dist/providers/review-host/index.d.ts +8 -2
  126. package/dist/providers/review-host/index.js +14 -3
  127. package/dist/providers/review-host/local.d.ts +72 -0
  128. package/dist/providers/review-host/local.js +434 -0
  129. package/dist/providers/review-host/types.d.ts +65 -3
  130. package/dist/providers/review-host/types.js +5 -3
  131. package/dist/providers/vcs/git.d.ts +10 -20
  132. package/dist/providers/vcs/git.js +71 -127
  133. package/dist/providers/vcs/github-remote.d.ts +67 -0
  134. package/dist/providers/vcs/github-remote.js +201 -0
  135. package/dist/providers/vcs/github.d.ts +81 -0
  136. package/dist/providers/vcs/github.js +203 -6
  137. package/dist/providers/vcs/index.d.ts +16 -1
  138. package/dist/providers/vcs/index.js +43 -15
  139. package/dist/providers/vcs/local-remote.d.ts +44 -0
  140. package/dist/providers/vcs/local-remote.js +108 -0
  141. package/dist/providers/vcs/remote.d.ts +58 -0
  142. package/dist/providers/vcs/remote.js +1 -0
  143. package/dist/providers/vcs/routing.d.ts +33 -0
  144. package/dist/providers/vcs/routing.js +52 -0
  145. package/dist/providers/vcs/types.d.ts +12 -1
  146. package/dist/quickstart/coding-db.d.ts +11 -0
  147. package/dist/quickstart/coding-db.js +75 -0
  148. package/dist/quickstart/coding-doctor.d.ts +13 -0
  149. package/dist/quickstart/coding-doctor.js +88 -0
  150. package/dist/quickstart/coding-images.d.ts +15 -0
  151. package/dist/quickstart/coding-images.js +79 -0
  152. package/dist/quickstart/coding-seed.d.ts +63 -0
  153. package/dist/quickstart/coding-seed.js +85 -0
  154. package/dist/quickstart/coding.d.ts +66 -0
  155. package/dist/quickstart/coding.js +430 -0
  156. package/dist/quickstart/images.d.ts +19 -0
  157. package/dist/quickstart/images.js +63 -0
  158. package/dist/quickstart/index.js +61 -12
  159. package/dist/quickstart/starter-services.d.ts +45 -0
  160. package/dist/quickstart/starter-services.js +186 -0
  161. package/dist/quickstart-images.json +1 -0
  162. package/dist/serve.js +14 -1
  163. package/dist/viewer/api-schema.d.ts +176 -0
  164. package/dist/viewer/api-schema.js +27 -2
  165. package/dist/viewer/graph.d.ts +7 -2
  166. package/dist/viewer/graph.js +25 -11
  167. package/dist/viewer/http.d.ts +7 -1
  168. package/dist/viewer/http.js +8 -3
  169. package/dist/viewer/infra.d.ts +2 -0
  170. package/dist/viewer/infra.js +23 -0
  171. package/dist/viewer/run-detail.d.ts +2 -1
  172. package/dist/viewer/run-detail.js +2 -2
  173. package/docs/agent-recipes.md +127 -5
  174. package/docs/code-review-agents.md +293 -38
  175. package/docs/coding-agent-setup.md +141 -2
  176. package/docs/coding-packages.md +21 -0
  177. package/docs/coding-services.md +3 -0
  178. package/docs/coding-worker-isolation.md +56 -20
  179. package/docs/getting-started-gke.md +24 -0
  180. package/docs/getting-started.md +196 -10
  181. package/docs/jira-agents.md +26 -5
  182. package/docs/security-deployment.md +9 -0
  183. package/docs/viewer-api.md +21 -2
  184. package/help/admin-viewer.md +11 -1
  185. package/help/agent-recipes.md +30 -4
  186. package/help/builder-agent.md +4 -0
  187. package/help/code-review-agents.md +82 -2
  188. package/help/coding-packages.md +12 -1
  189. package/help/coding-services.md +4 -0
  190. package/help/cost-attribution.md +3 -1
  191. package/help/creating-agents.md +3 -0
  192. package/help/deploy-gke.md +2 -1
  193. package/help/errors/coding-provider-not-configured.md +60 -0
  194. package/help/errors/coding-turn-limit.md +25 -0
  195. package/help/errors/continuation-closed.md +75 -0
  196. package/help/errors/local-branch-conflict.md +37 -0
  197. package/help/errors/local-path-invalid.md +30 -0
  198. package/help/errors/local-ref-invalid.md +36 -0
  199. package/help/errors/local-ref-not-found.md +41 -0
  200. package/help/errors/local-repo-not-allowed.md +46 -0
  201. package/help/errors/local-repo-not-found.md +39 -0
  202. package/help/errors/model-unavailable.md +7 -6
  203. package/help/errors/vcs-github-not-configured.md +34 -0
  204. package/help/getting-started.md +28 -0
  205. package/help/github.md +22 -5
  206. package/help/jira.md +9 -2
  207. package/help/local-repositories.md +182 -0
  208. package/help/related-pull-requests.md +89 -0
  209. package/help/review-fix-rounds.md +69 -0
  210. package/help/troubleshooting/budgets.md +11 -0
  211. package/help/troubleshooting/coding-workers.md +22 -0
  212. package/help/troubleshooting/repository-access.md +5 -0
  213. package/package.json +3 -2
  214. package/prisma/migrations/20261004100000_delegations_and_coding_turns/migration.sql +12 -0
  215. package/prisma/migrations/20261005000000_review_fix_rounds/migration.sql +5 -0
  216. package/prisma/migrations/20261006000000_parallel_delegations/migration.sql +6 -0
  217. package/prisma/migrations/20261007000000_ci_rereview/migration.sql +4 -0
  218. package/prisma/migrations/20261008000000_review_after_ci/migration.sql +33 -0
  219. package/prisma/migrations/20261009000001_coding_run_local_branch/migration.sql +7 -0
  220. package/prisma/migrations/20261009000002_local_review/migration.sql +50 -0
  221. package/prisma/schema.prisma +98 -1
@@ -1,8 +1,9 @@
1
- import { linkedPullRequestAttribution, RESPONSE_PATH_SNAPSHOT_BUDGET } from "./attribution.js";
1
+ import { linkedPullRequestAttribution, openingRunAttribution, RESPONSE_PATH_SNAPSHOT_BUDGET } from "./attribution.js";
2
2
  import { checkContinuation, dispatchRun } from "./dispatch.js";
3
3
  import { mentionStatusRow, postMentionStatus } from "./host-status.js";
4
4
  import { handlePullRequestClosed } from "./issue-bridge.js";
5
5
  import { logger } from "./logger.js";
6
+ import { MAX_SIBLING_HINTS, openSiblings, SIBLING_GUIDANCE } from "./related-pull-requests.js";
6
7
  import { requiredLevel } from "./repo-access.js";
7
8
  import { composeTaskOverride } from "./untrusted-content.js";
8
9
  import { DEFAULT_KNOWLEDGE_BUNDLE_PATH, parseConcept, RESERVED_BUNDLE_FILES, } from "../knowledge/concept.js";
@@ -11,7 +12,13 @@ import { spanHash } from "../knowledge/span-hash.js";
11
12
  const log = logger.child({ module: "host-events" });
12
13
  const MAX_TASK_BODY = 8000;
13
14
  const MAX_TITLE = 256;
14
- const HOST_NAMES = { github: "GitHub" };
15
+ const HOST_NAMES = { github: "GitHub", local: "local repository" };
16
+ /** A deferred review whose head's CI has not finished after this long is started anyway (by the reconciler sweep). */
17
+ export const DEFERRED_REVIEW_MAX_WAIT_MS = 15 * 60_000;
18
+ /** A deferred review row older than this is dropped without starting. */
19
+ export const DEFERRED_REVIEW_MAX_AGE_MS = 24 * 60 * 60_000;
20
+ /** Upper bound on deferred reviews the sweep starts per pass, so a backlog drains over several passes. */
21
+ export const DEFERRED_REVIEW_BATCH = 50;
15
22
  function escapeRegExp(value) {
16
23
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
24
  }
@@ -233,6 +240,36 @@ async function verifyCitations(host, event, concepts, budget) {
233
240
  }
234
241
  return statuses;
235
242
  }
243
+ /** The exact phrase every continuation hint uses (a prompt may match it). */
244
+ export function continuePriorRunPhrase(runId) {
245
+ return `pass continuePriorRun set to exactly "${runId}"`;
246
+ }
247
+ /** The continuation hint a router agent follows to continue a PR's branch instead of opening a new one. */
248
+ export function continuationHint(prNumber, runId) {
249
+ return (`[This request is a follow-up on PR #${prNumber}, originally opened by wardby run ${runId}. ` +
250
+ `If you delegate, ${continuePriorRunPhrase(runId)} so the same PR/branch is ` +
251
+ `continued instead of opening a new one.]`);
252
+ }
253
+ const HINT_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
254
+ const HINT_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
255
+ /** One hint per open sibling (stored rows only; links built, ids re-validated), then the shared guidance. */
256
+ export function siblingContinuationHints(siblings) {
257
+ const lines = siblings
258
+ .filter((s) => HINT_RUN_ID.test(s.openedByRunId) &&
259
+ HINT_REPOSITORY.test(s.repository) &&
260
+ Number.isSafeInteger(s.number) &&
261
+ s.number > 0)
262
+ .slice(0, MAX_SIBLING_HINTS)
263
+ .map((s) => `- ${s.repository}#${s.number} (https://github.com/${s.repository}/pull/${s.number}): to change it, ` +
264
+ `delegate to that repository's coding agent and ${continuePriorRunPhrase(s.openedByRunId)}.`);
265
+ if (lines.length === 0)
266
+ return undefined;
267
+ return [
268
+ "[This pull request is one of a set wardby opened for the same request. The set's other open pull requests:",
269
+ ...lines,
270
+ `${SIBLING_GUIDANCE}]`,
271
+ ].join("\n");
272
+ }
236
273
  /**
237
274
  * The mention agent's task text. Deterministic so a prompt can parse it.
238
275
  *
@@ -249,15 +286,16 @@ async function verifyCitations(host, event, concepts, budget) {
249
286
  * never in the system prompt. composeTaskOverride stores both in the one
250
287
  * taskOverride column.
251
288
  */
252
- export function mentionTaskText(event) {
289
+ export function mentionTaskText(event, siblings = []) {
253
290
  const kind = event.isPullRequest ? "PR" : "issue";
254
291
  const Kind = event.isPullRequest ? "PR" : "Issue";
255
292
  const inSubject = event.comment.kind === "subject";
256
293
  const sections = [];
257
294
  if (event.priorRunId) {
258
- sections.push(`[This request is a follow-up on PR #${event.number}, originally opened by wardby run ${event.priorRunId}. ` +
259
- `If you delegate, pass continuePriorRun set to exactly "${event.priorRunId}" so the same PR/branch is ` +
260
- `continued instead of opening a new one.]`);
295
+ sections.push(continuationHint(event.number, event.priorRunId));
296
+ const hints = siblingContinuationHints(siblings);
297
+ if (hints)
298
+ sections.push(hints);
261
299
  }
262
300
  const title = event.subject?.title.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE);
263
301
  const description = event.subject?.body.trim() ? event.subject.body.slice(0, MAX_TASK_BODY) : "";
@@ -329,7 +367,23 @@ skipReviewedCommits = false) {
329
367
  // One lookup per PR, not per reviewer, and none at all when every reviewer
330
368
  // skips this commit: taken at the first actual dispatch.
331
369
  let attribution;
332
- const linkedAttribution = () => (attribution ??= linkedPullRequestAttribution(deps.db, deps.issueTrackers, { codeProvider: host.provider, repository, number: prNumber }, RESPONSE_PATH_SNAPSHOT_BUDGET));
370
+ const linkedAttribution = () => (attribution ??= (async () => {
371
+ const linked = await linkedPullRequestAttribution(deps.db, deps.issueTrackers, { codeProvider: host.provider, repository, number: prNumber }, RESPONSE_PATH_SNAPSHOT_BUDGET);
372
+ if (linked || !host.pullRequestOrigin)
373
+ return linked;
374
+ // Not linked to an issue yet (the lead run that opened it may still be
375
+ // running): attribute to the issue of the run whose marker the PR carries.
376
+ try {
377
+ const origin = await host.pullRequestOrigin(repository, prNumber);
378
+ return origin.markerRunId
379
+ ? await openingRunAttribution(deps.db, deps.issueTrackers, origin.markerRunId, RESPONSE_PATH_SNAPSHOT_BUDGET)
380
+ : undefined;
381
+ }
382
+ catch (err) {
383
+ log.warn({ err, repository, prNumber }, "could not read the pull request's opening run; the review is unattributed");
384
+ return undefined;
385
+ }
386
+ })());
333
387
  for (const target of targets) {
334
388
  if (skipReviewedCommits && (await alreadyReviewed(deps, repository, prNumber, headSha, target.agentId))) {
335
389
  log.info({ repository, prNumber, agentId: target.agentId, headSha }, "review skipped: this commit was already reviewed");
@@ -382,6 +436,62 @@ skipReviewedCommits = false) {
382
436
  }
383
437
  return runIds;
384
438
  }
439
+ /**
440
+ * CI finished on a pull request's head: re-runs, once per head, each review
441
+ * that published only a COMMENT while CI there was still running or had not
442
+ * reported (RunHostCheck.ciPendingAtReview). Waits for the last suite: while
443
+ * any CI on the head is still pending, a later completion decides. Never
444
+ * throws; a failure is logged and the delivery is not retried.
445
+ */
446
+ async function rereviewAfterCi(deps, host, event, prNumber, reviewerLinks) {
447
+ try {
448
+ if (!host.readCi)
449
+ return [];
450
+ const onHead = await deps.db.runHostCheck.findMany({
451
+ where: { provider: event.provider, repository: event.repository, prNumber, headSha: event.headSha },
452
+ select: {
453
+ runId: true,
454
+ verdict: true,
455
+ ciPendingAtReview: true,
456
+ ciRereviewAt: true,
457
+ run: { select: { agentId: true } },
458
+ },
459
+ });
460
+ // Once per head and reviewer: an agent already re-run here is never re-run again on it.
461
+ const rerun = new Set(onHead.filter((r) => r.ciRereviewAt !== null).map((r) => r.run.agentId));
462
+ const waiting = onHead.filter((r) => r.verdict === "COMMENT" && r.ciPendingAtReview === true && !rerun.has(r.run.agentId));
463
+ if (waiting.length === 0)
464
+ return [];
465
+ const head = await host.pullRequestHead(event.repository, prNumber);
466
+ if (head.isFork || head.state !== "open" || head.headSha !== event.headSha)
467
+ return [];
468
+ const ci = await host.readCi(event.repository, event.headSha);
469
+ if (ci.state === "pending" || ci.state === "none" || ci.state === "unavailable")
470
+ return [];
471
+ // Only reviewers still authorized are claimed, so a denied one keeps its chance for later.
472
+ const allowed = await authorizedLinks(deps, event, reviewerLinks.filter((l) => waiting.some((r) => r.run.agentId === l.agentId)));
473
+ const allowedIds = new Set(allowed.map((l) => l.agentId));
474
+ // Claimed per row, so concurrent completions re-run each review only once.
475
+ const agentIds = new Set();
476
+ for (const row of waiting.filter((r) => allowedIds.has(r.run.agentId))) {
477
+ const claimed = await deps.db.runHostCheck.updateMany({
478
+ where: { runId: row.runId, ciRereviewAt: null },
479
+ data: { ciRereviewAt: new Date() },
480
+ });
481
+ if (claimed.count > 0)
482
+ agentIds.add(row.run.agentId);
483
+ }
484
+ if (agentIds.size === 0)
485
+ return [];
486
+ const targets = reviewTargets(allowed.filter((l) => agentIds.has(l.agentId)));
487
+ log.info({ repository: event.repository, prNumber, headSha: event.headSha, ci: ci.state }, "CI finished: re-running the review");
488
+ return await startReviews(deps, host, event.repository, prNumber, event.headSha, targets);
489
+ }
490
+ catch (err) {
491
+ log.warn({ err, repository: event.repository, prNumber }, "could not re-run the review after CI finished");
492
+ return [];
493
+ }
494
+ }
385
495
  /**
386
496
  * Keeps only the links the gate still authorizes: the agent must have an
387
497
  * owner, and the owner's current access (or a recorded admin/grandfathered
@@ -406,6 +516,222 @@ async function authorizedLinks(deps, event, links) {
406
516
  return kept;
407
517
  }
408
518
  const reviewTargets = (links) => links.map((l) => ({ agentId: l.agentId, checkName: l.checkName }));
519
+ /** A link that reviews pull requests: write access, the pull_request trigger, and a check to report on. */
520
+ const isReviewerLink = (l) => l.access === "write" && l.triggers.includes("pull_request") && Boolean(l.checkName);
521
+ async function loadLinks(deps, provider, repository) {
522
+ return (await deps.db.agentRepository.findMany({
523
+ where: { provider, repository },
524
+ include: { agent: { select: { ownerId: true } } },
525
+ }));
526
+ }
527
+ /** CI states that mean "not finished yet" when a head is pushed; `none` also covers checks not registered yet. */
528
+ const CI_NOT_FINISHED_ON_PUSH = new Set(["pending", "none"]);
529
+ /**
530
+ * pr_updated for reviewers linked with waitForCi: reads CI on the head once.
531
+ * While it is pending (or nothing has reported yet), records one
532
+ * DeferredReview row per reviewer and starts nothing; ci_completed, or the
533
+ * reconciler sweep after DEFERRED_REVIEW_MAX_WAIT_MS, starts it later; CI is
534
+ * read once more after recording, in case it finished in between. Returns the
535
+ * links to start now (all of them when CI has already finished, cannot be
536
+ * read, or the deferral cannot be recorded: as without waitForCi) and the runs
537
+ * the re-read started.
538
+ */
539
+ async function deferUntilCi(deps, host, key, links) {
540
+ const now = { startNow: links, runIds: [] };
541
+ if (links.length === 0 || !host.readCi)
542
+ return now;
543
+ let state;
544
+ try {
545
+ state = (await host.readCi(key.repository, key.headSha)).state;
546
+ }
547
+ catch (err) {
548
+ log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not read CI; reviewing now");
549
+ return now;
550
+ }
551
+ if (!CI_NOT_FINISHED_ON_PUSH.has(state))
552
+ return now;
553
+ try {
554
+ // Idempotent per head and reviewer: a repeated delivery or a second push event for the same head adds nothing.
555
+ await deps.db.deferredReview.createMany({
556
+ data: links.map((l) => ({
557
+ provider: key.provider,
558
+ repository: key.repository,
559
+ prNumber: key.prNumber,
560
+ headSha: key.headSha,
561
+ agentId: l.agentId,
562
+ checkName: l.checkName,
563
+ })),
564
+ skipDuplicates: true,
565
+ });
566
+ }
567
+ catch (err) {
568
+ log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not record the deferred review; reviewing now");
569
+ return now;
570
+ }
571
+ log.info({
572
+ repository: key.repository,
573
+ prNumber: key.prNumber,
574
+ headSha: key.headSha,
575
+ ci: state,
576
+ agentIds: links.map((l) => l.agentId),
577
+ }, "review deferred until CI finishes");
578
+ // CI may have finished between the read above and the insert, with its
579
+ // ci_completed delivery finding no row yet: read once more and, if it has,
580
+ // start now (claimed by delete, so a racing delivery starts it once). A
581
+ // failure leaves the rows for the sweep.
582
+ const runIds = [];
583
+ try {
584
+ const again = (await host.readCi(key.repository, key.headSha)).state;
585
+ if (!CI_NOT_FINISHED_ON_PUSH.has(again)) {
586
+ const rows = await deps.db.deferredReview.findMany({
587
+ where: {
588
+ provider: key.provider,
589
+ repository: key.repository,
590
+ prNumber: key.prNumber,
591
+ headSha: key.headSha,
592
+ agentId: { in: links.map((l) => l.agentId) },
593
+ },
594
+ select: { id: true, agentId: true, checkName: true },
595
+ });
596
+ if (rows.length > 0)
597
+ runIds.push(...(await startDeferred(deps, host, key, rows, links, "ci", "CI finished")));
598
+ }
599
+ }
600
+ catch (err) {
601
+ log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not re-read CI after deferring; the sweep starts the review");
602
+ }
603
+ return { startNow: [], runIds };
604
+ }
605
+ /**
606
+ * Starts deferred reviews for one PR head. The head must still be that sha,
607
+ * open, and not a fork; otherwise every row is dropped (a newer push records
608
+ * its own). Each row is claimed by deleting it, so a concurrent ci_completed
609
+ * delivery or sweep starts each review only once. On `ci`, only reviewers
610
+ * still authorized are claimed (the rest wait for the sweep); on `sweep` (the
611
+ * last attempt) every row is claimed and unauthorized or unlinked reviewers
612
+ * are dropped.
613
+ */
614
+ async function startDeferred(deps, host, key, rows, reviewerLinks, mode, reason) {
615
+ const where = { repository: key.repository, prNumber: key.prNumber, headSha: key.headSha };
616
+ const head = await host.pullRequestHead(key.repository, key.prNumber);
617
+ if (head.isFork || head.state !== "open" || head.headSha !== key.headSha) {
618
+ await deps.db.deferredReview.deleteMany({ where: { id: { in: rows.map((r) => r.id) } } });
619
+ log.info({ ...where, state: head.state }, "deferred review dropped: the pull request moved on or closed");
620
+ return [];
621
+ }
622
+ const allowed = await authorizedLinks(deps, key, reviewerLinks.filter((l) => rows.some((r) => r.agentId === l.agentId)));
623
+ const allowedIds = new Set(allowed.map((l) => l.agentId));
624
+ const targets = [];
625
+ for (const row of rows) {
626
+ if (mode === "ci" && !allowedIds.has(row.agentId))
627
+ continue;
628
+ const claimed = await deps.db.deferredReview.deleteMany({ where: { id: row.id } });
629
+ if (claimed.count !== 1)
630
+ continue;
631
+ if (allowedIds.has(row.agentId))
632
+ targets.push({ agentId: row.agentId, checkName: row.checkName });
633
+ else
634
+ log.info({ ...where, agentId: row.agentId }, "deferred review dropped: the reviewer is no longer linked or authorized");
635
+ }
636
+ if (targets.length === 0)
637
+ return [];
638
+ log.info({ ...where, agentIds: targets.map((t) => t.agentId), reason }, "starting the deferred review");
639
+ try {
640
+ return await startReviews(deps, host, key.repository, key.prNumber, key.headSha, targets, true);
641
+ }
642
+ catch (err) {
643
+ // The rows are already claimed (at most once): make the lost start visible.
644
+ log.warn({ err, ...where, agentIds: targets.map((t) => t.agentId) }, "claimed deferred reviews could not be started");
645
+ return [];
646
+ }
647
+ }
648
+ /**
649
+ * ci_completed: starts the reviews deferred on this head (see deferUntilCi),
650
+ * unless CI there is still pending (a later completion decides). Never
651
+ * throws; a failure is logged and the rows stay for the reconciler sweep.
652
+ */
653
+ async function startDeferredAfterCi(deps, host, event, prNumber, reviewerLinks) {
654
+ try {
655
+ const key = { provider: event.provider, repository: event.repository, prNumber, headSha: event.headSha };
656
+ const rows = await deps.db.deferredReview.findMany({
657
+ where: key,
658
+ select: { id: true, agentId: true, checkName: true },
659
+ });
660
+ if (rows.length === 0)
661
+ return [];
662
+ if (host.readCi) {
663
+ const ci = await host.readCi(event.repository, event.headSha);
664
+ if (ci.state === "pending")
665
+ return [];
666
+ }
667
+ return await startDeferred(deps, host, key, rows, reviewerLinks, "ci", "CI finished");
668
+ }
669
+ catch (err) {
670
+ log.warn({ err, repository: event.repository, prNumber }, "could not start the deferred review after CI finished");
671
+ return [];
672
+ }
673
+ }
674
+ /**
675
+ * The reconciler's fallback for deferred reviews: drops rows older than
676
+ * DEFERRED_REVIEW_MAX_AGE_MS, and starts (regardless of CI) those waiting
677
+ * longer than DEFERRED_REVIEW_MAX_WAIT_MS, at most DEFERRED_REVIEW_BATCH per
678
+ * pass, oldest first. Covers CI that never reported, ran very long, or whose
679
+ * completion webhook was missed. Never throws; returns the runs started.
680
+ */
681
+ export async function startDeferredReviews(deps, now = new Date()) {
682
+ const runIds = [];
683
+ try {
684
+ const expired = await deps.db.deferredReview.deleteMany({
685
+ where: { createdAt: { lt: new Date(now.getTime() - DEFERRED_REVIEW_MAX_AGE_MS) } },
686
+ });
687
+ if (expired.count > 0)
688
+ log.info({ count: expired.count }, "dropped deferred reviews older than 24 h");
689
+ const providers = Object.keys(deps.hosts);
690
+ if (providers.length === 0)
691
+ return runIds;
692
+ const due = await deps.db.deferredReview.findMany({
693
+ where: {
694
+ provider: { in: providers },
695
+ createdAt: { lte: new Date(now.getTime() - DEFERRED_REVIEW_MAX_WAIT_MS) },
696
+ },
697
+ select: {
698
+ id: true,
699
+ provider: true,
700
+ repository: true,
701
+ prNumber: true,
702
+ headSha: true,
703
+ agentId: true,
704
+ checkName: true,
705
+ },
706
+ orderBy: { createdAt: "asc" },
707
+ take: DEFERRED_REVIEW_BATCH,
708
+ });
709
+ const groups = new Map();
710
+ for (const row of due) {
711
+ const id = JSON.stringify([row.provider, row.repository, row.prNumber, row.headSha]);
712
+ groups.set(id, [...(groups.get(id) ?? []), row]);
713
+ }
714
+ for (const rows of groups.values()) {
715
+ const first = rows[0];
716
+ const provider = first.provider;
717
+ const key = { provider, repository: first.repository, prNumber: first.prNumber, headSha: first.headSha };
718
+ const host = deps.hosts[provider];
719
+ if (!host)
720
+ continue;
721
+ try {
722
+ const reviewerLinks = (await loadLinks(deps, provider, key.repository)).filter(isReviewerLink);
723
+ runIds.push(...(await startDeferred(deps, host, key, rows, reviewerLinks, "sweep", "CI did not finish within 15 min")));
724
+ }
725
+ catch (err) {
726
+ log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not start a deferred review");
727
+ }
728
+ }
729
+ }
730
+ catch (err) {
731
+ log.warn({ err }, "deferred review sweep failed");
732
+ }
733
+ return runIds;
734
+ }
409
735
  export async function routeHostEvent(event, deps) {
410
736
  const none = { runIds: [], followUps: [] };
411
737
  if (event.kind === "pr_closed") {
@@ -421,11 +747,8 @@ export async function routeHostEvent(event, deps) {
421
747
  const host = deps.hosts[event.provider];
422
748
  if (!host)
423
749
  return none;
424
- const links = (await deps.db.agentRepository.findMany({
425
- where: { provider: event.provider, repository: event.repository },
426
- include: { agent: { select: { ownerId: true } } },
427
- }));
428
- const reviewerLinks = links.filter((l) => l.access === "write" && l.triggers.includes("pull_request") && l.checkName);
750
+ const links = await loadLinks(deps, event.provider, event.repository);
751
+ const reviewerLinks = links.filter(isReviewerLink);
429
752
  switch (event.kind) {
430
753
  case "push": {
431
754
  const watchers = await authorizedLinks(deps, event, links.filter((l) => l.access === "write" && l.triggers.includes("push")));
@@ -495,13 +818,28 @@ export async function routeHostEvent(event, deps) {
495
818
  case "pr_updated": {
496
819
  if (event.isFork || reviewerLinks.length === 0)
497
820
  return none;
498
- const reviewers = reviewTargets(await authorizedLinks(deps, event, reviewerLinks));
499
- if (reviewers.length === 0)
821
+ const allowed = await authorizedLinks(deps, event, reviewerLinks);
822
+ if (allowed.length === 0)
500
823
  return none;
501
- return {
502
- runIds: await startReviews(deps, host, event.repository, event.prNumber, event.headSha, reviewers, true),
503
- followUps: [],
504
- };
824
+ // Reviewers linked with waitForCi review this head only once its CI finished.
825
+ const deferral = await deferUntilCi(deps, host, event, allowed.filter((l) => l.waitForCi));
826
+ const startNow = new Set(deferral.startNow);
827
+ const reviewers = reviewTargets(allowed.filter((l) => !l.waitForCi || startNow.has(l)));
828
+ const runIds = [...deferral.runIds];
829
+ if (reviewers.length > 0) {
830
+ runIds.push(...(await startReviews(deps, host, event.repository, event.prNumber, event.headSha, reviewers, true)));
831
+ }
832
+ return { runIds, followUps: [] };
833
+ }
834
+ case "ci_completed": {
835
+ if (reviewerLinks.length === 0)
836
+ return none;
837
+ const runIds = [];
838
+ for (const prNumber of event.prNumbers) {
839
+ runIds.push(...(await rereviewAfterCi(deps, host, event, prNumber, reviewerLinks)));
840
+ runIds.push(...(await startDeferredAfterCi(deps, host, event, prNumber, reviewerLinks)));
841
+ }
842
+ return { runIds, followUps: [] };
505
843
  }
506
844
  case "check_rerun": {
507
845
  const owners = reviewerLinks.filter((l) => l.checkName === event.checkName);
@@ -570,13 +908,17 @@ export async function routeHostEvent(event, deps) {
570
908
  };
571
909
  return { runIds: [], followUps: [react, reply] };
572
910
  }
911
+ // Seeded from the PR's validated marker run: the request it belongs to. Stored state only.
912
+ const siblings = event.isPullRequest && event.priorRunId
913
+ ? await openSiblings(deps.db, event.priorRunId, { repository: event.repository, number: event.number })
914
+ : [];
573
915
  const dispatched = await dispatchRun({
574
916
  db: deps.db,
575
917
  executor: deps.executor,
576
918
  selfDefects: { db: deps.db, issueTrackers: deps.issueTrackers },
577
919
  agentId: allowed[0].agentId,
578
920
  trigger: "host_event",
579
- taskOverride: mentionTaskText(event),
921
+ taskOverride: mentionTaskText(event, siblings),
580
922
  attribution: event.isPullRequest
581
923
  ? await linkedPullRequestAttribution(deps.db, deps.issueTrackers, { codeProvider: host.provider, repository: event.repository, number: event.number }, RESPONSE_PATH_SNAPSHOT_BUDGET)
582
924
  : undefined,
@@ -8,8 +8,10 @@
8
8
  * died first, say) still gets one: the reconciler posts the outcome as a new
9
9
  * comment. Best effort throughout: nothing here throws.
10
10
  */
11
- import type { Prisma, PrismaClient, Run } from "#prisma";
11
+ import type { Prisma, PrismaClient, Run, RunStatus } from "#prisma";
12
12
  import type { CodeReviewHost, ReviewHostProvider, ReviewHostRegistry } from "../providers/review-host/types.js";
13
+ /** The run statuses a run never leaves. */
14
+ export declare const TERMINAL_RUN_STATUSES: readonly RunStatus[];
13
15
  export type HostStatusDb = Pick<PrismaClient, "runHostStatus" | "run">;
14
16
  export type FinishedRun = Pick<Run, "id" | "status" | "finalText">;
15
17
  /** The parts of a CodingRun result this comment uses. */
@@ -23,6 +25,8 @@ export interface PullRequestOutcome {
23
25
  codeProvider: string;
24
26
  /** The coding sub-run that produced it; set by collectRunOutcome. */
25
27
  runId?: string;
28
+ /** How many of the run's own checks (result.tests) failed; the commands stay in the PR body. */
29
+ failedChecks?: number;
26
30
  }
27
31
  /** A sub-run that ended without succeeding. */
28
32
  export interface FailedChild {
@@ -36,13 +40,14 @@ export interface FailedChild {
36
40
  services?: string[];
37
41
  }
38
42
  export declare const runLine: (runId: string) => string;
39
- export declare function workingBody(runId: string): string;
43
+ export declare function workingBody(runId: string, heading?: string): string;
40
44
  /** The status row for a mention run, created in the same transaction as the run. */
41
45
  export declare function mentionStatusRow(provider: ReviewHostProvider, event: {
42
46
  repository: string;
43
47
  number: number;
44
48
  replyToReviewCommentId?: string;
45
49
  }, runId: string): Prisma.RunHostStatusUncheckedCreateInput;
50
+ export declare function pullRequestOutcome(result: unknown): PullRequestOutcome | null;
46
51
  export declare function outcomeBody(run: FinishedRun, repository: string, pullRequests: PullRequestOutcome[], failedChildren?: FailedChild[], opts?: {
47
52
  budgetSentence?: string;
48
53
  noPullRequestText?: string;
@@ -75,4 +80,4 @@ export declare function completeHostStatus(db: HostStatusDb, run: FinishedRun, h
75
80
  * once. Does nothing when the row is gone, already has a comment, or is
76
81
  * already complete. Never throws.
77
82
  */
78
- export declare function postMentionStatus(db: HostStatusDb, host: CodeReviewHost, runId: string, hosts: ReviewHostRegistry | undefined): Promise<void>;
83
+ export declare function postMentionStatus(db: HostStatusDb, host: CodeReviewHost, runId: string, hosts: ReviewHostRegistry | undefined, heading?: string): Promise<void>;
@@ -2,16 +2,26 @@ import { CODING_CODE_PROVIDER } from "../coding/protocol.js";
2
2
  import { storedServiceNames } from "../coding/services/catalog.js";
3
3
  import { SERVICE_UNREADY_CATEGORY, serviceRefusalSentence, serviceUnreadySentence, } from "../coding/services/wording.js";
4
4
  import { PROTECTED_PATH_CATEGORY, PROTECTED_PATH_HOST_LINE } from "../coding/protected-path-wording.js";
5
+ import { CONTINUATION_CLOSED_CATEGORY, CONTINUATION_CLOSED_HOST_LINE } from "../coding/continuation-wording.js";
5
6
  import { loadBudgetSentence } from "./budget-wording.js";
6
7
  import { logger } from "./logger.js";
7
8
  import { providerClassOfCategory, providerSentence } from "./provider-wording.js";
8
9
  const log = logger.child({ module: "host-status" });
9
10
  /** Keeps the edited comment readable; the full reply stays on the run. */
10
11
  const MAX_REPLY_CHARS = 2000;
11
- const TERMINAL = new Set(["succeeded", "failed", "refused", "lost", "budget_exhausted", "cancelled"]);
12
+ /** The run statuses a run never leaves. */
13
+ export const TERMINAL_RUN_STATUSES = [
14
+ "succeeded",
15
+ "failed",
16
+ "refused",
17
+ "lost",
18
+ "budget_exhausted",
19
+ "cancelled",
20
+ ];
21
+ const TERMINAL = new Set(TERMINAL_RUN_STATUSES);
12
22
  export const runLine = (runId) => `<sub>wardby run \`${runId}\`</sub>`;
13
- export function workingBody(runId) {
14
- return `👀 Working on it.\n\n${runLine(runId)}`;
23
+ export function workingBody(runId, heading = "👀 Working on it.") {
24
+ return `${heading}\n\n${runLine(runId)}`;
15
25
  }
16
26
  /** The status row for a mention run, created in the same transaction as the run. */
17
27
  export function mentionStatusRow(provider, event, runId) {
@@ -24,7 +34,7 @@ export function mentionStatusRow(provider, event, runId) {
24
34
  replyToReviewCommentId: event.replyToReviewCommentId ?? null,
25
35
  };
26
36
  }
27
- function pullRequestOutcome(result) {
37
+ export function pullRequestOutcome(result) {
28
38
  if (!result || typeof result !== "object")
29
39
  return null;
30
40
  const r = result;
@@ -40,8 +50,16 @@ function pullRequestOutcome(result) {
40
50
  pullRequestNumber: r.pullRequestNumber,
41
51
  codeProvider: CODING_CODE_PROVIDER,
42
52
  ...(typeof r.pullRequestUrl === "string" ? { pullRequestUrl: r.pullRequestUrl } : {}),
53
+ ...failedCheckCount(r.tests),
43
54
  };
44
55
  }
56
+ /** Only a count reaches the comment: test commands are agent-authored and belong in the PR body. */
57
+ function failedCheckCount(tests) {
58
+ if (!Array.isArray(tests))
59
+ return {};
60
+ const failed = tests.filter((t) => t && typeof t === "object" && t.outcome === "failed").length;
61
+ return failed > 0 ? { failedChecks: failed } : {};
62
+ }
45
63
  /** The agent's reply as a quote, cut to MAX_REPLY_CHARS, with @-mentions defused so nobody is pinged. */
46
64
  const cutReply = (text) => text.length > MAX_REPLY_CHARS ? `${text.slice(0, MAX_REPLY_CHARS)}…` : text;
47
65
  function quoteReply(text) {
@@ -85,13 +103,19 @@ export function outcomeBody(run, repository, pullRequests, failedChildren = [],
85
103
  : null;
86
104
  // No path reaches this comment (see coding/protected-path-wording.ts), only the category.
87
105
  const protectedPathOf = (c) => c.status === "failed" && c.failureCategory === PROTECTED_PATH_CATEGORY;
106
+ // A continuation whose PR closed before the sub-run ever spent anything
107
+ // is stored as "refused" (a preflight refusal), not "failed" -- the
108
+ // category is the same either way, so both statuses are recognised here.
109
+ const continuationClosedOf = (c) => (c.status === "failed" || c.status === "refused") && c.failureCategory === CONTINUATION_CLOSED_CATEGORY;
88
110
  const providerClasses = new Set(failedChildren.map(providerClassOf).filter((c) => c !== null));
89
111
  const serviceSentences = new Set(failedChildren.map(serviceSentenceOf).filter((s) => s !== null));
90
112
  const protectedPathFailed = failedChildren.some(protectedPathOf);
113
+ const continuationClosedFailed = failedChildren.some(continuationClosedOf);
91
114
  const other = failedChildren.filter((c) => c.status !== "budget_exhausted" &&
92
115
  providerClassOf(c) === null &&
93
116
  serviceSentenceOf(c) === null &&
94
- !protectedPathOf(c));
117
+ !protectedPathOf(c) &&
118
+ !continuationClosedOf(c));
95
119
  const lines = [];
96
120
  if (outOfBudget.length > 0) {
97
121
  lines.push(`A sub-run ran out of budget: ${outOfBudget.map((c) => `\`${c.id}\``).join(", ")}.`);
@@ -106,11 +130,18 @@ export function outcomeBody(run, repository, pullRequests, failedChildren = [],
106
130
  }
107
131
  if (protectedPathFailed)
108
132
  lines.push(PROTECTED_PATH_HOST_LINE);
133
+ if (continuationClosedFailed)
134
+ lines.push(CONTINUATION_CLOSED_HOST_LINE);
109
135
  if (other.length > 0) {
110
136
  lines.push(`A sub-run did not succeed: ${other.map((c) => `\`${c.id}\` (\`${c.status}\`)`).join(", ")}.`);
111
137
  }
112
138
  return `❌ ${lines.join(" ")}${partial}${quoted}\n\n${footer}`;
113
139
  }
140
+ const failedChecks = pullRequests.reduce((sum, pr) => sum + (pr.failedChecks ?? 0), 0);
141
+ if (links.length > 0 && failedChecks > 0) {
142
+ const checks = failedChecks === 1 ? "1 check" : `${failedChecks} checks`;
143
+ return `⚠️ ${links.join(", ")}, but ${checks} failed in the run: see the pull request's description before merging.\n\n${footer}`;
144
+ }
114
145
  if (links.length > 0)
115
146
  return `✅ ${links.join(", ")}.\n\n${footer}`;
116
147
  if (opts.replyAsAnswer && reply)
@@ -199,14 +230,14 @@ export async function completeHostStatus(db, run, hosts, opts = {}) {
199
230
  * once. Does nothing when the row is gone, already has a comment, or is
200
231
  * already complete. Never throws.
201
232
  */
202
- export async function postMentionStatus(db, host, runId, hosts) {
233
+ export async function postMentionStatus(db, host, runId, hosts, heading) {
203
234
  try {
204
235
  const status = await db.runHostStatus.findUnique({ where: { runId } });
205
236
  if (!status || status.commentId || status.completedAt)
206
237
  return;
207
238
  const posted = await host.comment(status.repository, {
208
239
  number: status.number,
209
- body: workingBody(runId),
240
+ body: workingBody(runId, heading),
210
241
  ...(status.replyToReviewCommentId ? { replyToReviewCommentId: status.replyToReviewCommentId } : {}),
211
242
  });
212
243
  const claimed = await db.runHostStatus.updateMany({
@@ -11,6 +11,7 @@ import type { PrismaClient } from "#prisma";
11
11
  import type { Executor } from "../providers/executor/types.js";
12
12
  import type { IssueEvent, IssueEventKind, IssueTrackerRegistry } from "../providers/issue-tracker/types.js";
13
13
  import { type DispatchDb } from "./dispatch.js";
14
+ import { type OpenSibling } from "./related-pull-requests.js";
14
15
  export type IssueEventDb = DispatchDb & Pick<PrismaClient, "agentIssueProject" | "runIssueStatus" | "issuePullRequest" | "workItem">;
15
16
  export interface RouteIssueEventDeps {
16
17
  db: IssueEventDb;
@@ -21,12 +22,7 @@ export interface RouteResult {
21
22
  runIds: string[];
22
23
  followUps: Array<() => Promise<void>>;
23
24
  }
24
- export interface OpenIssuePr {
25
- repository: string;
26
- number: number;
27
- url: string;
28
- openedByRunId: string;
29
- }
25
+ export type OpenIssuePr = OpenSibling;
30
26
  type TaskLink = {
31
27
  triggerLabels: string[];
32
28
  trustedAccountIds: string[];