@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.
- package/README.md +42 -4
- package/dist/app-types.d.mts +1 -3
- package/dist/asset-manifest.mjs +13 -5
- package/dist/asset-manifest.mjs.map +1 -1
- package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
- package/dist/assets/htmx.mjs.map +1 -0
- package/dist/assets/mark.mjs +10 -0
- package/dist/assets/mark.mjs.map +1 -0
- package/dist/boot-tasks.mjs +80 -0
- package/dist/boot-tasks.mjs.map +1 -0
- package/dist/build-state-flags.mjs +18 -0
- package/dist/build-state-flags.mjs.map +1 -0
- package/dist/build-state-setup.mjs +75 -0
- package/dist/build-state-setup.mjs.map +1 -0
- package/dist/capture-alerts.mjs +46 -0
- package/dist/capture-alerts.mjs.map +1 -0
- package/dist/capture-process.mjs +59 -0
- package/dist/capture-process.mjs.map +1 -0
- package/dist/capture-setup.mjs +95 -117
- package/dist/capture-setup.mjs.map +1 -1
- package/dist/connections.mjs +34 -0
- package/dist/connections.mjs.map +1 -0
- package/dist/consumer-setup.mjs +58 -0
- package/dist/consumer-setup.mjs.map +1 -0
- package/dist/credentials.mjs +3 -2
- package/dist/credentials.mjs.map +1 -1
- package/dist/git-status.mjs +46 -0
- package/dist/git-status.mjs.map +1 -0
- package/dist/hidden-data.mjs +24 -0
- package/dist/hidden-data.mjs.map +1 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +202 -124
- package/dist/index.mjs.map +1 -1
- package/dist/insights/budget.mjs +49 -0
- package/dist/insights/budget.mjs.map +1 -0
- package/dist/insights/deps.mjs +21 -0
- package/dist/insights/deps.mjs.map +1 -0
- package/dist/insights/errors.mjs +22 -0
- package/dist/insights/errors.mjs.map +1 -0
- package/dist/insights/health-panel.mjs +40 -0
- package/dist/insights/health-panel.mjs.map +1 -0
- package/dist/insights/job.mjs +103 -0
- package/dist/insights/job.mjs.map +1 -0
- package/dist/insights/load-health.mjs +59 -0
- package/dist/insights/load-health.mjs.map +1 -0
- package/dist/insights/load-triage.mjs +98 -0
- package/dist/insights/load-triage.mjs.map +1 -0
- package/dist/insights/panel.mjs +28 -0
- package/dist/insights/panel.mjs.map +1 -0
- package/dist/insights/request.mjs +113 -0
- package/dist/insights/request.mjs.map +1 -0
- package/dist/insights/stale.mjs +21 -0
- package/dist/insights/stale.mjs.map +1 -0
- package/dist/insights/usage-report.mjs +60 -0
- package/dist/insights/usage-report.mjs.map +1 -0
- package/dist/insights/view.mjs +48 -0
- package/dist/insights/view.mjs.map +1 -0
- package/dist/job-canceller.mjs +14 -0
- package/dist/job-canceller.mjs.map +1 -0
- package/dist/job-queue.mjs +51 -0
- package/dist/job-queue.mjs.map +1 -0
- package/dist/jobs-setup.mjs +132 -0
- package/dist/jobs-setup.mjs.map +1 -0
- package/dist/lifecycle.mjs +109 -19
- package/dist/lifecycle.mjs.map +1 -1
- package/dist/middleware/auth-gate.mjs +4 -2
- package/dist/middleware/auth-gate.mjs.map +1 -1
- package/dist/middleware/rate-limit.mjs +41 -33
- package/dist/middleware/rate-limit.mjs.map +1 -1
- package/dist/middleware/request-log.mjs +3 -2
- package/dist/middleware/request-log.mjs.map +1 -1
- package/dist/middleware/require-review.mjs +51 -0
- package/dist/middleware/require-review.mjs.map +1 -0
- package/dist/middleware/require-testing.mjs +41 -0
- package/dist/middleware/require-testing.mjs.map +1 -0
- package/dist/middleware/setup-gate.mjs.map +1 -1
- package/dist/middleware/store-scope.mjs +12 -2
- package/dist/middleware/store-scope.mjs.map +1 -1
- package/dist/notifications-pause.mjs +33 -0
- package/dist/notifications-pause.mjs.map +1 -0
- package/dist/notify.d.mts +2 -1
- package/dist/notify.mjs +78 -39
- package/dist/notify.mjs.map +1 -1
- package/dist/pages/a11y-violations.mjs +116 -0
- package/dist/pages/a11y-violations.mjs.map +1 -0
- package/dist/pages/admin-ai.mjs +78 -0
- package/dist/pages/admin-ai.mjs.map +1 -0
- package/dist/pages/admin-system-origin.mjs +123 -0
- package/dist/pages/admin-system-origin.mjs.map +1 -0
- package/dist/pages/admin-system-targets.mjs +53 -0
- package/dist/pages/admin-system-targets.mjs.map +1 -0
- package/dist/pages/admin-system.mjs +63 -13
- package/dist/pages/admin-system.mjs.map +1 -1
- package/dist/pages/build-detail.mjs +104 -76
- package/dist/pages/build-detail.mjs.map +1 -1
- package/dist/pages/build-diff-actions.mjs +87 -33
- package/dist/pages/build-diff-actions.mjs.map +1 -1
- package/dist/pages/build-diff-comments.mjs +16 -14
- package/dist/pages/build-diff-comments.mjs.map +1 -1
- package/dist/pages/build-diff-environment.mjs +82 -0
- package/dist/pages/build-diff-environment.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +22 -4
- package/dist/pages/build-diff-header.mjs.map +1 -1
- package/dist/pages/build-diff-insight.mjs +113 -0
- package/dist/pages/build-diff-insight.mjs.map +1 -0
- package/dist/pages/build-diff-model.mjs +55 -10
- package/dist/pages/build-diff-model.mjs.map +1 -1
- package/dist/pages/build-diff-nav.mjs +28 -12
- package/dist/pages/build-diff-nav.mjs.map +1 -1
- package/dist/pages/build-diff-shortcuts.mjs +5 -1
- package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
- package/dist/pages/build-diff-usage.mjs +63 -0
- package/dist/pages/build-diff-usage.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +62 -5
- package/dist/pages/build-diff-viewer.mjs.map +1 -1
- package/dist/pages/build-diff.mjs +34 -13
- package/dist/pages/build-diff.mjs.map +1 -1
- package/dist/pages/build-tests.mjs +74 -0
- package/dist/pages/build-tests.mjs.map +1 -0
- package/dist/pages/component-detail.mjs +249 -0
- package/dist/pages/component-detail.mjs.map +1 -0
- package/dist/pages/component-stories.mjs +168 -0
- package/dist/pages/component-stories.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +13 -26
- package/dist/pages/compute-jobs.mjs.map +1 -1
- package/dist/pages/design-links.mjs +182 -0
- package/dist/pages/design-links.mjs.map +1 -0
- package/dist/pages/environment-change.mjs +53 -0
- package/dist/pages/environment-change.mjs.map +1 -0
- package/dist/pages/health-panel.mjs +126 -0
- package/dist/pages/health-panel.mjs.map +1 -0
- package/dist/pages/library-grid.mjs +105 -0
- package/dist/pages/library-grid.mjs.map +1 -0
- package/dist/pages/library-model.mjs +47 -0
- package/dist/pages/library-model.mjs.map +1 -0
- package/dist/pages/library-snapshots.mjs +130 -0
- package/dist/pages/library-snapshots.mjs.map +1 -0
- package/dist/pages/library.mjs +112 -218
- package/dist/pages/library.mjs.map +1 -1
- package/dist/pages/notifications-paused.mjs +22 -0
- package/dist/pages/notifications-paused.mjs.map +1 -0
- package/dist/pages/profile.mjs +53 -48
- package/dist/pages/profile.mjs.map +1 -1
- package/dist/pages/project-builds-table.mjs +2 -2
- package/dist/pages/project-builds-table.mjs.map +1 -1
- package/dist/pages/project-builds.mjs +51 -21
- package/dist/pages/project-builds.mjs.map +1 -1
- package/dist/pages/project-settings.mjs +51 -12
- package/dist/pages/project-settings.mjs.map +1 -1
- package/dist/pages/projects-card.mjs +14 -1
- package/dist/pages/projects-card.mjs.map +1 -1
- package/dist/pages/projects.mjs +6 -2
- package/dist/pages/projects.mjs.map +1 -1
- package/dist/pages/review-changeset.mjs +116 -0
- package/dist/pages/review-changeset.mjs.map +1 -0
- package/dist/pages/review-comments.mjs +103 -0
- package/dist/pages/review-comments.mjs.map +1 -0
- package/dist/pages/review-verdict.mjs +102 -0
- package/dist/pages/review-verdict.mjs.map +1 -0
- package/dist/pages/settings-ai.mjs +69 -0
- package/dist/pages/settings-ai.mjs.map +1 -0
- package/dist/pages/settings-capture.mjs +124 -0
- package/dist/pages/settings-capture.mjs.map +1 -0
- package/dist/pages/settings-design-credential.mjs +99 -0
- package/dist/pages/settings-design-credential.mjs.map +1 -0
- package/dist/pages/settings-design-links.mjs +179 -0
- package/dist/pages/settings-design-links.mjs.map +1 -0
- package/dist/pages/settings-design.mjs +48 -0
- package/dist/pages/settings-design.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +46 -21
- package/dist/pages/settings-general.mjs.map +1 -1
- package/dist/pages/settings-labels.mjs +43 -2
- package/dist/pages/settings-labels.mjs.map +1 -1
- package/dist/pages/settings-links.mjs +112 -0
- package/dist/pages/settings-links.mjs.map +1 -0
- package/dist/pages/settings-notifications-git.mjs +128 -0
- package/dist/pages/settings-notifications-git.mjs.map +1 -0
- package/dist/pages/settings-notifications.mjs +79 -42
- package/dist/pages/settings-notifications.mjs.map +1 -1
- package/dist/pages/settings-tests.mjs +33 -2
- package/dist/pages/settings-tests.mjs.map +1 -1
- package/dist/pages/target-choices.mjs +39 -0
- package/dist/pages/target-choices.mjs.map +1 -0
- package/dist/publish-setup.mjs +89 -0
- package/dist/publish-setup.mjs.map +1 -0
- package/dist/retention-timer.mjs +2 -1
- package/dist/retention-timer.mjs.map +1 -1
- package/dist/review-events.mjs +48 -0
- package/dist/review-events.mjs.map +1 -0
- package/dist/routers/admin-pages.mjs +45 -3
- package/dist/routers/admin-pages.mjs.map +1 -1
- package/dist/routers/admin-public-origin.mjs +85 -0
- package/dist/routers/admin-public-origin.mjs.map +1 -0
- package/dist/routers/assets.mjs +9 -2
- package/dist/routers/assets.mjs.map +1 -1
- package/dist/routers/auth.mjs +2 -2
- package/dist/routers/auth.mjs.map +1 -1
- package/dist/routers/baseline-guard.mjs +2 -2
- package/dist/routers/baseline-guard.mjs.map +1 -1
- package/dist/routers/baselines.handlers.mjs +64 -0
- package/dist/routers/baselines.handlers.mjs.map +1 -0
- package/dist/routers/baselines.mjs +125 -0
- package/dist/routers/baselines.mjs.map +1 -0
- package/dist/routers/build-cancel.mjs +68 -0
- package/dist/routers/build-cancel.mjs.map +1 -0
- package/dist/routers/build-changeset.mjs +70 -0
- package/dist/routers/build-changeset.mjs.map +1 -0
- package/dist/routers/build-reviews.handlers.mjs +86 -0
- package/dist/routers/build-reviews.handlers.mjs.map +1 -0
- package/dist/routers/build-reviews.mjs +74 -0
- package/dist/routers/build-reviews.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +46 -55
- package/dist/routers/builds.handlers.mjs.map +1 -1
- package/dist/routers/builds.mjs +71 -17
- package/dist/routers/builds.mjs.map +1 -1
- package/dist/routers/capture-config.mjs +35 -0
- package/dist/routers/capture-config.mjs.map +1 -0
- package/dist/routers/comments.mjs +41 -15
- package/dist/routers/comments.mjs.map +1 -1
- package/dist/routers/component-catalog.handlers.mjs +71 -0
- package/dist/routers/component-catalog.handlers.mjs.map +1 -0
- package/dist/routers/component.handlers.mjs +121 -0
- package/dist/routers/component.handlers.mjs.map +1 -0
- package/dist/routers/connections.mjs +118 -0
- package/dist/routers/connections.mjs.map +1 -0
- package/dist/routers/design-connect.handlers.mjs +44 -0
- package/dist/routers/design-connect.handlers.mjs.map +1 -0
- package/dist/routers/design.handlers.mjs +114 -0
- package/dist/routers/design.handlers.mjs.map +1 -0
- package/dist/routers/health-report.mjs +2 -3
- package/dist/routers/health-report.mjs.map +1 -1
- package/dist/routers/health.mjs +4 -3
- package/dist/routers/health.mjs.map +1 -1
- package/dist/routers/helpers.mjs +19 -3
- package/dist/routers/helpers.mjs.map +1 -1
- package/dist/routers/insights-ui.mjs +64 -0
- package/dist/routers/insights-ui.mjs.map +1 -0
- package/dist/routers/insights.handlers.mjs +45 -0
- package/dist/routers/insights.handlers.mjs.map +1 -0
- package/dist/routers/insights.mjs +189 -0
- package/dist/routers/insights.mjs.map +1 -0
- package/dist/routers/links.handlers.mjs +80 -0
- package/dist/routers/links.handlers.mjs.map +1 -0
- package/dist/routers/links.mjs +139 -0
- package/dist/routers/links.mjs.map +1 -0
- package/dist/routers/media.mjs +18 -4
- package/dist/routers/media.mjs.map +1 -1
- package/dist/routers/members.mjs +2 -1
- package/dist/routers/members.mjs.map +1 -1
- package/dist/routers/notification-channels.mjs +17 -23
- package/dist/routers/notification-channels.mjs.map +1 -1
- package/dist/routers/oauth-state.mjs +45 -0
- package/dist/routers/oauth-state.mjs.map +1 -0
- package/dist/routers/preview-base.mjs +38 -0
- package/dist/routers/preview-base.mjs.map +1 -0
- package/dist/routers/preview-token.mjs +51 -0
- package/dist/routers/preview-token.mjs.map +1 -0
- package/dist/routers/profile.mjs +4 -2
- package/dist/routers/profile.mjs.map +1 -1
- package/dist/routers/projects.mjs +17 -1
- package/dist/routers/projects.mjs.map +1 -1
- package/dist/routers/publish-upload.mjs +78 -0
- package/dist/routers/publish-upload.mjs.map +1 -0
- package/dist/routers/published-access.mjs +52 -0
- package/dist/routers/published-access.mjs.map +1 -0
- package/dist/routers/published-files.mjs +52 -0
- package/dist/routers/published-files.mjs.map +1 -0
- package/dist/routers/review-forms.mjs +72 -0
- package/dist/routers/review-forms.mjs.map +1 -0
- package/dist/routers/review-section.handlers.mjs +85 -0
- package/dist/routers/review-section.handlers.mjs.map +1 -0
- package/dist/routers/review-switch-off.mjs +29 -0
- package/dist/routers/review-switch-off.mjs.map +1 -0
- package/dist/routers/schemas.mjs +140 -22
- package/dist/routers/schemas.mjs.map +1 -1
- package/dist/routers/scoped-models.mjs +22 -0
- package/dist/routers/scoped-models.mjs.map +1 -0
- package/dist/routers/settings-ai.mjs +35 -0
- package/dist/routers/settings-ai.mjs.map +1 -0
- package/dist/routers/settings-capture.mjs +77 -0
- package/dist/routers/settings-capture.mjs.map +1 -0
- package/dist/routers/settings-design.mjs +103 -0
- package/dist/routers/settings-design.mjs.map +1 -0
- package/dist/routers/settings-general.mjs +55 -10
- package/dist/routers/settings-general.mjs.map +1 -1
- package/dist/routers/settings-git-channel.mjs +77 -0
- package/dist/routers/settings-git-channel.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +27 -1
- package/dist/routers/settings-labels.mjs.map +1 -1
- package/dist/routers/settings-links.mjs +63 -0
- package/dist/routers/settings-links.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +4 -7
- package/dist/routers/settings-members.mjs.map +1 -1
- package/dist/routers/settings-notifications.mjs +15 -10
- package/dist/routers/settings-notifications.mjs.map +1 -1
- package/dist/routers/settings-tokens.mjs +2 -5
- package/dist/routers/settings-tokens.mjs.map +1 -1
- package/dist/routers/settings-webhooks.mjs +7 -24
- package/dist/routers/settings-webhooks.mjs.map +1 -1
- package/dist/routers/settings.handlers.mjs +107 -26
- package/dist/routers/settings.handlers.mjs.map +1 -1
- package/dist/routers/settings.mjs +16 -4
- package/dist/routers/settings.mjs.map +1 -1
- package/dist/routers/signed-storybook.mjs +53 -0
- package/dist/routers/signed-storybook.mjs.map +1 -0
- package/dist/routers/snapshot-groups.mjs +63 -0
- package/dist/routers/snapshot-groups.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +103 -5
- package/dist/routers/snapshots.mjs.map +1 -1
- package/dist/routers/storybook.mjs +37 -88
- package/dist/routers/storybook.mjs.map +1 -1
- package/dist/routers/tokens.mjs +2 -5
- package/dist/routers/tokens.mjs.map +1 -1
- package/dist/routers/ui.mjs +34 -5
- package/dist/routers/ui.mjs.map +1 -1
- package/dist/routers/webhooks.mjs +5 -11
- package/dist/routers/webhooks.mjs.map +1 -1
- package/dist/routers/well-known.mjs +11 -4
- package/dist/routers/well-known.mjs.map +1 -1
- package/dist/routes/api-routes.mjs +93 -0
- package/dist/routes/api-routes.mjs.map +1 -0
- package/dist/routes/classification.mjs +38 -0
- package/dist/routes/classification.mjs.map +1 -0
- package/dist/routes/openapi-classes.mjs +22 -0
- package/dist/routes/openapi-classes.mjs.map +1 -0
- package/dist/routes/page-routes.mjs +104 -0
- package/dist/routes/page-routes.mjs.map +1 -0
- package/dist/runtime.mjs +18 -13
- package/dist/runtime.mjs.map +1 -1
- package/dist/serverless.mjs +5 -0
- package/dist/store.mjs +19 -1
- package/dist/store.mjs.map +1 -1
- package/dist/targets.mjs +46 -0
- package/dist/targets.mjs.map +1 -0
- package/dist/ui/buttons.mjs +16 -3
- package/dist/ui/buttons.mjs.map +1 -1
- package/dist/ui/design-reference.mjs +97 -0
- package/dist/ui/design-reference.mjs.map +1 -0
- package/dist/ui/document.mjs +1 -2
- package/dist/ui/document.mjs.map +1 -1
- package/dist/ui/forms.mjs +6 -5
- package/dist/ui/forms.mjs.map +1 -1
- package/dist/ui/icons/paths.mjs +1 -0
- package/dist/ui/icons/paths.mjs.map +1 -1
- package/dist/ui/preview-embed.mjs +82 -0
- package/dist/ui/preview-embed.mjs.map +1 -0
- package/dist/ui/shell/client-script.mjs +85 -0
- package/dist/ui/shell/client-script.mjs.map +1 -1
- package/dist/ui/shell/nav.mjs +12 -8
- package/dist/ui/shell/nav.mjs.map +1 -1
- package/dist/ui/shell/review-script.mjs +8 -0
- package/dist/ui/shell/review-script.mjs.map +1 -1
- package/dist/ui/shell/sidebar.mjs +2 -2
- package/dist/ui/shell/sidebar.mjs.map +1 -1
- package/package.json +11 -6
- package/dist/asset-paths.mjs +0 -23
- package/dist/asset-paths.mjs.map +0 -1
- package/dist/assets/mark.svg +0 -1
- package/dist/pages/settings-status.mjs +0 -137
- package/dist/pages/settings-status.mjs.map +0 -1
- package/dist/routers/settings-status.mjs +0 -77
- package/dist/routers/settings-status.mjs.map +0 -1
- package/dist/routers/status-configs.mjs +0 -108
- package/dist/routers/status-configs.mjs.map +0 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { DEFAULT_SCRATCH_DIR } from "@storyshelf/core/config";
|
|
6
|
+
import { storybookZipPath } from "@storyshelf/core/utils";
|
|
7
|
+
import { FILE_BY_FILE_HINT, extractionLimitsOf, publishRoute, publishStoredZip, resolvePublishLimits } from "@storyshelf/core/capture";
|
|
8
|
+
import { BuildModel } from "@storyshelf/core/models";
|
|
9
|
+
import { HTTPException } from "hono/http-exception";
|
|
10
|
+
//#region src/routers/publish-upload.ts
|
|
11
|
+
/**
|
|
12
|
+
* What the zip upload route does once the zip is stored (ADR 0006, decision 10): extract it
|
|
13
|
+
* inline when it is small, hand it to a `publish` job when it is larger, or refuse it with a
|
|
14
|
+
* pointer to the file-by-file route on a host that runs no consumer.
|
|
15
|
+
*/
|
|
16
|
+
/** How an upload of `zipBytes` will be published, or a 413 when this host cannot publish it. */
|
|
17
|
+
function planUpload(zipBytes) {
|
|
18
|
+
const { config, publish } = getStore();
|
|
19
|
+
const route = publishRoute(zipBytes, resolvePublishLimits(config));
|
|
20
|
+
if (route === "job" && !publish?.enqueuePublish) throw new HTTPException(413, { message: FILE_BY_FILE_HINT });
|
|
21
|
+
return route;
|
|
22
|
+
}
|
|
23
|
+
/** Remove a stored zip that was refused (best effort), so no orphan is left behind. */
|
|
24
|
+
async function discardZip(projectId, buildId) {
|
|
25
|
+
try {
|
|
26
|
+
await getStore().storage.delete(storybookZipPath(projectId, buildId));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
getStore().logger.warn({
|
|
29
|
+
err: error,
|
|
30
|
+
buildId
|
|
31
|
+
}, "could not remove a refused zip");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Publish the stored zip of `build` per `route`. Inline: extract now and complete the build (a
|
|
36
|
+
* failure fails it, or, with a capture to come, is left for capture to backfill). Job: queue it.
|
|
37
|
+
* Resolves to the build as it stands afterwards.
|
|
38
|
+
*/
|
|
39
|
+
async function publishUpload(route, build, reqId) {
|
|
40
|
+
const { publish } = getStore();
|
|
41
|
+
if (route === "job") {
|
|
42
|
+
await publish?.enqueuePublish?.(build.id, reqId);
|
|
43
|
+
return build;
|
|
44
|
+
}
|
|
45
|
+
await publishInline(build);
|
|
46
|
+
return await new BuildModel(getStore().db).get(build.id) ?? build;
|
|
47
|
+
}
|
|
48
|
+
async function publishInline(build) {
|
|
49
|
+
const { storage, config, logger, publish, db } = getStore();
|
|
50
|
+
try {
|
|
51
|
+
await publishStoredZip({
|
|
52
|
+
storage,
|
|
53
|
+
scratchDir: config.scratchDir ?? DEFAULT_SCRATCH_DIR,
|
|
54
|
+
limits: extractionLimitsOf(resolvePublishLimits(config)),
|
|
55
|
+
db,
|
|
56
|
+
logger
|
|
57
|
+
}, {
|
|
58
|
+
projectId: build.projectId,
|
|
59
|
+
buildId: build.id
|
|
60
|
+
});
|
|
61
|
+
logger.info({ buildId: build.id }, "upload statics persisted inline");
|
|
62
|
+
await publish?.completer.complete(build.id, { ok: true });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
logger.error({
|
|
66
|
+
err: error,
|
|
67
|
+
buildId: build.id
|
|
68
|
+
}, "inline publish failed");
|
|
69
|
+
await publish?.completer.complete(build.id, {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: message
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
export { discardZip, planUpload, publishUpload };
|
|
77
|
+
|
|
78
|
+
//# sourceMappingURL=publish-upload.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"publish-upload.mjs","names":[],"sources":["../../src/routers/publish-upload.ts"],"sourcesContent":["/**\n * What the zip upload route does once the zip is stored (ADR 0006, decision 10): extract it\n * inline when it is small, hand it to a `publish` job when it is larger, or refuse it with a\n * pointer to the file-by-file route on a host that runs no consumer.\n */\nimport {\n extractionLimitsOf,\n FILE_BY_FILE_HINT,\n publishRoute,\n publishStoredZip,\n resolvePublishLimits,\n} from \"@storyshelf/core/capture\";\nimport { DEFAULT_SCRATCH_DIR } from \"@storyshelf/core/config\";\nimport { BuildModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport { storybookZipPath } from \"@storyshelf/core/utils\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { getStore } from \"../store.ts\";\n\n/** How an upload of `zipBytes` will be published, or a 413 when this host cannot publish it. */\nexport function planUpload(zipBytes: number): \"inline\" | \"job\" {\n const { config, publish } = getStore();\n const route = publishRoute(zipBytes, resolvePublishLimits(config));\n if (route === \"job\" && !publish?.enqueuePublish) {\n throw new HTTPException(413, { message: FILE_BY_FILE_HINT });\n }\n return route;\n}\n\n/** Remove a stored zip that was refused (best effort), so no orphan is left behind. */\nexport async function discardZip(projectId: string, buildId: string): Promise<void> {\n try {\n await getStore().storage.delete(storybookZipPath(projectId, buildId));\n } catch (error) {\n getStore().logger.warn({ err: error, buildId }, \"could not remove a refused zip\");\n }\n}\n\n/**\n * Publish the stored zip of `build` per `route`. Inline: extract now and complete the build (a\n * failure fails it, or, with a capture to come, is left for capture to backfill). Job: queue it.\n * Resolves to the build as it stands afterwards.\n */\nexport async function publishUpload(\n route: \"inline\" | \"job\",\n build: Build,\n reqId: string | undefined,\n): Promise<Build> {\n const { publish } = getStore();\n if (route === \"job\") {\n await publish?.enqueuePublish?.(build.id, reqId);\n return build;\n }\n await publishInline(build);\n return (await new BuildModel(getStore().db).get(build.id)) ?? build;\n}\n\nasync function publishInline(build: Build): Promise<void> {\n const { storage, config, logger, publish, db } = getStore();\n try {\n await publishStoredZip(\n {\n storage,\n scratchDir: config.scratchDir ?? DEFAULT_SCRATCH_DIR,\n limits: extractionLimitsOf(resolvePublishLimits(config)),\n db,\n logger,\n },\n { projectId: build.projectId, buildId: build.id },\n );\n logger.info({ buildId: build.id }, \"upload statics persisted inline\");\n await publish?.completer.complete(build.id, { ok: true });\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n logger.error({ err: error, buildId: build.id }, \"inline publish failed\");\n await publish?.completer.complete(build.id, { ok: false, error: message });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAoBA,SAAgB,WAAW,UAAoC;CAC7D,MAAM,EAAE,QAAQ,YAAY,SAAS;CACrC,MAAM,QAAQ,aAAa,UAAU,qBAAqB,MAAM,CAAC;CACjE,IAAI,UAAU,SAAS,CAAC,SAAS,gBAC/B,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,kBAAkB,CAAC;CAE7D,OAAO;AACT;;AAGA,eAAsB,WAAW,WAAmB,SAAgC;CAClF,IAAI;EACF,MAAM,SAAS,CAAC,CAAC,QAAQ,OAAO,iBAAiB,WAAW,OAAO,CAAC;CACtE,SAAS,OAAO;EACd,SAAS,CAAC,CAAC,OAAO,KAAK;GAAE,KAAK;GAAO;EAAQ,GAAG,gCAAgC;CAClF;AACF;;;;;;AAOA,eAAsB,cACpB,OACA,OACA,OACgB;CAChB,MAAM,EAAE,YAAY,SAAS;CAC7B,IAAI,UAAU,OAAO;EACnB,MAAM,SAAS,iBAAiB,MAAM,IAAI,KAAK;EAC/C,OAAO;CACT;CACA,MAAM,cAAc,KAAK;CACzB,OAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,EAAE,KAAM;AAChE;AAEA,eAAe,cAAc,OAA6B;CACxD,MAAM,EAAE,SAAS,QAAQ,QAAQ,SAAS,OAAO,SAAS;CAC1D,IAAI;EACF,MAAM,iBACJ;GACE;GACA,YAAY,OAAO,cAAc;GACjC,QAAQ,mBAAmB,qBAAqB,MAAM,CAAC;GACvD;GACA;EACF,GACA;GAAE,WAAW,MAAM;GAAW,SAAS,MAAM;EAAG,CAClD;EACA,OAAO,KAAK,EAAE,SAAS,MAAM,GAAG,GAAG,iCAAiC;EACpE,MAAM,SAAS,UAAU,SAAS,MAAM,IAAI,EAAE,IAAI,KAAK,CAAC;CAC1D,SAAS,OAAO;EACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrE,OAAO,MAAM;GAAE,KAAK;GAAO,SAAS,MAAM;EAAG,GAAG,uBAAuB;EACvE,MAAM,SAAS,UAAU,SAAS,MAAM,IAAI;GAAE,IAAI;GAAO,OAAO;EAAQ,CAAC;CAC3E;AACF"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { currentProjectRole } from "./helpers.mjs";
|
|
6
|
+
import { isPublicBuild } from "@storyshelf/core/models";
|
|
7
|
+
//#region src/routers/published-access.ts
|
|
8
|
+
/**
|
|
9
|
+
* The one policy for reading a build's published files (ADR 0007, decision 5; ADR 0011): a signed
|
|
10
|
+
* token naming the build, or a build anyone may open (public, or an instance without sign-in), or
|
|
11
|
+
* a signed-in viewer of the project. Every route that serves published files asks
|
|
12
|
+
* {@link publishedAccess} and takes its CORS headers from {@link publishedCorsHeaders}.
|
|
13
|
+
*/
|
|
14
|
+
const VIEW_ROLES = /* @__PURE__ */ new Set([
|
|
15
|
+
"viewer",
|
|
16
|
+
"developer",
|
|
17
|
+
"approver",
|
|
18
|
+
"admin"
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Whether the request may read `build`'s published files, and how. A `grant` (verified claims of a
|
|
22
|
+
* signed URL) decides alone: it opens exactly the build it names and nothing falls through to the
|
|
23
|
+
* other rules, so a token that does not fit answers like a missing one.
|
|
24
|
+
*/
|
|
25
|
+
async function publishedAccess(build, project, grant) {
|
|
26
|
+
if (grant) return grant.projectId === project.id && grant.buildId === build.id && build.projectId === project.id ? "token" : null;
|
|
27
|
+
if (isAnonymouslyViewable(build, project)) return "public";
|
|
28
|
+
const role = await currentProjectRole(project.id);
|
|
29
|
+
return role && VIEW_ROLES.has(role) ? "session" : null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether anyone who can reach the app may fetch the build's published Storybook with no
|
|
33
|
+
* credentials: the instance has no sign-in, or the build is public (ADR 0011).
|
|
34
|
+
*/
|
|
35
|
+
function isAnonymouslyViewable(build, project) {
|
|
36
|
+
return !getStore().authEnabled || isPublicBuild(project, build);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* CORS headers of a published file. A sandboxed preview frame runs on an opaque origin, so its
|
|
40
|
+
* module scripts are cross-origin requests without cookies: they load only when the file answers
|
|
41
|
+
* `Access-Control-Allow-Origin: *`. That is offered exactly where no credentials are involved, for
|
|
42
|
+
* a public build and for a signed URL (which is itself the credential, never sent as a cookie or
|
|
43
|
+
* header, and never with `Allow-Credentials`). A signed-in viewer's private build answers without
|
|
44
|
+
* it: only same-origin pages may read it.
|
|
45
|
+
*/
|
|
46
|
+
function publishedCorsHeaders(access) {
|
|
47
|
+
return access === "session" ? {} : { "access-control-allow-origin": "*" };
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { isAnonymouslyViewable, publishedAccess, publishedCorsHeaders };
|
|
51
|
+
|
|
52
|
+
//# sourceMappingURL=published-access.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"published-access.mjs","names":[],"sources":["../../src/routers/published-access.ts"],"sourcesContent":["/**\n * The one policy for reading a build's published files (ADR 0007, decision 5; ADR 0011): a signed\n * token naming the build, or a build anyone may open (public, or an instance without sign-in), or\n * a signed-in viewer of the project. Every route that serves published files asks\n * {@link publishedAccess} and takes its CORS headers from {@link publishedCorsHeaders}.\n */\nimport { isPublicBuild } from \"@storyshelf/core/models\";\nimport type { Build, Project } from \"@storyshelf/core/schema\";\nimport type { StorybookTokenClaims } from \"@storyshelf/core/utils\";\nimport { getStore } from \"../store.ts\";\nimport { currentProjectRole } from \"./helpers.ts\";\n\n/** How a request may read a build: by its token, because the build is open, or by a session. */\nexport type PublishedAccess = \"token\" | \"public\" | \"session\";\n\nconst VIEW_ROLES: ReadonlySet<string> = new Set([\"viewer\", \"developer\", \"approver\", \"admin\"]);\n\n/**\n * Whether the request may read `build`'s published files, and how. A `grant` (verified claims of a\n * signed URL) decides alone: it opens exactly the build it names and nothing falls through to the\n * other rules, so a token that does not fit answers like a missing one.\n */\nexport async function publishedAccess(\n build: Pick<Build, \"id\" | \"projectId\" | \"public\" | \"gitBranch\">,\n project: Project,\n grant?: StorybookTokenClaims,\n): Promise<PublishedAccess | null> {\n if (grant) {\n return grant.projectId === project.id &&\n grant.buildId === build.id &&\n build.projectId === project.id\n ? \"token\"\n : null;\n }\n if (isAnonymouslyViewable(build, project)) {\n return \"public\";\n }\n const role = await currentProjectRole(project.id);\n return role && VIEW_ROLES.has(role) ? \"session\" : null;\n}\n\n/**\n * Whether anyone who can reach the app may fetch the build's published Storybook with no\n * credentials: the instance has no sign-in, or the build is public (ADR 0011).\n */\nexport function isAnonymouslyViewable(\n build: Pick<Build, \"public\" | \"gitBranch\">,\n project: Project,\n): boolean {\n return !getStore().authEnabled || isPublicBuild(project, build);\n}\n\n/**\n * CORS headers of a published file. A sandboxed preview frame runs on an opaque origin, so its\n * module scripts are cross-origin requests without cookies: they load only when the file answers\n * `Access-Control-Allow-Origin: *`. That is offered exactly where no credentials are involved, for\n * a public build and for a signed URL (which is itself the credential, never sent as a cookie or\n * header, and never with `Allow-Credentials`). A signed-in viewer's private build answers without\n * it: only same-origin pages may read it.\n */\nexport function publishedCorsHeaders(access: PublishedAccess): Record<string, string> {\n return access === \"session\" ? {} : { \"access-control-allow-origin\": \"*\" };\n}\n"],"mappings":";;;;;;;;;;;;;AAeA,MAAM,6BAAkC,IAAI,IAAI;CAAC;CAAU;CAAa;CAAY;AAAO,CAAC;;;;;;AAO5F,eAAsB,gBACpB,OACA,SACA,OACiC;CACjC,IAAI,OACF,OAAO,MAAM,cAAc,QAAQ,MACjC,MAAM,YAAY,MAAM,MACxB,MAAM,cAAc,QAAQ,KAC1B,UACA;CAEN,IAAI,sBAAsB,OAAO,OAAO,GACtC,OAAO;CAET,MAAM,OAAO,MAAM,mBAAmB,QAAQ,EAAE;CAChD,OAAO,QAAQ,WAAW,IAAI,IAAI,IAAI,YAAY;AACpD;;;;;AAMA,SAAgB,sBACd,OACA,SACS;CACT,OAAO,CAAC,SAAS,CAAC,CAAC,eAAe,cAAc,SAAS,KAAK;AAChE;;;;;;;;;AAUA,SAAgB,qBAAqB,QAAiD;CACpF,OAAO,WAAW,YAAY,CAAC,IAAI,EAAE,+BAA+B,IAAI;AAC1E"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { publishedCorsHeaders } from "./published-access.mjs";
|
|
6
|
+
import { isSafeSegment, mimeFor } from "@storyshelf/core/utils";
|
|
7
|
+
import { readPublishedFile } from "@storyshelf/core/capture";
|
|
8
|
+
//#region src/routers/published-files.ts
|
|
9
|
+
/**
|
|
10
|
+
* Serving a build's published files (ADR 0010, ADR 0007 decision 5): the single place that decodes
|
|
11
|
+
* and checks a request path, reads the file through the build's manifest and sets the headers.
|
|
12
|
+
* The published Storybook routes (`/projects/:slug/storybook/build/:id/*`, `/_/:id/*`) and the
|
|
13
|
+
* signed URL route (`/_s/:token/*`) all end here, each bringing the access {@link publishedAccess}
|
|
14
|
+
* granted.
|
|
15
|
+
*/
|
|
16
|
+
/** Headers of every signed URL answer: a token URL must not be cached, indexed or leaked by `Referer`. */
|
|
17
|
+
const SIGNED_URL_HEADERS = {
|
|
18
|
+
"cache-control": "private, no-store",
|
|
19
|
+
"referrer-policy": "no-referrer",
|
|
20
|
+
"x-robots-tag": "noindex, nofollow",
|
|
21
|
+
"x-content-type-options": "nosniff"
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The file path after `base`, decoded once. `""` is the root; `null` when the path is not under
|
|
25
|
+
* `base` or could leave the build: empty, `.` or `..` segments, backslashes and NUL are refused.
|
|
26
|
+
*/
|
|
27
|
+
function publishedRelativePath(path, base) {
|
|
28
|
+
if (!path.startsWith(base)) return null;
|
|
29
|
+
let rest;
|
|
30
|
+
try {
|
|
31
|
+
rest = decodeURIComponent(path.slice(base.length));
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return rest === "" || rest.split("/").every((segment) => segment !== "" && segment !== "." && !segment.includes("\0") && isSafeSegment(segment)) ? rest : null;
|
|
36
|
+
}
|
|
37
|
+
/** The file as a response, or `null` when the build has no such file. */
|
|
38
|
+
async function servePublishedFile(c, request) {
|
|
39
|
+
const { projectId, buildId, rel, access } = request;
|
|
40
|
+
const file = await readPublishedFile(getStore().storage, projectId, buildId, rel);
|
|
41
|
+
if (!file) return null;
|
|
42
|
+
const headers = access === "token" ? SIGNED_URL_HEADERS : { "cache-control": request.cacheControl };
|
|
43
|
+
return c.body(new Uint8Array(file), 200, {
|
|
44
|
+
...headers,
|
|
45
|
+
...publishedCorsHeaders(access),
|
|
46
|
+
"content-type": mimeFor(rel)
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { SIGNED_URL_HEADERS, publishedRelativePath, servePublishedFile };
|
|
51
|
+
|
|
52
|
+
//# sourceMappingURL=published-files.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"published-files.mjs","names":[],"sources":["../../src/routers/published-files.ts"],"sourcesContent":["/**\n * Serving a build's published files (ADR 0010, ADR 0007 decision 5): the single place that decodes\n * and checks a request path, reads the file through the build's manifest and sets the headers.\n * The published Storybook routes (`/projects/:slug/storybook/build/:id/*`, `/_/:id/*`) and the\n * signed URL route (`/_s/:token/*`) all end here, each bringing the access {@link publishedAccess}\n * granted.\n */\nimport { readPublishedFile } from \"@storyshelf/core/capture\";\nimport { isSafeSegment, mimeFor } from \"@storyshelf/core/utils\";\nimport type { Context } from \"hono\";\nimport { getStore } from \"../store.ts\";\nimport { publishedCorsHeaders, type PublishedAccess } from \"./published-access.ts\";\n\n/** Headers of every signed URL answer: a token URL must not be cached, indexed or leaked by `Referer`. */\nexport const SIGNED_URL_HEADERS = {\n \"cache-control\": \"private, no-store\",\n \"referrer-policy\": \"no-referrer\",\n \"x-robots-tag\": \"noindex, nofollow\",\n \"x-content-type-options\": \"nosniff\",\n} as const;\n\n/**\n * The file path after `base`, decoded once. `\"\"` is the root; `null` when the path is not under\n * `base` or could leave the build: empty, `.` or `..` segments, backslashes and NUL are refused.\n */\nexport function publishedRelativePath(path: string, base: string): string | null {\n if (!path.startsWith(base)) {\n return null;\n }\n let rest: string;\n try {\n rest = decodeURIComponent(path.slice(base.length));\n } catch {\n return null;\n }\n const safe =\n rest === \"\" ||\n rest\n .split(\"/\")\n .every(\n (segment) =>\n segment !== \"\" && segment !== \".\" && !segment.includes(\"\\0\") && isSafeSegment(segment),\n );\n return safe ? rest : null;\n}\n\n/** Where a file of a build lives and how the request got to it. */\nexport interface PublishedFileRequest {\n projectId: string;\n buildId: string;\n /** A path accepted by {@link publishedRelativePath}, not the root. */\n rel: string;\n access: PublishedAccess;\n /** `Cache-Control` of a normal answer; a signed URL always answers `private, no-store`. */\n cacheControl: string;\n}\n\n/** The file as a response, or `null` when the build has no such file. */\nexport async function servePublishedFile(\n c: Context,\n request: PublishedFileRequest,\n): Promise<Response | null> {\n const { projectId, buildId, rel, access } = request;\n const file = await readPublishedFile(getStore().storage, projectId, buildId, rel);\n if (!file) {\n return null;\n }\n const headers =\n access === \"token\" ? SIGNED_URL_HEADERS : { \"cache-control\": request.cacheControl };\n return c.body(new Uint8Array(file), 200, {\n ...headers,\n ...publishedCorsHeaders(access),\n \"content-type\": mimeFor(rel),\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAcA,MAAa,qBAAqB;CAChC,iBAAiB;CACjB,mBAAmB;CACnB,gBAAgB;CAChB,0BAA0B;AAC5B;;;;;AAMA,SAAgB,sBAAsB,MAAc,MAA6B;CAC/E,IAAI,CAAC,KAAK,WAAW,IAAI,GACvB,OAAO;CAET,IAAI;CACJ,IAAI;EACF,OAAO,mBAAmB,KAAK,MAAM,KAAK,MAAM,CAAC;CACnD,QAAQ;EACN,OAAO;CACT;CASA,OAPE,SAAS,MACT,KACG,MAAM,GAAG,CAAC,CACV,OACE,YACC,YAAY,MAAM,YAAY,OAAO,CAAC,QAAQ,SAAS,IAAI,KAAK,cAAc,OAAO,CACzF,IACU,OAAO;AACvB;;AAcA,eAAsB,mBACpB,GACA,SAC0B;CAC1B,MAAM,EAAE,WAAW,SAAS,KAAK,WAAW;CAC5C,MAAM,OAAO,MAAM,kBAAkB,SAAS,CAAC,CAAC,SAAS,WAAW,SAAS,GAAG;CAChF,IAAI,CAAC,MACH,OAAO;CAET,MAAM,UACJ,WAAW,UAAU,qBAAqB,EAAE,iBAAiB,QAAQ,aAAa;CACpF,OAAO,EAAE,KAAK,IAAI,WAAW,IAAI,GAAG,KAAK;EACvC,GAAG;EACH,GAAG,qBAAqB,MAAM;EAC9B,gBAAgB,QAAQ,GAAG;CAC7B,CAAC;AACH"}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { flash } from "./flash.mjs";
|
|
7
|
+
import { buildReviewCreateSchema, commentCreateSchema } from "./schemas.mjs";
|
|
8
|
+
import { APPROVER_ROLES, DEVELOPER_ROLES, buildForProject } from "./builds.handlers.mjs";
|
|
9
|
+
import { giveVerdict } from "./build-reviews.handlers.mjs";
|
|
10
|
+
import { createComment, resolveBuildComment } from "./comments.mjs";
|
|
11
|
+
import { HTTPException } from "hono/http-exception";
|
|
12
|
+
//#region src/routers/review-forms.ts
|
|
13
|
+
/** Register the form routes of review (`review` class). */
|
|
14
|
+
function registerReviewForms(app) {
|
|
15
|
+
const base = "/projects/:slug/builds/:buildId";
|
|
16
|
+
app.post(`${base}/comments`, async (c) => {
|
|
17
|
+
const { project, build } = await target(c, DEVELOPER_ROLES);
|
|
18
|
+
const input = commentCreateSchema.safeParse(await fields(c));
|
|
19
|
+
if (!input.success) throw new HTTPException(400, { message: "A comment needs some text (up to 4000 characters)" });
|
|
20
|
+
await createComment(project, build.id, input.data);
|
|
21
|
+
return done(c, build.id, "Comment added");
|
|
22
|
+
});
|
|
23
|
+
app.post(`${base}/comments/:commentId/resolve`, async (c) => {
|
|
24
|
+
const { build } = await target(c, DEVELOPER_ROLES);
|
|
25
|
+
await resolveBuildComment(build.id, c.req.param("commentId"));
|
|
26
|
+
return done(c, build.id, "Comment resolved");
|
|
27
|
+
});
|
|
28
|
+
app.post(`${base}/reviews`, async (c) => {
|
|
29
|
+
const { project, build } = await target(c, APPROVER_ROLES);
|
|
30
|
+
const input = buildReviewCreateSchema.safeParse(await fields(c));
|
|
31
|
+
if (!input.success) throw new HTTPException(400, { message: "Choose approve or reject" });
|
|
32
|
+
await giveVerdict(project, build, await resolveReviewer(c), input.data);
|
|
33
|
+
return done(c, build.id, "Verdict recorded");
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async function target(c, roles) {
|
|
37
|
+
const project = await resolveAuthorizedProject(c, c.req.param("slug") ?? "", ...roles);
|
|
38
|
+
return {
|
|
39
|
+
project,
|
|
40
|
+
build: await buildForProject(project.id, c.req.param("buildId") ?? "")
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** The form's text fields, blanks left out (an empty `storyId` means no story). */
|
|
44
|
+
async function fields(c) {
|
|
45
|
+
const form = await c.req.formData();
|
|
46
|
+
const values = {};
|
|
47
|
+
for (const [key, value] of form.entries()) if (typeof value === "string" && key !== "csrf_token" && value.trim() !== "") values[key] = value.trim();
|
|
48
|
+
return values;
|
|
49
|
+
}
|
|
50
|
+
/** HTMX reloads the page it posted from; a plain form goes back to the page, else the build. */
|
|
51
|
+
function done(c, buildId, message) {
|
|
52
|
+
flash(c, message);
|
|
53
|
+
if (c.req.header("HX-Request") === "true") {
|
|
54
|
+
hxRefresh(c);
|
|
55
|
+
return c.body(null, 204);
|
|
56
|
+
}
|
|
57
|
+
return c.redirect(returnPath(c, buildId), 303);
|
|
58
|
+
}
|
|
59
|
+
/** The path the form was posted from when it is on this host, else the build page. */
|
|
60
|
+
function returnPath(c, buildId) {
|
|
61
|
+
const fallback = `/projects/${c.req.param("slug") ?? ""}/builds/${buildId}`;
|
|
62
|
+
try {
|
|
63
|
+
const referer = new URL(c.req.header("referer") ?? "", c.req.url);
|
|
64
|
+
return referer.origin === new URL(c.req.url).origin ? `${referer.pathname}${referer.search}` : fallback;
|
|
65
|
+
} catch {
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
export { registerReviewForms };
|
|
71
|
+
|
|
72
|
+
//# sourceMappingURL=review-forms.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-forms.mjs","names":[],"sources":["../../src/routers/review-forms.ts"],"sourcesContent":["/**\n * The review forms of the build, diff and component pages. HTMX and plain forms post\n * `application/x-www-form-urlencoded`, which the JSON endpoints under `/api/v1` refuse (415), so\n * each form has a page route that reads the form and calls the same handlers as the API. All\n * three are `review` routes: they answer 404 for a project with review off.\n */\nimport type { Build, Project } from \"@storyshelf/core/schema\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { giveVerdict } from \"./build-reviews.handlers.ts\";\nimport { APPROVER_ROLES, DEVELOPER_ROLES, buildForProject } from \"./builds.handlers.ts\";\nimport { createComment, resolveBuildComment } from \"./comments.ts\";\nimport { flash } from \"./flash.ts\";\nimport { resolveAuthorizedProject, resolveReviewer } from \"./helpers.ts\";\nimport { hxRefresh } from \"./htmx.ts\";\nimport { buildReviewCreateSchema, commentCreateSchema } from \"./schemas.ts\";\n\n/** Register the form routes of review (`review` class). */\nexport function registerReviewForms(app: ShelfRouter): void {\n const base = \"/projects/:slug/builds/:buildId\";\n app.post(`${base}/comments`, async (c) => {\n const { project, build } = await target(c, DEVELOPER_ROLES);\n const input = commentCreateSchema.safeParse(await fields(c));\n if (!input.success) {\n throw new HTTPException(400, {\n message: \"A comment needs some text (up to 4000 characters)\",\n });\n }\n await createComment(project, build.id, input.data);\n return done(c, build.id, \"Comment added\");\n });\n\n app.post(`${base}/comments/:commentId/resolve`, async (c) => {\n const { build } = await target(c, DEVELOPER_ROLES);\n await resolveBuildComment(build.id, c.req.param(\"commentId\"));\n return done(c, build.id, \"Comment resolved\");\n });\n\n app.post(`${base}/reviews`, async (c) => {\n const { project, build } = await target(c, APPROVER_ROLES);\n const input = buildReviewCreateSchema.safeParse(await fields(c));\n if (!input.success) {\n throw new HTTPException(400, { message: \"Choose approve or reject\" });\n }\n await giveVerdict(project, build, await resolveReviewer(c), input.data);\n return done(c, build.id, \"Verdict recorded\");\n });\n}\n\nasync function target(\n c: Context,\n roles: readonly ProjectRole[],\n): Promise<{ project: Project; build: Build }> {\n const project = await resolveAuthorizedProject(c, c.req.param(\"slug\") ?? \"\", ...roles);\n const build = await buildForProject(project.id, c.req.param(\"buildId\") ?? \"\");\n return { project, build };\n}\n\n/** The form's text fields, blanks left out (an empty `storyId` means no story). */\nasync function fields(c: Context): Promise<Record<string, string>> {\n const form = await c.req.formData();\n const values: Record<string, string> = {};\n for (const [key, value] of form.entries()) {\n if (typeof value === \"string\" && key !== \"csrf_token\" && value.trim() !== \"\") {\n values[key] = value.trim();\n }\n }\n return values;\n}\n\n/** HTMX reloads the page it posted from; a plain form goes back to the page, else the build. */\nfunction done(c: Context, buildId: string, message: string): Response {\n flash(c, message);\n if (c.req.header(\"HX-Request\") === \"true\") {\n hxRefresh(c);\n return c.body(null, 204);\n }\n return c.redirect(returnPath(c, buildId), 303);\n}\n\n/** The path the form was posted from when it is on this host, else the build page. */\nfunction returnPath(c: Context, buildId: string): string {\n const slug = c.req.param(\"slug\") ?? \"\";\n const fallback = `/projects/${slug}/builds/${buildId}`;\n try {\n const referer = new URL(c.req.header(\"referer\") ?? \"\", c.req.url);\n return referer.origin === new URL(c.req.url).origin\n ? `${referer.pathname}${referer.search}`\n : fallback;\n } catch {\n return fallback;\n }\n}\n"],"mappings":";;;;;;;;;;;;;AAoBA,SAAgB,oBAAoB,KAAwB;CAC1D,MAAM,OAAO;CACb,IAAI,KAAK,GAAG,KAAK,YAAY,OAAO,MAAM;EACxC,MAAM,EAAE,SAAS,UAAU,MAAM,OAAO,GAAG,eAAe;EAC1D,MAAM,QAAQ,oBAAoB,UAAU,MAAM,OAAO,CAAC,CAAC;EAC3D,IAAI,CAAC,MAAM,SACT,MAAM,IAAI,cAAc,KAAK,EAC3B,SAAS,oDACX,CAAC;EAEH,MAAM,cAAc,SAAS,MAAM,IAAI,MAAM,IAAI;EACjD,OAAO,KAAK,GAAG,MAAM,IAAI,eAAe;CAC1C,CAAC;CAED,IAAI,KAAK,GAAG,KAAK,+BAA+B,OAAO,MAAM;EAC3D,MAAM,EAAE,UAAU,MAAM,OAAO,GAAG,eAAe;EACjD,MAAM,oBAAoB,MAAM,IAAI,EAAE,IAAI,MAAM,WAAW,CAAC;EAC5D,OAAO,KAAK,GAAG,MAAM,IAAI,kBAAkB;CAC7C,CAAC;CAED,IAAI,KAAK,GAAG,KAAK,WAAW,OAAO,MAAM;EACvC,MAAM,EAAE,SAAS,UAAU,MAAM,OAAO,GAAG,cAAc;EACzD,MAAM,QAAQ,wBAAwB,UAAU,MAAM,OAAO,CAAC,CAAC;EAC/D,IAAI,CAAC,MAAM,SACT,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,2BAA2B,CAAC;EAEtE,MAAM,YAAY,SAAS,OAAO,MAAM,gBAAgB,CAAC,GAAG,MAAM,IAAI;EACtE,OAAO,KAAK,GAAG,MAAM,IAAI,kBAAkB;CAC7C,CAAC;AACH;AAEA,eAAe,OACb,GACA,OAC6C;CAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,MAAM,KAAK,IAAI,GAAG,KAAK;CAErF,OAAO;EAAE;EAAS,OAAA,MADE,gBAAgB,QAAQ,IAAI,EAAE,IAAI,MAAM,SAAS,KAAK,EAAE;CACpD;AAC1B;;AAGA,eAAe,OAAO,GAA6C;CACjE,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;CAClC,MAAM,SAAiC,CAAC;CACxC,KAAK,MAAM,CAAC,KAAK,UAAU,KAAK,QAAQ,GACtC,IAAI,OAAO,UAAU,YAAY,QAAQ,gBAAgB,MAAM,KAAK,MAAM,IACxE,OAAO,OAAO,MAAM,KAAK;CAG7B,OAAO;AACT;;AAGA,SAAS,KAAK,GAAY,SAAiB,SAA2B;CACpE,MAAM,GAAG,OAAO;CAChB,IAAI,EAAE,IAAI,OAAO,YAAY,MAAM,QAAQ;EACzC,UAAU,CAAC;EACX,OAAO,EAAE,KAAK,MAAM,GAAG;CACzB;CACA,OAAO,EAAE,SAAS,WAAW,GAAG,OAAO,GAAG,GAAG;AAC/C;;AAGA,SAAS,WAAW,GAAY,SAAyB;CAEvD,MAAM,WAAW,aADJ,EAAE,IAAI,MAAM,MAAM,KAAK,GACD,UAAU;CAC7C,IAAI;EACF,MAAM,UAAU,IAAI,IAAI,EAAE,IAAI,OAAO,SAAS,KAAK,IAAI,EAAE,IAAI,GAAG;EAChE,OAAO,QAAQ,WAAW,IAAI,IAAI,EAAE,IAAI,GAAG,CAAC,CAAC,SACzC,GAAG,QAAQ,WAAW,QAAQ,WAC9B;CACN,QAAQ;EACN,OAAO;CACT;AACF"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { currentProjectRole } from "./helpers.mjs";
|
|
6
|
+
import { reviewStateOf } from "./build-reviews.handlers.mjs";
|
|
7
|
+
import { publishingOf } from "@storyshelf/core/capture";
|
|
8
|
+
import { CommentModel, UserModel, changesetOf } from "@storyshelf/core/models";
|
|
9
|
+
//#region src/routers/review-section.handlers.ts
|
|
10
|
+
/** Data behind the review section of the build page (ADR 0013): verdicts, changeset and comments. */
|
|
11
|
+
/** Whether review is on for the project (a row without the column counts as on). */
|
|
12
|
+
function reviewIsOn(project) {
|
|
13
|
+
return project.reviewEnabled !== false;
|
|
14
|
+
}
|
|
15
|
+
/** The viewer's review rights: everything with auth off, else by their role on the project. */
|
|
16
|
+
async function reviewCapabilities(project) {
|
|
17
|
+
if (!getStore().authEnabled) return {
|
|
18
|
+
canComment: true,
|
|
19
|
+
canVerdict: true
|
|
20
|
+
};
|
|
21
|
+
const role = await currentProjectRole(project.id);
|
|
22
|
+
return {
|
|
23
|
+
canComment: role === "developer" || role === "approver" || role === "admin",
|
|
24
|
+
canVerdict: role === "approver" || role === "admin"
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The review section of a build; null when the project has review off. */
|
|
28
|
+
async function loadReviewSection(project, build, catalog) {
|
|
29
|
+
if (!reviewIsOn(project)) return null;
|
|
30
|
+
const { db, testing } = getStore();
|
|
31
|
+
const [state, changeset, comments, capabilities, publishing] = await Promise.all([
|
|
32
|
+
reviewStateOf(project, build),
|
|
33
|
+
changesetOf(db, project, build),
|
|
34
|
+
new CommentModel(db).listByBuild(build.id),
|
|
35
|
+
reviewCapabilities(project),
|
|
36
|
+
publishingOrPending(build)
|
|
37
|
+
]);
|
|
38
|
+
const shown = comments.filter((comment) => comment.snapshotId === null);
|
|
39
|
+
return {
|
|
40
|
+
project,
|
|
41
|
+
build,
|
|
42
|
+
state,
|
|
43
|
+
changeset,
|
|
44
|
+
firstStory: firstStoryOf(catalog),
|
|
45
|
+
comments: shown,
|
|
46
|
+
names: await namesOf([...state.latest, ...shown].map((item) => item.userId)),
|
|
47
|
+
testing,
|
|
48
|
+
publishing,
|
|
49
|
+
...capabilities
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** How publishing stands; a storage probe that fails reads as the storage-independent state. */
|
|
53
|
+
async function publishingOrPending(build) {
|
|
54
|
+
try {
|
|
55
|
+
return await publishingOf(getStore().storage, build);
|
|
56
|
+
} catch {
|
|
57
|
+
return await publishingOf(void 0, build);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Display names of the given users; ids that no longer resolve show as the id. */
|
|
61
|
+
async function namesOf(userIds) {
|
|
62
|
+
const users = new UserModel(getStore().db);
|
|
63
|
+
const ids = [...new Set(userIds.filter((id) => id !== null))];
|
|
64
|
+
const found = await Promise.all(ids.map(async (id) => [id, await users.get(id)]));
|
|
65
|
+
return new Map(found.map(([id, user]) => [id, user?.name ?? user?.email ?? id]));
|
|
66
|
+
}
|
|
67
|
+
/** Story file -> id of its first story. */
|
|
68
|
+
function firstStoryOf(catalog) {
|
|
69
|
+
const first = /* @__PURE__ */ new Map();
|
|
70
|
+
for (const entry of catalog?.entries ?? []) if (entry.type === "story" && !first.has(entry.importPath)) first.set(entry.importPath, entry.id);
|
|
71
|
+
return first;
|
|
72
|
+
}
|
|
73
|
+
/** Story comments of `build` for a component page; null when the project has review off. */
|
|
74
|
+
async function loadComponentReview(project, build) {
|
|
75
|
+
if (!reviewIsOn(project)) return null;
|
|
76
|
+
const [comments, capabilities] = await Promise.all([new CommentModel(getStore().db).listByBuild(build.id), reviewCapabilities(project)]);
|
|
77
|
+
return {
|
|
78
|
+
comments: comments.filter((comment) => comment.storyId !== null),
|
|
79
|
+
canComment: capabilities.canComment
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
export { loadComponentReview, loadReviewSection, namesOf, reviewCapabilities, reviewIsOn };
|
|
84
|
+
|
|
85
|
+
//# sourceMappingURL=review-section.handlers.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-section.handlers.mjs","names":[],"sources":["../../src/routers/review-section.handlers.ts"],"sourcesContent":["/** Data behind the review section of the build page (ADR 0013): verdicts, changeset and comments. */\nimport { publishingOf, type Catalog } from \"@storyshelf/core/capture\";\nimport { changesetOf, CommentModel, UserModel, type BuildChangeset } from \"@storyshelf/core/models\";\nimport type { Build, Comment, Project } from \"@storyshelf/core/schema\";\nimport { getStore } from \"../store.ts\";\nimport { reviewStateOf, type ReviewState } from \"./build-reviews.handlers.ts\";\nimport { currentProjectRole } from \"./helpers.ts\";\n\n/** What the viewer may do in review. */\nexport interface ReviewCapabilities {\n /** Write build, story and snapshot comments (developer and above). */\n canComment: boolean;\n /** Give a verdict (approver and admin). */\n canVerdict: boolean;\n}\n\n/** Everything the review cards render. */\nexport interface ReviewSectionData extends ReviewCapabilities {\n project: Project;\n build: Build;\n /** The verdicts and what they make of the build. */\n state: ReviewState;\n changeset: BuildChangeset;\n /** First story of each story file in the build, to link a component to its published story. */\n firstStory: ReadonlyMap<string, string>;\n /** Build and story comments (snapshot comments belong to the diff view). */\n comments: Comment[];\n /** Display names of the users behind verdicts and comments, by user id. */\n names: ReadonlyMap<string, string>;\n /** Whether the snapshot rollup decides the build, so the verdict is a sign-off only. */\n testing: boolean;\n /** Whether the build's Storybook is published; a verdict waits for `success` (ADR 0013). */\n publishing: Awaited<ReturnType<typeof publishingOf>>;\n}\n\n/** Whether review is on for the project (a row without the column counts as on). */\nexport function reviewIsOn(project: Pick<Project, \"reviewEnabled\">): boolean {\n return project.reviewEnabled !== false;\n}\n\n/** The viewer's review rights: everything with auth off, else by their role on the project. */\nexport async function reviewCapabilities(project: Project): Promise<ReviewCapabilities> {\n if (!getStore().authEnabled) {\n return { canComment: true, canVerdict: true };\n }\n const role = await currentProjectRole(project.id);\n return {\n canComment: role === \"developer\" || role === \"approver\" || role === \"admin\",\n canVerdict: role === \"approver\" || role === \"admin\",\n };\n}\n\n/** The review section of a build; null when the project has review off. */\nexport async function loadReviewSection(\n project: Project,\n build: Build,\n catalog: Catalog | null,\n): Promise<ReviewSectionData | null> {\n if (!reviewIsOn(project)) {\n return null;\n }\n const { db, testing } = getStore();\n const [state, changeset, comments, capabilities, publishing] = await Promise.all([\n reviewStateOf(project, build),\n changesetOf(db, project, build),\n new CommentModel(db).listByBuild(build.id),\n reviewCapabilities(project),\n publishingOrPending(build),\n ]);\n const shown = comments.filter((comment) => comment.snapshotId === null);\n return {\n project,\n build,\n state,\n changeset,\n firstStory: firstStoryOf(catalog),\n comments: shown,\n names: await namesOf([...state.latest, ...shown].map((item) => item.userId)),\n testing,\n publishing,\n ...capabilities,\n };\n}\n\n/** How publishing stands; a storage probe that fails reads as the storage-independent state. */\nasync function publishingOrPending(build: Build): Promise<ReviewSectionData[\"publishing\"]> {\n try {\n return await publishingOf(getStore().storage, build);\n } catch {\n return await publishingOf(undefined, build);\n }\n}\n\n/** Display names of the given users; ids that no longer resolve show as the id. */\nexport async function namesOf(userIds: readonly (string | null)[]): Promise<Map<string, string>> {\n const users = new UserModel(getStore().db);\n const ids = [...new Set(userIds.filter((id): id is string => id !== null))];\n const found = await Promise.all(ids.map(async (id) => [id, await users.get(id)] as const));\n return new Map(found.map(([id, user]) => [id, user?.name ?? user?.email ?? id]));\n}\n\n/** Story file -> id of its first story. */\nfunction firstStoryOf(catalog: Catalog | null): Map<string, string> {\n const first = new Map<string, string>();\n for (const entry of catalog?.entries ?? []) {\n if (entry.type === \"story\" && !first.has(entry.importPath)) {\n first.set(entry.importPath, entry.id);\n }\n }\n return first;\n}\n\n/** What a component page needs of review: the story comments of the build it shows. */\nexport interface ComponentReview {\n comments: Comment[];\n canComment: boolean;\n}\n\n/** Story comments of `build` for a component page; null when the project has review off. */\nexport async function loadComponentReview(\n project: Project,\n build: Build,\n): Promise<ComponentReview | null> {\n if (!reviewIsOn(project)) {\n return null;\n }\n const [comments, capabilities] = await Promise.all([\n new CommentModel(getStore().db).listByBuild(build.id),\n reviewCapabilities(project),\n ]);\n return {\n comments: comments.filter((comment) => comment.storyId !== null),\n canComment: capabilities.canComment,\n };\n}\n"],"mappings":";;;;;;;;;;;AAoCA,SAAgB,WAAW,SAAkD;CAC3E,OAAO,QAAQ,kBAAkB;AACnC;;AAGA,eAAsB,mBAAmB,SAA+C;CACtF,IAAI,CAAC,SAAS,CAAC,CAAC,aACd,OAAO;EAAE,YAAY;EAAM,YAAY;CAAK;CAE9C,MAAM,OAAO,MAAM,mBAAmB,QAAQ,EAAE;CAChD,OAAO;EACL,YAAY,SAAS,eAAe,SAAS,cAAc,SAAS;EACpE,YAAY,SAAS,cAAc,SAAS;CAC9C;AACF;;AAGA,eAAsB,kBACpB,SACA,OACA,SACmC;CACnC,IAAI,CAAC,WAAW,OAAO,GACrB,OAAO;CAET,MAAM,EAAE,IAAI,YAAY,SAAS;CACjC,MAAM,CAAC,OAAO,WAAW,UAAU,cAAc,cAAc,MAAM,QAAQ,IAAI;EAC/E,cAAc,SAAS,KAAK;EAC5B,YAAY,IAAI,SAAS,KAAK;EAC9B,IAAI,aAAa,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;EACzC,mBAAmB,OAAO;EAC1B,oBAAoB,KAAK;CAC3B,CAAC;CACD,MAAM,QAAQ,SAAS,QAAQ,YAAY,QAAQ,eAAe,IAAI;CACtE,OAAO;EACL;EACA;EACA;EACA;EACA,YAAY,aAAa,OAAO;EAChC,UAAU;EACV,OAAO,MAAM,QAAQ,CAAC,GAAG,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,KAAK,SAAS,KAAK,MAAM,CAAC;EAC3E;EACA;EACA,GAAG;CACL;AACF;;AAGA,eAAe,oBAAoB,OAAwD;CACzF,IAAI;EACF,OAAO,MAAM,aAAa,SAAS,CAAC,CAAC,SAAS,KAAK;CACrD,QAAQ;EACN,OAAO,MAAM,aAAa,KAAA,GAAW,KAAK;CAC5C;AACF;;AAGA,eAAsB,QAAQ,SAAmE;CAC/F,MAAM,QAAQ,IAAI,UAAU,SAAS,CAAC,CAAC,EAAE;CACzC,MAAM,MAAM,CAAC,GAAG,IAAI,IAAI,QAAQ,QAAQ,OAAqB,OAAO,IAAI,CAAC,CAAC;CAC1E,MAAM,QAAQ,MAAM,QAAQ,IAAI,IAAI,IAAI,OAAO,OAAO,CAAC,IAAI,MAAM,MAAM,IAAI,EAAE,CAAC,CAAU,CAAC;CACzF,OAAO,IAAI,IAAI,MAAM,KAAK,CAAC,IAAI,UAAU,CAAC,IAAI,MAAM,QAAQ,MAAM,SAAS,EAAE,CAAC,CAAC;AACjF;;AAGA,SAAS,aAAa,SAA8C;CAClE,MAAM,wBAAQ,IAAI,IAAoB;CACtC,KAAK,MAAM,SAAS,SAAS,WAAW,CAAC,GACvC,IAAI,MAAM,SAAS,WAAW,CAAC,MAAM,IAAI,MAAM,UAAU,GACvD,MAAM,IAAI,MAAM,YAAY,MAAM,EAAE;CAGxC,OAAO;AACT;;AASA,eAAsB,oBACpB,SACA,OACiC;CACjC,IAAI,CAAC,WAAW,OAAO,GACrB,OAAO;CAET,MAAM,CAAC,UAAU,gBAAgB,MAAM,QAAQ,IAAI,CACjD,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE,GACpD,mBAAmB,OAAO,CAC5B,CAAC;CACD,OAAO;EACL,UAAU,SAAS,QAAQ,YAAY,QAAQ,YAAY,IAAI;EAC/D,YAAY,aAAa;CAC3B;AACF"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { requestBuildState } from "../git-status.mjs";
|
|
6
|
+
import { acceptPendingWithoutWork } from "@storyshelf/core/models";
|
|
7
|
+
//#region src/routers/review-switch-off.ts
|
|
8
|
+
/** The switch-off pass at the app level (ADR 0013, decision 1): accept the queue review leaves behind. */
|
|
9
|
+
/**
|
|
10
|
+
* When a save turned review off (`before` had it on), accept the project's pending builds that no
|
|
11
|
+
* capture worked on, in one pass. It raises no approval event (nobody approved anything), but each
|
|
12
|
+
* build's git channels are asked to show its new state. Returns the number of builds accepted.
|
|
13
|
+
*/
|
|
14
|
+
async function runSwitchOffPass(before, after) {
|
|
15
|
+
if (before.reviewEnabled === false || after.reviewEnabled !== false) return 0;
|
|
16
|
+
const accepted = await acceptPendingWithoutWork(getStore().db, after.id);
|
|
17
|
+
await Promise.all(accepted.map(async (build) => {
|
|
18
|
+
await requestBuildState(build.id);
|
|
19
|
+
}));
|
|
20
|
+
getStore().logger.info({
|
|
21
|
+
projectId: after.id,
|
|
22
|
+
accepted: accepted.length
|
|
23
|
+
}, "review turned off: accepted pending builds");
|
|
24
|
+
return accepted.length;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { runSwitchOffPass };
|
|
28
|
+
|
|
29
|
+
//# sourceMappingURL=review-switch-off.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-switch-off.mjs","names":[],"sources":["../../src/routers/review-switch-off.ts"],"sourcesContent":["/** The switch-off pass at the app level (ADR 0013, decision 1): accept the queue review leaves behind. */\nimport { acceptPendingWithoutWork } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { requestBuildState } from \"../git-status.ts\";\nimport { getStore } from \"../store.ts\";\n\n/**\n * When a save turned review off (`before` had it on), accept the project's pending builds that no\n * capture worked on, in one pass. It raises no approval event (nobody approved anything), but each\n * build's git channels are asked to show its new state. Returns the number of builds accepted.\n */\nexport async function runSwitchOffPass(before: Project, after: Project): Promise<number> {\n if (before.reviewEnabled === false || after.reviewEnabled !== false) {\n return 0;\n }\n const accepted = await acceptPendingWithoutWork(getStore().db, after.id);\n await Promise.all(\n accepted.map(async (build) => {\n await requestBuildState(build.id);\n }),\n );\n getStore().logger.info(\n { projectId: after.id, accepted: accepted.length },\n \"review turned off: accepted pending builds\",\n );\n return accepted.length;\n}\n"],"mappings":";;;;;;;;;;;;;AAWA,eAAsB,iBAAiB,QAAiB,OAAiC;CACvF,IAAI,OAAO,kBAAkB,SAAS,MAAM,kBAAkB,OAC5D,OAAO;CAET,MAAM,WAAW,MAAM,yBAAyB,SAAS,CAAC,CAAC,IAAI,MAAM,EAAE;CACvE,MAAM,QAAQ,IACZ,SAAS,IAAI,OAAO,UAAU;EAC5B,MAAM,kBAAkB,MAAM,EAAE;CAClC,CAAC,CACH;CACA,SAAS,CAAC,CAAC,OAAO,KAChB;EAAE,WAAW,MAAM;EAAI,UAAU,SAAS;CAAO,GACjD,4CACF;CACA,OAAO,SAAS;AAClB"}
|