@herdctl/core 0.0.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 (520) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +219 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/coverage/base.css +224 -0
  5. package/coverage/block-navigation.js +87 -0
  6. package/coverage/coverage-final.json +51 -0
  7. package/coverage/favicon.png +0 -0
  8. package/coverage/index.html +251 -0
  9. package/coverage/prettify.css +1 -0
  10. package/coverage/prettify.js +2 -0
  11. package/coverage/sort-arrow-sprite.png +0 -0
  12. package/coverage/sorter.js +210 -0
  13. package/coverage/src/config/index.html +191 -0
  14. package/coverage/src/config/index.ts.html +442 -0
  15. package/coverage/src/config/interpolate.ts.html +652 -0
  16. package/coverage/src/config/loader.ts.html +1501 -0
  17. package/coverage/src/config/merge.ts.html +823 -0
  18. package/coverage/src/config/parser.ts.html +1213 -0
  19. package/coverage/src/config/schema.ts.html +1123 -0
  20. package/coverage/src/fleet-manager/errors.ts.html +2326 -0
  21. package/coverage/src/fleet-manager/event-types.ts.html +1219 -0
  22. package/coverage/src/fleet-manager/fleet-manager.ts.html +7030 -0
  23. package/coverage/src/fleet-manager/index.html +206 -0
  24. package/coverage/src/fleet-manager/index.ts.html +469 -0
  25. package/coverage/src/fleet-manager/job-manager.ts.html +2074 -0
  26. package/coverage/src/fleet-manager/job-queue.ts.html +2479 -0
  27. package/coverage/src/fleet-manager/types.ts.html +2602 -0
  28. package/coverage/src/index.html +116 -0
  29. package/coverage/src/index.ts.html +181 -0
  30. package/coverage/src/runner/errors.ts.html +1006 -0
  31. package/coverage/src/runner/index.html +191 -0
  32. package/coverage/src/runner/index.ts.html +256 -0
  33. package/coverage/src/runner/job-executor.ts.html +1429 -0
  34. package/coverage/src/runner/message-processor.ts.html +1150 -0
  35. package/coverage/src/runner/sdk-adapter.ts.html +658 -0
  36. package/coverage/src/runner/types.ts.html +559 -0
  37. package/coverage/src/scheduler/errors.ts.html +388 -0
  38. package/coverage/src/scheduler/index.html +206 -0
  39. package/coverage/src/scheduler/index.ts.html +244 -0
  40. package/coverage/src/scheduler/interval.ts.html +652 -0
  41. package/coverage/src/scheduler/schedule-runner.ts.html +1411 -0
  42. package/coverage/src/scheduler/schedule-state.ts.html +718 -0
  43. package/coverage/src/scheduler/scheduler.ts.html +1795 -0
  44. package/coverage/src/scheduler/types.ts.html +733 -0
  45. package/coverage/src/state/directory.ts.html +736 -0
  46. package/coverage/src/state/errors.ts.html +376 -0
  47. package/coverage/src/state/fleet-state.ts.html +937 -0
  48. package/coverage/src/state/index.html +221 -0
  49. package/coverage/src/state/index.ts.html +322 -0
  50. package/coverage/src/state/job-metadata.ts.html +1420 -0
  51. package/coverage/src/state/job-output.ts.html +1033 -0
  52. package/coverage/src/state/schemas/fleet-state.ts.html +445 -0
  53. package/coverage/src/state/schemas/index.html +176 -0
  54. package/coverage/src/state/schemas/index.ts.html +286 -0
  55. package/coverage/src/state/schemas/job-metadata.ts.html +628 -0
  56. package/coverage/src/state/schemas/job-output.ts.html +616 -0
  57. package/coverage/src/state/schemas/session-info.ts.html +361 -0
  58. package/coverage/src/state/session.ts.html +844 -0
  59. package/coverage/src/state/types.ts.html +262 -0
  60. package/coverage/src/state/utils/atomic.ts.html +748 -0
  61. package/coverage/src/state/utils/index.html +146 -0
  62. package/coverage/src/state/utils/index.ts.html +103 -0
  63. package/coverage/src/state/utils/reads.ts.html +1621 -0
  64. package/coverage/src/work-sources/adapters/github.ts.html +3583 -0
  65. package/coverage/src/work-sources/adapters/index.html +131 -0
  66. package/coverage/src/work-sources/adapters/index.ts.html +277 -0
  67. package/coverage/src/work-sources/errors.ts.html +298 -0
  68. package/coverage/src/work-sources/index.html +176 -0
  69. package/coverage/src/work-sources/index.ts.html +529 -0
  70. package/coverage/src/work-sources/manager.ts.html +1324 -0
  71. package/coverage/src/work-sources/registry.ts.html +619 -0
  72. package/coverage/src/work-sources/types.ts.html +568 -0
  73. package/dist/config/__tests__/agent.test.d.ts +2 -0
  74. package/dist/config/__tests__/agent.test.d.ts.map +1 -0
  75. package/dist/config/__tests__/agent.test.js +752 -0
  76. package/dist/config/__tests__/agent.test.js.map +1 -0
  77. package/dist/config/__tests__/interpolate.test.d.ts +2 -0
  78. package/dist/config/__tests__/interpolate.test.d.ts.map +1 -0
  79. package/dist/config/__tests__/interpolate.test.js +509 -0
  80. package/dist/config/__tests__/interpolate.test.js.map +1 -0
  81. package/dist/config/__tests__/loader.test.d.ts +2 -0
  82. package/dist/config/__tests__/loader.test.d.ts.map +1 -0
  83. package/dist/config/__tests__/loader.test.js +631 -0
  84. package/dist/config/__tests__/loader.test.js.map +1 -0
  85. package/dist/config/__tests__/merge.test.d.ts +2 -0
  86. package/dist/config/__tests__/merge.test.d.ts.map +1 -0
  87. package/dist/config/__tests__/merge.test.js +672 -0
  88. package/dist/config/__tests__/merge.test.js.map +1 -0
  89. package/dist/config/__tests__/parser.test.d.ts +2 -0
  90. package/dist/config/__tests__/parser.test.d.ts.map +1 -0
  91. package/dist/config/__tests__/parser.test.js +476 -0
  92. package/dist/config/__tests__/parser.test.js.map +1 -0
  93. package/dist/config/__tests__/schema.test.d.ts +2 -0
  94. package/dist/config/__tests__/schema.test.d.ts.map +1 -0
  95. package/dist/config/__tests__/schema.test.js +776 -0
  96. package/dist/config/__tests__/schema.test.js.map +1 -0
  97. package/dist/config/index.d.ts +11 -0
  98. package/dist/config/index.d.ts.map +1 -0
  99. package/dist/config/index.js +26 -0
  100. package/dist/config/index.js.map +1 -0
  101. package/dist/config/interpolate.d.ts +76 -0
  102. package/dist/config/interpolate.d.ts.map +1 -0
  103. package/dist/config/interpolate.js +143 -0
  104. package/dist/config/interpolate.js.map +1 -0
  105. package/dist/config/loader.d.ts +147 -0
  106. package/dist/config/loader.d.ts.map +1 -0
  107. package/dist/config/loader.js +336 -0
  108. package/dist/config/loader.js.map +1 -0
  109. package/dist/config/merge.d.ts +84 -0
  110. package/dist/config/merge.d.ts.map +1 -0
  111. package/dist/config/merge.js +138 -0
  112. package/dist/config/merge.js.map +1 -0
  113. package/dist/config/parser.d.ts +143 -0
  114. package/dist/config/parser.d.ts.map +1 -0
  115. package/dist/config/parser.js +316 -0
  116. package/dist/config/parser.js.map +1 -0
  117. package/dist/config/schema.d.ts +1906 -0
  118. package/dist/config/schema.d.ts.map +1 -0
  119. package/dist/config/schema.js +268 -0
  120. package/dist/config/schema.js.map +1 -0
  121. package/dist/fleet-manager/__tests__/coverage.test.d.ts +13 -0
  122. package/dist/fleet-manager/__tests__/coverage.test.d.ts.map +1 -0
  123. package/dist/fleet-manager/__tests__/coverage.test.js +2282 -0
  124. package/dist/fleet-manager/__tests__/coverage.test.js.map +1 -0
  125. package/dist/fleet-manager/__tests__/errors.test.d.ts +7 -0
  126. package/dist/fleet-manager/__tests__/errors.test.d.ts.map +1 -0
  127. package/dist/fleet-manager/__tests__/errors.test.js +557 -0
  128. package/dist/fleet-manager/__tests__/errors.test.js.map +1 -0
  129. package/dist/fleet-manager/__tests__/event-helpers.test.d.ts +7 -0
  130. package/dist/fleet-manager/__tests__/event-helpers.test.d.ts.map +1 -0
  131. package/dist/fleet-manager/__tests__/event-helpers.test.js +368 -0
  132. package/dist/fleet-manager/__tests__/event-helpers.test.js.map +1 -0
  133. package/dist/fleet-manager/__tests__/integration.test.d.ts +11 -0
  134. package/dist/fleet-manager/__tests__/integration.test.d.ts.map +1 -0
  135. package/dist/fleet-manager/__tests__/integration.test.js +949 -0
  136. package/dist/fleet-manager/__tests__/integration.test.js.map +1 -0
  137. package/dist/fleet-manager/__tests__/job-control.test.d.ts +7 -0
  138. package/dist/fleet-manager/__tests__/job-control.test.d.ts.map +1 -0
  139. package/dist/fleet-manager/__tests__/job-control.test.js +215 -0
  140. package/dist/fleet-manager/__tests__/job-control.test.js.map +1 -0
  141. package/dist/fleet-manager/__tests__/job-manager.test.d.ts +7 -0
  142. package/dist/fleet-manager/__tests__/job-manager.test.d.ts.map +1 -0
  143. package/dist/fleet-manager/__tests__/job-manager.test.js +659 -0
  144. package/dist/fleet-manager/__tests__/job-manager.test.js.map +1 -0
  145. package/dist/fleet-manager/__tests__/job-queue.test.d.ts +5 -0
  146. package/dist/fleet-manager/__tests__/job-queue.test.d.ts.map +1 -0
  147. package/dist/fleet-manager/__tests__/job-queue.test.js +315 -0
  148. package/dist/fleet-manager/__tests__/job-queue.test.js.map +1 -0
  149. package/dist/fleet-manager/__tests__/reload.test.d.ts +7 -0
  150. package/dist/fleet-manager/__tests__/reload.test.d.ts.map +1 -0
  151. package/dist/fleet-manager/__tests__/reload.test.js +609 -0
  152. package/dist/fleet-manager/__tests__/reload.test.js.map +1 -0
  153. package/dist/fleet-manager/__tests__/status-queries.test.d.ts +7 -0
  154. package/dist/fleet-manager/__tests__/status-queries.test.d.ts.map +1 -0
  155. package/dist/fleet-manager/__tests__/status-queries.test.js +488 -0
  156. package/dist/fleet-manager/__tests__/status-queries.test.js.map +1 -0
  157. package/dist/fleet-manager/__tests__/trigger.test.d.ts +7 -0
  158. package/dist/fleet-manager/__tests__/trigger.test.d.ts.map +1 -0
  159. package/dist/fleet-manager/__tests__/trigger.test.js +471 -0
  160. package/dist/fleet-manager/__tests__/trigger.test.js.map +1 -0
  161. package/dist/fleet-manager/errors.d.ts +407 -0
  162. package/dist/fleet-manager/errors.d.ts.map +1 -0
  163. package/dist/fleet-manager/errors.js +569 -0
  164. package/dist/fleet-manager/errors.js.map +1 -0
  165. package/dist/fleet-manager/event-types.d.ts +302 -0
  166. package/dist/fleet-manager/event-types.d.ts.map +1 -0
  167. package/dist/fleet-manager/event-types.js +9 -0
  168. package/dist/fleet-manager/event-types.js.map +1 -0
  169. package/dist/fleet-manager/fleet-manager.d.ts +699 -0
  170. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -0
  171. package/dist/fleet-manager/fleet-manager.js +1906 -0
  172. package/dist/fleet-manager/fleet-manager.js.map +1 -0
  173. package/dist/fleet-manager/index.d.ts +17 -0
  174. package/dist/fleet-manager/index.d.ts.map +1 -0
  175. package/dist/fleet-manager/index.js +29 -0
  176. package/dist/fleet-manager/index.js.map +1 -0
  177. package/dist/fleet-manager/job-manager.d.ts +271 -0
  178. package/dist/fleet-manager/job-manager.d.ts.map +1 -0
  179. package/dist/fleet-manager/job-manager.js +443 -0
  180. package/dist/fleet-manager/job-manager.js.map +1 -0
  181. package/dist/fleet-manager/job-queue.d.ts +422 -0
  182. package/dist/fleet-manager/job-queue.d.ts.map +1 -0
  183. package/dist/fleet-manager/job-queue.js +448 -0
  184. package/dist/fleet-manager/job-queue.js.map +1 -0
  185. package/dist/fleet-manager/types.d.ts +680 -0
  186. package/dist/fleet-manager/types.d.ts.map +1 -0
  187. package/dist/fleet-manager/types.js +8 -0
  188. package/dist/fleet-manager/types.js.map +1 -0
  189. package/dist/index.d.ts +20 -0
  190. package/dist/index.d.ts.map +1 -0
  191. package/dist/index.js +26 -0
  192. package/dist/index.js.map +1 -0
  193. package/dist/runner/__tests__/errors.test.d.ts +2 -0
  194. package/dist/runner/__tests__/errors.test.d.ts.map +1 -0
  195. package/dist/runner/__tests__/errors.test.js +264 -0
  196. package/dist/runner/__tests__/errors.test.js.map +1 -0
  197. package/dist/runner/__tests__/job-executor.test.d.ts +2 -0
  198. package/dist/runner/__tests__/job-executor.test.d.ts.map +1 -0
  199. package/dist/runner/__tests__/job-executor.test.js +1345 -0
  200. package/dist/runner/__tests__/job-executor.test.js.map +1 -0
  201. package/dist/runner/__tests__/message-processor.test.d.ts +2 -0
  202. package/dist/runner/__tests__/message-processor.test.d.ts.map +1 -0
  203. package/dist/runner/__tests__/message-processor.test.js +768 -0
  204. package/dist/runner/__tests__/message-processor.test.js.map +1 -0
  205. package/dist/runner/__tests__/sdk-adapter.test.d.ts +2 -0
  206. package/dist/runner/__tests__/sdk-adapter.test.d.ts.map +1 -0
  207. package/dist/runner/__tests__/sdk-adapter.test.js +554 -0
  208. package/dist/runner/__tests__/sdk-adapter.test.js.map +1 -0
  209. package/dist/runner/errors.d.ts +121 -0
  210. package/dist/runner/errors.d.ts.map +1 -0
  211. package/dist/runner/errors.js +212 -0
  212. package/dist/runner/errors.js.map +1 -0
  213. package/dist/runner/index.d.ts +12 -0
  214. package/dist/runner/index.d.ts.map +1 -0
  215. package/dist/runner/index.js +15 -0
  216. package/dist/runner/index.js.map +1 -0
  217. package/dist/runner/job-executor.d.ts +98 -0
  218. package/dist/runner/job-executor.d.ts.map +1 -0
  219. package/dist/runner/job-executor.js +333 -0
  220. package/dist/runner/job-executor.js.map +1 -0
  221. package/dist/runner/message-processor.d.ts +45 -0
  222. package/dist/runner/message-processor.d.ts.map +1 -0
  223. package/dist/runner/message-processor.js +294 -0
  224. package/dist/runner/message-processor.js.map +1 -0
  225. package/dist/runner/sdk-adapter.d.ts +60 -0
  226. package/dist/runner/sdk-adapter.d.ts.map +1 -0
  227. package/dist/runner/sdk-adapter.js +138 -0
  228. package/dist/runner/sdk-adapter.js.map +1 -0
  229. package/dist/runner/types.d.ts +135 -0
  230. package/dist/runner/types.d.ts.map +1 -0
  231. package/dist/runner/types.js +7 -0
  232. package/dist/runner/types.js.map +1 -0
  233. package/dist/scheduler/__tests__/errors.test.d.ts +2 -0
  234. package/dist/scheduler/__tests__/errors.test.d.ts.map +1 -0
  235. package/dist/scheduler/__tests__/errors.test.js +101 -0
  236. package/dist/scheduler/__tests__/errors.test.js.map +1 -0
  237. package/dist/scheduler/__tests__/interval.test.d.ts +2 -0
  238. package/dist/scheduler/__tests__/interval.test.d.ts.map +1 -0
  239. package/dist/scheduler/__tests__/interval.test.js +419 -0
  240. package/dist/scheduler/__tests__/interval.test.js.map +1 -0
  241. package/dist/scheduler/__tests__/schedule-runner.test.d.ts +2 -0
  242. package/dist/scheduler/__tests__/schedule-runner.test.d.ts.map +1 -0
  243. package/dist/scheduler/__tests__/schedule-runner.test.js +634 -0
  244. package/dist/scheduler/__tests__/schedule-runner.test.js.map +1 -0
  245. package/dist/scheduler/__tests__/schedule-state.test.d.ts +2 -0
  246. package/dist/scheduler/__tests__/schedule-state.test.d.ts.map +1 -0
  247. package/dist/scheduler/__tests__/schedule-state.test.js +572 -0
  248. package/dist/scheduler/__tests__/schedule-state.test.js.map +1 -0
  249. package/dist/scheduler/__tests__/scheduler.test.d.ts +2 -0
  250. package/dist/scheduler/__tests__/scheduler.test.d.ts.map +1 -0
  251. package/dist/scheduler/__tests__/scheduler.test.js +987 -0
  252. package/dist/scheduler/__tests__/scheduler.test.js.map +1 -0
  253. package/dist/scheduler/errors.d.ts +61 -0
  254. package/dist/scheduler/errors.d.ts.map +1 -0
  255. package/dist/scheduler/errors.js +81 -0
  256. package/dist/scheduler/errors.js.map +1 -0
  257. package/dist/scheduler/index.d.ts +13 -0
  258. package/dist/scheduler/index.d.ts.map +1 -0
  259. package/dist/scheduler/index.js +17 -0
  260. package/dist/scheduler/index.js.map +1 -0
  261. package/dist/scheduler/interval.d.ts +64 -0
  262. package/dist/scheduler/interval.d.ts.map +1 -0
  263. package/dist/scheduler/interval.js +139 -0
  264. package/dist/scheduler/interval.js.map +1 -0
  265. package/dist/scheduler/schedule-runner.d.ts +149 -0
  266. package/dist/scheduler/schedule-runner.d.ts.map +1 -0
  267. package/dist/scheduler/schedule-runner.js +277 -0
  268. package/dist/scheduler/schedule-runner.js.map +1 -0
  269. package/dist/scheduler/schedule-state.d.ts +105 -0
  270. package/dist/scheduler/schedule-state.d.ts.map +1 -0
  271. package/dist/scheduler/schedule-state.js +151 -0
  272. package/dist/scheduler/schedule-state.js.map +1 -0
  273. package/dist/scheduler/scheduler.d.ts +138 -0
  274. package/dist/scheduler/scheduler.d.ts.map +1 -0
  275. package/dist/scheduler/scheduler.js +423 -0
  276. package/dist/scheduler/scheduler.js.map +1 -0
  277. package/dist/scheduler/types.d.ts +160 -0
  278. package/dist/scheduler/types.d.ts.map +1 -0
  279. package/dist/scheduler/types.js +8 -0
  280. package/dist/scheduler/types.js.map +1 -0
  281. package/dist/state/__tests__/directory.test.d.ts +2 -0
  282. package/dist/state/__tests__/directory.test.d.ts.map +1 -0
  283. package/dist/state/__tests__/directory.test.js +414 -0
  284. package/dist/state/__tests__/directory.test.js.map +1 -0
  285. package/dist/state/__tests__/fleet-state.test.d.ts +2 -0
  286. package/dist/state/__tests__/fleet-state.test.d.ts.map +1 -0
  287. package/dist/state/__tests__/fleet-state.test.js +696 -0
  288. package/dist/state/__tests__/fleet-state.test.js.map +1 -0
  289. package/dist/state/__tests__/job-metadata-schema.test.d.ts +2 -0
  290. package/dist/state/__tests__/job-metadata-schema.test.d.ts.map +1 -0
  291. package/dist/state/__tests__/job-metadata-schema.test.js +329 -0
  292. package/dist/state/__tests__/job-metadata-schema.test.js.map +1 -0
  293. package/dist/state/__tests__/job-metadata.test.d.ts +2 -0
  294. package/dist/state/__tests__/job-metadata.test.d.ts.map +1 -0
  295. package/dist/state/__tests__/job-metadata.test.js +667 -0
  296. package/dist/state/__tests__/job-metadata.test.js.map +1 -0
  297. package/dist/state/__tests__/job-output.test.d.ts +2 -0
  298. package/dist/state/__tests__/job-output.test.d.ts.map +1 -0
  299. package/dist/state/__tests__/job-output.test.js +672 -0
  300. package/dist/state/__tests__/job-output.test.js.map +1 -0
  301. package/dist/state/__tests__/session-schema.test.d.ts +2 -0
  302. package/dist/state/__tests__/session-schema.test.d.ts.map +1 -0
  303. package/dist/state/__tests__/session-schema.test.js +323 -0
  304. package/dist/state/__tests__/session-schema.test.js.map +1 -0
  305. package/dist/state/__tests__/session.test.d.ts +2 -0
  306. package/dist/state/__tests__/session.test.d.ts.map +1 -0
  307. package/dist/state/__tests__/session.test.js +468 -0
  308. package/dist/state/__tests__/session.test.js.map +1 -0
  309. package/dist/state/directory.d.ts +42 -0
  310. package/dist/state/directory.d.ts.map +1 -0
  311. package/dist/state/directory.js +170 -0
  312. package/dist/state/directory.js.map +1 -0
  313. package/dist/state/errors.d.ts +44 -0
  314. package/dist/state/errors.d.ts.map +1 -0
  315. package/dist/state/errors.js +82 -0
  316. package/dist/state/errors.js.map +1 -0
  317. package/dist/state/fleet-state.d.ts +126 -0
  318. package/dist/state/fleet-state.d.ts.map +1 -0
  319. package/dist/state/fleet-state.js +196 -0
  320. package/dist/state/fleet-state.js.map +1 -0
  321. package/dist/state/index.d.ts +21 -0
  322. package/dist/state/index.d.ts.map +1 -0
  323. package/dist/state/index.js +30 -0
  324. package/dist/state/index.js.map +1 -0
  325. package/dist/state/job-metadata.d.ts +151 -0
  326. package/dist/state/job-metadata.d.ts.map +1 -0
  327. package/dist/state/job-metadata.js +287 -0
  328. package/dist/state/job-metadata.js.map +1 -0
  329. package/dist/state/job-output.d.ts +116 -0
  330. package/dist/state/job-output.d.ts.map +1 -0
  331. package/dist/state/job-output.js +218 -0
  332. package/dist/state/job-output.js.map +1 -0
  333. package/dist/state/schemas/__tests__/job-output.test.d.ts +2 -0
  334. package/dist/state/schemas/__tests__/job-output.test.d.ts.map +1 -0
  335. package/dist/state/schemas/__tests__/job-output.test.js +279 -0
  336. package/dist/state/schemas/__tests__/job-output.test.js.map +1 -0
  337. package/dist/state/schemas/fleet-state.d.ts +249 -0
  338. package/dist/state/schemas/fleet-state.d.ts.map +1 -0
  339. package/dist/state/schemas/fleet-state.js +97 -0
  340. package/dist/state/schemas/fleet-state.js.map +1 -0
  341. package/dist/state/schemas/index.d.ts +10 -0
  342. package/dist/state/schemas/index.d.ts.map +1 -0
  343. package/dist/state/schemas/index.js +10 -0
  344. package/dist/state/schemas/index.js.map +1 -0
  345. package/dist/state/schemas/job-metadata.d.ts +118 -0
  346. package/dist/state/schemas/job-metadata.d.ts.map +1 -0
  347. package/dist/state/schemas/job-metadata.js +123 -0
  348. package/dist/state/schemas/job-metadata.js.map +1 -0
  349. package/dist/state/schemas/job-output.d.ts +291 -0
  350. package/dist/state/schemas/job-output.d.ts.map +1 -0
  351. package/dist/state/schemas/job-output.js +132 -0
  352. package/dist/state/schemas/job-output.js.map +1 -0
  353. package/dist/state/schemas/session-info.d.ts +65 -0
  354. package/dist/state/schemas/session-info.d.ts.map +1 -0
  355. package/dist/state/schemas/session-info.js +58 -0
  356. package/dist/state/schemas/session-info.js.map +1 -0
  357. package/dist/state/session.d.ts +92 -0
  358. package/dist/state/session.d.ts.map +1 -0
  359. package/dist/state/session.js +173 -0
  360. package/dist/state/session.js.map +1 -0
  361. package/dist/state/types.d.ts +54 -0
  362. package/dist/state/types.d.ts.map +1 -0
  363. package/dist/state/types.js +18 -0
  364. package/dist/state/types.js.map +1 -0
  365. package/dist/state/utils/__tests__/atomic.test.d.ts +2 -0
  366. package/dist/state/utils/__tests__/atomic.test.d.ts.map +1 -0
  367. package/dist/state/utils/__tests__/atomic.test.js +537 -0
  368. package/dist/state/utils/__tests__/atomic.test.js.map +1 -0
  369. package/dist/state/utils/__tests__/reads.test.d.ts +2 -0
  370. package/dist/state/utils/__tests__/reads.test.d.ts.map +1 -0
  371. package/dist/state/utils/__tests__/reads.test.js +792 -0
  372. package/dist/state/utils/__tests__/reads.test.js.map +1 -0
  373. package/dist/state/utils/atomic.d.ts +89 -0
  374. package/dist/state/utils/atomic.d.ts.map +1 -0
  375. package/dist/state/utils/atomic.js +157 -0
  376. package/dist/state/utils/atomic.js.map +1 -0
  377. package/dist/state/utils/index.d.ts +6 -0
  378. package/dist/state/utils/index.d.ts.map +1 -0
  379. package/dist/state/utils/index.js +6 -0
  380. package/dist/state/utils/index.js.map +1 -0
  381. package/dist/state/utils/reads.d.ts +196 -0
  382. package/dist/state/utils/reads.d.ts.map +1 -0
  383. package/dist/state/utils/reads.js +346 -0
  384. package/dist/state/utils/reads.js.map +1 -0
  385. package/dist/work-sources/__tests__/github.test.d.ts +2 -0
  386. package/dist/work-sources/__tests__/github.test.d.ts.map +1 -0
  387. package/dist/work-sources/__tests__/github.test.js +1334 -0
  388. package/dist/work-sources/__tests__/github.test.js.map +1 -0
  389. package/dist/work-sources/__tests__/manager.test.d.ts +2 -0
  390. package/dist/work-sources/__tests__/manager.test.d.ts.map +1 -0
  391. package/dist/work-sources/__tests__/manager.test.js +424 -0
  392. package/dist/work-sources/__tests__/manager.test.js.map +1 -0
  393. package/dist/work-sources/__tests__/registry.test.d.ts +2 -0
  394. package/dist/work-sources/__tests__/registry.test.d.ts.map +1 -0
  395. package/dist/work-sources/__tests__/registry.test.js +381 -0
  396. package/dist/work-sources/__tests__/registry.test.js.map +1 -0
  397. package/dist/work-sources/__tests__/types.test.d.ts +2 -0
  398. package/dist/work-sources/__tests__/types.test.d.ts.map +1 -0
  399. package/dist/work-sources/__tests__/types.test.js +406 -0
  400. package/dist/work-sources/__tests__/types.test.js.map +1 -0
  401. package/dist/work-sources/adapters/github.d.ts +290 -0
  402. package/dist/work-sources/adapters/github.d.ts.map +1 -0
  403. package/dist/work-sources/adapters/github.js +803 -0
  404. package/dist/work-sources/adapters/github.js.map +1 -0
  405. package/dist/work-sources/adapters/index.d.ts +10 -0
  406. package/dist/work-sources/adapters/index.d.ts.map +1 -0
  407. package/dist/work-sources/adapters/index.js +31 -0
  408. package/dist/work-sources/adapters/index.js.map +1 -0
  409. package/dist/work-sources/errors.d.ts +40 -0
  410. package/dist/work-sources/errors.d.ts.map +1 -0
  411. package/dist/work-sources/errors.js +54 -0
  412. package/dist/work-sources/errors.js.map +1 -0
  413. package/dist/work-sources/index.d.ts +105 -0
  414. package/dist/work-sources/index.d.ts.map +1 -0
  415. package/dist/work-sources/index.js +24 -0
  416. package/dist/work-sources/index.js.map +1 -0
  417. package/dist/work-sources/manager.d.ts +370 -0
  418. package/dist/work-sources/manager.d.ts.map +1 -0
  419. package/dist/work-sources/manager.js +61 -0
  420. package/dist/work-sources/manager.js.map +1 -0
  421. package/dist/work-sources/registry.d.ts +128 -0
  422. package/dist/work-sources/registry.d.ts.map +1 -0
  423. package/dist/work-sources/registry.js +132 -0
  424. package/dist/work-sources/registry.js.map +1 -0
  425. package/dist/work-sources/types.d.ts +127 -0
  426. package/dist/work-sources/types.d.ts.map +1 -0
  427. package/dist/work-sources/types.js +8 -0
  428. package/dist/work-sources/types.js.map +1 -0
  429. package/package.json +23 -0
  430. package/src/config/__tests__/agent.test.ts +864 -0
  431. package/src/config/__tests__/interpolate.test.ts +644 -0
  432. package/src/config/__tests__/loader.test.ts +784 -0
  433. package/src/config/__tests__/merge.test.ts +751 -0
  434. package/src/config/__tests__/parser.test.ts +533 -0
  435. package/src/config/__tests__/schema.test.ts +873 -0
  436. package/src/config/index.ts +119 -0
  437. package/src/config/interpolate.ts +189 -0
  438. package/src/config/loader.ts +472 -0
  439. package/src/config/merge.ts +246 -0
  440. package/src/config/parser.ts +376 -0
  441. package/src/config/schema.ts +346 -0
  442. package/src/fleet-manager/__tests__/coverage.test.ts +2869 -0
  443. package/src/fleet-manager/__tests__/errors.test.ts +660 -0
  444. package/src/fleet-manager/__tests__/event-helpers.test.ts +448 -0
  445. package/src/fleet-manager/__tests__/integration.test.ts +1209 -0
  446. package/src/fleet-manager/__tests__/job-control.test.ts +283 -0
  447. package/src/fleet-manager/__tests__/job-manager.test.ts +869 -0
  448. package/src/fleet-manager/__tests__/job-queue.test.ts +401 -0
  449. package/src/fleet-manager/__tests__/reload.test.ts +751 -0
  450. package/src/fleet-manager/__tests__/status-queries.test.ts +595 -0
  451. package/src/fleet-manager/__tests__/trigger.test.ts +601 -0
  452. package/src/fleet-manager/errors.ts +747 -0
  453. package/src/fleet-manager/event-types.ts +378 -0
  454. package/src/fleet-manager/fleet-manager.ts +2315 -0
  455. package/src/fleet-manager/index.ts +128 -0
  456. package/src/fleet-manager/job-manager.ts +663 -0
  457. package/src/fleet-manager/job-queue.ts +798 -0
  458. package/src/fleet-manager/types.ts +839 -0
  459. package/src/index.ts +32 -0
  460. package/src/runner/__tests__/errors.test.ts +382 -0
  461. package/src/runner/__tests__/job-executor.test.ts +1708 -0
  462. package/src/runner/__tests__/message-processor.test.ts +960 -0
  463. package/src/runner/__tests__/sdk-adapter.test.ts +626 -0
  464. package/src/runner/errors.ts +307 -0
  465. package/src/runner/index.ts +57 -0
  466. package/src/runner/job-executor.ts +448 -0
  467. package/src/runner/message-processor.ts +355 -0
  468. package/src/runner/sdk-adapter.ts +191 -0
  469. package/src/runner/types.ts +158 -0
  470. package/src/scheduler/__tests__/errors.test.ts +159 -0
  471. package/src/scheduler/__tests__/interval.test.ts +515 -0
  472. package/src/scheduler/__tests__/schedule-runner.test.ts +798 -0
  473. package/src/scheduler/__tests__/schedule-state.test.ts +671 -0
  474. package/src/scheduler/__tests__/scheduler.test.ts +1280 -0
  475. package/src/scheduler/errors.ts +101 -0
  476. package/src/scheduler/index.ts +53 -0
  477. package/src/scheduler/interval.ts +189 -0
  478. package/src/scheduler/schedule-runner.ts +442 -0
  479. package/src/scheduler/schedule-state.ts +211 -0
  480. package/src/scheduler/scheduler.ts +570 -0
  481. package/src/scheduler/types.ts +216 -0
  482. package/src/state/__tests__/directory.test.ts +595 -0
  483. package/src/state/__tests__/fleet-state.test.ts +868 -0
  484. package/src/state/__tests__/job-metadata-schema.test.ts +414 -0
  485. package/src/state/__tests__/job-metadata.test.ts +831 -0
  486. package/src/state/__tests__/job-output.test.ts +856 -0
  487. package/src/state/__tests__/session-schema.test.ts +378 -0
  488. package/src/state/__tests__/session.test.ts +604 -0
  489. package/src/state/directory.ts +217 -0
  490. package/src/state/errors.ts +97 -0
  491. package/src/state/fleet-state.ts +284 -0
  492. package/src/state/index.ts +79 -0
  493. package/src/state/job-metadata.ts +445 -0
  494. package/src/state/job-output.ts +316 -0
  495. package/src/state/schemas/__tests__/job-output.test.ts +338 -0
  496. package/src/state/schemas/fleet-state.ts +120 -0
  497. package/src/state/schemas/index.ts +67 -0
  498. package/src/state/schemas/job-metadata.ts +181 -0
  499. package/src/state/schemas/job-output.ts +177 -0
  500. package/src/state/schemas/session-info.ts +92 -0
  501. package/src/state/session.ts +253 -0
  502. package/src/state/types.ts +59 -0
  503. package/src/state/utils/__tests__/atomic.test.ts +723 -0
  504. package/src/state/utils/__tests__/reads.test.ts +1071 -0
  505. package/src/state/utils/atomic.ts +221 -0
  506. package/src/state/utils/index.ts +6 -0
  507. package/src/state/utils/reads.ts +512 -0
  508. package/src/work-sources/__tests__/github.test.ts +1800 -0
  509. package/src/work-sources/__tests__/manager.test.ts +529 -0
  510. package/src/work-sources/__tests__/registry.test.ts +477 -0
  511. package/src/work-sources/__tests__/types.test.ts +479 -0
  512. package/src/work-sources/adapters/github.ts +1166 -0
  513. package/src/work-sources/adapters/index.ts +64 -0
  514. package/src/work-sources/errors.ts +71 -0
  515. package/src/work-sources/index.ts +148 -0
  516. package/src/work-sources/manager.ts +413 -0
  517. package/src/work-sources/registry.ts +178 -0
  518. package/src/work-sources/types.ts +161 -0
  519. package/tsconfig.json +9 -0
  520. package/vitest.config.ts +19 -0
@@ -0,0 +1,1800 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ GitHubWorkSourceAdapter,
4
+ GitHubAPIError,
5
+ GitHubAuthError,
6
+ createGitHubAdapter,
7
+ extractRateLimitInfo,
8
+ isRateLimitResponse,
9
+ calculateBackoffDelay,
10
+ type GitHubWorkSourceConfig,
11
+ type GitHubIssue,
12
+ type RateLimitInfo,
13
+ type RetryOptions,
14
+ } from "../adapters/github.js";
15
+
16
+ // =============================================================================
17
+ // Test Fixtures
18
+ // =============================================================================
19
+
20
+ /**
21
+ * Create a mock GitHub issue
22
+ */
23
+ function createMockIssue(overrides: Partial<GitHubIssue> = {}): GitHubIssue {
24
+ return {
25
+ number: 1,
26
+ title: "Test Issue",
27
+ body: "Test issue body",
28
+ html_url: "https://github.com/owner/repo/issues/1",
29
+ state: "open",
30
+ labels: [{ name: "ready" }],
31
+ created_at: "2024-01-15T10:00:00Z",
32
+ updated_at: "2024-01-15T12:00:00Z",
33
+ assignee: null,
34
+ assignees: [],
35
+ milestone: null,
36
+ user: { login: "testuser" },
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Create a default adapter config
43
+ */
44
+ function createConfig(
45
+ overrides: Partial<GitHubWorkSourceConfig> = {}
46
+ ): GitHubWorkSourceConfig {
47
+ return {
48
+ type: "github",
49
+ owner: "testowner",
50
+ repo: "testrepo",
51
+ token: "test-token",
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Mock fetch response helper
58
+ */
59
+ function mockFetchResponse(
60
+ data: unknown,
61
+ options: { status?: number; headers?: Record<string, string> } = {}
62
+ ) {
63
+ const { status = 200, headers = {} } = options;
64
+ return Promise.resolve({
65
+ ok: status >= 200 && status < 300,
66
+ status,
67
+ statusText: status === 200 ? "OK" : "Error",
68
+ json: () => Promise.resolve(data),
69
+ headers: {
70
+ get: (name: string) => headers[name] ?? null,
71
+ },
72
+ } as Response);
73
+ }
74
+
75
+ /**
76
+ * Helper to get a mock call safely with type assertions
77
+ */
78
+ interface MockCallInfo {
79
+ url: string;
80
+ method: string;
81
+ body: string | undefined;
82
+ headers: Record<string, string>;
83
+ }
84
+
85
+ function getMockCall(
86
+ mockFetch: ReturnType<typeof vi.fn>,
87
+ index: number
88
+ ): MockCallInfo {
89
+ const call = mockFetch.mock.calls[index];
90
+ if (!call) {
91
+ throw new Error(`Mock call at index ${index} not found`);
92
+ }
93
+ const [url, init] = call as [string, RequestInit | undefined];
94
+ return {
95
+ url,
96
+ method: init?.method ?? "GET",
97
+ body: typeof init?.body === "string" ? init.body : undefined,
98
+ headers: (init?.headers as Record<string, string>) ?? {},
99
+ };
100
+ }
101
+
102
+ // =============================================================================
103
+ // Test Setup
104
+ // =============================================================================
105
+
106
+ describe("GitHubWorkSourceAdapter", () => {
107
+ let originalFetch: typeof global.fetch;
108
+ let mockFetch: ReturnType<typeof vi.fn<typeof global.fetch>>;
109
+
110
+ beforeEach(() => {
111
+ originalFetch = global.fetch;
112
+ mockFetch = vi.fn<typeof global.fetch>();
113
+ global.fetch = mockFetch;
114
+ });
115
+
116
+ afterEach(() => {
117
+ global.fetch = originalFetch;
118
+ vi.restoreAllMocks();
119
+ });
120
+
121
+ // ===========================================================================
122
+ // Constructor Tests
123
+ // ===========================================================================
124
+
125
+ describe("constructor", () => {
126
+ it("uses default labels when not configured", () => {
127
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
128
+ expect(adapter.type).toBe("github");
129
+ });
130
+
131
+ it("uses custom labels when configured", () => {
132
+ const adapter = new GitHubWorkSourceAdapter(
133
+ createConfig({
134
+ labels: {
135
+ ready: "custom-ready",
136
+ in_progress: "custom-wip",
137
+ },
138
+ })
139
+ );
140
+ expect(adapter.type).toBe("github");
141
+ });
142
+
143
+ it("uses default exclude_labels when not configured", () => {
144
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
145
+ // Default exclude_labels are ["blocked", "wip"]
146
+ expect(adapter.type).toBe("github");
147
+ });
148
+
149
+ it("uses custom exclude_labels when configured", () => {
150
+ const adapter = new GitHubWorkSourceAdapter(
151
+ createConfig({
152
+ exclude_labels: ["on-hold", "needs-review"],
153
+ })
154
+ );
155
+ expect(adapter.type).toBe("github");
156
+ });
157
+ });
158
+
159
+ // ===========================================================================
160
+ // fetchAvailableWork Tests
161
+ // ===========================================================================
162
+
163
+ describe("fetchAvailableWork", () => {
164
+ it("fetches issues with the ready label", async () => {
165
+ const mockIssues = [createMockIssue({ number: 1 }), createMockIssue({ number: 2 })];
166
+ mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
167
+
168
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
169
+ const result = await adapter.fetchAvailableWork();
170
+
171
+ expect(mockFetch).toHaveBeenCalledWith(
172
+ expect.stringContaining("/repos/testowner/testrepo/issues"),
173
+ expect.objectContaining({
174
+ method: "GET",
175
+ headers: expect.objectContaining({
176
+ Authorization: "Bearer test-token",
177
+ }),
178
+ })
179
+ );
180
+ expect(result.items).toHaveLength(2);
181
+ });
182
+
183
+ it("filters by ready label in query params", async () => {
184
+ mockFetch.mockReturnValue(mockFetchResponse([]));
185
+
186
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
187
+ await adapter.fetchAvailableWork();
188
+
189
+ const callUrl = mockFetch.mock.calls[0][0] as string;
190
+ expect(callUrl).toContain("labels=ready");
191
+ });
192
+
193
+ it("uses custom ready label", async () => {
194
+ mockFetch.mockReturnValue(mockFetchResponse([]));
195
+
196
+ const adapter = new GitHubWorkSourceAdapter(
197
+ createConfig({
198
+ labels: { ready: "agent-ready" },
199
+ })
200
+ );
201
+ await adapter.fetchAvailableWork();
202
+
203
+ const callUrl = mockFetch.mock.calls[0][0] as string;
204
+ expect(callUrl).toContain("labels=agent-ready");
205
+ });
206
+
207
+ it("sorts by creation date ascending (oldest first)", async () => {
208
+ mockFetch.mockReturnValue(mockFetchResponse([]));
209
+
210
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
211
+ await adapter.fetchAvailableWork();
212
+
213
+ const callUrl = mockFetch.mock.calls[0][0] as string;
214
+ expect(callUrl).toContain("sort=created");
215
+ expect(callUrl).toContain("direction=asc");
216
+ });
217
+
218
+ it("excludes issues with exclude_labels", async () => {
219
+ const mockIssues = [
220
+ createMockIssue({ number: 1, labels: [{ name: "ready" }] }),
221
+ createMockIssue({ number: 2, labels: [{ name: "ready" }, { name: "blocked" }] }),
222
+ createMockIssue({ number: 3, labels: [{ name: "ready" }, { name: "wip" }] }),
223
+ ];
224
+ mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
225
+
226
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
227
+ const result = await adapter.fetchAvailableWork();
228
+
229
+ // Only issue 1 should be returned (2 has blocked, 3 has wip)
230
+ expect(result.items).toHaveLength(1);
231
+ expect(result.items[0].externalId).toBe("1");
232
+ });
233
+
234
+ it("excludes issues with custom exclude_labels", async () => {
235
+ const mockIssues = [
236
+ createMockIssue({ number: 1, labels: [{ name: "ready" }] }),
237
+ createMockIssue({ number: 2, labels: [{ name: "ready" }, { name: "on-hold" }] }),
238
+ ];
239
+ mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
240
+
241
+ const adapter = new GitHubWorkSourceAdapter(
242
+ createConfig({
243
+ exclude_labels: ["on-hold"],
244
+ })
245
+ );
246
+ const result = await adapter.fetchAvailableWork();
247
+
248
+ expect(result.items).toHaveLength(1);
249
+ expect(result.items[0].externalId).toBe("1");
250
+ });
251
+
252
+ it("excludes issues with in_progress label by default", async () => {
253
+ const mockIssues = [
254
+ createMockIssue({ number: 1, labels: [{ name: "ready" }] }),
255
+ createMockIssue({
256
+ number: 2,
257
+ labels: [{ name: "ready" }, { name: "agent-working" }],
258
+ }),
259
+ ];
260
+ mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
261
+
262
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
263
+ const result = await adapter.fetchAvailableWork();
264
+
265
+ expect(result.items).toHaveLength(1);
266
+ expect(result.items[0].externalId).toBe("1");
267
+ });
268
+
269
+ it("includes claimed issues when includeClaimed is true", async () => {
270
+ const mockIssues = [
271
+ createMockIssue({ number: 1, labels: [{ name: "ready" }] }),
272
+ createMockIssue({
273
+ number: 2,
274
+ labels: [{ name: "ready" }, { name: "agent-working" }],
275
+ }),
276
+ ];
277
+ mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
278
+
279
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
280
+ const result = await adapter.fetchAvailableWork({ includeClaimed: true });
281
+
282
+ expect(result.items).toHaveLength(2);
283
+ });
284
+
285
+ it("applies additional label filters", async () => {
286
+ const mockIssues = [
287
+ createMockIssue({ number: 1, labels: [{ name: "ready" }, { name: "bug" }] }),
288
+ createMockIssue({ number: 2, labels: [{ name: "ready" }, { name: "feature" }] }),
289
+ ];
290
+ mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
291
+
292
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
293
+ const result = await adapter.fetchAvailableWork({ labels: ["bug"] });
294
+
295
+ expect(result.items).toHaveLength(1);
296
+ expect(result.items[0].externalId).toBe("1");
297
+ });
298
+
299
+ it("supports pagination with limit", async () => {
300
+ mockFetch.mockReturnValue(mockFetchResponse([]));
301
+
302
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
303
+ await adapter.fetchAvailableWork({ limit: 10 });
304
+
305
+ const callUrl = mockFetch.mock.calls[0][0] as string;
306
+ expect(callUrl).toContain("per_page=10");
307
+ });
308
+
309
+ it("caps limit at 100 (GitHub API max)", async () => {
310
+ mockFetch.mockReturnValue(mockFetchResponse([]));
311
+
312
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
313
+ await adapter.fetchAvailableWork({ limit: 200 });
314
+
315
+ const callUrl = mockFetch.mock.calls[0][0] as string;
316
+ expect(callUrl).toContain("per_page=100");
317
+ });
318
+
319
+ it("supports pagination with cursor", async () => {
320
+ mockFetch.mockReturnValue(mockFetchResponse([]));
321
+
322
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
323
+ await adapter.fetchAvailableWork({ cursor: "2" });
324
+
325
+ const callUrl = mockFetch.mock.calls[0][0] as string;
326
+ expect(callUrl).toContain("page=2");
327
+ });
328
+
329
+ it("extracts nextCursor from Link header", async () => {
330
+ mockFetch.mockReturnValue(
331
+ mockFetchResponse([createMockIssue()], {
332
+ headers: {
333
+ Link: '<https://api.github.com/repos/owner/repo/issues?page=2>; rel="next", <https://api.github.com/repos/owner/repo/issues?page=5>; rel="last"',
334
+ },
335
+ })
336
+ );
337
+
338
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
339
+ const result = await adapter.fetchAvailableWork();
340
+
341
+ expect(result.nextCursor).toBe("2");
342
+ });
343
+
344
+ it("returns undefined nextCursor when no more pages", async () => {
345
+ mockFetch.mockReturnValue(mockFetchResponse([createMockIssue()]));
346
+
347
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
348
+ const result = await adapter.fetchAvailableWork();
349
+
350
+ expect(result.nextCursor).toBeUndefined();
351
+ });
352
+
353
+ it("filters by priority when specified", async () => {
354
+ const mockIssues = [
355
+ createMockIssue({ number: 1, labels: [{ name: "ready" }, { name: "critical" }] }),
356
+ createMockIssue({ number: 2, labels: [{ name: "ready" }] }),
357
+ createMockIssue({ number: 3, labels: [{ name: "ready" }, { name: "low" }] }),
358
+ ];
359
+ mockFetch.mockReturnValue(mockFetchResponse(mockIssues));
360
+
361
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
362
+ const result = await adapter.fetchAvailableWork({ priority: ["critical", "high"] });
363
+
364
+ expect(result.items).toHaveLength(1);
365
+ expect(result.items[0].externalId).toBe("1");
366
+ });
367
+
368
+ it("maps issue to WorkItem correctly", async () => {
369
+ const mockIssue = createMockIssue({
370
+ number: 42,
371
+ title: "Fix bug in login",
372
+ body: "The login form is broken",
373
+ html_url: "https://github.com/owner/repo/issues/42",
374
+ labels: [{ name: "ready" }, { name: "bug" }, { name: "high" }],
375
+ assignee: { login: "dev1" },
376
+ assignees: [{ login: "dev1" }, { login: "dev2" }],
377
+ milestone: { title: "v1.0", number: 1 },
378
+ user: { login: "reporter" },
379
+ created_at: "2024-01-10T09:00:00Z",
380
+ updated_at: "2024-01-12T14:30:00Z",
381
+ });
382
+ mockFetch.mockReturnValue(mockFetchResponse([mockIssue]));
383
+
384
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
385
+ const result = await adapter.fetchAvailableWork();
386
+
387
+ expect(result.items).toHaveLength(1);
388
+ const item = result.items[0];
389
+ expect(item.id).toBe("github-42");
390
+ expect(item.source).toBe("github");
391
+ expect(item.externalId).toBe("42");
392
+ expect(item.title).toBe("Fix bug in login");
393
+ expect(item.description).toBe("The login form is broken");
394
+ expect(item.priority).toBe("high");
395
+ expect(item.labels).toEqual(["ready", "bug", "high"]);
396
+ expect(item.url).toBe("https://github.com/owner/repo/issues/42");
397
+ expect(item.metadata).toEqual({
398
+ state: "open",
399
+ assignee: "dev1",
400
+ assignees: ["dev1", "dev2"],
401
+ milestone: { title: "v1.0", number: 1 },
402
+ author: "reporter",
403
+ });
404
+ expect(item.createdAt).toEqual(new Date("2024-01-10T09:00:00Z"));
405
+ expect(item.updatedAt).toEqual(new Date("2024-01-12T14:30:00Z"));
406
+ });
407
+
408
+ it("handles null body in issue", async () => {
409
+ const mockIssue = createMockIssue({ body: null });
410
+ mockFetch.mockReturnValue(mockFetchResponse([mockIssue]));
411
+
412
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
413
+ const result = await adapter.fetchAvailableWork();
414
+
415
+ expect(result.items[0].description).toBe("");
416
+ });
417
+
418
+ it("throws GitHubAPIError when missing owner/repo config", async () => {
419
+ const adapter = new GitHubWorkSourceAdapter({ type: "github" });
420
+
421
+ await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
422
+ await expect(adapter.fetchAvailableWork()).rejects.toThrow(
423
+ "GitHub adapter requires 'owner' and 'repo' configuration"
424
+ );
425
+ });
426
+
427
+ it("throws GitHubAPIError on API error", async () => {
428
+ mockFetch.mockReturnValue(
429
+ mockFetchResponse({ message: "Not Found" }, { status: 404 })
430
+ );
431
+
432
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
433
+
434
+ await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
435
+ });
436
+
437
+ it("handles network errors gracefully", async () => {
438
+ mockFetch.mockRejectedValue(new Error("Network error"));
439
+
440
+ // Disable retries to test immediate error handling
441
+ const adapter = new GitHubWorkSourceAdapter(
442
+ createConfig({ retry: { maxRetries: 0 } })
443
+ );
444
+
445
+ await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
446
+ await expect(adapter.fetchAvailableWork()).rejects.toThrow("Network error");
447
+ });
448
+ });
449
+
450
+ // ===========================================================================
451
+ // Priority Inference Tests
452
+ // ===========================================================================
453
+
454
+ describe("priority inference", () => {
455
+ it.each([
456
+ [["critical"], "critical"],
457
+ [["p0"], "critical"],
458
+ [["urgent"], "critical"],
459
+ [["high"], "high"],
460
+ [["p1"], "high"],
461
+ [["important"], "high"],
462
+ [["low"], "low"],
463
+ [["p3"], "low"],
464
+ [["enhancement"], "medium"],
465
+ [[], "medium"],
466
+ ])("infers priority %s as %s", async (labels, expectedPriority) => {
467
+ const mockIssue = createMockIssue({
468
+ labels: [{ name: "ready" }, ...labels.map((name) => ({ name }))],
469
+ });
470
+ mockFetch.mockReturnValue(mockFetchResponse([mockIssue]));
471
+
472
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
473
+ const result = await adapter.fetchAvailableWork();
474
+
475
+ expect(result.items[0].priority).toBe(expectedPriority);
476
+ });
477
+
478
+ it("handles case-insensitive priority labels", async () => {
479
+ const mockIssue = createMockIssue({
480
+ labels: [{ name: "ready" }, { name: "CRITICAL" }],
481
+ });
482
+ mockFetch.mockReturnValue(mockFetchResponse([mockIssue]));
483
+
484
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
485
+ const result = await adapter.fetchAvailableWork();
486
+
487
+ expect(result.items[0].priority).toBe("critical");
488
+ });
489
+ });
490
+
491
+ // ===========================================================================
492
+ // claimWork Tests
493
+ // ===========================================================================
494
+
495
+ describe("claimWork", () => {
496
+ it("adds in_progress label and removes ready label", async () => {
497
+ const mockIssue = createMockIssue({
498
+ number: 5,
499
+ labels: [{ name: "ready" }],
500
+ });
501
+ const updatedIssue = createMockIssue({
502
+ number: 5,
503
+ labels: [{ name: "agent-working" }],
504
+ });
505
+
506
+ mockFetch
507
+ .mockReturnValueOnce(mockFetchResponse(mockIssue)) // GET issue
508
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 200 })) // POST labels
509
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE ready label
510
+ .mockReturnValueOnce(mockFetchResponse(updatedIssue)); // GET updated issue
511
+
512
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
513
+ const result = await adapter.claimWork("github-5");
514
+
515
+ expect(result.success).toBe(true);
516
+ expect(result.workItem).toBeDefined();
517
+ expect(result.workItem?.id).toBe("github-5");
518
+
519
+ // Verify the API calls
520
+ expect(mockFetch).toHaveBeenCalledTimes(4);
521
+
522
+ // Check POST to add label
523
+ const addLabelCall = getMockCall(mockFetch, 1);
524
+ expect(addLabelCall.url).toContain("/issues/5/labels");
525
+ expect(addLabelCall.method).toBe("POST");
526
+ expect(JSON.parse(addLabelCall.body!)).toEqual({
527
+ labels: ["agent-working"],
528
+ });
529
+
530
+ // Check DELETE to remove ready label
531
+ const removeLabelCall = getMockCall(mockFetch, 2);
532
+ expect(removeLabelCall.url).toContain("/issues/5/labels/ready");
533
+ expect(removeLabelCall.method).toBe("DELETE");
534
+ });
535
+
536
+ it("returns already_claimed when issue has in_progress label", async () => {
537
+ const mockIssue = createMockIssue({
538
+ number: 5,
539
+ labels: [{ name: "ready" }, { name: "agent-working" }],
540
+ });
541
+ mockFetch.mockReturnValueOnce(mockFetchResponse(mockIssue));
542
+
543
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
544
+ const result = await adapter.claimWork("github-5");
545
+
546
+ expect(result.success).toBe(false);
547
+ expect(result.reason).toBe("already_claimed");
548
+ expect(result.message).toContain("already claimed");
549
+ });
550
+
551
+ it("returns invalid_state when issue is closed", async () => {
552
+ const mockIssue = createMockIssue({
553
+ number: 5,
554
+ state: "closed",
555
+ });
556
+ mockFetch.mockReturnValueOnce(mockFetchResponse(mockIssue));
557
+
558
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
559
+ const result = await adapter.claimWork("github-5");
560
+
561
+ expect(result.success).toBe(false);
562
+ expect(result.reason).toBe("invalid_state");
563
+ expect(result.message).toContain("closed");
564
+ });
565
+
566
+ it("returns not_found when issue does not exist", async () => {
567
+ mockFetch.mockReturnValueOnce(
568
+ mockFetchResponse({ message: "Not Found" }, { status: 404 })
569
+ );
570
+
571
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
572
+ const result = await adapter.claimWork("github-999");
573
+
574
+ expect(result.success).toBe(false);
575
+ expect(result.reason).toBe("not_found");
576
+ });
577
+
578
+ it("returns permission_denied on 403 error", async () => {
579
+ mockFetch.mockReturnValueOnce(
580
+ mockFetchResponse({ message: "Forbidden" }, { status: 403 })
581
+ );
582
+
583
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
584
+ const result = await adapter.claimWork("github-5");
585
+
586
+ expect(result.success).toBe(false);
587
+ expect(result.reason).toBe("permission_denied");
588
+ });
589
+
590
+ it("returns source_error on other API errors", async () => {
591
+ mockFetch.mockReturnValueOnce(
592
+ mockFetchResponse({ message: "Server Error" }, { status: 500 })
593
+ );
594
+
595
+ // Disable retries to test immediate error handling
596
+ const adapter = new GitHubWorkSourceAdapter(
597
+ createConfig({ retry: { maxRetries: 0 } })
598
+ );
599
+ const result = await adapter.claimWork("github-5");
600
+
601
+ expect(result.success).toBe(false);
602
+ expect(result.reason).toBe("source_error");
603
+ });
604
+
605
+ it("throws on invalid work item ID format", async () => {
606
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
607
+
608
+ await expect(adapter.claimWork("invalid-id")).rejects.toThrow(
609
+ GitHubAPIError
610
+ );
611
+ await expect(adapter.claimWork("invalid-id")).rejects.toThrow(
612
+ 'Invalid work item ID format: "invalid-id"'
613
+ );
614
+ });
615
+
616
+ it("uses custom in_progress label", async () => {
617
+ const mockIssue = createMockIssue({ number: 5 });
618
+ const updatedIssue = createMockIssue({ number: 5 });
619
+
620
+ mockFetch
621
+ .mockReturnValueOnce(mockFetchResponse(mockIssue))
622
+ .mockReturnValueOnce(mockFetchResponse(undefined))
623
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
624
+ .mockReturnValueOnce(mockFetchResponse(updatedIssue));
625
+
626
+ const adapter = new GitHubWorkSourceAdapter(
627
+ createConfig({
628
+ labels: { in_progress: "custom-wip" },
629
+ })
630
+ );
631
+ await adapter.claimWork("github-5");
632
+
633
+ const addLabelCall = getMockCall(mockFetch, 1);
634
+ expect(JSON.parse(addLabelCall.body!)).toEqual({
635
+ labels: ["custom-wip"],
636
+ });
637
+ });
638
+ });
639
+
640
+ // ===========================================================================
641
+ // completeWork Tests
642
+ // ===========================================================================
643
+
644
+ describe("completeWork", () => {
645
+ it("posts comment and closes issue on success outcome", async () => {
646
+ mockFetch
647
+ .mockReturnValueOnce(mockFetchResponse({ id: 1 })) // POST comment
648
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE label
649
+ .mockReturnValueOnce(mockFetchResponse({})); // PATCH issue
650
+
651
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
652
+ await adapter.completeWork("github-5", {
653
+ outcome: "success",
654
+ summary: "Fixed the bug",
655
+ });
656
+
657
+ // Verify comment was posted
658
+ const commentCall = getMockCall(mockFetch, 0);
659
+ expect(commentCall.url).toContain("/issues/5/comments");
660
+ expect(commentCall.method).toBe("POST");
661
+ const commentBody = JSON.parse(commentCall.body!).body;
662
+ expect(commentBody).toContain("✅");
663
+ expect(commentBody).toContain("success");
664
+ expect(commentBody).toContain("Fixed the bug");
665
+
666
+ // Verify issue was closed
667
+ const closeCall = getMockCall(mockFetch, 2);
668
+ expect(closeCall.url).toContain("/issues/5");
669
+ expect(closeCall.method).toBe("PATCH");
670
+ expect(JSON.parse(closeCall.body!)).toEqual({
671
+ state: "closed",
672
+ state_reason: "completed",
673
+ });
674
+ });
675
+
676
+ it("posts comment but does not close on failure outcome", async () => {
677
+ mockFetch
678
+ .mockReturnValueOnce(mockFetchResponse({ id: 1 })) // POST comment
679
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })); // DELETE label
680
+
681
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
682
+ await adapter.completeWork("github-5", {
683
+ outcome: "failure",
684
+ summary: "Could not fix the bug",
685
+ error: "Compilation error",
686
+ });
687
+
688
+ // Should only have 2 calls (comment + delete label), no close
689
+ expect(mockFetch).toHaveBeenCalledTimes(2);
690
+
691
+ const failureCommentCall = getMockCall(mockFetch, 0);
692
+ const failureCommentBody = JSON.parse(failureCommentCall.body!).body;
693
+ expect(failureCommentBody).toContain("❌");
694
+ expect(failureCommentBody).toContain("failure");
695
+ expect(failureCommentBody).toContain("Compilation error");
696
+ });
697
+
698
+ it("posts comment but does not close on partial outcome", async () => {
699
+ mockFetch
700
+ .mockReturnValueOnce(mockFetchResponse({ id: 1 }))
701
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }));
702
+
703
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
704
+ await adapter.completeWork("github-5", {
705
+ outcome: "partial",
706
+ summary: "Partially completed",
707
+ });
708
+
709
+ expect(mockFetch).toHaveBeenCalledTimes(2);
710
+
711
+ const partialCommentCall = getMockCall(mockFetch, 0);
712
+ const partialCommentBody = JSON.parse(partialCommentCall.body!).body;
713
+ expect(partialCommentBody).toContain("⚠️");
714
+ expect(partialCommentBody).toContain("partial");
715
+ });
716
+
717
+ it("includes details in comment when provided", async () => {
718
+ mockFetch
719
+ .mockReturnValueOnce(mockFetchResponse({ id: 1 }))
720
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
721
+ .mockReturnValueOnce(mockFetchResponse({}));
722
+
723
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
724
+ await adapter.completeWork("github-5", {
725
+ outcome: "success",
726
+ summary: "Fixed the bug",
727
+ details: "Changed the validation logic in login.ts",
728
+ });
729
+
730
+ const detailsCommentCall = getMockCall(mockFetch, 0);
731
+ const detailsCommentBody = JSON.parse(detailsCommentCall.body!).body;
732
+ expect(detailsCommentBody).toContain("### Details");
733
+ expect(detailsCommentBody).toContain("Changed the validation logic");
734
+ });
735
+
736
+ it("includes artifacts in comment when provided", async () => {
737
+ mockFetch
738
+ .mockReturnValueOnce(mockFetchResponse({ id: 1 }))
739
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
740
+ .mockReturnValueOnce(mockFetchResponse({}));
741
+
742
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
743
+ await adapter.completeWork("github-5", {
744
+ outcome: "success",
745
+ summary: "Created files",
746
+ artifacts: ["src/new-file.ts", "tests/new-file.test.ts"],
747
+ });
748
+
749
+ const artifactsCommentCall = getMockCall(mockFetch, 0);
750
+ const artifactsCommentBody = JSON.parse(artifactsCommentCall.body!).body;
751
+ expect(artifactsCommentBody).toContain("### Artifacts");
752
+ expect(artifactsCommentBody).toContain("src/new-file.ts");
753
+ expect(artifactsCommentBody).toContain("tests/new-file.test.ts");
754
+ });
755
+
756
+ it("removes in_progress label", async () => {
757
+ mockFetch
758
+ .mockReturnValueOnce(mockFetchResponse({ id: 1 }))
759
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
760
+ .mockReturnValueOnce(mockFetchResponse({}));
761
+
762
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
763
+ await adapter.completeWork("github-5", {
764
+ outcome: "success",
765
+ summary: "Done",
766
+ });
767
+
768
+ const deleteCall = getMockCall(mockFetch, 1);
769
+ expect(deleteCall.url).toContain("/labels/agent-working");
770
+ expect(deleteCall.method).toBe("DELETE");
771
+ });
772
+ });
773
+
774
+ // ===========================================================================
775
+ // releaseWork Tests
776
+ // ===========================================================================
777
+
778
+ describe("releaseWork", () => {
779
+ it("removes in_progress label and adds ready label", async () => {
780
+ mockFetch
781
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE in_progress
782
+ .mockReturnValueOnce(mockFetchResponse([{ name: "ready" }])); // POST ready
783
+
784
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
785
+ const result = await adapter.releaseWork("github-5");
786
+
787
+ expect(result.success).toBe(true);
788
+
789
+ // Verify DELETE call
790
+ const deleteCall = getMockCall(mockFetch, 0);
791
+ expect(deleteCall.url).toContain("/labels/agent-working");
792
+ expect(deleteCall.method).toBe("DELETE");
793
+
794
+ // Verify POST call
795
+ const postCall = getMockCall(mockFetch, 1);
796
+ expect(postCall.url).toContain("/issues/5/labels");
797
+ expect(postCall.method).toBe("POST");
798
+ expect(JSON.parse(postCall.body!)).toEqual({ labels: ["ready"] });
799
+ });
800
+
801
+ it("adds comment when addComment is true", async () => {
802
+ mockFetch
803
+ .mockReturnValueOnce(mockFetchResponse({ id: 1 })) // POST comment
804
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE label
805
+ .mockReturnValueOnce(mockFetchResponse([{ name: "ready" }])); // POST ready
806
+
807
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
808
+ const result = await adapter.releaseWork("github-5", {
809
+ addComment: true,
810
+ reason: "Agent timed out",
811
+ });
812
+
813
+ expect(result.success).toBe(true);
814
+
815
+ const releaseCommentCall = getMockCall(mockFetch, 0);
816
+ expect(releaseCommentCall.url).toContain("/issues/5/comments");
817
+ const releaseCommentBody = JSON.parse(releaseCommentCall.body!).body;
818
+ expect(releaseCommentBody).toContain("Work Released");
819
+ expect(releaseCommentBody).toContain("Agent timed out");
820
+ });
821
+
822
+ it("does not add comment when addComment is false", async () => {
823
+ mockFetch
824
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
825
+ .mockReturnValueOnce(mockFetchResponse([{ name: "ready" }]));
826
+
827
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
828
+ await adapter.releaseWork("github-5", {
829
+ addComment: false,
830
+ reason: "Agent timed out",
831
+ });
832
+
833
+ // Should only have 2 calls (delete label + add label)
834
+ expect(mockFetch).toHaveBeenCalledTimes(2);
835
+ const noCommentCall = getMockCall(mockFetch, 0);
836
+ expect(noCommentCall.url).toContain("/labels/");
837
+ });
838
+
839
+ it("returns failure on API error", async () => {
840
+ mockFetch
841
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 }))
842
+ .mockRejectedValueOnce(new Error("Network error"));
843
+
844
+ // Disable retries to test immediate error handling
845
+ const adapter = new GitHubWorkSourceAdapter(
846
+ createConfig({ retry: { maxRetries: 0 } })
847
+ );
848
+ const result = await adapter.releaseWork("github-5");
849
+
850
+ expect(result.success).toBe(false);
851
+ expect(result.message).toContain("Network error");
852
+ });
853
+
854
+ it("respects cleanup_on_failure: true (default)", async () => {
855
+ mockFetch
856
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE in_progress
857
+ .mockReturnValueOnce(mockFetchResponse([{ name: "ready" }])); // POST ready
858
+
859
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
860
+ const result = await adapter.releaseWork("github-5");
861
+
862
+ expect(result.success).toBe(true);
863
+
864
+ // Should have 2 calls: DELETE in_progress + POST ready
865
+ expect(mockFetch).toHaveBeenCalledTimes(2);
866
+ const postCall = getMockCall(mockFetch, 1);
867
+ expect(postCall.url).toContain("/issues/5/labels");
868
+ expect(postCall.method).toBe("POST");
869
+ expect(JSON.parse(postCall.body!)).toEqual({ labels: ["ready"] });
870
+ });
871
+
872
+ it("respects cleanup_on_failure: false (skips re-adding ready label)", async () => {
873
+ mockFetch.mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })); // DELETE in_progress
874
+
875
+ const adapter = new GitHubWorkSourceAdapter(
876
+ createConfig({ cleanup_on_failure: false })
877
+ );
878
+ const result = await adapter.releaseWork("github-5");
879
+
880
+ expect(result.success).toBe(true);
881
+
882
+ // Should only have 1 call: DELETE in_progress (no POST ready)
883
+ expect(mockFetch).toHaveBeenCalledTimes(1);
884
+ const deleteCall = getMockCall(mockFetch, 0);
885
+ expect(deleteCall.url).toContain("/labels/agent-working");
886
+ expect(deleteCall.method).toBe("DELETE");
887
+ });
888
+
889
+ it("respects cleanup_on_failure: true when explicitly set", async () => {
890
+ mockFetch
891
+ .mockReturnValueOnce(mockFetchResponse(undefined, { status: 204 })) // DELETE in_progress
892
+ .mockReturnValueOnce(mockFetchResponse([{ name: "ready" }])); // POST ready
893
+
894
+ const adapter = new GitHubWorkSourceAdapter(
895
+ createConfig({ cleanup_on_failure: true })
896
+ );
897
+ const result = await adapter.releaseWork("github-5");
898
+
899
+ expect(result.success).toBe(true);
900
+
901
+ // Should have 2 calls: DELETE in_progress + POST ready
902
+ expect(mockFetch).toHaveBeenCalledTimes(2);
903
+ });
904
+ });
905
+
906
+ // ===========================================================================
907
+ // getWork Tests
908
+ // ===========================================================================
909
+
910
+ describe("getWork", () => {
911
+ it("fetches and returns work item", async () => {
912
+ const mockIssue = createMockIssue({
913
+ number: 10,
914
+ title: "Test Issue",
915
+ });
916
+ mockFetch.mockReturnValueOnce(mockFetchResponse(mockIssue));
917
+
918
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
919
+ const result = await adapter.getWork("github-10");
920
+
921
+ expect(result).toBeDefined();
922
+ expect(result?.id).toBe("github-10");
923
+ expect(result?.title).toBe("Test Issue");
924
+
925
+ expect(mockFetch).toHaveBeenCalledWith(
926
+ expect.stringContaining("/repos/testowner/testrepo/issues/10"),
927
+ expect.any(Object)
928
+ );
929
+ });
930
+
931
+ it("returns undefined when issue not found", async () => {
932
+ mockFetch.mockReturnValueOnce(
933
+ mockFetchResponse({ message: "Not Found" }, { status: 404 })
934
+ );
935
+
936
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
937
+ const result = await adapter.getWork("github-999");
938
+
939
+ expect(result).toBeUndefined();
940
+ });
941
+
942
+ it("throws on other API errors", async () => {
943
+ mockFetch.mockReturnValueOnce(
944
+ mockFetchResponse({ message: "Server Error" }, { status: 500 })
945
+ );
946
+
947
+ // Disable retries to test immediate error handling
948
+ const adapter = new GitHubWorkSourceAdapter(
949
+ createConfig({ retry: { maxRetries: 0 } })
950
+ );
951
+
952
+ await expect(adapter.getWork("github-5")).rejects.toThrow(GitHubAPIError);
953
+ });
954
+ });
955
+
956
+ // ===========================================================================
957
+ // GitHubAPIError Tests
958
+ // ===========================================================================
959
+
960
+ describe("GitHubAPIError", () => {
961
+ it("has correct name and properties", () => {
962
+ const error = new GitHubAPIError("Test error", {
963
+ statusCode: 404,
964
+ endpoint: "/repos/test",
965
+ });
966
+
967
+ expect(error.name).toBe("GitHubAPIError");
968
+ expect(error.message).toBe("Test error");
969
+ expect(error.statusCode).toBe(404);
970
+ expect(error.endpoint).toBe("/repos/test");
971
+ });
972
+
973
+ it("preserves cause", () => {
974
+ const cause = new Error("Original error");
975
+ const error = new GitHubAPIError("Wrapped error", { cause });
976
+
977
+ expect(error.cause).toBe(cause);
978
+ });
979
+ });
980
+
981
+ // ===========================================================================
982
+ // createGitHubAdapter Factory Tests
983
+ // ===========================================================================
984
+
985
+ describe("createGitHubAdapter", () => {
986
+ it("creates a GitHubWorkSourceAdapter instance", () => {
987
+ const adapter = createGitHubAdapter(createConfig());
988
+
989
+ expect(adapter).toBeInstanceOf(GitHubWorkSourceAdapter);
990
+ expect(adapter.type).toBe("github");
991
+ });
992
+
993
+ it("passes config to adapter", () => {
994
+ const adapter = createGitHubAdapter(
995
+ createConfig({
996
+ owner: "myorg",
997
+ repo: "myrepo",
998
+ })
999
+ );
1000
+
1001
+ expect(adapter.type).toBe("github");
1002
+ });
1003
+ });
1004
+
1005
+ // ===========================================================================
1006
+ // Token Handling Tests
1007
+ // ===========================================================================
1008
+
1009
+ describe("token handling", () => {
1010
+ it("uses token from config", async () => {
1011
+ mockFetch.mockReturnValue(mockFetchResponse([]));
1012
+
1013
+ const adapter = new GitHubWorkSourceAdapter(
1014
+ createConfig({ token: "config-token" })
1015
+ );
1016
+ await adapter.fetchAvailableWork();
1017
+
1018
+ const tokenCall = getMockCall(mockFetch, 0);
1019
+ expect(tokenCall.headers.Authorization).toBe("Bearer config-token");
1020
+ });
1021
+
1022
+ it("uses GITHUB_TOKEN env var when no config token", async () => {
1023
+ const originalEnv = process.env.GITHUB_TOKEN;
1024
+ process.env.GITHUB_TOKEN = "env-token";
1025
+
1026
+ try {
1027
+ mockFetch.mockReturnValue(mockFetchResponse([]));
1028
+
1029
+ const adapter = new GitHubWorkSourceAdapter(
1030
+ createConfig({ token: undefined })
1031
+ );
1032
+ await adapter.fetchAvailableWork();
1033
+
1034
+ const envTokenCall = getMockCall(mockFetch, 0);
1035
+ expect(envTokenCall.headers.Authorization).toBe("Bearer env-token");
1036
+ } finally {
1037
+ if (originalEnv !== undefined) {
1038
+ process.env.GITHUB_TOKEN = originalEnv;
1039
+ } else {
1040
+ delete process.env.GITHUB_TOKEN;
1041
+ }
1042
+ }
1043
+ });
1044
+
1045
+ it("makes unauthenticated request when no token available", async () => {
1046
+ const originalEnv = process.env.GITHUB_TOKEN;
1047
+ delete process.env.GITHUB_TOKEN;
1048
+
1049
+ try {
1050
+ mockFetch.mockReturnValue(mockFetchResponse([]));
1051
+
1052
+ const adapter = new GitHubWorkSourceAdapter(
1053
+ createConfig({ token: undefined })
1054
+ );
1055
+ await adapter.fetchAvailableWork();
1056
+
1057
+ const noTokenCall = getMockCall(mockFetch, 0);
1058
+ expect(noTokenCall.headers.Authorization).toBeUndefined();
1059
+ } finally {
1060
+ if (originalEnv !== undefined) {
1061
+ process.env.GITHUB_TOKEN = originalEnv;
1062
+ }
1063
+ }
1064
+ });
1065
+ });
1066
+
1067
+ // ===========================================================================
1068
+ // Custom API Base URL Tests
1069
+ // ===========================================================================
1070
+
1071
+ describe("custom API base URL", () => {
1072
+ it("uses default api.github.com when not configured", async () => {
1073
+ mockFetch.mockReturnValue(mockFetchResponse([]));
1074
+
1075
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
1076
+ await adapter.fetchAvailableWork();
1077
+
1078
+ const defaultUrlCall = getMockCall(mockFetch, 0);
1079
+ expect(defaultUrlCall.url.startsWith("https://api.github.com")).toBe(true);
1080
+ });
1081
+
1082
+ it("uses custom API base URL for GitHub Enterprise", async () => {
1083
+ mockFetch.mockReturnValue(mockFetchResponse([]));
1084
+
1085
+ const adapter = new GitHubWorkSourceAdapter(
1086
+ createConfig({
1087
+ apiBaseUrl: "https://github.mycompany.com/api/v3",
1088
+ })
1089
+ );
1090
+ await adapter.fetchAvailableWork();
1091
+
1092
+ const customUrlCall = getMockCall(mockFetch, 0);
1093
+ expect(customUrlCall.url.startsWith("https://github.mycompany.com/api/v3")).toBe(true);
1094
+ });
1095
+ });
1096
+
1097
+ // ===========================================================================
1098
+ // Rate Limit Handling Tests
1099
+ // ===========================================================================
1100
+
1101
+ describe("rate limit handling", () => {
1102
+ it("extracts rate limit info from response headers", async () => {
1103
+ const mockIssues = [createMockIssue()];
1104
+ mockFetch.mockReturnValue(
1105
+ mockFetchResponse(mockIssues, {
1106
+ headers: {
1107
+ "X-RateLimit-Limit": "5000",
1108
+ "X-RateLimit-Remaining": "4999",
1109
+ "X-RateLimit-Reset": "1700000000",
1110
+ "X-RateLimit-Resource": "core",
1111
+ },
1112
+ })
1113
+ );
1114
+
1115
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
1116
+ await adapter.fetchAvailableWork();
1117
+
1118
+ expect(adapter.lastRateLimitInfo).toEqual({
1119
+ limit: 5000,
1120
+ remaining: 4999,
1121
+ reset: 1700000000,
1122
+ resource: "core",
1123
+ });
1124
+ });
1125
+
1126
+ it("triggers rate limit warning when remaining is below threshold", async () => {
1127
+ const warningCallback = vi.fn();
1128
+ const mockIssues = [createMockIssue()];
1129
+ mockFetch.mockReturnValue(
1130
+ mockFetchResponse(mockIssues, {
1131
+ headers: {
1132
+ "X-RateLimit-Limit": "5000",
1133
+ "X-RateLimit-Remaining": "50",
1134
+ "X-RateLimit-Reset": "1700000000",
1135
+ "X-RateLimit-Resource": "core",
1136
+ },
1137
+ })
1138
+ );
1139
+
1140
+ const adapter = new GitHubWorkSourceAdapter(
1141
+ createConfig({
1142
+ rateLimitWarning: {
1143
+ warningThreshold: 100,
1144
+ onWarning: warningCallback,
1145
+ },
1146
+ })
1147
+ );
1148
+ await adapter.fetchAvailableWork();
1149
+
1150
+ expect(warningCallback).toHaveBeenCalledWith({
1151
+ limit: 5000,
1152
+ remaining: 50,
1153
+ reset: 1700000000,
1154
+ resource: "core",
1155
+ });
1156
+ });
1157
+
1158
+ it("does not trigger warning when remaining is above threshold", async () => {
1159
+ const warningCallback = vi.fn();
1160
+ const mockIssues = [createMockIssue()];
1161
+ mockFetch.mockReturnValue(
1162
+ mockFetchResponse(mockIssues, {
1163
+ headers: {
1164
+ "X-RateLimit-Limit": "5000",
1165
+ "X-RateLimit-Remaining": "150",
1166
+ "X-RateLimit-Reset": "1700000000",
1167
+ "X-RateLimit-Resource": "core",
1168
+ },
1169
+ })
1170
+ );
1171
+
1172
+ const adapter = new GitHubWorkSourceAdapter(
1173
+ createConfig({
1174
+ rateLimitWarning: {
1175
+ warningThreshold: 100,
1176
+ onWarning: warningCallback,
1177
+ },
1178
+ })
1179
+ );
1180
+ await adapter.fetchAvailableWork();
1181
+
1182
+ expect(warningCallback).not.toHaveBeenCalled();
1183
+ });
1184
+
1185
+ it("detects rate limit error from 403 with remaining=0", async () => {
1186
+ mockFetch.mockReturnValue(
1187
+ mockFetchResponse({ message: "API rate limit exceeded" }, {
1188
+ status: 403,
1189
+ headers: {
1190
+ "X-RateLimit-Limit": "5000",
1191
+ "X-RateLimit-Remaining": "0",
1192
+ "X-RateLimit-Reset": "1700000000",
1193
+ "X-RateLimit-Resource": "core",
1194
+ },
1195
+ })
1196
+ );
1197
+
1198
+ const adapter = new GitHubWorkSourceAdapter(
1199
+ createConfig({
1200
+ retry: { maxRetries: 0 }, // Disable retries for this test
1201
+ })
1202
+ );
1203
+
1204
+ try {
1205
+ await adapter.fetchAvailableWork();
1206
+ expect.fail("Should have thrown");
1207
+ } catch (error) {
1208
+ expect(error).toBeInstanceOf(GitHubAPIError);
1209
+ const apiError = error as GitHubAPIError;
1210
+ expect(apiError.isRateLimitError).toBe(true);
1211
+ expect(apiError.statusCode).toBe(403);
1212
+ expect(apiError.rateLimitInfo).toEqual({
1213
+ limit: 5000,
1214
+ remaining: 0,
1215
+ reset: 1700000000,
1216
+ resource: "core",
1217
+ });
1218
+ }
1219
+ });
1220
+
1221
+ it("detects rate limit error from 429 status", async () => {
1222
+ mockFetch.mockReturnValue(
1223
+ mockFetchResponse({ message: "Too Many Requests" }, { status: 429 })
1224
+ );
1225
+
1226
+ const adapter = new GitHubWorkSourceAdapter(
1227
+ createConfig({
1228
+ retry: { maxRetries: 0 },
1229
+ })
1230
+ );
1231
+
1232
+ try {
1233
+ await adapter.fetchAvailableWork();
1234
+ expect.fail("Should have thrown");
1235
+ } catch (error) {
1236
+ expect(error).toBeInstanceOf(GitHubAPIError);
1237
+ const apiError = error as GitHubAPIError;
1238
+ expect(apiError.isRateLimitError).toBe(true);
1239
+ expect(apiError.statusCode).toBe(429);
1240
+ }
1241
+ });
1242
+ });
1243
+
1244
+ // ===========================================================================
1245
+ // Retry Logic Tests
1246
+ // ===========================================================================
1247
+
1248
+ describe("retry logic", () => {
1249
+ it("retries on rate limit error with exponential backoff", async () => {
1250
+ const mockIssues = [createMockIssue()];
1251
+
1252
+ // First call fails with rate limit, second succeeds
1253
+ mockFetch
1254
+ .mockReturnValueOnce(
1255
+ mockFetchResponse({ message: "Rate limit exceeded" }, {
1256
+ status: 403,
1257
+ headers: {
1258
+ "X-RateLimit-Limit": "5000",
1259
+ "X-RateLimit-Remaining": "0",
1260
+ "X-RateLimit-Reset": String(Math.floor(Date.now() / 1000) + 1),
1261
+ },
1262
+ })
1263
+ )
1264
+ .mockReturnValueOnce(mockFetchResponse(mockIssues));
1265
+
1266
+ const adapter = new GitHubWorkSourceAdapter(
1267
+ createConfig({
1268
+ retry: {
1269
+ maxRetries: 1,
1270
+ baseDelayMs: 10, // Short delay for tests
1271
+ maxDelayMs: 100,
1272
+ },
1273
+ })
1274
+ );
1275
+
1276
+ const result = await adapter.fetchAvailableWork();
1277
+
1278
+ expect(mockFetch).toHaveBeenCalledTimes(2);
1279
+ expect(result.items).toHaveLength(1);
1280
+ });
1281
+
1282
+ it("retries on network errors", async () => {
1283
+ const mockIssues = [createMockIssue()];
1284
+
1285
+ // First call fails with network error, second succeeds
1286
+ mockFetch
1287
+ .mockRejectedValueOnce(new Error("Network connection failed"))
1288
+ .mockReturnValueOnce(mockFetchResponse(mockIssues));
1289
+
1290
+ const adapter = new GitHubWorkSourceAdapter(
1291
+ createConfig({
1292
+ retry: {
1293
+ maxRetries: 1,
1294
+ baseDelayMs: 10,
1295
+ },
1296
+ })
1297
+ );
1298
+
1299
+ const result = await adapter.fetchAvailableWork();
1300
+
1301
+ expect(mockFetch).toHaveBeenCalledTimes(2);
1302
+ expect(result.items).toHaveLength(1);
1303
+ });
1304
+
1305
+ it("retries on 5xx server errors", async () => {
1306
+ const mockIssues = [createMockIssue()];
1307
+
1308
+ mockFetch
1309
+ .mockReturnValueOnce(
1310
+ mockFetchResponse({ message: "Internal Server Error" }, { status: 500 })
1311
+ )
1312
+ .mockReturnValueOnce(mockFetchResponse(mockIssues));
1313
+
1314
+ const adapter = new GitHubWorkSourceAdapter(
1315
+ createConfig({
1316
+ retry: {
1317
+ maxRetries: 1,
1318
+ baseDelayMs: 10,
1319
+ },
1320
+ })
1321
+ );
1322
+
1323
+ const result = await adapter.fetchAvailableWork();
1324
+
1325
+ expect(mockFetch).toHaveBeenCalledTimes(2);
1326
+ expect(result.items).toHaveLength(1);
1327
+ });
1328
+
1329
+ it("does not retry on 404 errors", async () => {
1330
+ mockFetch.mockReturnValue(
1331
+ mockFetchResponse({ message: "Not Found" }, { status: 404 })
1332
+ );
1333
+
1334
+ const adapter = new GitHubWorkSourceAdapter(
1335
+ createConfig({
1336
+ retry: { maxRetries: 3, baseDelayMs: 10 },
1337
+ })
1338
+ );
1339
+
1340
+ await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
1341
+ expect(mockFetch).toHaveBeenCalledTimes(1);
1342
+ });
1343
+
1344
+ it("does not retry on 401 unauthorized errors", async () => {
1345
+ mockFetch.mockReturnValue(
1346
+ mockFetchResponse({ message: "Bad credentials" }, { status: 401 })
1347
+ );
1348
+
1349
+ const adapter = new GitHubWorkSourceAdapter(
1350
+ createConfig({
1351
+ retry: { maxRetries: 3, baseDelayMs: 10 },
1352
+ })
1353
+ );
1354
+
1355
+ await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
1356
+ expect(mockFetch).toHaveBeenCalledTimes(1);
1357
+ });
1358
+
1359
+ it("gives up after max retries", async () => {
1360
+ mockFetch.mockReturnValue(
1361
+ mockFetchResponse({ message: "Server Error" }, { status: 500 })
1362
+ );
1363
+
1364
+ const adapter = new GitHubWorkSourceAdapter(
1365
+ createConfig({
1366
+ retry: {
1367
+ maxRetries: 2,
1368
+ baseDelayMs: 10,
1369
+ },
1370
+ })
1371
+ );
1372
+
1373
+ await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
1374
+ // Initial attempt + 2 retries = 3 total calls
1375
+ expect(mockFetch).toHaveBeenCalledTimes(3);
1376
+ });
1377
+
1378
+ it("respects custom retry configuration", async () => {
1379
+ mockFetch.mockReturnValue(
1380
+ mockFetchResponse({ message: "Server Error" }, { status: 500 })
1381
+ );
1382
+
1383
+ const adapter = new GitHubWorkSourceAdapter(
1384
+ createConfig({
1385
+ retry: {
1386
+ maxRetries: 5,
1387
+ baseDelayMs: 5,
1388
+ },
1389
+ })
1390
+ );
1391
+
1392
+ await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
1393
+ expect(mockFetch).toHaveBeenCalledTimes(6); // 1 + 5 retries
1394
+ });
1395
+ });
1396
+
1397
+ // ===========================================================================
1398
+ // 404 Error Handling Tests
1399
+ // ===========================================================================
1400
+
1401
+ describe("404 error handling", () => {
1402
+ it("handles 404 gracefully in getWork (returns undefined)", async () => {
1403
+ mockFetch.mockReturnValue(
1404
+ mockFetchResponse({ message: "Not Found" }, { status: 404 })
1405
+ );
1406
+
1407
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
1408
+ const result = await adapter.getWork("github-999");
1409
+
1410
+ expect(result).toBeUndefined();
1411
+ });
1412
+
1413
+ it("handles 404 gracefully in claimWork (returns not_found)", async () => {
1414
+ mockFetch.mockReturnValue(
1415
+ mockFetchResponse({ message: "Not Found" }, { status: 404 })
1416
+ );
1417
+
1418
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
1419
+ const result = await adapter.claimWork("github-999");
1420
+
1421
+ expect(result.success).toBe(false);
1422
+ expect(result.reason).toBe("not_found");
1423
+ });
1424
+
1425
+ it("throws 404 in fetchAvailableWork (indicates config error)", async () => {
1426
+ mockFetch.mockReturnValue(
1427
+ mockFetchResponse({ message: "Not Found" }, { status: 404 })
1428
+ );
1429
+
1430
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
1431
+
1432
+ await expect(adapter.fetchAvailableWork()).rejects.toThrow(GitHubAPIError);
1433
+ });
1434
+ });
1435
+
1436
+ // ===========================================================================
1437
+ // PAT Validation Tests
1438
+ // ===========================================================================
1439
+
1440
+ describe("validateToken", () => {
1441
+ it("validates token with required scopes", async () => {
1442
+ mockFetch.mockReturnValue(
1443
+ mockFetchResponse({ login: "testuser" }, {
1444
+ headers: {
1445
+ "X-OAuth-Scopes": "repo, user",
1446
+ "X-RateLimit-Limit": "5000",
1447
+ "X-RateLimit-Remaining": "4999",
1448
+ "X-RateLimit-Reset": "1700000000",
1449
+ },
1450
+ })
1451
+ );
1452
+
1453
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
1454
+ const result = await adapter.validateToken();
1455
+
1456
+ expect(result.valid).toBe(true);
1457
+ expect(result.scopes).toContain("repo");
1458
+ });
1459
+
1460
+ it("throws GitHubAuthError when required scopes are missing", async () => {
1461
+ mockFetch.mockReturnValue(
1462
+ mockFetchResponse({ login: "testuser" }, {
1463
+ headers: {
1464
+ "X-OAuth-Scopes": "user, read:org",
1465
+ "X-RateLimit-Limit": "5000",
1466
+ "X-RateLimit-Remaining": "4999",
1467
+ "X-RateLimit-Reset": "1700000000",
1468
+ },
1469
+ })
1470
+ );
1471
+
1472
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
1473
+
1474
+ try {
1475
+ await adapter.validateToken();
1476
+ expect.fail("Should have thrown");
1477
+ } catch (error) {
1478
+ expect(error).toBeInstanceOf(GitHubAuthError);
1479
+ const authError = error as GitHubAuthError;
1480
+ expect(authError.missingScopes).toContain("repo");
1481
+ expect(authError.foundScopes).toContain("user");
1482
+ }
1483
+ });
1484
+
1485
+ it("throws GitHubAuthError when token is missing", async () => {
1486
+ const originalEnv = process.env.GITHUB_TOKEN;
1487
+ delete process.env.GITHUB_TOKEN;
1488
+
1489
+ try {
1490
+ const adapter = new GitHubWorkSourceAdapter(
1491
+ createConfig({ token: undefined })
1492
+ );
1493
+
1494
+ await expect(adapter.validateToken()).rejects.toThrow(GitHubAuthError);
1495
+ } finally {
1496
+ if (originalEnv !== undefined) {
1497
+ process.env.GITHUB_TOKEN = originalEnv;
1498
+ }
1499
+ }
1500
+ });
1501
+
1502
+ it("throws GitHubAuthError on 401 unauthorized", async () => {
1503
+ mockFetch.mockReturnValue(
1504
+ mockFetchResponse({ message: "Bad credentials" }, {
1505
+ status: 401,
1506
+ headers: {
1507
+ "X-OAuth-Scopes": "",
1508
+ },
1509
+ })
1510
+ );
1511
+
1512
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
1513
+
1514
+ await expect(adapter.validateToken()).rejects.toThrow(GitHubAuthError);
1515
+ });
1516
+
1517
+ it("updates rate limit info during validation", async () => {
1518
+ mockFetch.mockReturnValue(
1519
+ mockFetchResponse({ login: "testuser" }, {
1520
+ headers: {
1521
+ "X-OAuth-Scopes": "repo",
1522
+ "X-RateLimit-Limit": "5000",
1523
+ "X-RateLimit-Remaining": "4500",
1524
+ "X-RateLimit-Reset": "1700000000",
1525
+ },
1526
+ })
1527
+ );
1528
+
1529
+ const adapter = new GitHubWorkSourceAdapter(createConfig());
1530
+ await adapter.validateToken();
1531
+
1532
+ expect(adapter.lastRateLimitInfo?.remaining).toBe(4500);
1533
+ });
1534
+ });
1535
+
1536
+ // ===========================================================================
1537
+ // GitHubAPIError Enhanced Tests
1538
+ // ===========================================================================
1539
+
1540
+ describe("GitHubAPIError enhanced features", () => {
1541
+ it("isRetryable returns true for rate limit errors", () => {
1542
+ const error = new GitHubAPIError("Rate limited", {
1543
+ statusCode: 403,
1544
+ isRateLimitError: true,
1545
+ });
1546
+
1547
+ expect(error.isRetryable()).toBe(true);
1548
+ });
1549
+
1550
+ it("isRetryable returns true for network errors (no status code)", () => {
1551
+ const error = new GitHubAPIError("Network error");
1552
+
1553
+ expect(error.isRetryable()).toBe(true);
1554
+ });
1555
+
1556
+ it("isRetryable returns true for 5xx errors", () => {
1557
+ const error500 = new GitHubAPIError("Server error", { statusCode: 500 });
1558
+ const error502 = new GitHubAPIError("Bad gateway", { statusCode: 502 });
1559
+ const error503 = new GitHubAPIError("Service unavailable", { statusCode: 503 });
1560
+
1561
+ expect(error500.isRetryable()).toBe(true);
1562
+ expect(error502.isRetryable()).toBe(true);
1563
+ expect(error503.isRetryable()).toBe(true);
1564
+ });
1565
+
1566
+ it("isRetryable returns false for 4xx errors (except rate limit)", () => {
1567
+ const error401 = new GitHubAPIError("Unauthorized", { statusCode: 401 });
1568
+ const error403 = new GitHubAPIError("Forbidden", { statusCode: 403, isRateLimitError: false });
1569
+ const error404 = new GitHubAPIError("Not found", { statusCode: 404 });
1570
+
1571
+ expect(error401.isRetryable()).toBe(false);
1572
+ expect(error403.isRetryable()).toBe(false);
1573
+ expect(error404.isRetryable()).toBe(false);
1574
+ });
1575
+
1576
+ it("isNotFound returns true for 404 errors", () => {
1577
+ const error = new GitHubAPIError("Not found", { statusCode: 404 });
1578
+
1579
+ expect(error.isNotFound()).toBe(true);
1580
+ });
1581
+
1582
+ it("isPermissionDenied returns true for 403 without rate limit", () => {
1583
+ const error = new GitHubAPIError("Forbidden", { statusCode: 403, isRateLimitError: false });
1584
+
1585
+ expect(error.isPermissionDenied()).toBe(true);
1586
+ });
1587
+
1588
+ it("isPermissionDenied returns false for rate limit 403", () => {
1589
+ const error = new GitHubAPIError("Rate limited", { statusCode: 403, isRateLimitError: true });
1590
+
1591
+ expect(error.isPermissionDenied()).toBe(false);
1592
+ });
1593
+
1594
+ it("getTimeUntilReset returns time in ms", () => {
1595
+ const futureReset = Math.floor(Date.now() / 1000) + 60; // 60 seconds from now
1596
+ const error = new GitHubAPIError("Rate limited", {
1597
+ statusCode: 403,
1598
+ isRateLimitError: true,
1599
+ rateLimitInfo: {
1600
+ limit: 5000,
1601
+ remaining: 0,
1602
+ reset: futureReset,
1603
+ resource: "core",
1604
+ },
1605
+ });
1606
+
1607
+ const timeUntilReset = error.getTimeUntilReset();
1608
+ expect(timeUntilReset).toBeDefined();
1609
+ expect(timeUntilReset).toBeGreaterThan(50000); // Should be close to 60000
1610
+ expect(timeUntilReset).toBeLessThanOrEqual(60000);
1611
+ });
1612
+
1613
+ it("rateLimitResetAt is set correctly", () => {
1614
+ const resetTimestamp = 1700000000;
1615
+ const error = new GitHubAPIError("Rate limited", {
1616
+ rateLimitInfo: {
1617
+ limit: 5000,
1618
+ remaining: 0,
1619
+ reset: resetTimestamp,
1620
+ resource: "core",
1621
+ },
1622
+ });
1623
+
1624
+ expect(error.rateLimitResetAt).toEqual(new Date(resetTimestamp * 1000));
1625
+ });
1626
+ });
1627
+
1628
+ // ===========================================================================
1629
+ // GitHubAuthError Tests
1630
+ // ===========================================================================
1631
+
1632
+ describe("GitHubAuthError", () => {
1633
+ it("calculates missing scopes correctly", () => {
1634
+ const error = new GitHubAuthError("Missing scopes", {
1635
+ foundScopes: ["user", "read:org"],
1636
+ requiredScopes: ["repo", "user"],
1637
+ });
1638
+
1639
+ expect(error.missingScopes).toEqual(["repo"]);
1640
+ expect(error.foundScopes).toEqual(["user", "read:org"]);
1641
+ expect(error.requiredScopes).toEqual(["repo", "user"]);
1642
+ });
1643
+
1644
+ it("has correct name", () => {
1645
+ const error = new GitHubAuthError("Test", {
1646
+ foundScopes: [],
1647
+ requiredScopes: ["repo"],
1648
+ });
1649
+
1650
+ expect(error.name).toBe("GitHubAuthError");
1651
+ });
1652
+ });
1653
+
1654
+ // ===========================================================================
1655
+ // Utility Function Tests
1656
+ // ===========================================================================
1657
+
1658
+ describe("extractRateLimitInfo", () => {
1659
+ it("extracts all rate limit headers", () => {
1660
+ const headers = new Headers({
1661
+ "X-RateLimit-Limit": "5000",
1662
+ "X-RateLimit-Remaining": "4999",
1663
+ "X-RateLimit-Reset": "1700000000",
1664
+ "X-RateLimit-Resource": "core",
1665
+ });
1666
+
1667
+ const info = extractRateLimitInfo(headers);
1668
+
1669
+ expect(info).toEqual({
1670
+ limit: 5000,
1671
+ remaining: 4999,
1672
+ reset: 1700000000,
1673
+ resource: "core",
1674
+ });
1675
+ });
1676
+
1677
+ it("returns undefined when headers are missing", () => {
1678
+ const headers = new Headers();
1679
+
1680
+ const info = extractRateLimitInfo(headers);
1681
+
1682
+ expect(info).toBeUndefined();
1683
+ });
1684
+
1685
+ it("defaults resource to core when not provided", () => {
1686
+ const headers = new Headers({
1687
+ "X-RateLimit-Limit": "5000",
1688
+ "X-RateLimit-Remaining": "4999",
1689
+ "X-RateLimit-Reset": "1700000000",
1690
+ });
1691
+
1692
+ const info = extractRateLimitInfo(headers);
1693
+
1694
+ expect(info?.resource).toBe("core");
1695
+ });
1696
+ });
1697
+
1698
+ describe("isRateLimitResponse", () => {
1699
+ it("returns true for 403 with remaining=0", () => {
1700
+ const response = {
1701
+ status: 403,
1702
+ headers: {
1703
+ get: (name: string) => name === "X-RateLimit-Remaining" ? "0" : null,
1704
+ },
1705
+ } as unknown as Response;
1706
+
1707
+ expect(isRateLimitResponse(response)).toBe(true);
1708
+ });
1709
+
1710
+ it("returns true for 429 status", () => {
1711
+ const response = {
1712
+ status: 429,
1713
+ headers: {
1714
+ get: () => null,
1715
+ },
1716
+ } as unknown as Response;
1717
+
1718
+ expect(isRateLimitResponse(response)).toBe(true);
1719
+ });
1720
+
1721
+ it("returns false for 403 with remaining > 0", () => {
1722
+ const response = {
1723
+ status: 403,
1724
+ headers: {
1725
+ get: (name: string) => name === "X-RateLimit-Remaining" ? "100" : null,
1726
+ },
1727
+ } as unknown as Response;
1728
+
1729
+ expect(isRateLimitResponse(response)).toBe(false);
1730
+ });
1731
+
1732
+ it("returns false for non-403/429 status", () => {
1733
+ const response = {
1734
+ status: 404,
1735
+ headers: {
1736
+ get: () => null,
1737
+ },
1738
+ } as unknown as Response;
1739
+
1740
+ expect(isRateLimitResponse(response)).toBe(false);
1741
+ });
1742
+ });
1743
+
1744
+ describe("calculateBackoffDelay", () => {
1745
+ const options: Required<RetryOptions> = {
1746
+ maxRetries: 3,
1747
+ baseDelayMs: 1000,
1748
+ maxDelayMs: 30000,
1749
+ jitterFactor: 0,
1750
+ };
1751
+
1752
+ it("calculates exponential backoff", () => {
1753
+ expect(calculateBackoffDelay(0, options)).toBe(1000);
1754
+ expect(calculateBackoffDelay(1, options)).toBe(2000);
1755
+ expect(calculateBackoffDelay(2, options)).toBe(4000);
1756
+ expect(calculateBackoffDelay(3, options)).toBe(8000);
1757
+ });
1758
+
1759
+ it("respects max delay", () => {
1760
+ const smallMaxOptions = { ...options, maxDelayMs: 3000 };
1761
+
1762
+ expect(calculateBackoffDelay(0, smallMaxOptions)).toBe(1000);
1763
+ expect(calculateBackoffDelay(1, smallMaxOptions)).toBe(2000);
1764
+ expect(calculateBackoffDelay(2, smallMaxOptions)).toBe(3000); // Capped
1765
+ expect(calculateBackoffDelay(3, smallMaxOptions)).toBe(3000); // Still capped
1766
+ });
1767
+
1768
+ it("uses rate limit reset time when provided", () => {
1769
+ const resetMs = 5000;
1770
+ const delay = calculateBackoffDelay(0, options, resetMs);
1771
+
1772
+ // Should be resetMs + 1000 buffer
1773
+ expect(delay).toBe(6000);
1774
+ });
1775
+
1776
+ it("caps rate limit reset delay at maxDelayMs", () => {
1777
+ const resetMs = 60000; // 60 seconds
1778
+ const delay = calculateBackoffDelay(0, options, resetMs);
1779
+
1780
+ expect(delay).toBe(30000); // maxDelayMs
1781
+ });
1782
+
1783
+ it("adds jitter when jitterFactor > 0", () => {
1784
+ const jitterOptions = { ...options, jitterFactor: 0.1 };
1785
+
1786
+ // Run multiple times to verify jitter adds variance
1787
+ const delays = Array.from({ length: 10 }, () =>
1788
+ calculateBackoffDelay(0, jitterOptions)
1789
+ );
1790
+
1791
+ // Base delay is 1000, with 10% jitter range is 1000-1100
1792
+ expect(Math.min(...delays)).toBeGreaterThanOrEqual(1000);
1793
+ expect(Math.max(...delays)).toBeLessThanOrEqual(1100);
1794
+
1795
+ // Should have some variance (not all the same)
1796
+ const uniqueDelays = new Set(delays);
1797
+ expect(uniqueDelays.size).toBeGreaterThan(1);
1798
+ });
1799
+ });
1800
+ });