@alecsibilia/luca 14.0.0-alpha.2 → 14.0.0-alpha.21

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 (321) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +7 -2
  3. package/board/client/board-look.ts +31 -1
  4. package/board/client/board-panel-parts.tsx +150 -0
  5. package/board/client/board-panel-stages.ts +99 -0
  6. package/board/client/board-panel-styles.ts +223 -0
  7. package/board/client/board-panel.tsx +77 -1275
  8. package/board/client/engine-life.ts +48 -0
  9. package/board/client/final-review-stack.tsx +144 -0
  10. package/board/client/heartbeat.tsx +61 -0
  11. package/board/client/helper-lines.tsx +69 -0
  12. package/board/client/live-feed.tsx +86 -0
  13. package/board/client/live-line.ts +73 -0
  14. package/board/client/model-line.tsx +178 -0
  15. package/board/client/needs-you-section.tsx +133 -0
  16. package/board/client/one-line.tsx +193 -0
  17. package/board/client/repo-wait-banner.tsx +69 -0
  18. package/board/client/run-footer.tsx +68 -0
  19. package/board/client/run-header.tsx +189 -0
  20. package/board/client/run-picker.tsx +98 -0
  21. package/board/client/status-bar.tsx +131 -0
  22. package/board/client/step-dots.tsx +127 -0
  23. package/board/client/stuck-actions.tsx +269 -0
  24. package/board/client/ticket-card-view.tsx +238 -0
  25. package/board/client/ticket-detail.tsx +87 -0
  26. package/board/client/ticket-stack.tsx +119 -0
  27. package/board/client/ticket-wait.ts +188 -0
  28. package/board/client/timeline-rows.tsx +41 -14
  29. package/board/client/usage-line.tsx +51 -0
  30. package/board/client/wait-line.tsx +39 -0
  31. package/board/index.client.tsx +1 -1
  32. package/board/index.server.ts +7 -1
  33. package/board/package.json +1 -1
  34. package/board/server/board-panel-state-helpers.ts +67 -0
  35. package/board/server/board-server-check.ts +294 -0
  36. package/board/server/board-server-engine.ts +129 -0
  37. package/board/server/board-server-events.ts +134 -0
  38. package/board/server/board-server-read.ts +190 -0
  39. package/board/server/board-server-resume.ts +164 -0
  40. package/board/server/board-server-start.ts +174 -0
  41. package/board/server/board-server-store.ts +281 -0
  42. package/board/server/board-server-types.ts +114 -0
  43. package/board/server/board-server-util.ts +4 -0
  44. package/board/server/board-server.ts +52 -943
  45. package/board/server/board-vocabulary-engine.ts +144 -0
  46. package/board/server/board-vocabulary-review.ts +115 -0
  47. package/board/server/board-vocabulary-run.ts +145 -0
  48. package/board/server/board-vocabulary-shapes.ts +153 -0
  49. package/board/server/board-vocabulary.ts +20 -375
  50. package/board/server/current-step-panel-helpers.ts +108 -0
  51. package/board/server/describe-record.ts +403 -0
  52. package/board/server/engine-launch.ts +20 -3
  53. package/board/server/engine-restart-helpers.ts +105 -0
  54. package/board/server/engine-watch.ts +45 -5
  55. package/board/server/journal-rebuild-helpers.ts +169 -0
  56. package/board/server/live-file.ts +130 -0
  57. package/board/server/luca-version.ts +1 -1
  58. package/board/server/make-rows.ts +14 -629
  59. package/board/server/record-texts.ts +350 -0
  60. package/board/server/reduce-board-agent-start.ts +224 -0
  61. package/board/server/reduce-board-agents.ts +160 -0
  62. package/board/server/reduce-board-counters.ts +138 -0
  63. package/board/server/reduce-board-create.ts +148 -0
  64. package/board/server/reduce-board-final-review.ts +174 -0
  65. package/board/server/reduce-board-needs-you.ts +283 -0
  66. package/board/server/reduce-board-reasons.ts +59 -0
  67. package/board/server/reduce-board-run.ts +255 -0
  68. package/board/server/reduce-board-settle.ts +88 -0
  69. package/board/server/reduce-board-steps.ts +226 -0
  70. package/board/server/reduce-board-text.ts +139 -0
  71. package/board/server/reduce-board-ticket-checks.ts +168 -0
  72. package/board/server/reduce-board-ticket-join.ts +144 -0
  73. package/board/server/reduce-board-ticket-start.ts +132 -0
  74. package/board/server/reduce-board-update.ts +117 -0
  75. package/board/server/reduce-board-usage.ts +247 -0
  76. package/board/server/reduce-board.ts +100 -1917
  77. package/board/server/reply-post.ts +407 -0
  78. package/board/server/run-registry.ts +8 -4
  79. package/board/server/ticket-stages-panel-helpers.ts +296 -0
  80. package/board/shared/board-rows.ts +9 -0
  81. package/board/shared/board-rpc.ts +42 -0
  82. package/board/shared/board-state-counts.ts +77 -0
  83. package/board/shared/board-state-live.ts +99 -0
  84. package/board/shared/board-state-review.ts +59 -0
  85. package/board/shared/board-state-run.ts +236 -0
  86. package/board/shared/board-state-ticket.ts +257 -0
  87. package/board/shared/board-state-usage.ts +107 -0
  88. package/board/shared/board-state.ts +97 -567
  89. package/board/shared/reply-actions.ts +207 -0
  90. package/engine/agents/agent-launcher.ts +27 -4
  91. package/engine/agents/claude-launcher-helpers.ts +233 -0
  92. package/engine/agents/claude-launcher.ts +28 -669
  93. package/engine/agents/claude-messages.ts +212 -0
  94. package/engine/agents/claude-options.ts +43 -8
  95. package/engine/agents/common-instructions.ts +138 -0
  96. package/engine/agents/input-channel.ts +36 -0
  97. package/engine/agents/launch-paths.ts +29 -0
  98. package/engine/agents/launch-session.ts +227 -0
  99. package/engine/agents/learner-instructions.ts +31 -0
  100. package/engine/agents/open-session.ts +210 -0
  101. package/engine/agents/prompt-sections.ts +95 -0
  102. package/engine/agents/rejoin-prompts.ts +135 -0
  103. package/engine/agents/reviewer-instructions.ts +99 -0
  104. package/engine/agents/role-instructions.ts +31 -244
  105. package/engine/agents/role-prompts.ts +39 -109
  106. package/engine/agents/role-results.ts +106 -7
  107. package/engine/agents/scripted-launcher.ts +21 -6
  108. package/engine/agents/session-pool.ts +124 -0
  109. package/engine/agents/turn-progress.ts +283 -0
  110. package/engine/agents/writer-instructions.ts +151 -0
  111. package/engine/cli/computer-adapters-real.ts +15 -3
  112. package/engine/cli/computer-adapters.ts +2 -0
  113. package/engine/cli/computer-checks.ts +87 -4
  114. package/engine/cli/config-reload-text.ts +39 -0
  115. package/engine/cli/decision-model-setup.ts +70 -0
  116. package/engine/cli/doctor-command.ts +1 -0
  117. package/engine/cli/doctor.ts +21 -3
  118. package/engine/cli/going-runs.ts +192 -36
  119. package/engine/cli/init-command.ts +1 -0
  120. package/engine/cli/init.ts +23 -1
  121. package/engine/cli/live-runs.ts +73 -0
  122. package/engine/cli/luca-run.ts +42 -7
  123. package/engine/cli/luca-skills.ts +183 -0
  124. package/engine/cli/luca.ts +8 -1
  125. package/engine/cli/models-command.ts +119 -0
  126. package/engine/cli/read-run.ts +81 -0
  127. package/engine/cli/run-args.ts +33 -6
  128. package/engine/cli/run-demo.ts +143 -0
  129. package/engine/cli/run-end.ts +184 -0
  130. package/engine/cli/run-modes-helpers.ts +81 -0
  131. package/engine/cli/run-modes.ts +62 -439
  132. package/engine/cli/setup.ts +5 -48
  133. package/engine/cli/unfinished-runs.ts +122 -0
  134. package/engine/cli/upgrade-command.ts +212 -30
  135. package/engine/cli/upgrade-finish.ts +149 -0
  136. package/engine/cli/upgrade.ts +112 -71
  137. package/engine/config/config-fields.ts +127 -0
  138. package/engine/config/config-reload.ts +250 -0
  139. package/engine/config/engine-config-schema.ts +235 -0
  140. package/engine/config/engine-config.ts +72 -142
  141. package/engine/config/test-commands.ts +102 -0
  142. package/engine/core/agent-guards-helpers.ts +84 -0
  143. package/engine/core/agent-turn.ts +395 -0
  144. package/engine/core/already-done.ts +44 -1
  145. package/engine/core/build-action.ts +214 -0
  146. package/engine/core/build-context.ts +135 -0
  147. package/engine/core/build-launch.ts +259 -0
  148. package/engine/core/commit-in.ts +282 -0
  149. package/engine/core/decide-build-early.ts +108 -0
  150. package/engine/core/decide-build-ending.ts +145 -0
  151. package/engine/core/decide-build-helpers.ts +87 -0
  152. package/engine/core/decide-build-tickets.ts +207 -0
  153. package/engine/core/decide-build-view.ts +103 -0
  154. package/engine/core/decide-build.ts +101 -1368
  155. package/engine/core/decide-final-fix.ts +203 -0
  156. package/engine/core/decide-final-review.ts +55 -310
  157. package/engine/core/decide-red-green.ts +398 -0
  158. package/engine/core/decide-rejoin.ts +259 -0
  159. package/engine/core/decide-repo-wait.ts +129 -0
  160. package/engine/core/decide-run-budget.ts +86 -12
  161. package/engine/core/decide-stuck-helpers.ts +127 -0
  162. package/engine/core/decide-stuck.ts +60 -8
  163. package/engine/core/decide-ticket-review.ts +95 -0
  164. package/engine/core/decide-ticket.ts +267 -0
  165. package/engine/core/decide-waits.ts +47 -0
  166. package/engine/core/decide.ts +74 -23
  167. package/engine/core/engine-chores.ts +163 -0
  168. package/engine/core/environment-rerun.ts +83 -0
  169. package/engine/core/evidence-text.ts +84 -0
  170. package/engine/core/execute-action.ts +303 -0
  171. package/engine/core/execute-already-done-check.ts +133 -0
  172. package/engine/core/execute-build.ts +68 -944
  173. package/engine/core/execute-changeset.ts +5 -7
  174. package/engine/core/execute-final-review.ts +125 -13
  175. package/engine/core/execute-gates.ts +182 -0
  176. package/engine/core/execute-intake.ts +130 -0
  177. package/engine/core/execute-join.ts +276 -0
  178. package/engine/core/execute-memory.ts +12 -0
  179. package/engine/core/execute-pull-request.ts +84 -0
  180. package/engine/core/execute-red-check.ts +219 -0
  181. package/engine/core/execute-repo-wait.ts +129 -0
  182. package/engine/core/execute-stuck.ts +25 -10
  183. package/engine/core/execute-waits.ts +158 -0
  184. package/engine/core/execute-with-jev.ts +118 -0
  185. package/engine/core/execute.ts +124 -831
  186. package/engine/core/final-failure-cap.ts +38 -0
  187. package/engine/core/final-review-action.ts +125 -0
  188. package/engine/core/final-review-text.ts +63 -17
  189. package/engine/core/final-stuck-text.ts +86 -0
  190. package/engine/core/fix-loop-text.ts +49 -2
  191. package/engine/core/left-out.ts +120 -0
  192. package/engine/core/live-file.ts +372 -0
  193. package/engine/core/loop-caps.ts +25 -2
  194. package/engine/core/not-in-run.ts +7 -3
  195. package/engine/core/pull-request-text.ts +255 -6
  196. package/engine/core/repo-wait-text.ts +81 -0
  197. package/engine/core/review-text.ts +55 -1
  198. package/engine/core/run-crash-recovery-helpers.ts +16 -0
  199. package/engine/core/run-session-close-helpers.ts +96 -0
  200. package/engine/core/run-start.ts +148 -0
  201. package/engine/core/run-stuck-text.ts +86 -0
  202. package/engine/core/schedule-rules.ts +193 -0
  203. package/engine/core/stuck-detail-text.ts +89 -0
  204. package/engine/core/stuck-format.ts +22 -0
  205. package/engine/core/stuck-text.ts +29 -332
  206. package/engine/core/ticket-stuck-text.ts +192 -0
  207. package/engine/core/worktree-changes.ts +106 -0
  208. package/engine/gates/already-done-check.ts +120 -0
  209. package/engine/gates/feature-map.ts +96 -0
  210. package/engine/gates/gate-runner.ts +108 -108
  211. package/engine/gates/gate-schemas.ts +61 -2
  212. package/engine/gates/leftover-scan.ts +154 -31
  213. package/engine/gates/new-tests.ts +45 -0
  214. package/engine/gates/own-files-check.ts +98 -0
  215. package/engine/gates/prepare-check.ts +60 -0
  216. package/engine/gates/prepare-slots.ts +99 -0
  217. package/engine/gates/red-check.ts +184 -16
  218. package/engine/gates/shell-check.ts +64 -0
  219. package/engine/gates/slot-waits.ts +55 -0
  220. package/engine/gates/slow-test-runner.ts +59 -0
  221. package/engine/gates/test-checks.ts +161 -0
  222. package/engine/gates/test-runner.ts +164 -5
  223. package/engine/gates/test-slots.ts +48 -0
  224. package/engine/git/git-adapter.ts +55 -396
  225. package/engine/git/git-commits.ts +132 -0
  226. package/engine/git/git-join.ts +139 -0
  227. package/engine/git/git-read.ts +123 -0
  228. package/engine/git/git-run.ts +32 -0
  229. package/engine/git/git-worktrees.ts +143 -0
  230. package/engine/git/outside-links.ts +55 -0
  231. package/engine/guards/after-turn-check.ts +31 -5
  232. package/engine/guards/guard-hook.ts +60 -12
  233. package/engine/guards/guard-roles.ts +68 -0
  234. package/engine/guards/helper-rules.ts +92 -0
  235. package/engine/guards/permission-rules.ts +161 -0
  236. package/engine/guards/role-rules-helpers.ts +48 -0
  237. package/engine/guards/role-rules.ts +34 -606
  238. package/engine/guards/shell-rules.ts +344 -0
  239. package/engine/guards/worktree-git.ts +208 -0
  240. package/engine/guards/worktree-snapshot.ts +412 -0
  241. package/engine/guards/worktree-state.ts +28 -825
  242. package/engine/guards/worktree-undo.ts +260 -0
  243. package/engine/guards/write-rules.ts +149 -0
  244. package/engine/index.ts +30 -9
  245. package/engine/intake/criteria-checks.ts +114 -0
  246. package/engine/intake/intake-checks.ts +164 -30
  247. package/engine/intake/intake-schemas.ts +37 -2
  248. package/engine/jev/clef-client.ts +259 -0
  249. package/engine/jev/decision-model-credentials.ts +212 -0
  250. package/engine/jev/jev-jobs.ts +144 -60
  251. package/engine/jev/jev-schemas.ts +86 -5
  252. package/engine/jev/jev-shadow-helpers.ts +218 -0
  253. package/engine/jev/jev-shadow.ts +109 -64
  254. package/engine/jev/model-pick.ts +275 -0
  255. package/engine/journal/agent-entries.ts +167 -0
  256. package/engine/journal/agent-message-entries.ts +72 -0
  257. package/engine/journal/agent-turn-replay.ts +203 -0
  258. package/engine/journal/already-done-entries.ts +55 -0
  259. package/engine/journal/commit-entries.ts +56 -0
  260. package/engine/journal/cross-ticket-replay.ts +222 -0
  261. package/engine/journal/decision-model-entries.ts +86 -0
  262. package/engine/journal/entry-fields.ts +10 -0
  263. package/engine/journal/final-review-entries.ts +128 -0
  264. package/engine/journal/final-review-replay.ts +324 -0
  265. package/engine/journal/final-review-round.ts +320 -0
  266. package/engine/journal/final-review-state.ts +180 -0
  267. package/engine/journal/gate-entries.ts +81 -0
  268. package/engine/journal/intake-entries.ts +87 -0
  269. package/engine/journal/join-entries.ts +126 -0
  270. package/engine/journal/journal-entry-schema.ts +218 -0
  271. package/engine/journal/journal-kinds.ts +97 -0
  272. package/engine/journal/journal-record-schema.ts +218 -0
  273. package/engine/journal/journal-record.ts +43 -1400
  274. package/engine/journal/journal.ts +6 -1
  275. package/engine/journal/limit-entries.ts +90 -0
  276. package/engine/journal/memory-entries.ts +124 -0
  277. package/engine/journal/memory-replay.ts +157 -0
  278. package/engine/journal/plan-replay.ts +139 -0
  279. package/engine/journal/pull-request-entries.ts +41 -0
  280. package/engine/journal/replay.ts +157 -2144
  281. package/engine/journal/reply-replay.ts +144 -0
  282. package/engine/journal/repo-wait-entries.ts +53 -0
  283. package/engine/journal/run-entries.ts +172 -0
  284. package/engine/journal/run-state.ts +264 -0
  285. package/engine/journal/shared-failure-replay.ts +133 -0
  286. package/engine/journal/stuck-entries.ts +193 -0
  287. package/engine/journal/ticket-progress.ts +392 -0
  288. package/engine/journal/ticket-replay.ts +411 -0
  289. package/engine/journal/ticket-result-replay.ts +364 -0
  290. package/engine/journal/ticket-retry-replay.ts +142 -0
  291. package/engine/journal/worktree-entries.ts +122 -0
  292. package/engine/models/model-score.ts +512 -0
  293. package/engine/shell/run-command.ts +187 -18
  294. package/engine/testing/agent-turn-fixtures.ts +287 -0
  295. package/engine/testing/already-done-fixtures.ts +97 -0
  296. package/engine/testing/build-fixtures.ts +68 -973
  297. package/engine/testing/checkouts-fixtures.ts +48 -0
  298. package/engine/testing/final-review-fixtures.ts +62 -1
  299. package/engine/testing/final-review-steps.ts +143 -0
  300. package/engine/testing/limit-fixtures.ts +85 -0
  301. package/engine/testing/live-file-fixtures.ts +57 -0
  302. package/engine/testing/practice-repo.ts +10 -4
  303. package/engine/testing/practice-run.ts +1 -1
  304. package/engine/testing/reply-fixtures.ts +88 -0
  305. package/engine/testing/run-budget-fixtures.ts +165 -0
  306. package/engine/testing/run-fixtures.ts +219 -0
  307. package/engine/testing/sdk-play.ts +177 -0
  308. package/engine/testing/setup-repo.ts +194 -0
  309. package/engine/testing/step-record-fixtures.ts +55 -0
  310. package/engine/testing/ticket-step-fixtures.ts +272 -0
  311. package/engine/testing/wait-scenarios.ts +186 -0
  312. package/engine/tracker/gh-retry.ts +217 -0
  313. package/engine/tracker/github-tracker.ts +310 -90
  314. package/engine/tracker/in-memory-tracker.ts +8 -4
  315. package/engine/tracker/tracker.ts +131 -6
  316. package/package.json +2 -1
  317. package/skills/luca-retro/SKILL.md +74 -0
  318. package/skills/luca-retro/scripts/retro-summary.ts +1356 -0
  319. package/skills/luca-unstick/SKILL.md +153 -0
  320. package/skills/luca-unstick/scripts/stuck-summary.ts +1063 -0
  321. package/engine/jev/jev-client.ts +0 -173
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Retries for `gh` calls (#569). A call that fails in a way likely to pass
3
+ * on a second try (a timeout, a cut-off JSON reply, a 5xx, a secondary rate
4
+ * limit, a dropped connection or a TLS error) is tried again, three tries in
5
+ * all, after waits of 2 s then 8 s. Any other failure throws at once. Each
6
+ * retry is logged to the run log.
7
+ */
8
+ import { TrackerError } from './tracker'
9
+
10
+ import {
11
+ durationText,
12
+ runCommand,
13
+ type CommandResult,
14
+ } from '../shell/run-command'
15
+
16
+ /**
17
+ * Runs `gh` with `args` and collects its output. Never throws on a non-zero
18
+ * exit. The GitHub tracker makes every call through one, so tests can fake
19
+ * `gh`.
20
+ */
21
+ export type GhRunner = (args: string[]) => Promise<
22
+ Pick<CommandResult, 'exit_code' | 'stdout' | 'stderr'> & {
23
+ timed_out?: boolean
24
+ }
25
+ >
26
+
27
+ /** A `gh` call that runs longer is stopped, and counts as transient. */
28
+ export const GH_TIMEOUT_MS = 60_000
29
+
30
+ /** The waits before the second and third tries. */
31
+ export const GH_RETRY_WAITS_MS = [2_000, 8_000]
32
+
33
+ /** How waits and retry lines happen; tests fake both. */
34
+ export type GhRetryDeps = {
35
+ sleep: (ms: number) => Promise<void>
36
+ log: (line: string) => void
37
+ }
38
+
39
+ export const realRetryDeps: GhRetryDeps = {
40
+ sleep: (ms) => Bun.sleep(ms),
41
+ log: (line) => {
42
+ console.log(line)
43
+ },
44
+ }
45
+
46
+ /** The real `gh`, on the `PATH`, run in `cwd` (default: the engine's). */
47
+ export const realGh =
48
+ ({ cwd }: { cwd?: string } = {}): GhRunner =>
49
+ (args) =>
50
+ runCommand({
51
+ cmd: ['gh', ...args],
52
+ cwd: cwd ?? process.cwd(),
53
+ timeout_ms: GH_TIMEOUT_MS,
54
+ })
55
+
56
+ /** A failed `gh` call likely to pass on a second try. */
57
+ export class TransientGhError extends TrackerError {}
58
+
59
+ /** What `gh` says when a call fails in a way likely to pass next time. */
60
+ const TRANSIENT_STDERR = [
61
+ /unexpected end of JSON input/i,
62
+ /unexpected EOF/i,
63
+ /HTTP 5\d\d/,
64
+ /secondary rate limit/i,
65
+ /connection (reset|refused|timed out)/i,
66
+ /ECONNRESET|ECONNREFUSED|ETIMEDOUT/,
67
+ /i\/o timeout|deadline exceeded|timeout exceeded/i,
68
+ /\bTLS\b/i,
69
+ ]
70
+
71
+ const NOT_FOUND = /HTTP 404\b/
72
+
73
+ /** A failed `gh` call as a `TrackerError`, in `gh`'s own words. */
74
+ const ghError = ({
75
+ args,
76
+ result,
77
+ }: {
78
+ args: string[]
79
+ result: Awaited<ReturnType<GhRunner>>
80
+ }): TrackerError => {
81
+ const what = `\`gh ${args.slice(0, 2).join(' ')}\``
82
+ if (result.timed_out === true) {
83
+ return new TransientGhError(
84
+ `${what} failed (timed out after ${durationText(GH_TIMEOUT_MS)})`
85
+ )
86
+ }
87
+ const message = `${what} failed (exit code ${result.exit_code}): ${result.stderr.trim()}`
88
+ return TRANSIENT_STDERR.some((pattern) => pattern.test(result.stderr))
89
+ ? new TransientGhError(message)
90
+ : new TrackerError(message)
91
+ }
92
+
93
+ /**
94
+ * Runs `attempt` until it succeeds, it fails in a way that isn't transient,
95
+ * or the tries run out; then throws its last error. Before a retry,
96
+ * `landed` (for writes) looks for what the earlier try may have left: when
97
+ * it finds it, that is the answer and nothing is tried again.
98
+ */
99
+ export const withRetries = async <T>({
100
+ attempt,
101
+ landed,
102
+ sleep,
103
+ log,
104
+ }: GhRetryDeps & {
105
+ attempt: () => Promise<T>
106
+ landed?: () => Promise<T | null>
107
+ }): Promise<T> => {
108
+ for (let tries = 1; ; tries += 1) {
109
+ try {
110
+ return await attempt()
111
+ } catch (error) {
112
+ const wait = GH_RETRY_WAITS_MS[tries - 1]
113
+ if (!(error instanceof TransientGhError) || wait === undefined) {
114
+ throw error
115
+ }
116
+ log(
117
+ `[tracker] ${error.message}; trying again in ${durationText(wait)} (try ${tries + 1} of ${GH_RETRY_WAITS_MS.length + 1})`
118
+ )
119
+ await sleep(wait)
120
+ const earlier = landed === undefined ? null : await landed()
121
+ if (earlier !== null) return earlier
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The `gh` calls the tracker makes, each with retries:
128
+ * - `ok` returns the output, and throws when the call fails;
129
+ * - `read` parses the output with `parse`; output that doesn't parse as
130
+ * JSON counts as a cut-off reply, so as transient;
131
+ * - `api` reads `gh api <path>` as JSON, and is `null` only for a 404.
132
+ */
133
+ export const createGhCalls = ({
134
+ run,
135
+ sleep,
136
+ log,
137
+ }: GhRetryDeps & { run: GhRunner }) => {
138
+ const once = async ({
139
+ args,
140
+ not_found_null,
141
+ }: {
142
+ args: string[]
143
+ not_found_null: boolean
144
+ }): Promise<string | null> => {
145
+ const result = await run(args)
146
+ if (result.exit_code === 0 && result.timed_out !== true) {
147
+ return result.stdout
148
+ }
149
+ if (not_found_null && NOT_FOUND.test(result.stderr)) return null
150
+ throw ghError({ args, result })
151
+ }
152
+
153
+ const parsed = <T>({
154
+ args,
155
+ stdout,
156
+ parse,
157
+ }: {
158
+ args: string[]
159
+ stdout: string
160
+ parse: (stdout: string) => T
161
+ }): T => {
162
+ try {
163
+ return parse(stdout)
164
+ } catch (error) {
165
+ if (!(error instanceof SyntaxError)) throw error
166
+ throw new TransientGhError(
167
+ `\`gh ${args.slice(0, 2).join(' ')}\` failed (a cut-off reply: ${error.message})`
168
+ )
169
+ }
170
+ }
171
+
172
+ const read = <T>({
173
+ args,
174
+ parse,
175
+ landed,
176
+ }: {
177
+ args: string[]
178
+ parse: (stdout: string) => T
179
+ landed?: () => Promise<T | null>
180
+ }): Promise<T> =>
181
+ withRetries({
182
+ attempt: async () =>
183
+ parsed({
184
+ args,
185
+ stdout: (await once({ args, not_found_null: false })) ?? '',
186
+ parse,
187
+ }),
188
+ landed,
189
+ sleep,
190
+ log,
191
+ })
192
+
193
+ return {
194
+ read,
195
+ ok: ({
196
+ args,
197
+ landed,
198
+ }: {
199
+ args: string[]
200
+ landed?: () => Promise<string | null>
201
+ }): Promise<string> =>
202
+ read({ args, parse: (stdout) => stdout, landed }),
203
+ api: ({ path }: { path: string }): Promise<unknown> => {
204
+ const args = ['api', path]
205
+ return withRetries({
206
+ attempt: async () => {
207
+ const stdout = await once({ args, not_found_null: true })
208
+ return stdout === null
209
+ ? null
210
+ : parsed({ args, stdout, parse: JSON.parse })
211
+ },
212
+ sleep,
213
+ log,
214
+ })
215
+ },
216
+ }
217
+ }
@@ -1,15 +1,25 @@
1
- import { $ } from 'bun'
2
-
3
1
  import { z } from 'zod'
4
2
 
5
3
  import {
4
+ createGhCalls,
5
+ realGh,
6
+ realRetryDeps,
7
+ TransientGhError,
8
+ type GhRetryDeps,
9
+ type GhRunner,
10
+ } from './gh-retry'
11
+ import {
12
+ labelDefinition,
6
13
  OpenedPullRequestSchema,
7
14
  TrackerCommentSchema,
8
15
  TrackerIssueSchema,
16
+ type LabelDefinition,
9
17
  type Tracker,
10
18
  type TrackerIssue,
11
19
  } from './tracker'
12
20
 
21
+ export type { GhRunner } from './gh-retry'
22
+
13
23
  /** An issue as GitHub's REST API returns it (only the fields we use). */
14
24
  const GitHubIssueSchema = z.object({
15
25
  number: z.number().int().positive(),
@@ -33,17 +43,18 @@ const GitHubIssueNumbersSchema = z.array(
33
43
  z.object({ number: z.number().int().positive() })
34
44
  )
35
45
 
36
- /** Pull requests as `gh pr list --json number,url` prints them. */
37
- const GitHubPullListSchema = z.array(OpenedPullRequestSchema)
46
+ /**
47
+ * Pull requests as `gh pr list --json number,url,isCrossRepository` prints
48
+ * them. A fork's PR has `isCrossRepository` set.
49
+ */
50
+ const GitHubPullListSchema = z.array(
51
+ OpenedPullRequestSchema.extend({ isCrossRepository: z.boolean() })
52
+ )
38
53
 
39
54
  type GitHubIssue = z.infer<typeof GitHubIssueSchema>
40
55
 
41
- /** Calls `gh api`; `null` when the call fails (such as a 404). */
42
- const ghApi = async ({ path }: { path: string }): Promise<unknown> => {
43
- const result = await $`gh api ${path}`.quiet().nothrow()
44
- if (result.exitCode !== 0) return null
45
- return JSON.parse(result.stdout.toString())
46
- }
56
+ /** Whether `gh label create` failed because the label is there already. */
57
+ const ALREADY_EXISTS = /already exists/i
47
58
 
48
59
  const parseOrThrow = <T>({
49
60
  schema,
@@ -65,14 +76,50 @@ const parseOrThrow = <T>({
65
76
 
66
77
  /**
67
78
  * The real tracker: GitHub issues through the `gh` CLI, which must be
68
- * installed and logged in. Not exercised by tests; the in-memory tracker
69
- * stands in for it.
79
+ * installed and logged in. Engine tests use the in-memory tracker; the
80
+ * tracker's own tests pass a fake `gh`.
81
+ *
82
+ * Adding a label the repo lacks creates it first (#500): when the add
83
+ * fails, the repo's labels are read, and a missing one is created as
84
+ * `labelDefinition` describes it, then added again. A failure with the
85
+ * label there is thrown as it is. The happy path costs one call, and
86
+ * nothing hangs on the wording of `gh`'s error.
87
+ *
88
+ * Every call retries transient failures (see `gh-retry.ts`). A read throws
89
+ * after its last try; only a 404 reads as none. A write checks whether its
90
+ * first try landed before trying again: a comment with the same body is
91
+ * adopted, as is an open PR from the same branch, and a label that already
92
+ * exists counts as created.
93
+ *
94
+ * @param gh - Runs `gh`. Defaults to the real one.
95
+ * @param sleep - Waits between tries. Defaults to a real wait.
96
+ * @param log - Writes a retry to the run log. Defaults to stdout.
70
97
  *
71
98
  * @example
72
99
  * const tracker = createGitHubTracker({ repo: 'asibilia/luca-framework' })
73
100
  * const spec = await tracker.readSpec({ spec_number: 359 })
74
101
  */
75
- export const createGitHubTracker = ({ repo }: { repo: string }): Tracker => {
102
+ export const createGitHubTracker = ({
103
+ repo,
104
+ gh,
105
+ sleep,
106
+ log,
107
+ }: {
108
+ repo: string
109
+ gh?: GhRunner
110
+ } & Partial<GhRetryDeps>): Tracker => {
111
+ const calls = createGhCalls({
112
+ run: gh ?? realGh(),
113
+ sleep: sleep ?? realRetryDeps.sleep,
114
+ log: log ?? realRetryDeps.log,
115
+ })
116
+
117
+ /** Runs `gh` and returns its output; throws when it fails. */
118
+ const ghOk = (args: string[]): Promise<string> => calls.ok({ args })
119
+
120
+ /** Reads `gh api <path>` as JSON; `null` only for a 404. */
121
+ const ghApi = calls.api
122
+
76
123
  const blockedBy = async (number: number): Promise<number[]> => {
77
124
  const value = await ghApi({
78
125
  path: `repos/${repo}/issues/${number}/dependencies/blocked_by`,
@@ -111,6 +158,133 @@ export const createGitHubTracker = ({ repo }: { repo: string }): Tracker => {
111
158
  )
112
159
  }
113
160
 
161
+ const listLabels: Tracker['listLabels'] = async () =>
162
+ (
163
+ await ghOk([
164
+ 'label',
165
+ 'list',
166
+ '--repo',
167
+ repo,
168
+ '--limit',
169
+ '1000',
170
+ '--json',
171
+ 'name',
172
+ '--jq',
173
+ '.[].name',
174
+ ])
175
+ )
176
+ .split('\n')
177
+ .filter((name) => name !== '')
178
+
179
+ const createLabelArgs = ({
180
+ name,
181
+ color,
182
+ description,
183
+ }: LabelDefinition): string[] => [
184
+ 'label',
185
+ 'create',
186
+ name,
187
+ '--repo',
188
+ repo,
189
+ '--color',
190
+ color,
191
+ '--description',
192
+ description,
193
+ ]
194
+
195
+ const listComments: Tracker['listComments'] = async ({
196
+ number,
197
+ since_id,
198
+ }) =>
199
+ // One JSON object per line, across every page.
200
+ (
201
+ await calls.read({
202
+ args: [
203
+ 'api',
204
+ '--paginate',
205
+ `repos/${repo}/issues/${number}/comments?per_page=100`,
206
+ '--jq',
207
+ '.[] | {id, author: (.user.login // ""), body: (.body // "")}',
208
+ ],
209
+ parse: (listed) =>
210
+ listed
211
+ .split('\n')
212
+ .filter((line) => line.trim() !== '')
213
+ .map((line) =>
214
+ parseOrThrow({
215
+ schema: TrackerCommentSchema,
216
+ value: JSON.parse(line),
217
+ what: `a comment on #${number}`,
218
+ })
219
+ ),
220
+ })
221
+ ).filter(({ id }) => id > since_id)
222
+
223
+ /** Creates a label; one already there counts as created. */
224
+ const createLabel: Tracker['createLabel'] = async (label) => {
225
+ try {
226
+ await ghOk(createLabelArgs(label))
227
+ } catch (error) {
228
+ if (error instanceof Error && ALREADY_EXISTS.test(error.message)) {
229
+ return
230
+ }
231
+ throw error
232
+ }
233
+ }
234
+
235
+ /** Adds or removes one label of an issue; never throws. */
236
+ const editLabel = ({
237
+ number,
238
+ label,
239
+ change,
240
+ }: {
241
+ number: number
242
+ label: string
243
+ change: 'add' | 'remove'
244
+ }): Promise<Error | null> =>
245
+ ghOk([
246
+ 'issue',
247
+ 'edit',
248
+ String(number),
249
+ '--repo',
250
+ repo,
251
+ `--${change}-label`,
252
+ label,
253
+ ]).then(
254
+ () => null,
255
+ (error: Error) => error
256
+ )
257
+
258
+ const findOpenPullRequest: Tracker['findOpenPullRequest'] = async ({
259
+ head,
260
+ }) => {
261
+ // `--head` matches the branch name alone, so a fork's PR from a
262
+ // branch of the same name is left out: it isn't the run's.
263
+ const pull = await calls.read({
264
+ args: [
265
+ 'pr',
266
+ 'list',
267
+ '--repo',
268
+ repo,
269
+ '--head',
270
+ head,
271
+ '--state',
272
+ 'open',
273
+ '--json',
274
+ 'number,url,isCrossRepository',
275
+ ],
276
+ parse: (listed) =>
277
+ parseOrThrow({
278
+ schema: GitHubPullListSchema,
279
+ value: JSON.parse(listed),
280
+ what: `the open pull requests from ${head}`,
281
+ }).find(({ isCrossRepository }) => !isCrossRepository),
282
+ })
283
+ return pull === undefined
284
+ ? null
285
+ : { number: pull.number, url: pull.url }
286
+ }
287
+
114
288
  return {
115
289
  readIssue,
116
290
  readSpec: async ({ spec_number }) => {
@@ -132,79 +306,100 @@ export const createGitHubTracker = ({ repo }: { repo: string }): Tracker => {
132
306
  })
133
307
  return Promise.all(issues.map(toTrackerIssue))
134
308
  },
135
- comment: async ({ number, body }) => {
136
- const posted =
137
- await $`gh api repos/${repo}/issues/${number}/comments -f body=${body}`.quiet()
138
- return parseOrThrow({
139
- schema: GitHubCommentPostedSchema,
140
- value: JSON.parse(posted.stdout.toString()),
141
- what: `the comment posted on #${number}`,
142
- })
143
- },
144
- listComments: async ({ number, since_id }) => {
145
- // One JSON object per line, across every page.
146
- const listed =
147
- await $`gh api --paginate repos/${repo}/issues/${number}/comments?per_page=100 --jq ${'.[] | {id, author: (.user.login // ""), body: (.body // "")}'}`.quiet()
148
- return listed.stdout
149
- .toString()
150
- .split('\n')
151
- .filter((line) => line.trim() !== '')
152
- .map((line) =>
309
+ // Before a retry, a comment with the same body (its marker is in
310
+ // it) is the first try landing: it is adopted, not posted again.
311
+ comment: ({ number, body }) =>
312
+ calls.read({
313
+ args: [
314
+ 'api',
315
+ `repos/${repo}/issues/${number}/comments`,
316
+ '-f',
317
+ `body=${body}`,
318
+ ],
319
+ parse: (posted) =>
153
320
  parseOrThrow({
154
- schema: TrackerCommentSchema,
155
- value: JSON.parse(line),
156
- what: `a comment on #${number}`,
157
- })
158
- )
159
- .filter(({ id }) => id > since_id)
160
- },
321
+ schema: GitHubCommentPostedSchema,
322
+ value: JSON.parse(posted),
323
+ what: `the comment posted on #${number}`,
324
+ }),
325
+ landed: async () => {
326
+ const comments = await listComments({ number, since_id: 0 })
327
+ const same = comments.find((each) => each.body === body)
328
+ return same === undefined ? null : { id: same.id }
329
+ },
330
+ }),
331
+ listComments,
161
332
  addLabel: async ({ number, label }) => {
162
- await $`gh issue edit ${number} --repo ${repo} --add-label ${label}`.quiet()
333
+ const failed = await editLabel({ number, label, change: 'add' })
334
+ if (failed === null) return
335
+ if ((await listLabels()).includes(label)) throw failed
336
+ // A failed create still tries the add again: another run may
337
+ // have just created the label.
338
+ const created = await createLabel(
339
+ labelDefinition({ name: label })
340
+ ).then(
341
+ () => null,
342
+ (error: Error) => error
343
+ )
344
+ const again = await editLabel({ number, label, change: 'add' })
345
+ if (again === null) return
346
+ if (created === null) throw again
347
+ throw new Error(
348
+ `${again.message}; creating the label failed too: ${created.message}`
349
+ )
163
350
  },
164
351
  removeLabel: async ({ number, label }) => {
165
- await $`gh issue edit ${number} --repo ${repo} --remove-label ${label}`
166
- .quiet()
167
- .nothrow()
352
+ const failed = await editLabel({ number, label, change: 'remove' })
353
+ if (failed === null) return
354
+ // A label the repo lacks can't be on the issue.
355
+ if ((await listLabels()).includes(label)) throw failed
168
356
  },
357
+ // A read that fails fails the close too, rather than closing blind.
169
358
  closeIssue: async ({ number }) => {
170
359
  const issue = await readIssue({ number })
171
360
  if (issue?.state === 'closed') return
172
- await $`gh issue close ${number} --repo ${repo} --reason completed`.quiet()
173
- },
174
- openPullRequest: async ({ head, base, title, body }) => {
175
- const url = (
176
- await $`gh pr create --repo ${repo} --head ${head} --base ${base} --title ${title} --body ${body}`.quiet()
177
- ).stdout
178
- .toString()
179
- .trim()
180
- const number = Number(/\/pull\/(\d+)/.exec(url)?.[1])
181
- return parseOrThrow({
182
- schema: OpenedPullRequestSchema,
183
- value: { number, url },
184
- what: `the pull request from ${head}`,
185
- })
186
- },
187
- findOpenPullRequest: async ({ head }) => {
188
- const listed =
189
- await $`gh pr list --repo ${repo} --head ${head} --state open --json number,url`.quiet()
190
- const [pull] = parseOrThrow({
191
- schema: GitHubPullListSchema,
192
- value: JSON.parse(listed.stdout.toString()),
193
- what: `the open pull requests from ${head}`,
194
- })
195
- return pull ?? null
196
- },
197
- listLabels: async () => {
198
- const listed =
199
- await $`gh label list --repo ${repo} --limit 1000 --json name --jq ${'.[].name'}`.quiet()
200
- return listed.stdout
201
- .toString()
202
- .split('\n')
203
- .filter((name) => name !== '')
204
- },
205
- createLabel: async ({ name, color, description }) => {
206
- await $`gh label create ${name} --repo ${repo} --color ${color} --description ${description}`.quiet()
361
+ await ghOk([
362
+ 'issue',
363
+ 'close',
364
+ String(number),
365
+ '--repo',
366
+ repo,
367
+ '--reason',
368
+ 'completed',
369
+ ])
207
370
  },
371
+ // Retried only once no open PR from `head` shows the first try
372
+ // landed; one that does is adopted.
373
+ openPullRequest: ({ head, base, title, body }) =>
374
+ calls.read({
375
+ args: [
376
+ 'pr',
377
+ 'create',
378
+ '--repo',
379
+ repo,
380
+ '--head',
381
+ head,
382
+ '--base',
383
+ base,
384
+ '--title',
385
+ title,
386
+ '--body',
387
+ body,
388
+ ],
389
+ parse: (created) => {
390
+ const url = created.trim()
391
+ const number = Number(/\/pull\/(\d+)/.exec(url)?.[1])
392
+ return parseOrThrow({
393
+ schema: OpenedPullRequestSchema,
394
+ value: { number, url },
395
+ what: `the pull request from ${head}`,
396
+ })
397
+ },
398
+ landed: () => findOpenPullRequest({ head }),
399
+ }),
400
+ findOpenPullRequest,
401
+ listLabels,
402
+ createLabel,
208
403
  // Checked on the repo's newest issue; a repo with no issues yet
209
404
  // can't show them missing, so both count as there.
210
405
  issueLinks: async () => {
@@ -245,23 +440,48 @@ export const githubRepoOf = async ({
245
440
  }: {
246
441
  repo: string
247
442
  }): Promise<string> => {
248
- const result =
249
- await $`gh repo view --json nameWithOwner --jq .nameWithOwner`
250
- .cwd(repo)
251
- .quiet()
252
- .nothrow()
253
- const name = result.stdout.toString().trim()
254
- if (result.exitCode !== 0 || name === '') {
255
- throw new Error(
256
- `Could not find the GitHub repo of ${repo} with gh: ${result.stderr.toString().trim()}`
443
+ const calls = createGhCalls({
444
+ run: realGh({ cwd: repo }),
445
+ ...realRetryDeps,
446
+ })
447
+ const name = await calls
448
+ .ok({
449
+ args: [
450
+ 'repo',
451
+ 'view',
452
+ '--json',
453
+ 'nameWithOwner',
454
+ '--jq',
455
+ '.nameWithOwner',
456
+ ],
457
+ })
458
+ .then(
459
+ (stdout) => stdout.trim(),
460
+ (error: Error) => {
461
+ throw new Error(
462
+ `Could not find the GitHub repo of ${repo} with gh: ${error.message}`
463
+ )
464
+ }
257
465
  )
466
+ if (name === '') {
467
+ throw new Error(`Could not find the GitHub repo of ${repo} with gh`)
258
468
  }
259
469
  return name
260
470
  }
261
471
 
262
- /** The login `gh` is signed in as, or `null` when it isn't. */
472
+ /**
473
+ * The login `gh` is signed in as, or `null` when it isn't. A transient
474
+ * failure is retried, and throws after the last try.
475
+ */
263
476
  export const ghLogin = async (): Promise<string | null> => {
264
- const result = await $`gh api user --jq .login`.quiet().nothrow()
265
- const login = result.stdout.toString().trim()
266
- return result.exitCode === 0 && login !== '' ? login : null
477
+ const calls = createGhCalls({ run: realGh(), ...realRetryDeps })
478
+ try {
479
+ const login = (
480
+ await calls.ok({ args: ['api', 'user', '--jq', '.login'] })
481
+ ).trim()
482
+ return login === '' ? null : login
483
+ } catch (error) {
484
+ if (error instanceof TransientGhError) throw error
485
+ return null
486
+ }
267
487
  }