@storyshelf/app 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. package/README.md +42 -4
  2. package/dist/app-types.d.mts +1 -3
  3. package/dist/asset-manifest.mjs +13 -5
  4. package/dist/asset-manifest.mjs.map +1 -1
  5. package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
  6. package/dist/assets/htmx.mjs.map +1 -0
  7. package/dist/assets/mark.mjs +10 -0
  8. package/dist/assets/mark.mjs.map +1 -0
  9. package/dist/boot-tasks.mjs +80 -0
  10. package/dist/boot-tasks.mjs.map +1 -0
  11. package/dist/build-state-flags.mjs +18 -0
  12. package/dist/build-state-flags.mjs.map +1 -0
  13. package/dist/build-state-setup.mjs +75 -0
  14. package/dist/build-state-setup.mjs.map +1 -0
  15. package/dist/capture-alerts.mjs +46 -0
  16. package/dist/capture-alerts.mjs.map +1 -0
  17. package/dist/capture-process.mjs +59 -0
  18. package/dist/capture-process.mjs.map +1 -0
  19. package/dist/capture-setup.mjs +95 -117
  20. package/dist/capture-setup.mjs.map +1 -1
  21. package/dist/connections.mjs +34 -0
  22. package/dist/connections.mjs.map +1 -0
  23. package/dist/consumer-setup.mjs +58 -0
  24. package/dist/consumer-setup.mjs.map +1 -0
  25. package/dist/credentials.mjs +3 -2
  26. package/dist/credentials.mjs.map +1 -1
  27. package/dist/git-status.mjs +46 -0
  28. package/dist/git-status.mjs.map +1 -0
  29. package/dist/hidden-data.mjs +24 -0
  30. package/dist/hidden-data.mjs.map +1 -0
  31. package/dist/index.d.mts +3 -3
  32. package/dist/index.mjs +202 -124
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/insights/budget.mjs +49 -0
  35. package/dist/insights/budget.mjs.map +1 -0
  36. package/dist/insights/deps.mjs +21 -0
  37. package/dist/insights/deps.mjs.map +1 -0
  38. package/dist/insights/errors.mjs +22 -0
  39. package/dist/insights/errors.mjs.map +1 -0
  40. package/dist/insights/health-panel.mjs +40 -0
  41. package/dist/insights/health-panel.mjs.map +1 -0
  42. package/dist/insights/job.mjs +103 -0
  43. package/dist/insights/job.mjs.map +1 -0
  44. package/dist/insights/load-health.mjs +59 -0
  45. package/dist/insights/load-health.mjs.map +1 -0
  46. package/dist/insights/load-triage.mjs +98 -0
  47. package/dist/insights/load-triage.mjs.map +1 -0
  48. package/dist/insights/panel.mjs +28 -0
  49. package/dist/insights/panel.mjs.map +1 -0
  50. package/dist/insights/request.mjs +113 -0
  51. package/dist/insights/request.mjs.map +1 -0
  52. package/dist/insights/stale.mjs +21 -0
  53. package/dist/insights/stale.mjs.map +1 -0
  54. package/dist/insights/usage-report.mjs +60 -0
  55. package/dist/insights/usage-report.mjs.map +1 -0
  56. package/dist/insights/view.mjs +48 -0
  57. package/dist/insights/view.mjs.map +1 -0
  58. package/dist/job-canceller.mjs +14 -0
  59. package/dist/job-canceller.mjs.map +1 -0
  60. package/dist/job-queue.mjs +51 -0
  61. package/dist/job-queue.mjs.map +1 -0
  62. package/dist/jobs-setup.mjs +132 -0
  63. package/dist/jobs-setup.mjs.map +1 -0
  64. package/dist/lifecycle.mjs +109 -19
  65. package/dist/lifecycle.mjs.map +1 -1
  66. package/dist/middleware/auth-gate.mjs +4 -2
  67. package/dist/middleware/auth-gate.mjs.map +1 -1
  68. package/dist/middleware/rate-limit.mjs +41 -33
  69. package/dist/middleware/rate-limit.mjs.map +1 -1
  70. package/dist/middleware/request-log.mjs +3 -2
  71. package/dist/middleware/request-log.mjs.map +1 -1
  72. package/dist/middleware/require-review.mjs +51 -0
  73. package/dist/middleware/require-review.mjs.map +1 -0
  74. package/dist/middleware/require-testing.mjs +41 -0
  75. package/dist/middleware/require-testing.mjs.map +1 -0
  76. package/dist/middleware/setup-gate.mjs.map +1 -1
  77. package/dist/middleware/store-scope.mjs +12 -2
  78. package/dist/middleware/store-scope.mjs.map +1 -1
  79. package/dist/notifications-pause.mjs +33 -0
  80. package/dist/notifications-pause.mjs.map +1 -0
  81. package/dist/notify.d.mts +2 -1
  82. package/dist/notify.mjs +78 -39
  83. package/dist/notify.mjs.map +1 -1
  84. package/dist/pages/a11y-violations.mjs +116 -0
  85. package/dist/pages/a11y-violations.mjs.map +1 -0
  86. package/dist/pages/admin-ai.mjs +78 -0
  87. package/dist/pages/admin-ai.mjs.map +1 -0
  88. package/dist/pages/admin-system-origin.mjs +123 -0
  89. package/dist/pages/admin-system-origin.mjs.map +1 -0
  90. package/dist/pages/admin-system-targets.mjs +53 -0
  91. package/dist/pages/admin-system-targets.mjs.map +1 -0
  92. package/dist/pages/admin-system.mjs +63 -13
  93. package/dist/pages/admin-system.mjs.map +1 -1
  94. package/dist/pages/build-detail.mjs +104 -76
  95. package/dist/pages/build-detail.mjs.map +1 -1
  96. package/dist/pages/build-diff-actions.mjs +87 -33
  97. package/dist/pages/build-diff-actions.mjs.map +1 -1
  98. package/dist/pages/build-diff-comments.mjs +16 -14
  99. package/dist/pages/build-diff-comments.mjs.map +1 -1
  100. package/dist/pages/build-diff-environment.mjs +82 -0
  101. package/dist/pages/build-diff-environment.mjs.map +1 -0
  102. package/dist/pages/build-diff-header.mjs +22 -4
  103. package/dist/pages/build-diff-header.mjs.map +1 -1
  104. package/dist/pages/build-diff-insight.mjs +113 -0
  105. package/dist/pages/build-diff-insight.mjs.map +1 -0
  106. package/dist/pages/build-diff-model.mjs +55 -10
  107. package/dist/pages/build-diff-model.mjs.map +1 -1
  108. package/dist/pages/build-diff-nav.mjs +28 -12
  109. package/dist/pages/build-diff-nav.mjs.map +1 -1
  110. package/dist/pages/build-diff-shortcuts.mjs +5 -1
  111. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  112. package/dist/pages/build-diff-usage.mjs +63 -0
  113. package/dist/pages/build-diff-usage.mjs.map +1 -0
  114. package/dist/pages/build-diff-viewer.mjs +62 -5
  115. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  116. package/dist/pages/build-diff.mjs +34 -13
  117. package/dist/pages/build-diff.mjs.map +1 -1
  118. package/dist/pages/build-tests.mjs +74 -0
  119. package/dist/pages/build-tests.mjs.map +1 -0
  120. package/dist/pages/component-detail.mjs +249 -0
  121. package/dist/pages/component-detail.mjs.map +1 -0
  122. package/dist/pages/component-stories.mjs +168 -0
  123. package/dist/pages/component-stories.mjs.map +1 -0
  124. package/dist/pages/compute-jobs.mjs +13 -26
  125. package/dist/pages/compute-jobs.mjs.map +1 -1
  126. package/dist/pages/design-links.mjs +182 -0
  127. package/dist/pages/design-links.mjs.map +1 -0
  128. package/dist/pages/environment-change.mjs +53 -0
  129. package/dist/pages/environment-change.mjs.map +1 -0
  130. package/dist/pages/health-panel.mjs +126 -0
  131. package/dist/pages/health-panel.mjs.map +1 -0
  132. package/dist/pages/library-grid.mjs +105 -0
  133. package/dist/pages/library-grid.mjs.map +1 -0
  134. package/dist/pages/library-model.mjs +47 -0
  135. package/dist/pages/library-model.mjs.map +1 -0
  136. package/dist/pages/library-snapshots.mjs +130 -0
  137. package/dist/pages/library-snapshots.mjs.map +1 -0
  138. package/dist/pages/library.mjs +112 -218
  139. package/dist/pages/library.mjs.map +1 -1
  140. package/dist/pages/notifications-paused.mjs +22 -0
  141. package/dist/pages/notifications-paused.mjs.map +1 -0
  142. package/dist/pages/profile.mjs +53 -48
  143. package/dist/pages/profile.mjs.map +1 -1
  144. package/dist/pages/project-builds-table.mjs +2 -2
  145. package/dist/pages/project-builds-table.mjs.map +1 -1
  146. package/dist/pages/project-builds.mjs +51 -21
  147. package/dist/pages/project-builds.mjs.map +1 -1
  148. package/dist/pages/project-settings.mjs +51 -12
  149. package/dist/pages/project-settings.mjs.map +1 -1
  150. package/dist/pages/projects-card.mjs +14 -1
  151. package/dist/pages/projects-card.mjs.map +1 -1
  152. package/dist/pages/projects.mjs +6 -2
  153. package/dist/pages/projects.mjs.map +1 -1
  154. package/dist/pages/review-changeset.mjs +116 -0
  155. package/dist/pages/review-changeset.mjs.map +1 -0
  156. package/dist/pages/review-comments.mjs +103 -0
  157. package/dist/pages/review-comments.mjs.map +1 -0
  158. package/dist/pages/review-verdict.mjs +102 -0
  159. package/dist/pages/review-verdict.mjs.map +1 -0
  160. package/dist/pages/settings-ai.mjs +69 -0
  161. package/dist/pages/settings-ai.mjs.map +1 -0
  162. package/dist/pages/settings-capture.mjs +124 -0
  163. package/dist/pages/settings-capture.mjs.map +1 -0
  164. package/dist/pages/settings-design-credential.mjs +99 -0
  165. package/dist/pages/settings-design-credential.mjs.map +1 -0
  166. package/dist/pages/settings-design-links.mjs +179 -0
  167. package/dist/pages/settings-design-links.mjs.map +1 -0
  168. package/dist/pages/settings-design.mjs +48 -0
  169. package/dist/pages/settings-design.mjs.map +1 -0
  170. package/dist/pages/settings-general.mjs +46 -21
  171. package/dist/pages/settings-general.mjs.map +1 -1
  172. package/dist/pages/settings-labels.mjs +43 -2
  173. package/dist/pages/settings-labels.mjs.map +1 -1
  174. package/dist/pages/settings-links.mjs +112 -0
  175. package/dist/pages/settings-links.mjs.map +1 -0
  176. package/dist/pages/settings-notifications-git.mjs +128 -0
  177. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  178. package/dist/pages/settings-notifications.mjs +79 -42
  179. package/dist/pages/settings-notifications.mjs.map +1 -1
  180. package/dist/pages/settings-tests.mjs +33 -2
  181. package/dist/pages/settings-tests.mjs.map +1 -1
  182. package/dist/pages/target-choices.mjs +39 -0
  183. package/dist/pages/target-choices.mjs.map +1 -0
  184. package/dist/publish-setup.mjs +89 -0
  185. package/dist/publish-setup.mjs.map +1 -0
  186. package/dist/retention-timer.mjs +2 -1
  187. package/dist/retention-timer.mjs.map +1 -1
  188. package/dist/review-events.mjs +48 -0
  189. package/dist/review-events.mjs.map +1 -0
  190. package/dist/routers/admin-pages.mjs +45 -3
  191. package/dist/routers/admin-pages.mjs.map +1 -1
  192. package/dist/routers/admin-public-origin.mjs +85 -0
  193. package/dist/routers/admin-public-origin.mjs.map +1 -0
  194. package/dist/routers/assets.mjs +9 -2
  195. package/dist/routers/assets.mjs.map +1 -1
  196. package/dist/routers/auth.mjs +2 -2
  197. package/dist/routers/auth.mjs.map +1 -1
  198. package/dist/routers/baseline-guard.mjs +2 -2
  199. package/dist/routers/baseline-guard.mjs.map +1 -1
  200. package/dist/routers/baselines.handlers.mjs +64 -0
  201. package/dist/routers/baselines.handlers.mjs.map +1 -0
  202. package/dist/routers/baselines.mjs +125 -0
  203. package/dist/routers/baselines.mjs.map +1 -0
  204. package/dist/routers/build-cancel.mjs +68 -0
  205. package/dist/routers/build-cancel.mjs.map +1 -0
  206. package/dist/routers/build-changeset.mjs +70 -0
  207. package/dist/routers/build-changeset.mjs.map +1 -0
  208. package/dist/routers/build-reviews.handlers.mjs +86 -0
  209. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  210. package/dist/routers/build-reviews.mjs +74 -0
  211. package/dist/routers/build-reviews.mjs.map +1 -0
  212. package/dist/routers/builds.handlers.mjs +46 -55
  213. package/dist/routers/builds.handlers.mjs.map +1 -1
  214. package/dist/routers/builds.mjs +71 -17
  215. package/dist/routers/builds.mjs.map +1 -1
  216. package/dist/routers/capture-config.mjs +35 -0
  217. package/dist/routers/capture-config.mjs.map +1 -0
  218. package/dist/routers/comments.mjs +41 -15
  219. package/dist/routers/comments.mjs.map +1 -1
  220. package/dist/routers/component-catalog.handlers.mjs +71 -0
  221. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  222. package/dist/routers/component.handlers.mjs +121 -0
  223. package/dist/routers/component.handlers.mjs.map +1 -0
  224. package/dist/routers/connections.mjs +118 -0
  225. package/dist/routers/connections.mjs.map +1 -0
  226. package/dist/routers/design-connect.handlers.mjs +44 -0
  227. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  228. package/dist/routers/design.handlers.mjs +114 -0
  229. package/dist/routers/design.handlers.mjs.map +1 -0
  230. package/dist/routers/health-report.mjs +2 -3
  231. package/dist/routers/health-report.mjs.map +1 -1
  232. package/dist/routers/health.mjs +4 -3
  233. package/dist/routers/health.mjs.map +1 -1
  234. package/dist/routers/helpers.mjs +19 -3
  235. package/dist/routers/helpers.mjs.map +1 -1
  236. package/dist/routers/insights-ui.mjs +64 -0
  237. package/dist/routers/insights-ui.mjs.map +1 -0
  238. package/dist/routers/insights.handlers.mjs +45 -0
  239. package/dist/routers/insights.handlers.mjs.map +1 -0
  240. package/dist/routers/insights.mjs +189 -0
  241. package/dist/routers/insights.mjs.map +1 -0
  242. package/dist/routers/links.handlers.mjs +80 -0
  243. package/dist/routers/links.handlers.mjs.map +1 -0
  244. package/dist/routers/links.mjs +139 -0
  245. package/dist/routers/links.mjs.map +1 -0
  246. package/dist/routers/media.mjs +18 -4
  247. package/dist/routers/media.mjs.map +1 -1
  248. package/dist/routers/members.mjs +2 -1
  249. package/dist/routers/members.mjs.map +1 -1
  250. package/dist/routers/notification-channels.mjs +17 -23
  251. package/dist/routers/notification-channels.mjs.map +1 -1
  252. package/dist/routers/oauth-state.mjs +45 -0
  253. package/dist/routers/oauth-state.mjs.map +1 -0
  254. package/dist/routers/preview-base.mjs +38 -0
  255. package/dist/routers/preview-base.mjs.map +1 -0
  256. package/dist/routers/preview-token.mjs +51 -0
  257. package/dist/routers/preview-token.mjs.map +1 -0
  258. package/dist/routers/profile.mjs +4 -2
  259. package/dist/routers/profile.mjs.map +1 -1
  260. package/dist/routers/projects.mjs +17 -1
  261. package/dist/routers/projects.mjs.map +1 -1
  262. package/dist/routers/publish-upload.mjs +78 -0
  263. package/dist/routers/publish-upload.mjs.map +1 -0
  264. package/dist/routers/published-access.mjs +52 -0
  265. package/dist/routers/published-access.mjs.map +1 -0
  266. package/dist/routers/published-files.mjs +52 -0
  267. package/dist/routers/published-files.mjs.map +1 -0
  268. package/dist/routers/review-forms.mjs +72 -0
  269. package/dist/routers/review-forms.mjs.map +1 -0
  270. package/dist/routers/review-section.handlers.mjs +85 -0
  271. package/dist/routers/review-section.handlers.mjs.map +1 -0
  272. package/dist/routers/review-switch-off.mjs +29 -0
  273. package/dist/routers/review-switch-off.mjs.map +1 -0
  274. package/dist/routers/schemas.mjs +140 -22
  275. package/dist/routers/schemas.mjs.map +1 -1
  276. package/dist/routers/scoped-models.mjs +22 -0
  277. package/dist/routers/scoped-models.mjs.map +1 -0
  278. package/dist/routers/settings-ai.mjs +35 -0
  279. package/dist/routers/settings-ai.mjs.map +1 -0
  280. package/dist/routers/settings-capture.mjs +77 -0
  281. package/dist/routers/settings-capture.mjs.map +1 -0
  282. package/dist/routers/settings-design.mjs +103 -0
  283. package/dist/routers/settings-design.mjs.map +1 -0
  284. package/dist/routers/settings-general.mjs +55 -10
  285. package/dist/routers/settings-general.mjs.map +1 -1
  286. package/dist/routers/settings-git-channel.mjs +77 -0
  287. package/dist/routers/settings-git-channel.mjs.map +1 -0
  288. package/dist/routers/settings-labels.mjs +27 -1
  289. package/dist/routers/settings-labels.mjs.map +1 -1
  290. package/dist/routers/settings-links.mjs +63 -0
  291. package/dist/routers/settings-links.mjs.map +1 -0
  292. package/dist/routers/settings-members.mjs +4 -7
  293. package/dist/routers/settings-members.mjs.map +1 -1
  294. package/dist/routers/settings-notifications.mjs +15 -10
  295. package/dist/routers/settings-notifications.mjs.map +1 -1
  296. package/dist/routers/settings-tokens.mjs +2 -5
  297. package/dist/routers/settings-tokens.mjs.map +1 -1
  298. package/dist/routers/settings-webhooks.mjs +7 -24
  299. package/dist/routers/settings-webhooks.mjs.map +1 -1
  300. package/dist/routers/settings.handlers.mjs +107 -26
  301. package/dist/routers/settings.handlers.mjs.map +1 -1
  302. package/dist/routers/settings.mjs +16 -4
  303. package/dist/routers/settings.mjs.map +1 -1
  304. package/dist/routers/signed-storybook.mjs +53 -0
  305. package/dist/routers/signed-storybook.mjs.map +1 -0
  306. package/dist/routers/snapshot-groups.mjs +63 -0
  307. package/dist/routers/snapshot-groups.mjs.map +1 -0
  308. package/dist/routers/snapshots.mjs +103 -5
  309. package/dist/routers/snapshots.mjs.map +1 -1
  310. package/dist/routers/storybook.mjs +37 -88
  311. package/dist/routers/storybook.mjs.map +1 -1
  312. package/dist/routers/tokens.mjs +2 -5
  313. package/dist/routers/tokens.mjs.map +1 -1
  314. package/dist/routers/ui.mjs +34 -5
  315. package/dist/routers/ui.mjs.map +1 -1
  316. package/dist/routers/webhooks.mjs +5 -11
  317. package/dist/routers/webhooks.mjs.map +1 -1
  318. package/dist/routers/well-known.mjs +11 -4
  319. package/dist/routers/well-known.mjs.map +1 -1
  320. package/dist/routes/api-routes.mjs +93 -0
  321. package/dist/routes/api-routes.mjs.map +1 -0
  322. package/dist/routes/classification.mjs +38 -0
  323. package/dist/routes/classification.mjs.map +1 -0
  324. package/dist/routes/openapi-classes.mjs +22 -0
  325. package/dist/routes/openapi-classes.mjs.map +1 -0
  326. package/dist/routes/page-routes.mjs +104 -0
  327. package/dist/routes/page-routes.mjs.map +1 -0
  328. package/dist/runtime.mjs +18 -13
  329. package/dist/runtime.mjs.map +1 -1
  330. package/dist/serverless.mjs +5 -0
  331. package/dist/store.mjs +19 -1
  332. package/dist/store.mjs.map +1 -1
  333. package/dist/targets.mjs +46 -0
  334. package/dist/targets.mjs.map +1 -0
  335. package/dist/ui/buttons.mjs +16 -3
  336. package/dist/ui/buttons.mjs.map +1 -1
  337. package/dist/ui/design-reference.mjs +97 -0
  338. package/dist/ui/design-reference.mjs.map +1 -0
  339. package/dist/ui/document.mjs +1 -2
  340. package/dist/ui/document.mjs.map +1 -1
  341. package/dist/ui/forms.mjs +6 -5
  342. package/dist/ui/forms.mjs.map +1 -1
  343. package/dist/ui/icons/paths.mjs +1 -0
  344. package/dist/ui/icons/paths.mjs.map +1 -1
  345. package/dist/ui/preview-embed.mjs +82 -0
  346. package/dist/ui/preview-embed.mjs.map +1 -0
  347. package/dist/ui/shell/client-script.mjs +85 -0
  348. package/dist/ui/shell/client-script.mjs.map +1 -1
  349. package/dist/ui/shell/nav.mjs +12 -8
  350. package/dist/ui/shell/nav.mjs.map +1 -1
  351. package/dist/ui/shell/review-script.mjs +8 -0
  352. package/dist/ui/shell/review-script.mjs.map +1 -1
  353. package/dist/ui/shell/sidebar.mjs +2 -2
  354. package/dist/ui/shell/sidebar.mjs.map +1 -1
  355. package/package.json +11 -6
  356. package/dist/asset-paths.mjs +0 -23
  357. package/dist/asset-paths.mjs.map +0 -1
  358. package/dist/assets/mark.svg +0 -1
  359. package/dist/pages/settings-status.mjs +0 -137
  360. package/dist/pages/settings-status.mjs.map +0 -1
  361. package/dist/routers/settings-status.mjs +0 -77
  362. package/dist/routers/settings-status.mjs.map +0 -1
  363. package/dist/routers/status-configs.mjs +0 -108
  364. package/dist/routers/status-configs.mjs.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-reviews.handlers.mjs","names":[],"sources":["../../src/routers/build-reviews.handlers.ts"],"sourcesContent":["/**\n * Build-level verdicts (ADR 0013, decision 3): giving one and reading where a build stands. The\n * routes are classed `review`, so `requireReview` answers 404 for a project with review off.\n */\nimport { publishingOf } from \"@storyshelf/core/capture\";\nimport {\n BuildModel,\n BuildReviewModel,\n deriveReviewStatus,\n latestVerdicts,\n} from \"@storyshelf/core/models\";\nimport type { Build, BuildReview, Project } from \"@storyshelf/core/schema\";\nimport type { BuildStatus, ReviewVerdict } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { requestBuildState } from \"../git-status.ts\";\nimport { REVIEW_EVENTS, announceReview } from \"../review-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { announceBuildStatus } from \"./builds.handlers.ts\";\n\n/** Statuses the verdicts may move a build between; a failed, cancelled or capturing build is not theirs to decide. */\nconst VERDICT_STATUSES: ReadonlySet<BuildStatus> = new Set([\"pending\", \"approved\", \"rejected\"]);\n\n/** Where a build stands in review. */\nexport interface ReviewState {\n status: BuildStatus;\n approvalsRequired: number;\n approvals: number;\n latest: BuildReview[];\n history: BuildReview[];\n}\n\n/** The verdicts of a build and what they make of it. */\nexport async function reviewStateOf(project: Project, build: Build): Promise<ReviewState> {\n const history = await new BuildReviewModel(getStore().db).listByBuild(build.id);\n const latest = latestVerdicts(history);\n return {\n status: build.status,\n approvalsRequired: approvalsRequired(project),\n approvals: latest.filter((review) => review.verdict === \"approved\").length,\n latest,\n history,\n };\n}\n\n/**\n * Record `userId`'s verdict and let the verdicts decide the build. With `testing` on the verdict is\n * stored as a sign-off and the snapshot rollup keeps deciding. Every write asks for the build's\n * state to be shown on its git channels; a status the verdicts moved to `approved` or `rejected`\n * is announced like any other (webhook and notifications). Every verdict raises `review:verdict`.\n */\nexport async function giveVerdict(\n project: Project,\n build: Build,\n userId: string | null,\n input: { verdict: ReviewVerdict; body?: string | undefined },\n): Promise<BuildReview> {\n await assertReviewable(project, build, userId);\n const review = await new BuildReviewModel(getStore().db).add(\n build.id,\n userId,\n input.verdict,\n input.body,\n );\n const status = await decideBuild(project, build);\n await announceReview(\n project,\n REVIEW_EVENTS.verdict,\n {\n buildId: build.id,\n gitBranch: build.gitBranch,\n verdict: review.verdict,\n userId,\n body: review.body?.slice(0, 500) ?? null,\n status: status ?? build.status,\n },\n build.id,\n );\n // Every move is news: `approved` and `rejected` as ever, and `pending` when a rewritten verdict\n // sends the build back; a verdict that moves nothing only refreshes the git channels.\n if (status === null) {\n await requestBuildState(build.id);\n } else {\n await announceBuildStatus(build.id, status);\n }\n return review;\n}\n\n/**\n * A verdict needs a build that is neither `failed` nor `cancelled`, whose publishing has ended (a publish that later\n * failed would otherwise leave an approved build without statics), and, when it has no user (an\n * instance without authentication), a project that needs one approval: the anonymous reviewer is\n * one person, however many verdicts they give.\n */\nasync function assertReviewable(\n project: Project,\n build: Build,\n userId: string | null,\n): Promise<void> {\n if (build.status === \"failed\" || build.status === \"cancelled\") {\n throw new HTTPException(409, {\n message: `The build ${build.status === \"failed\" ? \"failed\" : \"was cancelled\"}; there is nothing to review`,\n });\n }\n if ((await publishingOf(getStore().storage, build)) !== \"success\") {\n throw new HTTPException(409, {\n message: \"The build is still publishing; give a verdict once its Storybook is published\",\n });\n }\n if (userId === null && approvalsRequired(project) > 1) {\n throw new HTTPException(409, {\n message: `Verdicts without authentication count as one reviewer, but this project needs ${String(approvalsRequired(project))} approvals; enable authentication to review`,\n });\n }\n}\n\n/** Move the build to the status its verdicts give it; the new status, or null when it did not move. */\nasync function decideBuild(\n project: Project,\n build: Build,\n): Promise<\"approved\" | \"rejected\" | \"pending\" | null> {\n const { db, testing } = getStore();\n const status = deriveReviewStatus({\n testing,\n review: true,\n approvalsRequired: approvalsRequired(project),\n reviews: await new BuildReviewModel(db).listByBuild(build.id),\n });\n if (status === null || !VERDICT_STATUSES.has(build.status) || status === build.status) {\n return null;\n }\n await new BuildModel(db).setStatus(build.id, status);\n return status;\n}\n\nfunction approvalsRequired(project: Project): number {\n return Math.max(1, project.reviewApprovals ?? 1);\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAoBA,MAAM,mCAA6C,IAAI,IAAI;CAAC;CAAW;CAAY;AAAU,CAAC;;AAY9F,eAAsB,cAAc,SAAkB,OAAoC;CACxF,MAAM,UAAU,MAAM,IAAI,iBAAiB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;CAC9E,MAAM,SAAS,eAAe,OAAO;CACrC,OAAO;EACL,QAAQ,MAAM;EACd,mBAAmB,kBAAkB,OAAO;EAC5C,WAAW,OAAO,QAAQ,WAAW,OAAO,YAAY,UAAU,CAAC,CAAC;EACpE;EACA;CACF;AACF;;;;;;;AAQA,eAAsB,YACpB,SACA,OACA,QACA,OACsB;CACtB,MAAM,iBAAiB,SAAS,OAAO,MAAM;CAC7C,MAAM,SAAS,MAAM,IAAI,iBAAiB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IACvD,MAAM,IACN,QACA,MAAM,SACN,MAAM,IACR;CACA,MAAM,SAAS,MAAM,YAAY,SAAS,KAAK;CAC/C,MAAM,eACJ,SACA,cAAc,SACd;EACE,SAAS,MAAM;EACf,WAAW,MAAM;EACjB,SAAS,OAAO;EAChB;EACA,MAAM,OAAO,MAAM,MAAM,GAAG,GAAG,KAAK;EACpC,QAAQ,UAAU,MAAM;CAC1B,GACA,MAAM,EACR;CAGA,IAAI,WAAW,MACb,MAAM,kBAAkB,MAAM,EAAE;MAEhC,MAAM,oBAAoB,MAAM,IAAI,MAAM;CAE5C,OAAO;AACT;;;;;;;AAQA,eAAe,iBACb,SACA,OACA,QACe;CACf,IAAI,MAAM,WAAW,YAAY,MAAM,WAAW,aAChD,MAAM,IAAI,cAAc,KAAK,EAC3B,SAAS,aAAa,MAAM,WAAW,WAAW,WAAW,gBAAgB,8BAC/E,CAAC;CAEH,IAAK,MAAM,aAAa,SAAS,CAAC,CAAC,SAAS,KAAK,MAAO,WACtD,MAAM,IAAI,cAAc,KAAK,EAC3B,SAAS,gFACX,CAAC;CAEH,IAAI,WAAW,QAAQ,kBAAkB,OAAO,IAAI,GAClD,MAAM,IAAI,cAAc,KAAK,EAC3B,SAAS,iFAAiF,OAAO,kBAAkB,OAAO,CAAC,EAAE,6CAC/H,CAAC;AAEL;;AAGA,eAAe,YACb,SACA,OACqD;CACrD,MAAM,EAAE,IAAI,YAAY,SAAS;CACjC,MAAM,SAAS,mBAAmB;EAChC;EACA,QAAQ;EACR,mBAAmB,kBAAkB,OAAO;EAC5C,SAAS,MAAM,IAAI,iBAAiB,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;CAC9D,CAAC;CACD,IAAI,WAAW,QAAQ,CAAC,iBAAiB,IAAI,MAAM,MAAM,KAAK,WAAW,MAAM,QAC7E,OAAO;CAET,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,UAAU,MAAM,IAAI,MAAM;CACnD,OAAO;AACT;AAEA,SAAS,kBAAkB,SAA0B;CACnD,OAAO,KAAK,IAAI,GAAG,QAAQ,mBAAmB,CAAC;AACjD"}
@@ -0,0 +1,74 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { resolveAuthorizedProject, resolveReviewer } from "./helpers.mjs";
5
+ import { hxRefresh } from "./htmx.mjs";
6
+ import { flashHx } from "./flash.mjs";
7
+ import { buildReviewCreateSchema, buildReviewSchema, buildReviewStateSchema, forbidden, notFound, unauthorized } from "./schemas.mjs";
8
+ import { APPROVER_ROLES, VIEW_ROLES, buildForProject } from "./builds.handlers.mjs";
9
+ import { giveVerdict, reviewStateOf } from "./build-reviews.handlers.mjs";
10
+ import { createRoute, z } from "@hono/zod-openapi";
11
+ //#region src/routers/build-reviews.ts
12
+ const params = z.object({
13
+ slug: z.string(),
14
+ buildId: z.string()
15
+ });
16
+ const getReviewsRoute = createRoute({
17
+ method: "get",
18
+ tags: ["Review"],
19
+ summary: "Get a build's review state",
20
+ description: "The verdicts reviewers gave the build and what they make of it: each reviewer's latest verdict, the approvals against the project's requirement, and the full history. 404 when the project has review off.",
21
+ path: "/api/v1/projects/{slug}/builds/{buildId}/reviews",
22
+ request: { params },
23
+ responses: {
24
+ 200: {
25
+ content: { "application/json": { schema: buildReviewStateSchema } },
26
+ description: "Review state of the build"
27
+ },
28
+ ...notFound,
29
+ ...unauthorized
30
+ }
31
+ });
32
+ const createReviewRoute = createRoute({
33
+ method: "post",
34
+ tags: ["Review"],
35
+ summary: "Approve or reject a build",
36
+ description: "Give the build a verdict (approver and admin). A reviewer rewrites their verdict by posting again; the newest counts. With `testing` off the verdicts decide the build: `approved` once the project's required approvals are present and no reviewer's latest verdict is `rejected`, `rejected` when any is. With `testing` on the verdict is a sign-off and the snapshot rollup keeps deciding. The verdict is attributed to the signed-in user, or to the user who owns the API token; a caller that is no user (the bootstrap admin token) gets 403, and only an instance with no authentication at all records an anonymous verdict, which counts when the project needs one approval (409 otherwise). 404 when the project has review off; 409 for a failed or cancelled build, while the build is still publishing, or for an anonymous verdict the project cannot count.",
37
+ path: "/api/v1/projects/{slug}/builds/{buildId}/reviews",
38
+ request: {
39
+ params,
40
+ body: { content: { "application/json": { schema: buildReviewCreateSchema } } }
41
+ },
42
+ responses: {
43
+ 201: {
44
+ content: { "application/json": { schema: buildReviewSchema } },
45
+ description: "Verdict recorded"
46
+ },
47
+ ...forbidden,
48
+ ...notFound,
49
+ ...unauthorized
50
+ }
51
+ });
52
+ /** Register the build-level verdict endpoints (`review` class). */
53
+ function registerBuildReviews(app) {
54
+ app.openapi(getReviewsRoute, async (c) => {
55
+ const { slug, buildId } = c.req.valid("param");
56
+ const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);
57
+ const build = await buildForProject(project.id, buildId);
58
+ return c.json(await reviewStateOf(project, build));
59
+ });
60
+ app.openapi(createReviewRoute, async (c) => {
61
+ const { slug, buildId } = c.req.valid("param");
62
+ const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);
63
+ const build = await buildForProject(project.id, buildId);
64
+ const userId = await resolveReviewer(c);
65
+ const review = await giveVerdict(project, build, userId, c.req.valid("json"));
66
+ hxRefresh(c);
67
+ flashHx(c, "Verdict recorded");
68
+ return c.json(review, 201);
69
+ });
70
+ }
71
+ //#endregion
72
+ export { registerBuildReviews };
73
+
74
+ //# sourceMappingURL=build-reviews.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-reviews.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/build-reviews.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { giveVerdict, reviewStateOf } from \"./build-reviews.handlers.ts\";\nimport { APPROVER_ROLES, VIEW_ROLES, buildForProject } from \"./builds.handlers.ts\";\nimport { flashHx } from \"./flash.ts\";\nimport { resolveAuthorizedProject, resolveReviewer } from \"./helpers.ts\";\nimport { hxRefresh } from \"./htmx.ts\";\nimport {\n buildReviewCreateSchema,\n buildReviewSchema,\n buildReviewStateSchema,\n forbidden as forbiddenResponse,\n notFound,\n unauthorized,\n} from \"./schemas.ts\";\n\nconst params = z.object({ slug: z.string(), buildId: z.string() });\n\nconst getReviewsRoute = createRoute({\n method: \"get\",\n tags: [\"Review\"],\n summary: \"Get a build's review state\",\n description:\n \"The verdicts reviewers gave the build and what they make of it: each reviewer's latest verdict, the approvals against the project's requirement, and the full history. 404 when the project has review off.\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/reviews\",\n request: { params },\n responses: {\n 200: {\n content: { \"application/json\": { schema: buildReviewStateSchema } },\n description: \"Review state of the build\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createReviewRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Approve or reject a build\",\n description:\n \"Give the build a verdict (approver and admin). A reviewer rewrites their verdict by posting again; the newest counts. With `testing` off the verdicts decide the build: `approved` once the project's required approvals are present and no reviewer's latest verdict is `rejected`, `rejected` when any is. With `testing` on the verdict is a sign-off and the snapshot rollup keeps deciding. The verdict is attributed to the signed-in user, or to the user who owns the API token; a caller that is no user (the bootstrap admin token) gets 403, and only an instance with no authentication at all records an anonymous verdict, which counts when the project needs one approval (409 otherwise). 404 when the project has review off; 409 for a failed or cancelled build, while the build is still publishing, or for an anonymous verdict the project cannot count.\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/reviews\",\n request: {\n params,\n body: { content: { \"application/json\": { schema: buildReviewCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: buildReviewSchema } },\n description: \"Verdict recorded\",\n },\n ...forbiddenResponse,\n ...notFound,\n ...unauthorized,\n },\n});\n\n/** Register the build-level verdict endpoints (`review` class). */\nexport function registerBuildReviews(app: ShelfRouter): void {\n app.openapi(getReviewsRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n return c.json(await reviewStateOf(project, build));\n });\n\n app.openapi(createReviewRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const userId = await resolveReviewer(c);\n const review = await giveVerdict(project, build, userId, c.req.valid(\"json\"));\n hxRefresh(c);\n flashHx(c, \"Verdict recorded\");\n return c.json(review, 201);\n });\n}\n"],"mappings":";;;;;;;;;;;AAgBA,MAAM,SAAS,EAAE,OAAO;CAAE,MAAM,EAAE,OAAO;CAAG,SAAS,EAAE,OAAO;AAAE,CAAC;AAEjE,MAAM,kBAAkB,YAAY;CAClC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS,EAAE,OAAO;CAClB,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,uBAAuB,EAAE;GAClE,aAAa;EACf;EACA,GAAG;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS;EACP;EACA,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,wBAAwB,EAAE,EAAE;CAC/E;CACA,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,kBAAkB,EAAE;GAC7D,aAAa;EACf;EACA,GAAGA;EACH,GAAG;EACH,GAAG;CACL;AACF,CAAC;;AAGD,SAAgB,qBAAqB,KAAwB;CAC3D,IAAI,QAAQ,iBAAiB,OAAO,MAAM;EACxC,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,UAAU;EACrE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,OAAO,EAAE,KAAK,MAAM,cAAc,SAAS,KAAK,CAAC;CACnD,CAAC;CAED,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,cAAc;EACzE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,SAAS,MAAM,gBAAgB,CAAC;EACtC,MAAM,SAAS,MAAM,YAAY,SAAS,OAAO,QAAQ,EAAE,IAAI,MAAM,MAAM,CAAC;EAC5E,UAAU,CAAC;EACX,QAAQ,GAAG,kBAAkB;EAC7B,OAAO,EAAE,KAAK,QAAQ,GAAG;CAC3B,CAAC;AACH"}
@@ -5,13 +5,13 @@ import { credentialKeys } from "../credential-keys.mjs";
5
5
  import { getStore } from "../store.mjs";
6
6
  import { notifyProject } from "../notify.mjs";
7
7
  import { notFound } from "./helpers.mjs";
8
+ import { requestBuildState } from "../git-status.mjs";
8
9
  import { assertBaselineCurrent } from "./baseline-guard.mjs";
9
10
  import { z } from "@hono/zod-openapi";
10
- import { extractStorybookToScratch, persistStorybookStatics } from "@storyshelf/core/capture";
11
11
  import { BaselineModel, BuildModel, LabelModel, ProjectModel, SnapshotModel } from "@storyshelf/core/models";
12
- import { BUILD_STATUSES } from "@storyshelf/core/types";
13
12
  import { HTTPException } from "hono/http-exception";
14
13
  import { emitWebhookEvent } from "@storyshelf/core/adapter/webhook-events";
14
+ import { BUILD_STATUSES } from "@storyshelf/core/types";
15
15
  import { Readable, Transform } from "node:stream";
16
16
  //#region src/routers/builds.handlers.ts
17
17
  /**
@@ -48,10 +48,12 @@ async function createBuildRecord(project, meta) {
48
48
  authorName: meta.authorName,
49
49
  message: meta.message
50
50
  }, `/builds/${build.id}`);
51
+ await requestBuildState(build.id);
51
52
  return build;
52
53
  }
53
54
  /**
54
- * Stream a raw request body into storage with a byte cap.
55
+ * Stream a raw request body into storage with a byte cap, counting the bytes as they pass (a
56
+ * chunked upload has no `Content-Length`). Resolves to the bytes stored.
55
57
  * Throws 400 for a missing body, 413 once `maxBytes` is exceeded.
56
58
  */
57
59
  async function storeUploadStream(storage, path, body, maxBytes) {
@@ -65,34 +67,12 @@ async function storeUploadStream(storage, path, body, maxBytes) {
65
67
  try {
66
68
  const nodeBody = Readable.fromWeb(body);
67
69
  await storage.writeStream(path, nodeBody.pipe(limiter));
70
+ return seen;
68
71
  } catch (error) {
69
72
  if (seen > maxBytes) throw new HTTPException(413, { message: "Upload exceeds size limit" });
70
73
  throw error;
71
74
  }
72
75
  }
73
- /**
74
- * Best-effort inline statics extraction for small uploads (see
75
- * `maxInlineUnzipSize`). Never throws: failures are logged and the capture
76
- * job backfills the same keys idempotently.
77
- */
78
- async function persistInlineStatics(storage, scratchDir, maxInlineBytes, contentLength, projectId, buildId, logger) {
79
- if (!scratchDir || !maxInlineBytes) return;
80
- const size = parseContentLength(contentLength);
81
- if (size === void 0 || size > maxInlineBytes) return;
82
- try {
83
- const extractedDir = await extractStorybookToScratch(storage, scratchDir, projectId, buildId);
84
- await persistStorybookStatics(storage, extractedDir, projectId, buildId);
85
- logger.info({
86
- buildId,
87
- size
88
- }, "upload statics persisted inline");
89
- } catch (error) {
90
- logger.error({
91
- err: error,
92
- buildId
93
- }, "inline statics persist failed; capture will backfill");
94
- }
95
- }
96
76
  /** Query filters accepted by the build list endpoint. */
97
77
  const buildListQuery = z.object({
98
78
  status: z.enum(BUILD_STATUSES).optional(),
@@ -112,34 +92,46 @@ async function snapshotForBuild(build, snapshotId) {
112
92
  if (!snapshot || snapshot.buildId !== build.id) notFound("Snapshot not found");
113
93
  return snapshot;
114
94
  }
95
+ /** Roll snapshot statuses up into the build's review status. */
96
+ function rollupStatus(snapshots) {
97
+ if (snapshots.some((s) => s.status === "new" || s.status === "changed")) return "reviewing";
98
+ return snapshots.some((s) => s.status === "rejected") ? "rejected" : "approved";
99
+ }
115
100
  /** Recompute a build's counts and roll its status up from its snapshots. */
116
101
  async function refreshBuild(buildId) {
117
- const { db, config } = getStore();
102
+ const { db } = getStore();
118
103
  await new BuildModel(db).updateCounts(buildId);
119
104
  const snapshots = await new SnapshotModel(db).listByBuild(buildId);
120
- const unresolved = snapshots.some((s) => s.status === "new" || s.status === "changed");
121
- let status;
122
- if (unresolved) status = "reviewing";
123
- else status = snapshots.some((s) => s.status === "rejected") ? "rejected" : "approved";
105
+ const status = rollupStatus(snapshots);
124
106
  const build = await new BuildModel(db).get(buildId);
125
- if (build) {
126
- await new BuildModel(db).setStatus(buildId, status);
127
- await emitWebhookEvent(db, { webhooks: getStore().db.tables.webhooks }, build.projectId, `build:${status}`, {
128
- buildId,
129
- status,
130
- snapshotCount: snapshots.length
131
- }, credentialKeys(config));
132
- const project = await new ProjectModel(db).get(build.projectId);
133
- if (project) await notifyProject(project, `build:${status}`, {
134
- buildId,
135
- status,
136
- snapshotCount: snapshots.length
137
- }, `/builds/${buildId}`);
138
- }
107
+ if (!build || build.status === "cancelled") return;
108
+ await new BuildModel(db).setStatus(buildId, status);
109
+ await announceBuildStatus(buildId, status, { snapshotCount: snapshots.length });
110
+ }
111
+ /**
112
+ * Tell the world a build reached `status`: its git channels, the `build:<status>` webhook and the
113
+ * project's notification channels. Every route that moves a build to a decided status ends here.
114
+ */
115
+ async function announceBuildStatus(buildId, status, extra = {}) {
116
+ const { db, config } = getStore();
117
+ const build = await new BuildModel(db).get(buildId);
118
+ if (!build) return;
119
+ await requestBuildState(buildId);
120
+ const payload = {
121
+ buildId,
122
+ status,
123
+ ...extra
124
+ };
125
+ await emitWebhookEvent(db, { webhooks: getStore().db.tables.webhooks }, build.projectId, `build:${status}`, payload, credentialKeys(config));
126
+ const project = await new ProjectModel(db).get(build.projectId);
127
+ if (!project) return;
128
+ await notifyProject(project, `build:${status}`, payload, `/builds/${buildId}`);
139
129
  }
140
130
  /**
141
131
  * Approve a snapshot, promote its screenshot to baseline, and refresh the build.
142
132
  * Refuses with 409 when the baseline changed since the diff, unless `force` is set.
133
+ * Bulk callers pass `deferRefresh` and call {@link refreshBuild} once, so the build's
134
+ * outcome (and its git status) is announced a single time.
143
135
  */
144
136
  async function approveSnapshot(snapshotId, userId, options = {}) {
145
137
  const { db, config } = getStore();
@@ -149,19 +141,24 @@ async function approveSnapshot(snapshotId, userId, options = {}) {
149
141
  const project = await new ProjectModel(db).get(snapshot.projectId);
150
142
  const build = await new BuildModel(db).get(snapshot.buildId);
151
143
  if (!project || !build) notFound("Project or build not found");
144
+ if (build.status === "cancelled") throw new HTTPException(409, { message: "The build was cancelled; retry it to review it" });
152
145
  await assertBaselineCurrent(project, build, snapshot, options.force ?? false);
153
146
  await snapshots.review(snapshotId, "approved", userId);
154
147
  const baselines = new BaselineModel(db, void 0, getStore().storage, credentialKeys(config));
155
- const prior = await baselines.getFor(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch);
156
- await baselines.upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);
148
+ const prior = await baselines.getFor(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.browser);
149
+ await baselines.upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath, void 0, {
150
+ browser: snapshot.browser,
151
+ mode: snapshot.mode
152
+ });
157
153
  await notifyProject(project, prior ? "baseline:updated" : "baseline:created", {
158
154
  storyId: snapshot.storyId,
159
155
  viewport: snapshot.viewportName,
156
+ browser: snapshot.browser ?? "chromium",
160
157
  branch: build.gitBranch,
161
158
  snapshotId: snapshot.id,
162
159
  buildId: build.id
163
160
  }, `/builds/${build.id}`);
164
- await refreshBuild(build.id);
161
+ if (!options.deferRefresh) await refreshBuild(build.id);
165
162
  }
166
163
  /** Roles permitted to view builds, snapshots, and comments. */
167
164
  const VIEW_ROLES = [
@@ -205,13 +202,7 @@ async function attachLabels(db, projectId, buildId, labels) {
205
202
  await models.attach(projectId, buildId, key, value);
206
203
  }
207
204
  }
208
- /** Parse a Content-Length header value into bytes, or undefined when unknown. */
209
- function parseContentLength(value) {
210
- if (!value) return;
211
- const parsed = Math.trunc(Number(value));
212
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
213
- }
214
205
  //#endregion
215
- export { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, approveSnapshot, buildCreateJsonSchema, buildForProject, buildListQuery, createBuildRecord, persistInlineStatics, refreshBuild, snapshotForBuild, storeUploadStream };
206
+ export { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, announceBuildStatus, approveSnapshot, buildCreateJsonSchema, buildForProject, buildListQuery, createBuildRecord, refreshBuild, snapshotForBuild, storeUploadStream };
216
207
 
217
208
  //# sourceMappingURL=builds.handlers.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"builds.handlers.mjs","names":[],"sources":["../../src/routers/builds.handlers.ts"],"sourcesContent":["import { z } from \"@hono/zod-openapi\";\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport { emitWebhookEvent } from \"@storyshelf/core/adapter/webhook-events\";\nimport { extractStorybookToScratch, persistStorybookStatics } from \"@storyshelf/core/capture\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { BaselineModel } from \"@storyshelf/core/models\";\nimport { BuildModel } from \"@storyshelf/core/models\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { SnapshotModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Build, Snapshot } from \"@storyshelf/core/schema\";\nimport { type ProjectRole, BUILD_STATUSES } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { Readable, Transform } from \"node:stream\";\nimport type { ReadableStream as NodeWebStream } from \"node:stream/web\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { notifyProject } from \"../notify.ts\";\nimport { getStore } from \"../store.ts\";\nimport { assertBaselineCurrent } from \"./baseline-guard.ts\";\nimport { notFound } from \"./helpers.ts\";\n/**\n * Create a build record with labels and the `build:created` webhook.\n * Shared by the build-creation route.\n */\nexport async function createBuildRecord(\n project: Project,\n meta: BuildCreateMetadata,\n): Promise<Build> {\n const { db, config } = getStore();\n if (!meta.gitSha || !meta.gitBranch) {\n throw new HTTPException(400, {\n message: \"gitSha and gitBranch are required (revision SHA and baseline namespace)\",\n });\n }\n const build = await new BuildModel(db).create(project.id, {\n gitSha: meta.gitSha,\n gitBranch: meta.gitBranch,\n isDefault: meta.gitBranch === project.gitDefaultBranch,\n authorEmail: meta.authorEmail,\n authorName: meta.authorName,\n message: meta.message,\n affectedOnly: meta.affectedOnly,\n baselineSha: await resolveBaselineSha(db, project.id, meta.gitBranch, meta.gitSha),\n });\n if (meta.labels && meta.labels.length > 0) {\n await attachLabels(db, project.id, build.id, meta.labels);\n }\n await emitWebhookEvent(\n db,\n { webhooks: getStore().db.tables.webhooks },\n project.id,\n \"build:created\",\n {\n buildId: build.id,\n gitSha: meta.gitSha,\n gitBranch: meta.gitBranch,\n authorEmail: meta.authorEmail,\n authorName: meta.authorName,\n message: meta.message,\n },\n credentialKeys(config),\n );\n await notifyProject(\n project,\n \"build:created\",\n {\n buildId: build.id,\n gitSha: meta.gitSha,\n gitBranch: meta.gitBranch,\n authorEmail: meta.authorEmail,\n authorName: meta.authorName,\n message: meta.message,\n },\n `/builds/${build.id}`,\n );\n return build;\n}\n\n/**\n * Stream a raw request body into storage with a byte cap.\n * Throws 400 for a missing body, 413 once `maxBytes` is exceeded.\n */\nexport async function storeUploadStream(\n storage: StorageAdapter,\n path: string,\n body: ReadableStream<Uint8Array> | null,\n maxBytes: number,\n): Promise<void> {\n if (!body) {\n throw new HTTPException(400, { message: \"Missing request body\" });\n }\n let seen = 0;\n const limiter = new Transform({\n transform(\n chunk: Buffer,\n _encoding: string,\n callback: (error?: Error | null, data?: Buffer) => void,\n ): void {\n seen += chunk.length;\n if (seen > maxBytes) {\n callback(new Error(`Upload exceeds ${maxBytes} byte limit`));\n } else {\n callback(null, chunk);\n }\n },\n });\n try {\n const nodeBody = Readable.fromWeb(body as unknown as NodeWebStream);\n await storage.writeStream(path, nodeBody.pipe(limiter));\n } catch (error) {\n if (seen > maxBytes) {\n throw new HTTPException(413, { message: \"Upload exceeds size limit\" });\n }\n throw error;\n }\n}\n\n/**\n * Best-effort inline statics extraction for small uploads (see\n * `maxInlineUnzipSize`). Never throws: failures are logged and the capture\n * job backfills the same keys idempotently.\n */\nexport async function persistInlineStatics(\n storage: StorageAdapter,\n scratchDir: string | undefined,\n maxInlineBytes: number | undefined,\n contentLength: string | undefined,\n projectId: string,\n buildId: string,\n logger: Logger,\n): Promise<void> {\n if (!scratchDir || !maxInlineBytes) {\n return;\n }\n const size = parseContentLength(contentLength);\n if (size === undefined || size > maxInlineBytes) {\n return;\n }\n try {\n const extractedDir = await extractStorybookToScratch(storage, scratchDir, projectId, buildId);\n await persistStorybookStatics(storage, extractedDir, projectId, buildId);\n logger.info({ buildId, size }, \"upload statics persisted inline\");\n } catch (error) {\n logger.error({ err: error, buildId }, \"inline statics persist failed; capture will backfill\");\n }\n}\n\n/** Query filters accepted by the build list endpoint. */\nexport const buildListQuery = z.object({\n status: z.enum(BUILD_STATUSES).optional(),\n branch: z.string().optional(),\n labelKey: z.string().optional(),\n labelValue: z.string().optional(),\n});\n\n/** Fetch a build scoped to its project, throwing 404 when it does not belong. */\nexport async function buildForProject(projectId: string, buildId: string): Promise<Build> {\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== projectId) {\n notFound(\"Build not found\");\n }\n return build;\n}\n\n/** Fetch a snapshot scoped to its build, throwing 404 when it does not belong. */\nexport async function snapshotForBuild(\n build: { id: string },\n snapshotId: string,\n): Promise<Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== build.id) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}\n\n/** Recompute a build's counts and roll its status up from its snapshots. */\nexport async function refreshBuild(buildId: string): Promise<void> {\n const { db, config } = getStore();\n await new BuildModel(db).updateCounts(buildId);\n const snapshots = await new SnapshotModel(db).listByBuild(buildId);\n const unresolved = snapshots.some((s) => s.status === \"new\" || s.status === \"changed\");\n let status: \"reviewing\" | \"rejected\" | \"approved\";\n if (unresolved) {\n status = \"reviewing\";\n } else {\n const rejected = snapshots.some((s) => s.status === \"rejected\");\n status = rejected ? \"rejected\" : \"approved\";\n }\n const build = await new BuildModel(db).get(buildId);\n if (build) {\n await new BuildModel(db).setStatus(buildId, status);\n await emitWebhookEvent(\n db,\n { webhooks: getStore().db.tables.webhooks },\n build.projectId,\n `build:${status}`,\n {\n buildId,\n status,\n snapshotCount: snapshots.length,\n },\n credentialKeys(config),\n );\n const project = await new ProjectModel(db).get(build.projectId);\n if (project) {\n await notifyProject(\n project,\n `build:${status}`,\n { buildId, status, snapshotCount: snapshots.length },\n `/builds/${buildId}`,\n );\n }\n }\n}\n\n/**\n * Approve a snapshot, promote its screenshot to baseline, and refresh the build.\n * Refuses with 409 when the baseline changed since the diff, unless `force` is set.\n */\nexport async function approveSnapshot(\n snapshotId: string,\n userId: string | null,\n options: { force?: boolean } = {},\n): Promise<void> {\n const { db, config } = getStore();\n const snapshots = new SnapshotModel(db);\n const snapshot = await snapshots.get(snapshotId);\n if (!snapshot) {\n notFound(\"Snapshot not found\");\n }\n const project = await new ProjectModel(db).get(snapshot.projectId);\n const build = await new BuildModel(db).get(snapshot.buildId);\n if (!project || !build) {\n notFound(\"Project or build not found\");\n }\n await assertBaselineCurrent(project, build, snapshot, options.force ?? false);\n await snapshots.review(snapshotId, \"approved\", userId);\n const baselines = new BaselineModel(db, undefined, getStore().storage, credentialKeys(config));\n const prior = await baselines.getFor(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n );\n await baselines.upsert(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n snapshot.id,\n snapshot.screenshotPath,\n );\n await notifyProject(\n project,\n prior ? \"baseline:updated\" : \"baseline:created\",\n {\n storyId: snapshot.storyId,\n viewport: snapshot.viewportName,\n branch: build.gitBranch,\n snapshotId: snapshot.id,\n buildId: build.id,\n },\n `/builds/${build.id}`,\n );\n await refreshBuild(build.id);\n}\n\n/** Roles permitted to view builds, snapshots, and comments. */\nexport const VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\n/** Roles permitted to upload builds and write comments. */\nexport const DEVELOPER_ROLES: readonly ProjectRole[] = [\"developer\", \"approver\", \"admin\"];\n/** Roles permitted to approve or reject snapshots and delete builds. */\nexport const APPROVER_ROLES: readonly ProjectRole[] = [\"approver\", \"admin\"];\n\n/** JSON input schema for creating a build before streaming its bundle. */\nexport const buildCreateJsonSchema = z\n .object({\n gitSha: z.string().min(1),\n gitBranch: z.string().min(1),\n authorEmail: z.string().optional(),\n authorName: z.string().optional(),\n message: z.string().optional(),\n affectedOnly: z.boolean().optional(),\n labels: z.array(z.object({ key: z.string().min(1), value: z.string() })).optional(),\n })\n .openapi(\"BuildCreate\");\n\n/** Metadata accepted by either build-creation route. */\nexport interface BuildCreateMetadata {\n gitSha: string;\n gitBranch: string;\n authorEmail?: string;\n authorName?: string;\n message?: string;\n affectedOnly?: boolean;\n labels?: { key: string; value: string }[];\n}\n\n/** Latest prior revision on a branch with a build, or null for first builds. */\nasync function resolveBaselineSha(\n db: DatabaseAdapter,\n projectId: string,\n branch: string,\n sha: string,\n): Promise<string | null> {\n const builds = await new BuildModel(db).list(projectId, { branch });\n return builds.find((build) => build.gitSha !== sha)?.gitSha ?? null;\n}\n\n/* oxlint-disable eslint/no-await-in-loop -- label attach is intentionally sequential */\nasync function attachLabels(\n db: DatabaseAdapter,\n projectId: string,\n buildId: string,\n labels: { key: string; value: string }[],\n): Promise<void> {\n const models = new LabelModel(db);\n for (const { key, value } of labels) {\n const existing = await models.getType(projectId, key);\n if (!existing) {\n await models.createType(projectId, { key, name: key });\n }\n await models.attach(projectId, buildId, key, value);\n }\n}\n/* oxlint-enable eslint/no-await-in-loop */\n\n/** Parse a Content-Length header value into bytes, or undefined when unknown. */\nfunction parseContentLength(value: string | undefined): number | undefined {\n if (!value) {\n return undefined;\n }\n const parsed = Math.trunc(Number(value));\n return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"builds.handlers.mjs","names":[],"sources":["../../src/routers/builds.handlers.ts"],"sourcesContent":["import { z } from \"@hono/zod-openapi\";\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport { emitWebhookEvent } from \"@storyshelf/core/adapter/webhook-events\";\nimport { BaselineModel } from \"@storyshelf/core/models\";\nimport { BuildModel } from \"@storyshelf/core/models\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { SnapshotModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Build, Snapshot } from \"@storyshelf/core/schema\";\nimport { type BuildStatus, type ProjectRole, BUILD_STATUSES } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { Readable, Transform } from \"node:stream\";\nimport type { ReadableStream as NodeWebStream } from \"node:stream/web\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { requestBuildState } from \"../git-status.ts\";\nimport { notifyProject } from \"../notify.ts\";\nimport { getStore } from \"../store.ts\";\nimport { assertBaselineCurrent } from \"./baseline-guard.ts\";\nimport { notFound } from \"./helpers.ts\";\n/**\n * Create a build record with labels and the `build:created` webhook.\n * Shared by the build-creation route.\n */\nexport async function createBuildRecord(\n project: Project,\n meta: BuildCreateMetadata,\n): Promise<Build> {\n const { db, config } = getStore();\n if (!meta.gitSha || !meta.gitBranch) {\n throw new HTTPException(400, {\n message: \"gitSha and gitBranch are required (revision SHA and baseline namespace)\",\n });\n }\n const build = await new BuildModel(db).create(project.id, {\n gitSha: meta.gitSha,\n gitBranch: meta.gitBranch,\n isDefault: meta.gitBranch === project.gitDefaultBranch,\n authorEmail: meta.authorEmail,\n authorName: meta.authorName,\n message: meta.message,\n affectedOnly: meta.affectedOnly,\n baselineSha: await resolveBaselineSha(db, project.id, meta.gitBranch, meta.gitSha),\n });\n if (meta.labels && meta.labels.length > 0) {\n await attachLabels(db, project.id, build.id, meta.labels);\n }\n await emitWebhookEvent(\n db,\n { webhooks: getStore().db.tables.webhooks },\n project.id,\n \"build:created\",\n {\n buildId: build.id,\n gitSha: meta.gitSha,\n gitBranch: meta.gitBranch,\n authorEmail: meta.authorEmail,\n authorName: meta.authorName,\n message: meta.message,\n },\n credentialKeys(config),\n );\n await notifyProject(\n project,\n \"build:created\",\n {\n buildId: build.id,\n gitSha: meta.gitSha,\n gitBranch: meta.gitBranch,\n authorEmail: meta.authorEmail,\n authorName: meta.authorName,\n message: meta.message,\n },\n `/builds/${build.id}`,\n );\n await requestBuildState(build.id);\n return build;\n}\n\n/**\n * Stream a raw request body into storage with a byte cap, counting the bytes as they pass (a\n * chunked upload has no `Content-Length`). Resolves to the bytes stored.\n * Throws 400 for a missing body, 413 once `maxBytes` is exceeded.\n */\nexport async function storeUploadStream(\n storage: StorageAdapter,\n path: string,\n body: ReadableStream<Uint8Array> | null,\n maxBytes: number,\n): Promise<number> {\n if (!body) {\n throw new HTTPException(400, { message: \"Missing request body\" });\n }\n let seen = 0;\n const limiter = new Transform({\n transform(\n chunk: Buffer,\n _encoding: string,\n callback: (error?: Error | null, data?: Buffer) => void,\n ): void {\n seen += chunk.length;\n if (seen > maxBytes) {\n callback(new Error(`Upload exceeds ${maxBytes} byte limit`));\n } else {\n callback(null, chunk);\n }\n },\n });\n try {\n const nodeBody = Readable.fromWeb(body as unknown as NodeWebStream);\n await storage.writeStream(path, nodeBody.pipe(limiter));\n return seen;\n } catch (error) {\n if (seen > maxBytes) {\n throw new HTTPException(413, { message: \"Upload exceeds size limit\" });\n }\n throw error;\n }\n}\n\n/** Query filters accepted by the build list endpoint. */\nexport const buildListQuery = z.object({\n status: z.enum(BUILD_STATUSES).optional(),\n branch: z.string().optional(),\n labelKey: z.string().optional(),\n labelValue: z.string().optional(),\n});\n\n/** Fetch a build scoped to its project, throwing 404 when it does not belong. */\nexport async function buildForProject(projectId: string, buildId: string): Promise<Build> {\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== projectId) {\n notFound(\"Build not found\");\n }\n return build;\n}\n\n/** Fetch a snapshot scoped to its build, throwing 404 when it does not belong. */\nexport async function snapshotForBuild(\n build: { id: string },\n snapshotId: string,\n): Promise<Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== build.id) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}\n\n/** Roll snapshot statuses up into the build's review status. */\nfunction rollupStatus(snapshots: Snapshot[]): \"reviewing\" | \"rejected\" | \"approved\" {\n if (snapshots.some((s) => s.status === \"new\" || s.status === \"changed\")) {\n return \"reviewing\";\n }\n return snapshots.some((s) => s.status === \"rejected\") ? \"rejected\" : \"approved\";\n}\n\n/** Recompute a build's counts and roll its status up from its snapshots. */\nexport async function refreshBuild(buildId: string): Promise<void> {\n const { db } = getStore();\n await new BuildModel(db).updateCounts(buildId);\n const snapshots = await new SnapshotModel(db).listByBuild(buildId);\n const status = rollupStatus(snapshots);\n const build = await new BuildModel(db).get(buildId);\n // A cancelled build is terminal: its snapshots may still be touched, but no verdict is announced.\n if (!build || build.status === \"cancelled\") return;\n await new BuildModel(db).setStatus(buildId, status);\n await announceBuildStatus(buildId, status, { snapshotCount: snapshots.length });\n}\n\n/**\n * Tell the world a build reached `status`: its git channels, the `build:<status>` webhook and the\n * project's notification channels. Every route that moves a build to a decided status ends here.\n */\nexport async function announceBuildStatus(\n buildId: string,\n status: BuildStatus,\n extra: { snapshotCount?: number } = {},\n): Promise<void> {\n const { db, config } = getStore();\n const build = await new BuildModel(db).get(buildId);\n if (!build) return;\n await requestBuildState(buildId);\n const payload = { buildId, status, ...extra };\n await emitWebhookEvent(\n db,\n { webhooks: getStore().db.tables.webhooks },\n build.projectId,\n `build:${status}`,\n payload,\n credentialKeys(config),\n );\n const project = await new ProjectModel(db).get(build.projectId);\n if (!project) return;\n await notifyProject(project, `build:${status}`, payload, `/builds/${buildId}`);\n}\n\n/**\n * Approve a snapshot, promote its screenshot to baseline, and refresh the build.\n * Refuses with 409 when the baseline changed since the diff, unless `force` is set.\n * Bulk callers pass `deferRefresh` and call {@link refreshBuild} once, so the build's\n * outcome (and its git status) is announced a single time.\n */\nexport async function approveSnapshot(\n snapshotId: string,\n userId: string | null,\n options: { force?: boolean; deferRefresh?: boolean } = {},\n): Promise<void> {\n const { db, config } = getStore();\n const snapshots = new SnapshotModel(db);\n const snapshot = await snapshots.get(snapshotId);\n if (!snapshot) {\n notFound(\"Snapshot not found\");\n }\n const project = await new ProjectModel(db).get(snapshot.projectId);\n const build = await new BuildModel(db).get(snapshot.buildId);\n if (!project || !build) {\n notFound(\"Project or build not found\");\n }\n if (build.status === \"cancelled\") {\n throw new HTTPException(409, { message: \"The build was cancelled; retry it to review it\" });\n }\n await assertBaselineCurrent(project, build, snapshot, options.force ?? false);\n await snapshots.review(snapshotId, \"approved\", userId);\n const baselines = new BaselineModel(db, undefined, getStore().storage, credentialKeys(config));\n const prior = await baselines.getFor(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n snapshot.browser,\n );\n await baselines.upsert(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n snapshot.id,\n snapshot.screenshotPath,\n undefined,\n { browser: snapshot.browser, mode: snapshot.mode },\n );\n await notifyProject(\n project,\n prior ? \"baseline:updated\" : \"baseline:created\",\n {\n storyId: snapshot.storyId,\n viewport: snapshot.viewportName,\n browser: snapshot.browser ?? \"chromium\",\n branch: build.gitBranch,\n snapshotId: snapshot.id,\n buildId: build.id,\n },\n `/builds/${build.id}`,\n );\n if (!options.deferRefresh) {\n await refreshBuild(build.id);\n }\n}\n\n/** Roles permitted to view builds, snapshots, and comments. */\nexport const VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\n/** Roles permitted to upload builds and write comments. */\nexport const DEVELOPER_ROLES: readonly ProjectRole[] = [\"developer\", \"approver\", \"admin\"];\n/** Roles permitted to approve or reject snapshots and delete builds. */\nexport const APPROVER_ROLES: readonly ProjectRole[] = [\"approver\", \"admin\"];\n\n/** JSON input schema for creating a build before streaming its bundle. */\nexport const buildCreateJsonSchema = z\n .object({\n gitSha: z.string().min(1),\n gitBranch: z.string().min(1),\n authorEmail: z.string().optional(),\n authorName: z.string().optional(),\n message: z.string().optional(),\n affectedOnly: z.boolean().optional(),\n labels: z.array(z.object({ key: z.string().min(1), value: z.string() })).optional(),\n })\n .openapi(\"BuildCreate\");\n\n/** Metadata accepted by either build-creation route. */\nexport interface BuildCreateMetadata {\n gitSha: string;\n gitBranch: string;\n authorEmail?: string;\n authorName?: string;\n message?: string;\n affectedOnly?: boolean;\n labels?: { key: string; value: string }[];\n}\n\n/** Latest prior revision on a branch with a build, or null for first builds. */\nasync function resolveBaselineSha(\n db: DatabaseAdapter,\n projectId: string,\n branch: string,\n sha: string,\n): Promise<string | null> {\n const builds = await new BuildModel(db).list(projectId, { branch });\n return builds.find((build) => build.gitSha !== sha)?.gitSha ?? null;\n}\n\n/* oxlint-disable eslint/no-await-in-loop -- label attach is intentionally sequential */\nasync function attachLabels(\n db: DatabaseAdapter,\n projectId: string,\n buildId: string,\n labels: { key: string; value: string }[],\n): Promise<void> {\n const models = new LabelModel(db);\n for (const { key, value } of labels) {\n const existing = await models.getType(projectId, key);\n if (!existing) {\n await models.createType(projectId, { key, name: key });\n }\n await models.attach(projectId, buildId, key, value);\n }\n}\n/* oxlint-enable eslint/no-await-in-loop */\n"],"mappings":"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"}
@@ -2,20 +2,33 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { getStore } from "../store.mjs";
5
+ import { resolveAuthorizedProject } from "./helpers.mjs";
5
6
  import { hxRefresh } from "./htmx.mjs";
6
7
  import { flashHx } from "./flash.mjs";
7
- import { resolveAuthorizedProject } from "./helpers.mjs";
8
8
  import { badRequest, buildSchema, forbidden, notFound, unauthorized } from "./schemas.mjs";
9
- import { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, buildCreateJsonSchema, buildForProject, buildListQuery, createBuildRecord, persistInlineStatics, storeUploadStream } from "./builds.handlers.mjs";
9
+ import { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, buildCreateJsonSchema, buildForProject, buildListQuery, createBuildRecord, storeUploadStream } from "./builds.handlers.mjs";
10
10
  import { registerAttempts } from "./attempts.mjs";
11
+ import { discardZip, planUpload, publishUpload } from "./publish-upload.mjs";
12
+ import { registerBuildCancel } from "./build-cancel.mjs";
13
+ import { registerBuildChangeset } from "./build-changeset.mjs";
14
+ import { registerBuildReviews } from "./build-reviews.mjs";
11
15
  import { registerComments } from "./comments.mjs";
12
16
  import { registerSnapshots } from "./snapshots.mjs";
13
17
  import { createRoute, z } from "@hono/zod-openapi";
14
- import { BuildModel } from "@storyshelf/core/models";
18
+ import { DEFAULT_MAX_UPLOAD_BYTES } from "@storyshelf/core/config";
15
19
  import { storybookZipPath } from "@storyshelf/core/utils";
20
+ import { BuildModel, PackageUsageModel } from "@storyshelf/core/models";
16
21
  import { HTTPException } from "hono/http-exception";
17
- import { DEFAULT_MAX_UPLOAD_BYTES } from "@storyshelf/core/config";
18
22
  //#region src/routers/builds.ts
23
+ /** Decide how the stored zip is published; a zip this host cannot publish is removed and refused. */
24
+ async function planOrDiscard(zipBytes, projectId, buildId) {
25
+ try {
26
+ return planUpload(zipBytes);
27
+ } catch (error) {
28
+ await discardZip(projectId, buildId);
29
+ throw error;
30
+ }
31
+ }
19
32
  async function upsertContentRef(hash) {
20
33
  try {
21
34
  const contentRefs = getStore().db.tables.contentRefs;
@@ -48,14 +61,13 @@ function registerBuilds(app) {
48
61
  });
49
62
  app.openapi(createBuildRoute, async (c) => {
50
63
  const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...DEVELOPER_ROLES);
51
- const parsed = buildCreateJsonSchema.safeParse(await c.req.json());
52
- if (!parsed.success) throw new HTTPException(400, { message: parsed.error.message });
53
- const build = await createBuildRecord(project, parsed.data);
64
+ const build = await createBuildRecord(project, c.req.valid("json"));
54
65
  const uploadUrl = `/api/v1/projects/${project.slug}/builds/${build.id}/zip`;
55
66
  return c.json({
56
67
  build,
57
68
  uploadUrl,
58
- baselineSha: build.baselineSha
69
+ baselineSha: build.baselineSha,
70
+ testing: getStore().testing
59
71
  }, 202);
60
72
  });
61
73
  app.openapi(affectedRoute, async (c) => {
@@ -70,17 +82,23 @@ function registerBuilds(app) {
70
82
  });
71
83
  return c.json(updated);
72
84
  });
85
+ app.openapi(usageRoute, async (c) => {
86
+ const { slug, buildId } = c.req.valid("param");
87
+ const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);
88
+ const build = await buildForProject(project.id, buildId);
89
+ const { usage } = c.req.valid("json");
90
+ const stored = await new PackageUsageModel(getStore().db).replaceForBuild(project.id, build.id, usage);
91
+ return c.json({ stored });
92
+ });
73
93
  app.openapi(uploadZipRoute, async (c) => {
74
94
  const { slug, buildId } = c.req.valid("param");
75
95
  const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);
76
96
  const build = await buildForProject(project.id, buildId);
77
97
  const maxBytes = getStore().config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
78
- await storeUploadStream(getStore().storage, storybookZipPath(project.id, build.id), c.req.raw.body, maxBytes);
98
+ const route = await planOrDiscard(await storeUploadStream(getStore().storage, storybookZipPath(project.id, build.id), c.req.raw.body, maxBytes), project.id, build.id);
79
99
  const reqId = c.get("requestId");
80
100
  await getStore().enqueueCapture?.(build.id, reqId);
81
- const { storage, config, logger } = getStore();
82
- await persistInlineStatics(storage, config.scratchDir, config.maxInlineUnzipSize, c.req.header("content-length"), project.id, build.id, logger);
83
- return c.json(build, 202);
101
+ return c.json(await publishUpload(route, build, reqId), 202);
84
102
  });
85
103
  app.openapi(dedupRoute, async (c) => {
86
104
  const { slug, buildId } = c.req.valid("param");
@@ -130,6 +148,7 @@ function registerBuilds(app) {
130
148
  });
131
149
  else await getStore().db.update(contentRefs, f.hash, { lastSeenAt: now });
132
150
  } catch {}
151
+ await getStore().publish?.completer.complete(build.id, { ok: true });
133
152
  const reqId = c.get("requestId");
134
153
  await getStore().enqueueCapture?.(build.id, reqId);
135
154
  return c.json({ ok: true });
@@ -144,7 +163,7 @@ function registerBuilds(app) {
144
163
  const { slug, buildId } = c.req.valid("param");
145
164
  const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);
146
165
  const build = await buildForProject(project.id, buildId);
147
- const updated = await new BuildModel(getStore().db).setStatus(build.id, "pending");
166
+ const updated = await new BuildModel(getStore().db).resetStatus(build.id, "pending");
148
167
  await getStore().enqueueCapture?.(build.id, c.get("requestId"));
149
168
  hxRefresh(c);
150
169
  flashHx(c, "Capture queued");
@@ -159,6 +178,9 @@ function registerBuilds(app) {
159
178
  });
160
179
  registerSnapshots(app);
161
180
  registerComments(app);
181
+ registerBuildReviews(app);
182
+ registerBuildChangeset(app);
183
+ registerBuildCancel(app);
162
184
  registerAttempts(app);
163
185
  }
164
186
  const listBuildsRoute = createRoute({
@@ -182,7 +204,8 @@ const listBuildsRoute = createRoute({
182
204
  const buildCreatedSchema = z.object({
183
205
  build: buildSchema,
184
206
  uploadUrl: z.string(),
185
- baselineSha: z.string().nullable()
207
+ baselineSha: z.string().nullable(),
208
+ testing: z.boolean().openapi({ description: "False when the server has no `testing` option: the build is published but never captured or compared." })
186
209
  }).openapi("BuildCreated");
187
210
  const createBuildRoute = createRoute({
188
211
  method: "post",
@@ -196,7 +219,7 @@ const createBuildRoute = createRoute({
196
219
  responses: {
197
220
  202: {
198
221
  content: { "application/json": { schema: buildCreatedSchema } },
199
- description: "Build created. PUT the Storybook zip to uploadUrl to queue capture."
222
+ description: "Build created. PUT the Storybook zip to uploadUrl to publish it (and queue capture when testing is on)."
200
223
  },
201
224
  ...badRequest,
202
225
  ...forbidden,
@@ -221,11 +244,11 @@ const uploadZipRoute = createRoute({
221
244
  responses: {
222
245
  202: {
223
246
  content: { "application/json": { schema: buildSchema } },
224
- description: "Zip stored and capture queued"
247
+ description: "Zip stored. A zip up to maxInlineUnzipSize (default 50 MB) is extracted inline in this request; a larger one is published by a background job. With testing off, the build becomes approved when publishing finishes on the project's default branch (failed if it cannot be published) and stays pending on other branches; with testing on, capture sets the status."
225
248
  },
226
249
  413: {
227
250
  content: { "application/json": { schema: z.object({ message: z.string() }).openapi("Error") } },
228
- description: "Upload exceeds the configured size limit"
251
+ description: "Upload exceeds the configured size limit, or the zip is over the inline limit and this host (serverless) runs no publish consumer: upload file by file instead (POST /dedup, PUT /content, POST /manifest)"
229
252
  },
230
253
  ...badRequest,
231
254
  ...forbidden,
@@ -238,6 +261,7 @@ const dedupRoute = createRoute({
238
261
  method: "post",
239
262
  tags: ["Builds"],
240
263
  summary: "Check which files need uploading",
264
+ description: "File-by-file upload, step 1 (dedup, content, manifest): needs no zip extraction and works on any host, serverless included. Send the content hashes; the response lists those the server does not hold yet.",
241
265
  path: "/api/v1/projects/{slug}/builds/{buildId}/dedup",
242
266
  request: {
243
267
  params: z.object({
@@ -258,6 +282,7 @@ const contentUploadRoute = createRoute({
258
282
  method: "post",
259
283
  tags: ["Builds"],
260
284
  summary: "Upload build content",
285
+ description: "File-by-file upload, step 2: store the missing files content-addressed (multipart, or one file with an X-Content-Hash header).",
261
286
  path: "/api/v1/projects/{slug}/builds/{buildId}/content",
262
287
  request: {
263
288
  params: z.object({
@@ -283,6 +308,7 @@ const manifestRoute = createRoute({
283
308
  method: "post",
284
309
  tags: ["Builds"],
285
310
  summary: "Store the build manifest",
311
+ description: "File-by-file upload, step 3: store the path-to-hash manifest. This completes publishing. With testing off the build becomes approved when it is on the project's default branch and stays pending on other branches; with testing on, a capture is queued and sets the status.",
286
312
  path: "/api/v1/projects/{slug}/builds/{buildId}/manifest",
287
313
  request: {
288
314
  params: z.object({
@@ -326,6 +352,34 @@ const affectedRoute = createRoute({
326
352
  ...notFound
327
353
  }
328
354
  });
355
+ const usagePayloadSchema = z.object({ usage: z.array(z.object({
356
+ storyImportPath: z.string().min(1).max(1024),
357
+ packageName: z.string().min(1).max(214),
358
+ modulePath: z.string().min(1).max(2048),
359
+ version: z.string().max(128).nullable().optional()
360
+ })).max(2e4) });
361
+ const usageRoute = createRoute({
362
+ method: "post",
363
+ tags: ["Builds"],
364
+ summary: "Record the dependency packages each story uses",
365
+ path: "/api/v1/projects/{slug}/builds/{buildId}/usage",
366
+ request: {
367
+ params: z.object({
368
+ slug: z.string(),
369
+ buildId: z.string()
370
+ }),
371
+ body: { content: { "application/json": { schema: usagePayloadSchema } } }
372
+ },
373
+ responses: {
374
+ 200: {
375
+ content: { "application/json": { schema: z.object({ stored: z.number() }) } },
376
+ description: "Usage recorded (replaces any earlier report for the build)"
377
+ },
378
+ ...badRequest,
379
+ ...forbidden,
380
+ ...notFound
381
+ }
382
+ });
329
383
  const getBuildRoute = createRoute({
330
384
  method: "get",
331
385
  tags: ["Builds"],