agent-dealer 0.2.0 → 1.0.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.
Files changed (276) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
  3. package/bundle/server/dist/adapters/agent-deck.js +38 -50
  4. package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
  5. package/bundle/server/dist/adapters/agent-health.js +230 -33
  6. package/bundle/server/dist/adapters/agent-health.test.js +237 -0
  7. package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
  8. package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
  9. package/bundle/server/dist/adapters/execution-authority.js +103 -0
  10. package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
  11. package/bundle/server/dist/adapters/external.js +0 -1
  12. package/bundle/server/dist/adapters/git-worktree.js +514 -0
  13. package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
  14. package/bundle/server/dist/adapters/github.js +170 -0
  15. package/bundle/server/dist/adapters/github.test.js +205 -0
  16. package/bundle/server/dist/adapters/linear-inbox.js +199 -30
  17. package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
  18. package/bundle/server/dist/adapters/linear-sync.js +3 -101
  19. package/bundle/server/dist/adapters/managed-repo.js +168 -0
  20. package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
  21. package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
  22. package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
  23. package/bundle/server/dist/adapters/reflect-authority.js +43 -0
  24. package/bundle/server/dist/cli-env.js +26 -0
  25. package/bundle/server/dist/cli-env.test.js +14 -1
  26. package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
  27. package/bundle/server/dist/coordinator/admission.js +330 -0
  28. package/bundle/server/dist/coordinator/admission.test.js +338 -0
  29. package/bundle/server/dist/coordinator/args.js +128 -0
  30. package/bundle/server/dist/coordinator/args.test.js +132 -0
  31. package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
  32. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
  33. package/bundle/server/dist/coordinator/auto-merge.js +286 -0
  34. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
  35. package/bundle/server/dist/coordinator/branch-progress.js +81 -0
  36. package/bundle/server/dist/coordinator/clock-jump.js +98 -0
  37. package/bundle/server/dist/coordinator/commands.js +1088 -0
  38. package/bundle/server/dist/coordinator/commands.test.js +554 -0
  39. package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
  40. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
  41. package/bundle/server/dist/coordinator/dependencies.js +242 -0
  42. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
  43. package/bundle/server/dist/coordinator/developer-effect.js +959 -0
  44. package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
  45. package/bundle/server/dist/coordinator/dispatcher.js +58 -0
  46. package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
  47. package/bundle/server/dist/coordinator/effect-registry.js +17 -0
  48. package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
  49. package/bundle/server/dist/coordinator/failure-reason.js +273 -0
  50. package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
  51. package/bundle/server/dist/coordinator/guidance.js +33 -0
  52. package/bundle/server/dist/coordinator/guidance.test.js +110 -0
  53. package/bundle/server/dist/coordinator/human-resolution.js +88 -0
  54. package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
  55. package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
  56. package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
  57. package/bundle/server/dist/coordinator/latest-failure.js +100 -0
  58. package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
  59. package/bundle/server/dist/coordinator/metrics.js +35 -0
  60. package/bundle/server/dist/coordinator/metrics.test.js +52 -0
  61. package/bundle/server/dist/coordinator/permissions.js +127 -0
  62. package/bundle/server/dist/coordinator/permissions.test.js +184 -0
  63. package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
  64. package/bundle/server/dist/coordinator/process-liveness.js +191 -0
  65. package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
  66. package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
  67. package/bundle/server/dist/coordinator/projection.js +147 -0
  68. package/bundle/server/dist/coordinator/projection.test.js +121 -0
  69. package/bundle/server/dist/coordinator/prompts.js +164 -0
  70. package/bundle/server/dist/coordinator/prompts.test.js +177 -0
  71. package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
  72. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
  73. package/bundle/server/dist/coordinator/recovery.js +466 -0
  74. package/bundle/server/dist/coordinator/recovery.test.js +164 -0
  75. package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
  76. package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
  77. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
  78. package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
  79. package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
  80. package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
  81. package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
  82. package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
  83. package/bundle/server/dist/coordinator/routing.js +160 -0
  84. package/bundle/server/dist/coordinator/routing.test.js +336 -0
  85. package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
  86. package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
  87. package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
  88. package/bundle/server/dist/coordinator/session-progress.js +351 -0
  89. package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
  90. package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
  91. package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
  92. package/bundle/server/dist/coordinator/spawn.js +49 -0
  93. package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
  94. package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
  95. package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
  96. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
  97. package/bundle/server/dist/coordinator/usage.js +40 -0
  98. package/bundle/server/dist/coordinator/usage.test.js +69 -0
  99. package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
  100. package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
  101. package/bundle/server/dist/coordinator/worker-loop.js +363 -0
  102. package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
  103. package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
  104. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
  105. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
  106. package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
  107. package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
  108. package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
  109. package/bundle/server/dist/db/index.js +359 -7
  110. package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
  111. package/bundle/server/dist/db/migrate-to-issues.js +824 -0
  112. package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
  113. package/bundle/server/dist/db/schema.sql +311 -1
  114. package/bundle/server/dist/db/schema.test.js +66 -0
  115. package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
  116. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
  117. package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
  118. package/bundle/server/dist/index.js +58 -5
  119. package/bundle/server/dist/paths.js +40 -4
  120. package/bundle/server/dist/paths.test.js +42 -0
  121. package/bundle/server/dist/pid-marker-claim-child.js +18 -0
  122. package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
  123. package/bundle/server/dist/pid-marker.js +141 -0
  124. package/bundle/server/dist/pid-marker.test.js +208 -0
  125. package/bundle/server/dist/queue/approve-deliver.js +128 -28
  126. package/bundle/server/dist/queue/reflect-schedule.js +25 -0
  127. package/bundle/server/dist/queue/result-qa.js +1 -1
  128. package/bundle/server/dist/queue/send-gate.test.js +172 -36
  129. package/bundle/server/dist/repository/agents.js +55 -18
  130. package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
  131. package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
  132. package/bundle/server/dist/repository/artifacts.js +47 -0
  133. package/bundle/server/dist/repository/artifacts.test.js +48 -0
  134. package/bundle/server/dist/repository/authority-attempts.js +189 -0
  135. package/bundle/server/dist/repository/findings.js +70 -0
  136. package/bundle/server/dist/repository/findings.test.js +68 -0
  137. package/bundle/server/dist/repository/human-actions.js +142 -0
  138. package/bundle/server/dist/repository/human-actions.test.js +123 -0
  139. package/bundle/server/dist/repository/intake-settings.js +5 -59
  140. package/bundle/server/dist/repository/issues.js +305 -0
  141. package/bundle/server/dist/repository/issues.test.js +69 -0
  142. package/bundle/server/dist/repository/outbound-drafts.js +28 -0
  143. package/bundle/server/dist/repository/queue-entries.js +162 -0
  144. package/bundle/server/dist/repository/review-publications.js +153 -0
  145. package/bundle/server/dist/repository/review-publications.test.js +78 -0
  146. package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
  147. package/bundle/server/dist/repository/runs.js +4 -211
  148. package/bundle/server/dist/repository/runtime-availability.js +54 -0
  149. package/bundle/server/dist/repository/usage-events.js +60 -0
  150. package/bundle/server/dist/repository/usage-events.test.js +56 -0
  151. package/bundle/server/dist/repository/work-items.js +305 -0
  152. package/bundle/server/dist/repository/work-items.test.js +142 -0
  153. package/bundle/server/dist/repository/worker-sessions.js +219 -0
  154. package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
  155. package/bundle/server/dist/repository/workflow-events.js +193 -0
  156. package/bundle/server/dist/repository/workflow-events.test.js +89 -0
  157. package/bundle/server/dist/resolve-tsx-bin.js +19 -0
  158. package/bundle/server/dist/routes/human-actions.js +68 -0
  159. package/bundle/server/dist/routes/human-actions.test.js +284 -0
  160. package/bundle/server/dist/routes/index.js +29 -496
  161. package/bundle/server/dist/routes/issues.js +240 -0
  162. package/bundle/server/dist/routes/issues.test.js +451 -0
  163. package/bundle/server/dist/routes/queue.js +41 -0
  164. package/bundle/server/dist/routes/queue.test.js +78 -0
  165. package/bundle/server/dist/runners/claude-args.js +18 -30
  166. package/bundle/server/dist/runners/claude.js +19 -163
  167. package/bundle/server/dist/runners/claude.test.js +15 -70
  168. package/bundle/server/dist/runners/codex-args.js +59 -0
  169. package/bundle/server/dist/runners/codex-args.test.js +72 -0
  170. package/bundle/server/dist/runners/codex-jsonl.js +150 -0
  171. package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
  172. package/bundle/server/dist/runners/codex-resume.test.js +19 -0
  173. package/bundle/server/dist/runners/codex.js +46 -0
  174. package/bundle/server/dist/runners/models-codex.test.js +14 -0
  175. package/bundle/server/dist/runners/models.js +53 -1
  176. package/bundle/server/dist/runners/persist.js +4 -1
  177. package/bundle/server/dist/runners/process-registry.js +11 -0
  178. package/bundle/server/dist/runners/prompts.js +13 -280
  179. package/bundle/server/dist/runners/prompts.test.js +22 -153
  180. package/bundle/server/dist/runners/qa.js +17 -1
  181. package/bundle/server/dist/runners/reflect.js +4 -16
  182. package/bundle/server/dist/runners/spawn-cli.js +197 -0
  183. package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
  184. package/bundle/server/dist/runners/stream-json.js +0 -141
  185. package/bundle/server/dist/runners/usage-cap.js +242 -0
  186. package/bundle/server/dist/runners/usage-cap.test.js +97 -0
  187. package/bundle/server/dist/server-liveness.js +37 -0
  188. package/bundle/server/dist/server-liveness.test.js +82 -0
  189. package/bundle/server/dist/static-ui.test.js +44 -0
  190. package/bundle/server/package.json +4 -2
  191. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
  192. package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
  193. package/bundle/server/static-ui/index.html +2 -2
  194. package/bundle/shared/dist/agents.d.ts +262 -133
  195. package/bundle/shared/dist/agents.js +85 -19
  196. package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
  197. package/bundle/shared/dist/cursor-auth-health.js +36 -0
  198. package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
  199. package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
  200. package/bundle/shared/dist/findings.d.ts +46 -0
  201. package/bundle/shared/dist/findings.js +17 -0
  202. package/bundle/shared/dist/findings.test.d.ts +1 -0
  203. package/bundle/shared/dist/findings.test.js +20 -0
  204. package/bundle/shared/dist/github-repo.d.ts +28 -0
  205. package/bundle/shared/dist/github-repo.js +98 -0
  206. package/bundle/shared/dist/github-repo.test.d.ts +1 -0
  207. package/bundle/shared/dist/github-repo.test.js +32 -0
  208. package/bundle/shared/dist/human-actions.d.ts +66 -0
  209. package/bundle/shared/dist/human-actions.js +51 -0
  210. package/bundle/shared/dist/human-actions.test.d.ts +1 -0
  211. package/bundle/shared/dist/human-actions.test.js +57 -0
  212. package/bundle/shared/dist/index.d.ts +567 -757
  213. package/bundle/shared/dist/index.js +22 -36
  214. package/bundle/shared/dist/issues.d.ts +385 -0
  215. package/bundle/shared/dist/issues.js +169 -0
  216. package/bundle/shared/dist/issues.test.d.ts +1 -0
  217. package/bundle/shared/dist/issues.test.js +72 -0
  218. package/bundle/shared/dist/outbound-draft.d.ts +6 -0
  219. package/bundle/shared/dist/outbound-draft.js +4 -0
  220. package/bundle/shared/dist/plan-triage.d.ts +6 -6
  221. package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
  222. package/bundle/shared/dist/profile-snapshot.js +145 -0
  223. package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
  224. package/bundle/shared/dist/profile-snapshot.test.js +67 -0
  225. package/bundle/shared/dist/queue-entries.d.ts +89 -0
  226. package/bundle/shared/dist/queue-entries.js +19 -0
  227. package/bundle/shared/dist/result-qa.d.ts +2 -2
  228. package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
  229. package/bundle/shared/dist/runtime-auth-health.js +186 -0
  230. package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
  231. package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
  232. package/bundle/shared/dist/runtime-availability.d.ts +51 -0
  233. package/bundle/shared/dist/runtime-availability.js +13 -0
  234. package/bundle/shared/dist/runtime.d.ts +1 -1
  235. package/bundle/shared/dist/runtime.js +1 -1
  236. package/bundle/shared/dist/usage-events.d.ts +36 -0
  237. package/bundle/shared/dist/usage-events.js +13 -0
  238. package/bundle/shared/dist/worker-sessions.d.ts +140 -0
  239. package/bundle/shared/dist/worker-sessions.js +70 -0
  240. package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
  241. package/bundle/shared/dist/worker-sessions.test.js +39 -0
  242. package/bundle/shared/dist/workflow.d.ts +78 -0
  243. package/bundle/shared/dist/workflow.js +59 -0
  244. package/bundle/shared/dist/workflow.test.d.ts +1 -0
  245. package/bundle/shared/dist/workflow.test.js +61 -0
  246. package/bundle/shared/package.json +1 -1
  247. package/dist/action.d.ts +1 -0
  248. package/dist/action.js +50 -0
  249. package/dist/action.test.d.ts +1 -0
  250. package/dist/action.test.js +70 -0
  251. package/dist/agent.d.ts +1 -0
  252. package/dist/agent.js +17 -0
  253. package/dist/agent.test.d.ts +1 -0
  254. package/dist/agent.test.js +19 -0
  255. package/dist/cli-check.d.ts +7 -0
  256. package/dist/cli-check.js +40 -0
  257. package/dist/doctor.js +59 -1
  258. package/dist/http.d.ts +5 -0
  259. package/dist/http.js +19 -0
  260. package/dist/index.js +28 -0
  261. package/dist/issue.d.ts +35 -0
  262. package/dist/issue.js +143 -0
  263. package/dist/issue.test.d.ts +1 -0
  264. package/dist/issue.test.js +240 -0
  265. package/dist/lifecycle.contract.test.d.ts +1 -0
  266. package/dist/lifecycle.contract.test.js +135 -0
  267. package/dist/queue.d.ts +11 -0
  268. package/dist/queue.js +60 -0
  269. package/dist/queue.test.d.ts +1 -0
  270. package/dist/queue.test.js +46 -0
  271. package/dist/test-fetch-stub.d.ts +6 -0
  272. package/dist/test-fetch-stub.js +31 -0
  273. package/package.json +2 -1
  274. package/templates/prod.env.example +1 -1
  275. package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
  276. package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
@@ -0,0 +1,170 @@
1
+ // packages/server/src/adapters/github.ts
2
+ //
3
+ // The coordinator's read/verify boundary onto GitHub via `gh`. Review bodies and PR
4
+ // bodies are always written to a file and passed with `--body-file`, never shell-
5
+ // interpolated (design §"Role permissions and GitHub access"). `publishReview` (NOT-62)
6
+ // is called from the coordinator process itself, never the reviewer worker — the reviewer
7
+ // profile's `publishReview` PermissionPolicy flag is always false; "the coordinator... is
8
+ // the only component that publishes the review result" (design §"Role permissions").
9
+ //
10
+ // `GithubAdapter` is the injectable seam: production code uses `realGithubAdapter` (shells
11
+ // out to the real `gh` CLI), while tests inject a fake — no real GitHub calls, matching the
12
+ // "controlled GitHub adapter fixture" the NOT-61 acceptance criteria asks for.
13
+ import { execFile } from "node:child_process";
14
+ import { promisify } from "node:util";
15
+ const run = promisify(execFile);
16
+ export function parsePrView(json) {
17
+ const raw = JSON.parse(json);
18
+ return {
19
+ number: raw.number,
20
+ url: raw.url,
21
+ baseRefName: raw.baseRefName,
22
+ headRefName: raw.headRefName,
23
+ headRefOid: raw.headRefOid,
24
+ isDraft: raw.isDraft,
25
+ };
26
+ }
27
+ export const PR_VIEW_FIELDS = "number,url,baseRefName,headRefName,headRefOid,isDraft";
28
+ const FAILURE_STATES = new Set([
29
+ "failure",
30
+ "error",
31
+ "cancelled",
32
+ "timed_out",
33
+ "action_required",
34
+ "stale",
35
+ "startup_failure",
36
+ ]);
37
+ const PENDING_STATES = new Set(["pending", "queued", "in_progress", "expected", "waiting", "requested"]);
38
+ /** Every terminal-and-genuinely-ok conclusion `gh` can report — anything else fails closed. */
39
+ const SUCCESS_STATES = new Set(["success", "neutral", "skipped"]);
40
+ export function summarizeChecks(rollup) {
41
+ if (rollup.length === 0)
42
+ return "none";
43
+ const states = rollup.map((c) => (c.conclusion || c.state || c.status || "").toLowerCase());
44
+ if (states.some((s) => FAILURE_STATES.has(s)))
45
+ return "failure";
46
+ if (states.some((s) => PENDING_STATES.has(s)))
47
+ return "pending";
48
+ if (states.every((s) => SUCCESS_STATES.has(s)))
49
+ return "success";
50
+ // An unrecognized terminal conclusion is never treated as success — fail closed rather
51
+ // than silently waving a handoff through on a vocabulary this code doesn't know yet.
52
+ return "failure";
53
+ }
54
+ const NO_COMMITS_PATTERN = /no commits between/i;
55
+ const defaultExec = (args, opts) => run("gh", args, opts);
56
+ async function ghPrView(exec, cwd, fields, selector) {
57
+ const args = ["pr", "view", ...(selector != null ? [selector] : []), "--json", fields];
58
+ try {
59
+ const { stdout } = await exec(args, { cwd });
60
+ return JSON.parse(stdout);
61
+ }
62
+ catch (err) {
63
+ const message = err.stderr ?? err.message;
64
+ if (/no pull requests found|no default remote|could not find/i.test(message))
65
+ return null;
66
+ throw new Error(`gh pr view failed: ${message}`);
67
+ }
68
+ }
69
+ /** Real `gh` adapter, parameterized over the shell-out so tests can assert exact CLI args without a real `gh`. */
70
+ export function createGithubAdapter(exec = defaultExec) {
71
+ return {
72
+ async viewPr({ cwd, number, branch }) {
73
+ const selector = number != null ? String(number) : branch;
74
+ const raw = await ghPrView(exec, cwd, PR_VIEW_FIELDS, selector);
75
+ return raw ? parsePrView(JSON.stringify(raw)) : null;
76
+ },
77
+ async createDraftPr({ cwd, base, head, title, bodyFilePath }) {
78
+ try {
79
+ const { stdout } = await exec(["pr", "create", "--draft", "--base", base, "--head", head, "--title", title, "--body-file", bodyFilePath], { cwd });
80
+ // Re-verified by the same explicit branch — a bare `gh pr view` right after this
81
+ // can hit the identical no-upstream failure `gh pr create` needed `--head` for.
82
+ const view = await ghPrView(exec, cwd, "number,url", head);
83
+ if (view)
84
+ return { ok: true, number: Number(view.number), url: String(view.url) };
85
+ // Fallback: `gh pr create` prints the PR URL on its last stdout line.
86
+ const url = stdout.trim().split("\n").pop() ?? "";
87
+ const match = url.match(/\/pull\/(\d+)/);
88
+ return { ok: true, number: match ? Number(match[1]) : 0, url };
89
+ }
90
+ catch (err) {
91
+ const message = err.stderr ?? err.message;
92
+ return { ok: false, reason: message, noCommits: NO_COMMITS_PATTERN.test(message) };
93
+ }
94
+ },
95
+ async checksSnapshot({ cwd, number, branch }) {
96
+ const selector = number != null ? String(number) : branch;
97
+ const raw = await ghPrView(exec, cwd, "statusCheckRollup", selector);
98
+ const rollup = raw?.statusCheckRollup ?? [];
99
+ return summarizeChecks(rollup);
100
+ },
101
+ async publishReview({ cwd, number, event, bodyFilePath }) {
102
+ const result = await runReview(exec, cwd, number, event, bodyFilePath);
103
+ if (result.ok || event === "COMMENT" || !OWN_PR_REVIEW_PATTERN.test(result.reason)) {
104
+ return result.ok ? { ok: true, event, usedCommentFallback: false } : result;
105
+ }
106
+ const fallback = await runReview(exec, cwd, number, "COMMENT", bodyFilePath);
107
+ return fallback.ok
108
+ ? { ok: true, event: "COMMENT", usedCommentFallback: true }
109
+ : { ok: false, reason: `${event} rejected as self-review, and comment fallback also failed: ${fallback.reason}` };
110
+ },
111
+ };
112
+ }
113
+ export const realGithubAdapter = createGithubAdapter();
114
+ const REVIEW_FLAG = {
115
+ APPROVE: "--approve",
116
+ REQUEST_CHANGES: "--request-changes",
117
+ COMMENT: "--comment",
118
+ };
119
+ /** GitHub's self-review rejection, worded per event ("approve" / "request changes"). */
120
+ const OWN_PR_REVIEW_PATTERN = /own pull request/i;
121
+ async function runReview(exec, cwd, number, event, bodyFilePath) {
122
+ try {
123
+ // The PR is targeted explicitly by number — the reviewer's worktree is a detached-HEAD
124
+ // checkout with no current branch for a bare `gh pr review` to resolve against.
125
+ await exec(["pr", "review", String(number), REVIEW_FLAG[event], "--body-file", bodyFilePath], { cwd });
126
+ return { ok: true };
127
+ }
128
+ catch (err) {
129
+ const message = err.stderr ?? err.message;
130
+ return { ok: false, reason: message };
131
+ }
132
+ }
133
+ /** Consecutive "none" reads required before concluding no checks are configured at all. */
134
+ const NONE_STREAK_REQUIRED = 2;
135
+ /**
136
+ * Bounded poll for CI checks to resolve — the confirmed scope call for NOT-61 (vs a
137
+ * one-shot read): "pending" keeps polling, "success"/"failure" return immediately once
138
+ * seen, and the poll gives up as "timeout" if nothing resolves in time or the lease is
139
+ * lost (`signal` aborts) mid-poll.
140
+ *
141
+ * "none" is NOT returned on the first read: GitHub can briefly report an empty
142
+ * statusCheckRollup before Actions has created its check runs, so a one-shot "empty
143
+ * rollup ⇒ no checks configured" read races a PR straight past CI — a review round caught
144
+ * this letting a real handoff through before its checks even appeared. `none` is only
145
+ * accepted once it has been read `NONE_STREAK_REQUIRED` times in a row (any "pending" in
146
+ * between resets the streak); if the deadline is hit before that streak completes, the
147
+ * poll fails closed as "timeout" rather than silently waving the handoff through.
148
+ */
149
+ export async function pollPrChecks(adapter, opts) {
150
+ const deadline = Date.now() + opts.timeoutMs;
151
+ let noneStreak = 0;
152
+ for (;;) {
153
+ if (opts.signal?.aborted)
154
+ return "timeout";
155
+ const snapshot = await adapter.checksSnapshot({ cwd: opts.cwd, number: opts.number, branch: opts.branch });
156
+ if (snapshot === "failure" || snapshot === "success")
157
+ return snapshot;
158
+ if (snapshot === "none") {
159
+ noneStreak++;
160
+ if (noneStreak >= NONE_STREAK_REQUIRED)
161
+ return "none";
162
+ }
163
+ else {
164
+ noneStreak = 0;
165
+ }
166
+ if (Date.now() >= deadline)
167
+ return "timeout";
168
+ await new Promise((resolve) => setTimeout(resolve, Math.min(opts.intervalMs, deadline - Date.now())));
169
+ }
170
+ }
@@ -0,0 +1,205 @@
1
+ // packages/server/src/adapters/github.test.ts
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { parsePrView, summarizeChecks, pollPrChecks, createGithubAdapter, PR_VIEW_FIELDS, } from "./github.js";
5
+ test("parsePrView extracts the ground-truth handoff fields, including draft status", () => {
6
+ const view = parsePrView(JSON.stringify({
7
+ number: 7,
8
+ url: "https://github.com/o/r/pull/7",
9
+ baseRefName: "main",
10
+ headRefName: "issue-1",
11
+ headRefOid: "abc123",
12
+ isDraft: true,
13
+ }));
14
+ assert.deepEqual(view, {
15
+ number: 7,
16
+ url: "https://github.com/o/r/pull/7",
17
+ baseRefName: "main",
18
+ headRefName: "issue-1",
19
+ headRefOid: "abc123",
20
+ isDraft: true,
21
+ });
22
+ });
23
+ test("summarizeChecks: empty rollup is none, any failure wins over pending, pending beats success", () => {
24
+ assert.equal(summarizeChecks([]), "none");
25
+ assert.equal(summarizeChecks([{ state: "success" }, { state: "success" }]), "success");
26
+ assert.equal(summarizeChecks([{ state: "success" }, { status: "in_progress" }]), "pending");
27
+ assert.equal(summarizeChecks([{ conclusion: "failure" }, { status: "in_progress" }]), "failure");
28
+ assert.equal(summarizeChecks([{ conclusion: "cancelled" }]), "failure");
29
+ });
30
+ test("summarizeChecks accepts neutral/skipped as success but fails closed on an unrecognized conclusion", () => {
31
+ assert.equal(summarizeChecks([{ conclusion: "neutral" }, { conclusion: "skipped" }]), "success");
32
+ assert.equal(summarizeChecks([{ conclusion: "some_new_gh_conclusion_this_code_does_not_know" }]), "failure");
33
+ });
34
+ /** Records every `gh` invocation and returns responses off a queue — never calls real `gh`. */
35
+ function queuedExec(responses) {
36
+ const calls = [];
37
+ const queue = [...responses];
38
+ const exec = async (args) => {
39
+ calls.push(args);
40
+ const next = queue.shift() ?? { stdout: "" };
41
+ if (next.error != null)
42
+ throw Object.assign(new Error(next.error), { stderr: next.error });
43
+ return { stdout: next.stdout ?? "" };
44
+ };
45
+ return { exec, calls };
46
+ }
47
+ test("viewPr looks up the PR explicitly by branch, never a bare `gh pr view`", async () => {
48
+ const { exec, calls } = queuedExec([
49
+ { stdout: JSON.stringify({ number: 5, url: "u", baseRefName: "main", headRefName: "issue-x", headRefOid: "abc", isDraft: true }) },
50
+ ]);
51
+ const view = await createGithubAdapter(exec).viewPr({ cwd: "/repo", branch: "issue-x" });
52
+ assert.deepEqual(calls[0], ["pr", "view", "issue-x", "--json", PR_VIEW_FIELDS]);
53
+ assert.equal(view?.number, 5);
54
+ });
55
+ test("viewPr prefers an explicit PR number over branch when both are given", async () => {
56
+ const { exec, calls } = queuedExec([
57
+ { stdout: JSON.stringify({ number: 5, url: "u", baseRefName: "main", headRefName: "issue-x", headRefOid: "abc", isDraft: true }) },
58
+ ]);
59
+ await createGithubAdapter(exec).viewPr({ cwd: "/repo", number: 5, branch: "issue-x" });
60
+ assert.deepEqual(calls[0], ["pr", "view", "5", "--json", PR_VIEW_FIELDS]);
61
+ });
62
+ test("createDraftPr passes --head explicitly and re-verifies by that same branch, not a bare `gh pr view`", async () => {
63
+ const { exec, calls } = queuedExec([
64
+ { stdout: "https://github.com/o/r/pull/9\n" },
65
+ { stdout: JSON.stringify({ number: 9, url: "https://github.com/o/r/pull/9" }) },
66
+ ]);
67
+ const result = await createGithubAdapter(exec).createDraftPr({
68
+ cwd: "/repo",
69
+ base: "main",
70
+ head: "issue-x",
71
+ title: "Add widget",
72
+ bodyFilePath: "/tmp/body.md",
73
+ });
74
+ assert.deepEqual(calls[0], ["pr", "create", "--draft", "--base", "main", "--head", "issue-x", "--title", "Add widget", "--body-file", "/tmp/body.md"]);
75
+ assert.deepEqual(calls[1], ["pr", "view", "issue-x", "--json", "number,url"]);
76
+ assert.deepEqual(result, { ok: true, number: 9, url: "https://github.com/o/r/pull/9" });
77
+ });
78
+ // NOT-82 dogfood repro: a real Dev-review run pushed its generated branch to origin, but
79
+ // the local worktree had no configured upstream — `gh pr create` (bare, no `--head`)
80
+ // refused with exactly this error, and the coordinator must never hit it.
81
+ const NO_UPSTREAM_ERROR = "aborted: you must first push the current branch to a remote, or use the --head flag";
82
+ test("createDraftPr: a branch pushed to origin with no local upstream still opens a draft PR when --head is explicit", async () => {
83
+ const exec = async (args) => {
84
+ if (args[0] === "pr" && args[1] === "create") {
85
+ if (!args.includes("--head"))
86
+ throw Object.assign(new Error(NO_UPSTREAM_ERROR), { stderr: NO_UPSTREAM_ERROR });
87
+ return { stdout: "https://github.com/o/r/pull/42\n" };
88
+ }
89
+ if (args[0] === "pr" && args[1] === "view" && args[2] === "issue-4e5eb611") {
90
+ return { stdout: JSON.stringify({ number: 42, url: "https://github.com/o/r/pull/42" }) };
91
+ }
92
+ throw new Error(`unexpected gh invocation: ${args.join(" ")}`);
93
+ };
94
+ const result = await createGithubAdapter(exec).createDraftPr({
95
+ cwd: "/repo",
96
+ base: "main",
97
+ head: "issue-4e5eb611",
98
+ title: "Complete CLI surface",
99
+ bodyFilePath: "/tmp/body.md",
100
+ });
101
+ assert.deepEqual(result, { ok: true, number: 42, url: "https://github.com/o/r/pull/42" });
102
+ });
103
+ test("viewPr: a retry can find the already-created PR by explicit branch even with no local upstream", async () => {
104
+ const exec = async (args) => {
105
+ // Bare `gh pr view` (no selector) can't resolve the current branch without upstream
106
+ // tracking — simulates the real failure mode this adapter must never hit.
107
+ if (args[1] === "view" && args[2] === "--json") {
108
+ throw Object.assign(new Error(), { stderr: 'no pull requests found for branch "HEAD"' });
109
+ }
110
+ return { stdout: JSON.stringify({ number: 42, url: "u", baseRefName: "main", headRefName: args[2], headRefOid: "abc", isDraft: true }) };
111
+ };
112
+ const view = await createGithubAdapter(exec).viewPr({ cwd: "/repo", branch: "issue-4e5eb611" });
113
+ assert.equal(view?.number, 42);
114
+ });
115
+ test("checksSnapshot looks up the PR's check rollup by explicit selector (number preferred), never a bare `gh pr view`", async () => {
116
+ const success = { stdout: JSON.stringify({ statusCheckRollup: [{ conclusion: "success" }] }) };
117
+ const byNumber = queuedExec([success]);
118
+ await createGithubAdapter(byNumber.exec).checksSnapshot({ cwd: "/repo", number: 42 });
119
+ assert.deepEqual(byNumber.calls[0], ["pr", "view", "42", "--json", "statusCheckRollup"]);
120
+ const byBranch = queuedExec([success]);
121
+ await createGithubAdapter(byBranch.exec).checksSnapshot({ cwd: "/repo", branch: "issue-x" });
122
+ assert.deepEqual(byBranch.calls[0], ["pr", "view", "issue-x", "--json", "statusCheckRollup"]);
123
+ const preferNumber = queuedExec([success]);
124
+ await createGithubAdapter(preferNumber.exec).checksSnapshot({ cwd: "/repo", number: 42, branch: "issue-x" });
125
+ assert.deepEqual(preferNumber.calls[0], ["pr", "view", "42", "--json", "statusCheckRollup"]);
126
+ // Bare, unselected lookup — the shape NOT-82's checks-poll stage must never fall back
127
+ // to now that pollPrChecks always forwards the identity-validated PR number.
128
+ const bare = queuedExec([success]);
129
+ await createGithubAdapter(bare.exec).checksSnapshot({ cwd: "/repo" });
130
+ assert.deepEqual(bare.calls[0], ["pr", "view", "--json", "statusCheckRollup"]);
131
+ });
132
+ test("pollPrChecks forwards its selector to checksSnapshot on every poll iteration", async () => {
133
+ const seen = [];
134
+ const adapter = {
135
+ viewPr: async () => null,
136
+ createDraftPr: async () => ({ ok: false, reason: "unused", noCommits: false }),
137
+ checksSnapshot: async ({ number, branch }) => {
138
+ seen.push({ number, branch });
139
+ return "success";
140
+ },
141
+ publishReview: async () => ({ ok: false, reason: "unused" }),
142
+ };
143
+ await pollPrChecks(adapter, { cwd: "/repo", timeoutMs: 1000, intervalMs: 5, number: 42 });
144
+ assert.deepEqual(seen, [{ number: 42, branch: undefined }]);
145
+ });
146
+ function fakeAdapter(sequence) {
147
+ const queue = [...sequence];
148
+ let last = "pending";
149
+ return {
150
+ viewPr: async () => null,
151
+ createDraftPr: async () => ({ ok: false, reason: "unused", noCommits: false }),
152
+ checksSnapshot: async () => {
153
+ if (queue.length)
154
+ last = queue.shift();
155
+ return last;
156
+ },
157
+ publishReview: async () => ({ ok: false, reason: "unused" }),
158
+ };
159
+ }
160
+ test("pollPrChecks returns immediately on success/failure without polling again", async () => {
161
+ assert.equal(await pollPrChecks(fakeAdapter(["success"]), { cwd: "/x", timeoutMs: 1000, intervalMs: 5 }), "success");
162
+ assert.equal(await pollPrChecks(fakeAdapter(["failure"]), { cwd: "/x", timeoutMs: 1000, intervalMs: 5 }), "failure");
163
+ });
164
+ test("pollPrChecks requires none to be read twice in a row before concluding no checks are configured", async () => {
165
+ // A single "none" read (Actions hasn't created its check runs yet) must NOT resolve.
166
+ const result = await pollPrChecks(fakeAdapter(["none", "pending", "none", "none"]), {
167
+ cwd: "/x",
168
+ timeoutMs: 1000,
169
+ intervalMs: 5,
170
+ });
171
+ assert.equal(result, "none");
172
+ });
173
+ test("pollPrChecks fails closed as timeout if the none-streak never completes in time", async () => {
174
+ // timeoutMs: 0 — the deadline is already passed after the very first read, before a
175
+ // second "none" can confirm the streak, so this must NOT resolve as "none".
176
+ const result = await pollPrChecks(fakeAdapter(["none"]), { cwd: "/x", timeoutMs: 0, intervalMs: 10 });
177
+ assert.equal(result, "timeout");
178
+ });
179
+ test("pollPrChecks keeps polling through pending until a terminal state resolves", async () => {
180
+ const result = await pollPrChecks(fakeAdapter(["pending", "pending", "success"]), {
181
+ cwd: "/x",
182
+ timeoutMs: 1000,
183
+ intervalMs: 5,
184
+ });
185
+ assert.equal(result, "success");
186
+ });
187
+ test("pollPrChecks gives up as timeout when checks stay pending past the deadline", async () => {
188
+ const result = await pollPrChecks(fakeAdapter(["pending", "pending", "pending", "pending"]), {
189
+ cwd: "/x",
190
+ timeoutMs: 20,
191
+ intervalMs: 10,
192
+ });
193
+ assert.equal(result, "timeout");
194
+ });
195
+ test("pollPrChecks bails out as timeout immediately when the lease signal is already aborted", async () => {
196
+ const controller = new AbortController();
197
+ controller.abort();
198
+ const result = await pollPrChecks(fakeAdapter(["pending"]), {
199
+ cwd: "/x",
200
+ timeoutMs: 1000,
201
+ intervalMs: 5,
202
+ signal: controller.signal,
203
+ });
204
+ assert.equal(result, "timeout");
205
+ });
@@ -1,10 +1,11 @@
1
- import { findActiveByExternalId, listRunsReadyForPlanReview } from "../repository/runs.js";
2
- import { getLinearIntakeConfig } from "../repository/intake-settings.js";
1
+ import { DEFAULT_LINEAR_STATE_FILTER, getLinearIntakeConfig } from "../repository/intake-settings.js";
2
+ export { DEFAULT_LINEAR_STATE_FILTER };
3
3
  const LINEAR_API = "https://api.linear.app/graphql";
4
+ const PAGE_SIZE = 50;
4
5
  function hasApiKey() {
5
6
  return Boolean(process.env.LINEAR_API_KEY);
6
7
  }
7
- async function linearQuery(query, variables) {
8
+ async function linearQuery(query, variables, opts) {
8
9
  const key = process.env.LINEAR_API_KEY;
9
10
  if (!key)
10
11
  throw new Error("LINEAR_API_KEY not set");
@@ -12,6 +13,7 @@ async function linearQuery(query, variables) {
12
13
  method: "POST",
13
14
  headers: { "Content-Type": "application/json", Authorization: key },
14
15
  body: JSON.stringify({ query, variables }),
16
+ ...(opts?.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : {}),
15
17
  });
16
18
  if (!res.ok)
17
19
  throw new Error(`Linear HTTP ${res.status}: ${await res.text()}`);
@@ -48,20 +50,6 @@ export async function getLinearViewer() {
48
50
  const data = (await linearQuery(`query { viewer { id name email } }`));
49
51
  return data.viewer;
50
52
  }
51
- export async function testLinearConnection() {
52
- if (!hasApiKey()) {
53
- return { connected: false, error: "LINEAR_API_KEY not set" };
54
- }
55
- try {
56
- const viewer = await getLinearViewer();
57
- if (!viewer)
58
- return { connected: false, error: "No viewer returned" };
59
- return { connected: true, viewer };
60
- }
61
- catch (e) {
62
- return { connected: false, error: String(e) };
63
- }
64
- }
65
53
  export function buildIssueFilter(settings, viewerId) {
66
54
  const filter = {
67
55
  state: { name: { in: settings.stateFilter } },
@@ -74,8 +62,23 @@ export function buildIssueFilter(settings, viewerId) {
74
62
  }
75
63
  return filter;
76
64
  }
77
- function isPromoted(issueId) {
78
- return findActiveByExternalId("linear", issueId) !== null;
65
+ /**
66
+ * Normalize free-form kick input to a Linear issue id or identifier.
67
+ * Accepts `NOT-103`, a UUID, or a Linear issue URL.
68
+ */
69
+ export function parseLinearIssueRef(raw) {
70
+ const trimmed = raw.trim();
71
+ if (!trimmed)
72
+ return null;
73
+ const fromUrl = trimmed.match(/linear\.app\/[^/]+\/issue\/([A-Za-z0-9_-]+)/i);
74
+ if (fromUrl?.[1])
75
+ return fromUrl[1];
76
+ if (/^[A-Z][A-Z0-9]*-\d+$/i.test(trimmed))
77
+ return trimmed.toUpperCase();
78
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)) {
79
+ return trimmed.toLowerCase();
80
+ }
81
+ return null;
79
82
  }
80
83
  export async function listLinearCandidates() {
81
84
  if (!hasApiKey())
@@ -89,14 +92,21 @@ export async function listLinearCandidates() {
89
92
  return [];
90
93
  }
91
94
  const filter = buildIssueFilter(settings, viewerId);
92
- const data = (await linearQuery(`query PollIssues($filter: IssueFilter) {
93
- issues(filter: $filter, first: 30) {
94
- nodes { ${ISSUE_FIELDS} }
95
- }
96
- }`, { filter }));
97
- return data.issues.nodes
98
- .filter((n) => !isPromoted(n.id))
99
- .map(nodeToCandidate);
95
+ const nodes = [];
96
+ let after;
97
+ for (;;) {
98
+ const data = (await linearQuery(`query PollIssues($filter: IssueFilter, $after: String) {
99
+ issues(filter: $filter, first: ${PAGE_SIZE}, after: $after) {
100
+ nodes { ${ISSUE_FIELDS} }
101
+ pageInfo { hasNextPage endCursor }
102
+ }
103
+ }`, { filter, after: after ?? null }));
104
+ nodes.push(...data.issues.nodes);
105
+ if (!data.issues.pageInfo.hasNextPage || !data.issues.pageInfo.endCursor)
106
+ break;
107
+ after = data.issues.pageInfo.endCursor;
108
+ }
109
+ return nodes.map(nodeToCandidate);
100
110
  }
101
111
  export async function getLinearIssue(issueId) {
102
112
  const data = (await linearQuery(`query Issue($id: String!) {
@@ -106,7 +116,166 @@ export async function getLinearIssue(issueId) {
106
116
  return null;
107
117
  return nodeToCandidate(data.issue);
108
118
  }
109
- /** Runs with a plan ready for human review. */
110
- export function listAwaitingPlanReview() {
111
- return listRunsReadyForPlanReview();
119
+ const RELATION_PAGE_FIELDS = `
120
+ nodes { type issue { id identifier state { name type } } }
121
+ pageInfo { hasNextPage endCursor }
122
+ `;
123
+ /** A blocker Linear won't let us read (other team, no permission, deleted) — never satisfied. */
124
+ const UNREADABLE_BLOCKER = {
125
+ id: "",
126
+ identifier: "an unreadable blocker",
127
+ stateName: "no access",
128
+ stateType: "unknown",
129
+ };
130
+ function relationToBlocker(node) {
131
+ if (!node.issue)
132
+ return UNREADABLE_BLOCKER;
133
+ return {
134
+ id: node.issue.id,
135
+ identifier: node.issue.identifier,
136
+ stateName: node.issue.state?.name ?? "unknown state",
137
+ stateType: node.issue.state?.type ?? "unknown",
138
+ };
139
+ }
140
+ /**
141
+ * NOT-104: for each requested Linear issue id, the issues that declare `blocks` on it.
142
+ *
143
+ * Linear models "Y blocks X" as a relation *from* Y, so X sees it in `inverseRelations`;
144
+ * X's own `relations` are its *downstream* issues and reading those would gate on the
145
+ * wrong side of every edge. Only `type === "blocks"` counts — `related`, `duplicate` and
146
+ * the parent/sub-issue hierarchy are deliberately ignored so an open parent epic does not
147
+ * block its own children.
148
+ *
149
+ * `includeArchived` is on because a queued dealer issue whose Linear ticket was archived
150
+ * must still resolve; archived blockers are judged by their state type, not the flag.
151
+ *
152
+ * Every request covers the whole batch — per-entry queries would make a 10-entry queue at a
153
+ * 60s TTL cost ~600 Linear requests/hour. Ids Linear does not return are simply absent from
154
+ * the map; the caller treats that as "unknown", never as "no blockers". An issue with more
155
+ * relations than fit in one page is *paged through* rather than dropped: `related` and
156
+ * `duplicate` links share that connection, so a well-linked ticket would otherwise park for
157
+ * good on relations that were never meant to gate it. Those nested pages are walked in
158
+ * rounds, one request per round across every issue still paging, so relation volume costs
159
+ * extra requests only in depth (rare) and never in queue width.
160
+ */
161
+ export async function fetchLinearBlockers(issueIds, opts = {}) {
162
+ const out = new Map();
163
+ if (issueIds.length === 0)
164
+ return out;
165
+ // One deadline for the whole batch, not per page: the caller's budget is a slice of a ~3s
166
+ // coordinator tick, and a queue deeper than one page must not multiply it.
167
+ const deadlineAt = opts.timeoutMs ? Date.now() + opts.timeoutMs : null;
168
+ const remaining = () => (deadlineAt ? Math.max(1, deadlineAt - Date.now()) : undefined);
169
+ // Relations gathered so far per issue. Deleting an id here is how an incomplete list stays
170
+ // "unknown": it never reaches `out`, and the caller parks the entry for this tick only.
171
+ const relationsById = new Map();
172
+ let paging = [];
173
+ let after;
174
+ for (;;) {
175
+ const data = (await linearQuery(`query BlockingRelations($ids: [ID!], $after: String) {
176
+ issues(filter: { id: { in: $ids } }, first: ${PAGE_SIZE}, after: $after, includeArchived: true) {
177
+ nodes {
178
+ id
179
+ inverseRelations(first: ${PAGE_SIZE}) { ${RELATION_PAGE_FIELDS} }
180
+ }
181
+ pageInfo { hasNextPage endCursor }
182
+ }
183
+ }`, { ids: issueIds, after: after ?? null }, { timeoutMs: remaining() }));
184
+ for (const node of data.issues.nodes) {
185
+ // A list we did not get at all is unknown, not "no blockers": the one unsatisfied
186
+ // blocker may be exactly the relation we are missing.
187
+ if (!node.inverseRelations)
188
+ continue;
189
+ relationsById.set(node.id, [...(node.inverseRelations.nodes ?? [])]);
190
+ const next = nextRelationCursor(node.inverseRelations, null);
191
+ if (next === INCOMPLETE)
192
+ relationsById.delete(node.id);
193
+ else if (next)
194
+ paging.push({ id: node.id, after: next });
195
+ }
196
+ if (!data.issues.pageInfo.hasNextPage || !data.issues.pageInfo.endCursor)
197
+ break;
198
+ after = data.issues.pageInfo.endCursor;
199
+ }
200
+ // One request per round, however many issues are still paging.
201
+ while (paging.length > 0) {
202
+ const pages = await fetchInverseRelationRound(paging, remaining);
203
+ const nextRound = [];
204
+ for (const pending of paging) {
205
+ const page = pages.get(pending.id);
206
+ const relations = relationsById.get(pending.id);
207
+ // Linear stopped returning the issue mid-walk: we saw part of the list and the blocker
208
+ // that matters may be in the part we did not.
209
+ if (!page || !relations) {
210
+ relationsById.delete(pending.id);
211
+ continue;
212
+ }
213
+ relations.push(...(page.nodes ?? []));
214
+ const next = nextRelationCursor(page, pending.after);
215
+ if (next === INCOMPLETE)
216
+ relationsById.delete(pending.id);
217
+ else if (next)
218
+ nextRound.push({ id: pending.id, after: next });
219
+ }
220
+ paging = nextRound;
221
+ }
222
+ for (const [id, relations] of relationsById) {
223
+ out.set(id, relations.filter((r) => r.type === "blocks").map(relationToBlocker));
224
+ }
225
+ return out;
226
+ }
227
+ /** A relation list Linear will not let us finish — the id stays out of the result. */
228
+ const INCOMPLETE = Symbol("incomplete relation page");
229
+ /**
230
+ * Where to continue a relation connection: `null` when the list is complete, a cursor when
231
+ * there is more, `INCOMPLETE` when Linear claims more but hands back no usable cursor (which
232
+ * would otherwise loop forever).
233
+ */
234
+ function nextRelationCursor(page, after) {
235
+ if (!page.pageInfo?.hasNextPage)
236
+ return null;
237
+ const cursor = page.pageInfo.endCursor;
238
+ if (!cursor || cursor === after)
239
+ return INCOMPLETE;
240
+ return cursor;
241
+ }
242
+ /**
243
+ * Advance every still-paging issue by one nested page in a single request.
244
+ *
245
+ * Each issue needs its own cursor, which one `issues(filter:)` selection cannot express, so
246
+ * the round is built as one aliased selection per issue. That keeps the cost of deep relation
247
+ * lists at one request per *round* rather than one per entry — the acceptance criterion is
248
+ * "no per-entry queries", and a queue of well-linked tickets is exactly where the naive
249
+ * version multiplies. Issues Linear omits come back absent from the map.
250
+ */
251
+ async function fetchInverseRelationRound(pending, remaining) {
252
+ const varDefs = [];
253
+ const selections = [];
254
+ const variables = {};
255
+ pending.forEach((entry, i) => {
256
+ varDefs.push(`$ids${i}: [ID!], $after${i}: String`);
257
+ selections.push(`r${i}: issues(filter: { id: { in: $ids${i} } }, first: 1, includeArchived: true) {
258
+ nodes {
259
+ id
260
+ inverseRelations(first: ${PAGE_SIZE}, after: $after${i}) { ${RELATION_PAGE_FIELDS} }
261
+ }
262
+ }`);
263
+ variables[`ids${i}`] = [entry.id];
264
+ variables[`after${i}`] = entry.after;
265
+ });
266
+ const data = (await linearQuery(`query BlockingRelationsPage(${varDefs.join(", ")}) {\n${selections.join("\n")}\n}`, variables, { timeoutMs: remaining() }));
267
+ const out = new Map();
268
+ pending.forEach((entry, i) => {
269
+ const page = data[`r${i}`]?.nodes?.find((n) => n.id === entry.id)?.inverseRelations;
270
+ if (page)
271
+ out.set(entry.id, page);
272
+ });
273
+ return out;
274
+ }
275
+ /** Resolve free-form kick text to a Linear candidate (or null if not found / unparseable). */
276
+ export async function lookupLinearIssue(raw) {
277
+ const id = parseLinearIssueRef(raw);
278
+ if (!id)
279
+ return null;
280
+ return getLinearIssue(id);
112
281
  }