@tokenoftrust/storefront-runner 1.4.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/astro.config.mjs +17 -1
- package/apps/storefront/env.d.ts +54 -5
- package/apps/storefront/integrations/materialize-guard.mjs +54 -0
- package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
- package/apps/storefront/migrations-apps/0004_deep_morph.sql +36 -0
- package/apps/storefront/migrations-apps/0005_common_mystique.sql +23 -0
- package/apps/storefront/migrations-apps/0006_broad_microchip.sql +16 -0
- package/apps/storefront/migrations-apps/0007_brave_outlaw_kid.sql +15 -0
- package/apps/storefront/migrations-apps/0008_blue_dazzler.sql +11 -0
- package/apps/storefront/migrations-apps/0009_bored_shinobi_shaw.sql +15 -0
- package/apps/storefront/migrations-apps/0010_ws4_verified_provider_measurement.sql +35 -0
- package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
- package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
- package/apps/storefront/migrations-apps/meta/0004_snapshot.json +1474 -0
- package/apps/storefront/migrations-apps/meta/0005_snapshot.json +1617 -0
- package/apps/storefront/migrations-apps/meta/0006_snapshot.json +1716 -0
- package/apps/storefront/migrations-apps/meta/0007_snapshot.json +1812 -0
- package/apps/storefront/migrations-apps/meta/0008_snapshot.json +1881 -0
- package/apps/storefront/migrations-apps/meta/0009_snapshot.json +1979 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +56 -0
- package/apps/storefront/public/apps/tot.demo.analytics/0.1.0/entry.js +42 -0
- package/apps/storefront/public/js/dashboard-team.js +38 -2
- package/apps/storefront/public/platform/amplitude-http/v1/entry.mjs +19 -0
- package/apps/storefront/public/platform/google-measurement/v1/entry.mjs +19 -0
- package/apps/storefront/public/shared/commerce-chrome.css +20 -2
- package/apps/storefront/scripts/build-v84-clean-clone-standalone.mjs +68 -0
- package/apps/storefront/scripts/deny-v84-clean-clone-standalone-capabilities.mjs +111 -0
- package/apps/storefront/scripts/deny-v84-dogfood-capabilities.mjs +2 -0
- package/apps/storefront/scripts/deny-v84-lifecycle-capabilities.mjs +247 -0
- package/apps/storefront/scripts/dogfood-v84-nonportable-routes.ts +73 -0
- package/apps/storefront/scripts/fixed-v84-native-install-producer.ts +79 -0
- package/apps/storefront/scripts/generated/v84-clean-clone-replay.bundle.mjs +10982 -0
- package/apps/storefront/scripts/import-v84-through-ws2.ts +38 -0
- package/apps/storefront/scripts/initialize-v84-lifecycle-tooling-dependency.ts +2 -0
- package/apps/storefront/scripts/initialize-v84-lifecycle-tooling.ts +4 -0
- package/apps/storefront/scripts/install-v84-native-through-ws1.ts +185 -0
- package/apps/storefront/scripts/prove-clone-isolation.ts +506 -0
- package/apps/storefront/scripts/replay-v84-clean-clone-pass.ts +148 -0
- package/apps/storefront/scripts/replay-v84-clean-clone-standalone.ts +35 -0
- package/apps/storefront/scripts/replay-v84-clean-clone.ts +214 -0
- package/apps/storefront/scripts/run-fixed-v84-native-install-producer.mjs +18 -0
- package/apps/storefront/scripts/run-v84-clone-isolation-proof.mjs +18 -0
- package/apps/storefront/scripts/run-v84-clone-lifecycle-proof.mjs +18 -0
- package/apps/storefront/scripts/run-v84-import-through-ws2-proof.mjs +17 -0
- package/apps/storefront/scripts/run-v84-nonportable-route-dogfood.mjs +15 -0
- package/apps/storefront/scripts/sanitize-v84-proof-environment.mjs +21 -0
- package/apps/storefront/scripts/v84CleanCloneGraphPin.ts +42 -0
- package/apps/storefront/scripts/v84CleanCloneSourceGraph.ts +145 -0
- package/apps/storefront/scripts/validate-v84-clone-lifecycle.ts +18 -0
- package/apps/storefront/src/components/Img.astro +1 -1
- package/apps/storefront/src/components/Seo.astro +65 -1
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +1435 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
- package/apps/storefront/src/components/admin/AdminIntegrationsTab.astro +105 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1980 -2564
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +5 -5
- package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
- package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
- package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +12 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +7 -2
- package/apps/storefront/src/components/chrome/SiteHeader.astro +46 -10
- package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +1 -1
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +1 -2
- package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +1 -2
- package/apps/storefront/src/components/compliance/PactActNotice.astro +1 -1
- package/apps/storefront/src/components/compliance/Prop65Warning.astro +1 -2
- package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +2 -3
- package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +2 -2
- package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +2 -3
- package/apps/storefront/src/components/content/TrustStrip.astro +4 -4
- package/apps/storefront/src/components/islands/MobileNav.tsx +26 -1
- package/apps/storefront/src/components/islands/VariantSelector.tsx +27 -2
- package/apps/storefront/src/components/islands/useCheckoutHandoffCorrelation.ts +75 -0
- package/apps/storefront/src/components/membership/IllustrativeLabel.astro +1 -2
- package/apps/storefront/src/components/membership/TierCards.astro +2 -2
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +2 -2
- package/apps/storefront/src/components/subscription/SavingsExplainer.astro +2 -2
- package/apps/storefront/src/config/adminTenantLookup.ts +35 -0
- package/apps/storefront/src/config/devTenantSeed.ts +9 -9
- package/apps/storefront/src/config/resolver.ts +4 -4
- package/apps/storefront/src/layouts/Layout.astro +146 -21
- package/apps/storefront/src/lib/activity/alerts.ts +17 -19
- package/apps/storefront/src/lib/activity/candidateSubmitLease.ts +87 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +89 -9
- package/apps/storefront/src/lib/activity/deployVersion.ts +20 -27
- package/apps/storefront/src/lib/activity/ingest.ts +8 -8
- package/apps/storefront/src/lib/activity/ingestAuth.ts +1 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +6 -6
- package/apps/storefront/src/lib/activity/query.ts +9 -9
- package/apps/storefront/src/lib/activity/recordActivity.ts +11 -15
- package/apps/storefront/src/lib/activity/store.ts +4 -6
- package/apps/storefront/src/lib/activity/uiActor.ts +12 -12
- package/apps/storefront/src/lib/activity/workerCommit.ts +6 -6
- package/apps/storefront/src/lib/admin/adminShell.ts +292 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
- package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
- package/apps/storefront/src/lib/admin/envelope.ts +1 -2
- package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
- package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
- package/apps/storefront/src/lib/apps/adminService.ts +14 -4
- package/apps/storefront/src/lib/apps/adminSession.ts +54 -45
- package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
- package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
- package/apps/storefront/src/lib/apps/appVersionStore.ts +281 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
- package/apps/storefront/src/lib/apps/consentConfigStore.ts +228 -0
- package/apps/storefront/src/lib/apps/credentials.ts +2 -2
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
- package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +447 -0
- package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +367 -0
- package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +315 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
- package/apps/storefront/src/lib/apps/integrationLifecycle.ts +614 -0
- package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +125 -0
- package/apps/storefront/src/lib/apps/integrationReadModel.ts +563 -0
- package/apps/storefront/src/lib/apps/integrationsTab.ts +547 -0
- package/apps/storefront/src/lib/apps/integrationsView.ts +106 -0
- package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +599 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/orderConversionD1.ts +150 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +9 -1
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/verifiedProviderMeasurement.ts +117 -0
- package/apps/storefront/src/lib/apps/registryService.ts +270 -16
- package/apps/storefront/src/lib/apps/scopes.ts +2 -2
- package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +170 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
- package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +16 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +105 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +177 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +251 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +241 -0
- package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +211 -0
- package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +409 -0
- package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +368 -0
- package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +188 -0
- package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +291 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
- package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
- package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +95 -0
- package/apps/storefront/src/lib/auth/adminApiGuard.ts +66 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +11 -11
- package/apps/storefront/src/lib/auth/adminLanding.ts +88 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
- package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +2 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- package/apps/storefront/src/lib/auth/session.ts +7 -8
- package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
- package/apps/storefront/src/lib/basePath.ts +9 -2
- package/apps/storefront/src/lib/blog/access.ts +29 -0
- package/apps/storefront/src/lib/blog/collection.ts +400 -0
- package/apps/storefront/src/lib/blog/markdown.ts +174 -0
- package/apps/storefront/src/lib/blog/provider.ts +62 -3
- package/apps/storefront/src/lib/blog/reactions.ts +87 -0
- package/apps/storefront/src/lib/blog/rss.ts +92 -0
- package/apps/storefront/src/lib/blog/teaser.ts +83 -0
- package/apps/storefront/src/lib/blog/types.ts +34 -0
- package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome-assignments.example.json +6 -0
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +79 -0
- package/apps/storefront/src/lib/chrome/model.ts +48 -253
- package/apps/storefront/src/lib/chrome/navCurrent.ts +29 -0
- package/apps/storefront/src/lib/chrome/parseConfig.ts +18 -32
- package/apps/storefront/src/lib/chrome/variant.ts +39 -0
- package/apps/storefront/src/lib/commerce/checkoutHandoffCorrelation.ts +105 -0
- package/apps/storefront/src/lib/compliance/tamper-sentinel-boot.ts +152 -0
- package/apps/storefront/src/lib/compliance/tamper-sentinel.ts +327 -0
- package/apps/storefront/src/lib/content/index.ts +5 -5
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +1 -1
- package/apps/storefront/src/lib/content-edit/route.ts +1 -1
- package/apps/storefront/src/lib/d1/catalog.ts +14 -2
- package/apps/storefront/src/lib/d1/schema-apps.ts +372 -17
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
- package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
- package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
- package/apps/storefront/src/lib/dev/cockpit/ask.ts +49 -0
- package/apps/storefront/src/lib/dev/cockpit/clickToSource.ts +71 -0
- package/apps/storefront/src/lib/dev/cockpit/copyCommand.ts +227 -0
- package/apps/storefront/src/lib/dev/cockpit/files.ts +249 -0
- package/apps/storefront/src/lib/dev/cockpit/hostedActivity.ts +500 -0
- package/apps/storefront/src/lib/dev/cockpit/infoPopover.ts +12 -0
- package/apps/storefront/src/lib/dev/cockpit/mcpCopy.ts +34 -0
- package/apps/storefront/src/lib/dev/cockpit/monitor.ts +107 -0
- package/apps/storefront/src/lib/dev/cockpit/stepper.ts +116 -0
- package/apps/storefront/src/lib/dev/cockpit/themeToggle.ts +23 -0
- package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
- package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
- package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
- package/apps/storefront/src/lib/dev/runtimeStore.ts +1 -1
- package/apps/storefront/src/lib/edgeCache.ts +34 -5
- package/apps/storefront/src/lib/email/candidateConflictEmail.ts +80 -0
- package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
- package/apps/storefront/src/lib/i18n.ts +25 -0
- package/apps/storefront/src/lib/integration/browserEnvironment.ts +137 -0
- package/apps/storefront/src/lib/integration/managedInstallDeclarations.ts +72 -0
- package/apps/storefront/src/lib/integration/nonceStamp.ts +49 -0
- package/apps/storefront/src/lib/integration/tenantDeclarations.ts +116 -0
- package/apps/storefront/src/lib/jsonld.ts +118 -0
- package/apps/storefront/src/lib/membership/eligibility.ts +1 -1
- package/apps/storefront/src/lib/membership/model.ts +1 -2
- package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
- package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
- package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
- package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
- package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
- package/apps/storefront/src/lib/newsletter/send.ts +265 -0
- package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
- package/apps/storefront/src/lib/previewReload.ts +57 -1
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +4625 -0
- package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
- package/apps/storefront/src/lib/publish/buildStream.ts +269 -0
- package/apps/storefront/src/lib/publish/collapsePersistence.ts +72 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
- package/apps/storefront/src/lib/publish/domainState.ts +22 -13
- package/apps/storefront/src/lib/publish/lifecycleState.ts +369 -0
- package/apps/storefront/src/lib/publish/liveReleaseSummary.ts +29 -0
- package/apps/storefront/src/lib/publish/mergeDoctorOnFailure.ts +78 -0
- package/apps/storefront/src/lib/publish/mergeHealthBanner.ts +62 -0
- package/apps/storefront/src/lib/publish/mergeRowFindings.ts +124 -0
- package/apps/storefront/src/lib/publish/mergeSupportEscalation.ts +75 -0
- package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
- package/apps/storefront/src/lib/publish/publish-guard.ts +88 -0
- package/apps/storefront/src/lib/publish/publishFormat.ts +165 -0
- package/apps/storefront/src/lib/publish/publishHero.ts +177 -0
- package/apps/storefront/src/lib/publish/reviewRelease/changeState.ts +66 -0
- package/apps/storefront/src/lib/publish/reviewRelease/copy.ts +141 -0
- package/apps/storefront/src/lib/publish/reviewRelease/index.ts +41 -0
- package/apps/storefront/src/lib/publish/reviewRelease/permissions.ts +47 -0
- package/apps/storefront/src/lib/publish/reviewRelease/releaseState.ts +42 -0
- package/apps/storefront/src/lib/publish/reviewRelease/types.ts +59 -0
- package/apps/storefront/src/lib/publish/reviewRelease/viewModel.ts +440 -0
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/actions.ts +161 -0
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/controller.ts +325 -0
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/render.ts +241 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +121 -51
- package/apps/storefront/src/lib/publish/sseRoute.ts +119 -0
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
- package/apps/storefront/src/lib/publish/streamLogConsole.ts +142 -0
- package/apps/storefront/src/lib/publish/versionNumber.ts +52 -0
- package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
- package/apps/storefront/src/lib/rawChrome.ts +125 -47
- package/apps/storefront/src/lib/rawMarketingHtml.ts +74 -3
- package/apps/storefront/src/lib/search/index.ts +2 -2
- package/apps/storefront/src/lib/seo/alternates.ts +42 -0
- package/apps/storefront/src/lib/seo/meta.ts +65 -0
- package/apps/storefront/src/lib/social/golive.ts +212 -0
- package/apps/storefront/src/lib/social/notify.ts +32 -0
- package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +7 -33
- package/apps/storefront/src/lib/storyblok/provider.ts +168 -60
- package/apps/storefront/src/lib/subscription/model.ts +4 -4
- package/apps/storefront/src/lib/the-build/provider.ts +32 -0
- package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
- package/apps/storefront/src/lib/the-build/toc.ts +95 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
- package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
- package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
- package/apps/storefront/src/middleware/index.ts +385 -52
- package/apps/storefront/src/pages/[...slug].astro +9 -4
- package/apps/storefront/src/pages/admin/review-release.astro +265 -0
- package/apps/storefront/src/pages/admin/select.astro +96 -0
- package/apps/storefront/src/pages/admin.astro +25 -1313
- package/apps/storefront/src/pages/api/activity.ts +8 -8
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +5 -20
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +7 -2
- package/apps/storefront/src/pages/api/admin/orders.ts +8 -2
- package/apps/storefront/src/pages/api/apps/admin/gtm-migration-install.ts +174 -0
- package/apps/storefront/src/pages/api/apps/admin/gtm-migration.ts +96 -0
- package/apps/storefront/src/pages/api/apps/admin/integration-lifecycle.ts +118 -0
- package/apps/storefront/src/pages/api/apps/admin/integrations.ts +89 -0
- package/apps/storefront/src/pages/api/apps/admin/platform-adapters.ts +85 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +74 -2
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
- package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +77 -0
- package/apps/storefront/src/pages/api/compliance/tamper.ts +88 -0
- package/apps/storefront/src/pages/api/integrations/v1/[operation].ts +108 -0
- package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
- package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
- package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +20 -2
- package/apps/storefront/src/pages/blog/[slug].astro +99 -19
- package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
- package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
- package/apps/storefront/src/pages/blog/index.astro +4 -2
- package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
- package/apps/storefront/src/pages/cockpit.astro +77 -1353
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
- package/apps/storefront/src/pages/index.astro +10 -5
- package/apps/storefront/src/pages/llms.txt.ts +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +7 -6
- package/apps/storefront/src/pages/search.astro +1 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -1
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +31 -27
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +1 -1
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +38 -2
- package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
- package/apps/storefront/src/pages/the-build/index.astro +146 -0
- package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
- package/apps/storefront/src/styles/admin.css +1106 -0
- package/apps/storefront/tsconfig.json +6 -1
- package/package.json +1 -1
- package/packages/public-runtime/package.json +3 -1
- package/packages/public-runtime/schemas/managed-analytics/v1/adapter-definition.schema.json +427 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/create-integration-install-request.schema.json +161 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/create-migration-draft-request.schema.json +157 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/delete-integration-install-request.schema.json +84 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/delete-migration-draft-request.schema.json +84 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/error-envelope.schema.json +180 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-adapter-definition-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-integration-install-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-integration-revision-request.schema.json +79 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-migration-draft-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/integration-install.schema.json +167 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/integration-revision.schema.json +208 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/list-adapter-definitions-request.schema.json +53 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/list-integration-installs-request.schema.json +53 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/list-integration-revisions-request.schema.json +82 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/migration-draft.schema.json +218 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/mutation-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/mutation-success-envelope.schema.json +49 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/read-request.schema.json +20 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/read-success-envelope.schema.json +45 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/revise-integration-install-request.schema.json +156 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/set-integration-install-status-request.schema.json +92 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/update-migration-draft-request.schema.json +184 -0
- package/packages/public-runtime/scripts/generate-managed-analytics-api-v1-schemas.mjs +13 -0
- package/packages/public-runtime/src/activity/catalog.ts +89 -33
- package/packages/public-runtime/src/activity/event.ts +6 -6
- package/packages/public-runtime/src/activity/index.ts +2 -2
- package/packages/public-runtime/src/activity/redaction.ts +4 -4
- package/packages/public-runtime/src/candidate-index.ts +42 -8
- package/packages/public-runtime/src/catalog-d1.ts +1 -1
- package/packages/public-runtime/src/checkout-orders.ts +2 -2
- package/packages/public-runtime/src/checkout-style-publish.ts +2 -2
- package/packages/public-runtime/src/checkout-style.ts +7 -7
- package/packages/public-runtime/src/checkout.ts +3 -3
- package/packages/public-runtime/src/chrome.ts +777 -0
- package/packages/public-runtime/src/compliance-obligations.ts +310 -0
- package/packages/public-runtime/src/csp.ts +99 -0
- package/packages/public-runtime/src/customization-preview.ts +25 -2
- package/packages/public-runtime/src/customization-runtime.ts +156 -3
- package/packages/public-runtime/src/customization-versioning.ts +10 -0
- package/packages/public-runtime/src/destination-webhook/contract.ts +353 -0
- package/packages/public-runtime/src/embed-catalog.d.mts +9 -0
- package/packages/public-runtime/src/embed-catalog.mjs +40 -0
- package/packages/public-runtime/src/evidence.ts +288 -0
- package/packages/public-runtime/src/gtm-migration/bounds.ts +138 -0
- package/packages/public-runtime/src/gtm-migration/disposition-rules.ts +361 -0
- package/packages/public-runtime/src/gtm-migration/dispositions.ts +181 -0
- package/packages/public-runtime/src/gtm-migration/draft-generator.ts +400 -0
- package/packages/public-runtime/src/gtm-migration/graph.ts +468 -0
- package/packages/public-runtime/src/gtm-migration/index.ts +95 -0
- package/packages/public-runtime/src/gtm-migration/parse.ts +410 -0
- package/packages/public-runtime/src/gtm-migration/references.ts +266 -0
- package/packages/public-runtime/src/gtm-migration/types.ts +86 -0
- package/packages/public-runtime/src/index.ts +17 -1
- package/packages/public-runtime/src/integration/assurance-decision.ts +259 -0
- package/packages/public-runtime/src/integration/assurance.ts +228 -0
- package/packages/public-runtime/src/integration/candidate-evidence.ts +377 -0
- package/packages/public-runtime/src/integration/commerce-broker.ts +497 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +337 -0
- package/packages/public-runtime/src/integration/consent-cmp-demand.ts +115 -0
- package/packages/public-runtime/src/integration/consent-config.ts +260 -0
- package/packages/public-runtime/src/integration/consent-preview.ts +125 -0
- package/packages/public-runtime/src/integration/correlation-token.ts +444 -0
- package/packages/public-runtime/src/integration/csp-compiler.ts +267 -0
- package/packages/public-runtime/src/integration/csp.ts +105 -0
- package/packages/public-runtime/src/integration/digest.ts +43 -0
- package/packages/public-runtime/src/integration/environment.ts +228 -0
- package/packages/public-runtime/src/integration/events-catalog.ts +186 -0
- package/packages/public-runtime/src/integration/events.ts +701 -0
- package/packages/public-runtime/src/integration/g2-action-boundary.ts +106 -0
- package/packages/public-runtime/src/integration/index.ts +90 -0
- package/packages/public-runtime/src/integration/interim-bridge.ts +138 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +317 -0
- package/packages/public-runtime/src/integration/lifecycle-runtime.ts +393 -0
- package/packages/public-runtime/src/integration/lifecycle.ts +94 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1-safety.d.mts +2 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1-safety.mjs +10 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +382 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +684 -0
- package/packages/public-runtime/src/integration/managed-analytics-bridge.ts +383 -0
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +14 -0
- package/packages/public-runtime/src/integration/managed-analytics.ts +277 -0
- package/packages/public-runtime/src/integration/manifest.ts +470 -0
- package/packages/public-runtime/src/integration/native-amplitude.ts +362 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +352 -0
- package/packages/public-runtime/src/integration/observability.ts +451 -0
- package/packages/public-runtime/src/integration/order-conversion.ts +0 -0
- package/packages/public-runtime/src/integration/phases.ts +182 -0
- package/packages/public-runtime/src/integration/plan.ts +112 -0
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +146 -0
- package/packages/public-runtime/src/integration/platform-adapters.ts +671 -0
- package/packages/public-runtime/src/integration/promotion-gate.ts +451 -0
- package/packages/public-runtime/src/integration/provider-checkout-extension.ts +425 -0
- package/packages/public-runtime/src/integration/receipt-measurement.ts +370 -0
- package/packages/public-runtime/src/integration/regulated-parent-cert.ts +311 -0
- package/packages/public-runtime/src/integration/render-phases.ts +267 -0
- package/packages/public-runtime/src/integration/resolve.ts +465 -0
- package/packages/public-runtime/src/integration/result.ts +239 -0
- package/packages/public-runtime/src/integration/secrets.ts +92 -0
- package/packages/public-runtime/src/integration/tamper-evidence.ts +205 -0
- package/packages/public-runtime/src/integration/tamper-recovery.ts +127 -0
- package/packages/public-runtime/src/integration/version.ts +92 -0
- package/packages/public-runtime/src/integration-capabilities.ts +520 -0
- package/packages/public-runtime/src/membership.ts +2 -2
- package/packages/public-runtime/src/raw-html-policy.ts +2 -4
- package/packages/public-runtime/src/regulated-commerce/broker/consent-dedupe.ts +144 -0
- package/packages/public-runtime/src/regulated-commerce/correlation/contract.ts +177 -0
- package/packages/public-runtime/src/regulated-commerce/diagnostics/local-dispatch.ts +24 -0
- package/packages/public-runtime/src/regulated-commerce/diagnostics/read-model.ts +69 -0
- package/packages/public-runtime/src/regulated-commerce/dispatch/core.ts +134 -0
- package/packages/public-runtime/src/regulated-commerce/events/contract.ts +113 -0
- package/packages/public-runtime/src/regulated-commerce/ledger/core.ts +81 -0
- package/packages/public-runtime/src/regulated-commerce/mappings/ga4-ads.ts +87 -0
- package/packages/public-runtime/src/regulated-commerce/provider-order/bridge.ts +52 -0
- package/packages/public-runtime/src/regulated-commerce/purchase-truth/contract.ts +252 -0
- package/packages/public-runtime/src/regulated-commerce/sources/accepted-browser-operation.ts +67 -0
- package/packages/public-runtime/src/regulated-commerce/testing/fake-destination.ts +93 -0
- package/packages/public-runtime/src/static-bundle.ts +357 -0
- package/packages/public-runtime/src/tenant-assets.ts +143 -4
- package/packages/public-runtime/src/tenant.ts +81 -17
- package/packages/public-runtime/tests/fixtures/integration/browser-environment.example.json +8 -0
- package/packages/public-runtime/tests/fixtures/integration/commerce-event.example.json +44 -0
- package/packages/public-runtime/tests/fixtures/integration/commerce-order-event.example.json +44 -0
- package/packages/public-runtime/tests/fixtures/integration/compliance-assurance-record.example.json +50 -0
- package/packages/public-runtime/tests/fixtures/integration/integration-manifest-v2.example.json +120 -0
- package/packages/public-runtime/tests/fixtures/integration/managed-analytics-v1/fake-adapter-catalog.json +35 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-action-registry.example.json +11 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/README.md +22 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/entry.js +30 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/manifest.json +75 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/public-config.json +3 -0
- package/packages/public-runtime/tests/fixtures/regulated-commerce/fake-destinations.ts +38 -0
- package/packages/public-runtime/tests/gtm-migration/fixtures/gtm-sample-container-v84.provenance.json +29 -0
- package/packages/public-runtime/tests/gtm-migration/fixtures/gtm-sample-container-v84.redacted.json +3397 -0
- package/scripts/build/copy-tenant-assets.mjs +14 -4
- package/scripts/dev/ai-edit.mjs +1 -1
- package/scripts/dev/checkout-watch.mjs +2 -2
- package/scripts/dev/file-browser.mjs +4 -6
- package/scripts/dev/file-writer.mjs +3 -3
- package/scripts/dev/git-status.mjs +2 -2
- package/scripts/dev/integration-guardrails.mjs +498 -0
- package/scripts/dev/locate-handler.mjs +3 -3
- package/scripts/dev/port-check.mjs +3 -3
- package/scripts/dev/publish.mjs +5 -9
- package/scripts/dev/safe-path.mjs +2 -2
- package/scripts/dev/shot.mjs +1 -1
- package/scripts/dev/tenant-source-root.mjs +1 -1
- package/scripts/dev/unified-diff.mjs +8 -3
- package/scripts/tot-dev.mjs +8 -11
- package/apps/storefront/src/lib/dev/vcBinding.ts +0 -80
|
@@ -0,0 +1,4625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client island for the admin Publish tab (`components/admin/AdminPublishTab.astro`).
|
|
3
|
+
*
|
|
4
|
+
* The tab's template exposes every runtime endpoint + capability flag as `data-*`
|
|
5
|
+
* attributes on `#publish`; this module reads them and wires the queue, aggregate,
|
|
6
|
+
* versions, branch-retention, and domain panels — the whole go-live surface. It runs
|
|
7
|
+
* from a PROCESSED `<script>` (Astro bundles + type-checks it), so the pure logic it
|
|
8
|
+
* used to hand-mirror inline is imported from its tested source of truth instead of
|
|
9
|
+
* duplicated (see docs/architecture/decisions/0013-client-island-pattern-and-inline-drift-guard.md).
|
|
10
|
+
*/
|
|
11
|
+
import { createKeyedCache, createCoalescer } from "@/lib/publish/panelPolling";
|
|
12
|
+
import {
|
|
13
|
+
shortSha,
|
|
14
|
+
shortDigest,
|
|
15
|
+
age,
|
|
16
|
+
linesNode,
|
|
17
|
+
chip,
|
|
18
|
+
cell,
|
|
19
|
+
subtle,
|
|
20
|
+
changeSummaryItem,
|
|
21
|
+
} from "@/lib/publish/publishFormat";
|
|
22
|
+
import { candidatePrUrl } from "@/lib/preview/candidateDashboard";
|
|
23
|
+
import { currentPreviewUrl } from "@/lib/preview/tenantPreview";
|
|
24
|
+
import { classifyResolveResponse } from "@/lib/change/views/resolve-conflict";
|
|
25
|
+
import { mergeHealthBanner } from "@/lib/publish/mergeHealthBanner";
|
|
26
|
+
import { mergeRowFindings, mergeRowKey } from "@/lib/publish/mergeRowFindings";
|
|
27
|
+
import { mergeSupportEscalation } from "@/lib/publish/mergeSupportEscalation";
|
|
28
|
+
// C5 — the doctor PUSH: on a failed accept/integrate or a refused ship, reduce the
|
|
29
|
+
// SAME /api/changes payload to the one-line diagnosis and append it where the failure
|
|
30
|
+
// already surfaced (so the developer doesn't have to go read the banner/chips).
|
|
31
|
+
import { integrateFailureDoctor, shipFailureDoctor } from "@/lib/publish/mergeDoctorOnFailure";
|
|
32
|
+
// Real-time build/check streaming: pure SSE parsing + progress reduction + copy text.
|
|
33
|
+
import {
|
|
34
|
+
BUILD_STAGES,
|
|
35
|
+
parseSseChunk,
|
|
36
|
+
reduceBuildProgress,
|
|
37
|
+
initialBuildProgress,
|
|
38
|
+
buildFailureDetail,
|
|
39
|
+
assembleBuildFailureCopy,
|
|
40
|
+
type BuildProgress,
|
|
41
|
+
type BuildResult,
|
|
42
|
+
} from "@/lib/publish/buildStream";
|
|
43
|
+
// The SHARED scrolling live-log console reused across build/integrate/rebuild/ship:
|
|
44
|
+
// pure phase→line formatting + terminal-line extraction (DOM glue verified live).
|
|
45
|
+
import {
|
|
46
|
+
logLineForPhase,
|
|
47
|
+
terminalLogLine,
|
|
48
|
+
type StreamTerminalResult,
|
|
49
|
+
} from "@/lib/publish/streamLogConsole";
|
|
50
|
+
// Fail-visible, never fail-silent: pure state-derivation for the publish lifecycle.
|
|
51
|
+
// classifyIntegrateOutcome derives an integrate attempt's HONEST result from the
|
|
52
|
+
// actual merge/build signals (killing the false "Integration did not land");
|
|
53
|
+
// classifyBuildLink withholds a rev deep-link that would 404 on an unbuilt sha.
|
|
54
|
+
import {
|
|
55
|
+
classifyIntegrateOutcome,
|
|
56
|
+
classifyBuildLink,
|
|
57
|
+
type IntegrateOutcomeView,
|
|
58
|
+
} from "@/lib/publish/lifecycleState";
|
|
59
|
+
// r01 — the SINGLE "next action" hero state machine. Reduces the same
|
|
60
|
+
// /api/changes read (queue count + merge health B1 + escalation B3 + ship plan)
|
|
61
|
+
// into ONE sentence + ONE CTA; folds the two former standalone banners in.
|
|
62
|
+
import { publishHeroState } from "@/lib/publish/publishHero";
|
|
63
|
+
import { computePublishGuard } from "@/lib/publish/publish-guard";
|
|
64
|
+
import { liveReleaseSummaryState } from "@/lib/publish/liveReleaseSummary";
|
|
65
|
+
// r05 — per-store persistence of each collapsible section's open/closed state.
|
|
66
|
+
import {
|
|
67
|
+
readPersistedOpen,
|
|
68
|
+
writePersistedOpen,
|
|
69
|
+
type CollapseSectionId,
|
|
70
|
+
} from "@/lib/publish/collapsePersistence";
|
|
71
|
+
|
|
72
|
+
export function initAdminPublishTab(): void {
|
|
73
|
+
const root: any = document.getElementById("publish");
|
|
74
|
+
if (!root) return;
|
|
75
|
+
const ds = root.dataset;
|
|
76
|
+
const canShip = ds.canShip === "true";
|
|
77
|
+
const repo = ds.repo || "";
|
|
78
|
+
const appDomain = ds.appDomain || "";
|
|
79
|
+
const publicSiteHost = ds.publicSiteHost || "";
|
|
80
|
+
|
|
81
|
+
// r08 — shareable, view-only `?__preview` capability links an owner can hand to
|
|
82
|
+
// a non-technical stakeholder (no sign-in). The token is minted server-side
|
|
83
|
+
// (see AdminPublishTab.astro) and rides in on `data-preview-share-token`; empty
|
|
84
|
+
// when signing isn't configured, in which case the copy affordances stay hidden.
|
|
85
|
+
// ONE token authorizes both the shared next-release preview and per-candidate
|
|
86
|
+
// links (it claims the tenant_id both verifiers accept).
|
|
87
|
+
const previewShareToken = ds.previewShareToken || "";
|
|
88
|
+
const canonicalDomain = ds.canonicalDomain || "";
|
|
89
|
+
// The shared preview channel = the next-release aggregate build.
|
|
90
|
+
const sharedPreviewUrl = (): string | null =>
|
|
91
|
+
previewShareToken && canonicalDomain
|
|
92
|
+
? currentPreviewUrl(window.location.origin, canonicalDomain, previewShareToken)
|
|
93
|
+
: null;
|
|
94
|
+
// A single candidate PR's preview (the friendly `/preview/pr/<N>` alias, which
|
|
95
|
+
// accepts the same tenant-scoped token for a session-less viewer).
|
|
96
|
+
const candidatePreviewShareUrl = (prNumber?: any): string | null => {
|
|
97
|
+
if (!previewShareToken || prNumber == null) return null;
|
|
98
|
+
const path = candidatePrUrl(appDomain, prNumber);
|
|
99
|
+
return path
|
|
100
|
+
? window.location.origin + path + "?__preview=" + encodeURIComponent(previewShareToken)
|
|
101
|
+
: null;
|
|
102
|
+
};
|
|
103
|
+
// Copy `url` and flash a brief "Copied" on the button's label element (icon+text
|
|
104
|
+
// stays intact — accessible, never color-only). Clipboard denial is silent (the
|
|
105
|
+
// link is selectable text on failure).
|
|
106
|
+
const copyPreviewLink = (url: string | null, labelEl?: any, restore = "Copy preview link") => {
|
|
107
|
+
if (!url || !labelEl) return;
|
|
108
|
+
navigator.clipboard.writeText(url).then(
|
|
109
|
+
() => {
|
|
110
|
+
labelEl.textContent = "Copied";
|
|
111
|
+
setTimeout(() => { labelEl.textContent = restore; }, 1600);
|
|
112
|
+
},
|
|
113
|
+
() => { /* clipboard denied — the link is selectable text */ },
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
// Build an inline `#i-copy` icon (matches the SSR sprite-`use` convention).
|
|
117
|
+
const copyIcon = (): SVGSVGElement => {
|
|
118
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
119
|
+
const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
|
|
120
|
+
use.setAttribute("href", "#i-copy");
|
|
121
|
+
svg.append(use);
|
|
122
|
+
return svg;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const statusEl = root.querySelector("[data-publish-status]");
|
|
126
|
+
const queueBody = root.querySelector("[data-publish-queue-body]");
|
|
127
|
+
const queueChip = root.querySelector("[data-publish-queue-chip]");
|
|
128
|
+
const queueCount = root.querySelector("[data-publish-queue-count]");
|
|
129
|
+
// "See all" closed/rejected history (§ see-all-rejected).
|
|
130
|
+
const closedPanel = root.querySelector("[data-publish-closed-panel]");
|
|
131
|
+
const closedBody = root.querySelector("[data-publish-closed-body]");
|
|
132
|
+
const closedChip = root.querySelector("[data-publish-closed-chip]");
|
|
133
|
+
const degradedStrip = root.querySelector("[data-publish-degraded]");
|
|
134
|
+
// r01: the SINGLE state hero. Folds in what used to be two standalone banners
|
|
135
|
+
// — the merge-doctor health summary (B1) and the platform-fault escalation
|
|
136
|
+
// (B3) — plus the queue count + ship plan, into one sentence + one CTA.
|
|
137
|
+
// Toggled by renderHero() off the same /api/changes read.
|
|
138
|
+
const heroEl = root.querySelector("[data-publish-hero]");
|
|
139
|
+
const heroIconEl = root.querySelector("[data-publish-hero-icon]");
|
|
140
|
+
const heroKickerEl = root.querySelector("[data-publish-hero-kicker]");
|
|
141
|
+
const heroHeadlineEl = root.querySelector("[data-publish-hero-headline]");
|
|
142
|
+
const heroDetailEl = root.querySelector("[data-publish-hero-detail]");
|
|
143
|
+
const heroCtaEl = root.querySelector("[data-publish-hero-cta]");
|
|
144
|
+
const heroCtaLinkEl = root.querySelector("[data-publish-hero-cta-link]");
|
|
145
|
+
|
|
146
|
+
// rux7 — in-flight UI state the auto-refresh poll must preserve across a
|
|
147
|
+
// re-render. These are the AUTHORITATIVE source of truth (never scraped
|
|
148
|
+
// back out of the DOM): an action's async closure holds a reference to the
|
|
149
|
+
// OLD button, so after a poll swaps the row the completion signal lands on a
|
|
150
|
+
// detached node — reading `disabled`/`data-building` off the fresh DOM would
|
|
151
|
+
// wedge forever. buildPreview/integrate/reject add on start and
|
|
152
|
+
// delete in `finally` (before their own loadQueue), so the next render
|
|
153
|
+
// reflects the true busy set. Open diff panes are the exception: the DOM IS
|
|
154
|
+
// their source of truth (toggleDiff owns open/close), so renderQueue detaches
|
|
155
|
+
// and re-attaches the actual pane node — see renderQueue.
|
|
156
|
+
const inflightBuilds = new Set(); // prNumber currently building
|
|
157
|
+
const busyChanges = new Set(); // changeId with an in-flight integrate/reject
|
|
158
|
+
let aggregatePublishing = false; // the aggregate Publish POST is in flight
|
|
159
|
+
let aggregateRetrying = false; // u15: the aggregate Retry-build POST is in flight
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
// A fingerprint of the current publish state (candidate head shas + live
|
|
163
|
+
// version), updated on every /api/changes read. Heavy panels key their
|
|
164
|
+
// client cache on it: unchanged fingerprint ⇒ no re-fetch. See changesSignature.
|
|
165
|
+
let currentStateKey: string | null = null;
|
|
166
|
+
|
|
167
|
+
// Visibility state, driven by an IntersectionObserver (see Wiring). Panels
|
|
168
|
+
// don't fetch until first viewed, and the queue poll only runs while the tab
|
|
169
|
+
// is active — a backgrounded/inactive Publish tab must not fire heavy reads.
|
|
170
|
+
let tabActive = false;
|
|
171
|
+
let branchesVisible = false;
|
|
172
|
+
|
|
173
|
+
const setStatus = (el?: any, message?: any, state?: any) => {
|
|
174
|
+
if (!el) return;
|
|
175
|
+
el.textContent = message || "";
|
|
176
|
+
if (state) el.dataset.state = state;
|
|
177
|
+
else delete el.dataset.state;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// r12 — a status chip must read as icon + TEXT, never colour alone. The leading
|
|
181
|
+
// glyph is keyed by tone so every chip across the console speaks one visual
|
|
182
|
+
// language: ✓ good, ⚠ needs-attention, ✕ failed, • neutral/in-progress.
|
|
183
|
+
const CHIP_GLYPH: Record<string, string> = {
|
|
184
|
+
green: "✓",
|
|
185
|
+
teal: "✓",
|
|
186
|
+
amber: "⚠",
|
|
187
|
+
red: "✕",
|
|
188
|
+
blue: "•",
|
|
189
|
+
"": "•",
|
|
190
|
+
};
|
|
191
|
+
// Repaint an EXISTING chip element (SSR-rendered, re-used across polls) with a
|
|
192
|
+
// state word + tone + a leading glyph — the in-place analogue of publishFormat's
|
|
193
|
+
// `chip()` node builder, for the chips that live in the template rather than being
|
|
194
|
+
// created per render.
|
|
195
|
+
const paintChip = (el?: any, text?: string, tone?: string, glyph?: string) => {
|
|
196
|
+
if (!el) return;
|
|
197
|
+
el.className = "chip" + (tone ? " " + tone : "");
|
|
198
|
+
el.textContent = "";
|
|
199
|
+
if (glyph) {
|
|
200
|
+
const g = document.createElement("span");
|
|
201
|
+
g.className = "chip-glyph";
|
|
202
|
+
g.setAttribute("aria-hidden", "true");
|
|
203
|
+
g.textContent = glyph;
|
|
204
|
+
el.append(g);
|
|
205
|
+
}
|
|
206
|
+
el.append(document.createTextNode(text ?? ""));
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// C5 — the doctor PUSH (admin side). On a failed accept/integrate or a refused
|
|
210
|
+
// ship, fetch the freshest /api/changes and, IF the merge-doctor has an opinion on
|
|
211
|
+
// this failure, append its one-line diagnosis to the failure's own outcome copy —
|
|
212
|
+
// so the developer sees WHY right where it failed, not only in the top-of-tab
|
|
213
|
+
// banner / row chips. Best-effort throughout: it never throws and never alters the
|
|
214
|
+
// action's outcome/status/exit path. `hintFor` runs the SAME pure analyzer the
|
|
215
|
+
// banner + row chips use (no new endpoint). `sinks` are elements to append to (the
|
|
216
|
+
// liftoff/shipoff outcome detail — stable, unlike statusEl which loadQueue rewrites).
|
|
217
|
+
const appendDoctorText = (el?: any, text?: string) => {
|
|
218
|
+
if (!el || !text) return;
|
|
219
|
+
const base = (el.textContent || "").trim();
|
|
220
|
+
el.textContent = base ? base + " " + text : text;
|
|
221
|
+
};
|
|
222
|
+
const surfaceDoctorHint = (
|
|
223
|
+
hintFor: (payload: any) => { show: boolean; text: string },
|
|
224
|
+
sinks: any[],
|
|
225
|
+
) => {
|
|
226
|
+
void (async () => {
|
|
227
|
+
try {
|
|
228
|
+
const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
|
|
229
|
+
if (!res.ok) return;
|
|
230
|
+
const hint = hintFor(await res.json());
|
|
231
|
+
if (!hint.show) return;
|
|
232
|
+
for (const el of sinks) appendDoctorText(el, hint.text);
|
|
233
|
+
} catch { /* best-effort — the failure's own copy already stands */ }
|
|
234
|
+
})();
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ── The SHARED scrolling live-log console ─────────────────────────────────
|
|
238
|
+
// ONE "CI build log" panel, reused across all four long-running flows
|
|
239
|
+
// (build/integrate/rebuild/ship). `makeLogConsole` binds it to a scrollable
|
|
240
|
+
// `<div data-*-log>`; `reset()` clears + stamps the clock start, `pushEvent`
|
|
241
|
+
// appends one timestamped human line per server `phase` event (auto-scrolled
|
|
242
|
+
// to the bottom), and `finish` appends the terminal ✓/✗ line. Pure formatting
|
|
243
|
+
// lives in the tested streamLogConsole.ts; this is the DOM glue only.
|
|
244
|
+
const makeLogConsole = (logEl?: any) => {
|
|
245
|
+
let startedAt = Date.now();
|
|
246
|
+
const append = (line?: string | null) => {
|
|
247
|
+
if (!logEl || !line) return;
|
|
248
|
+
const div = document.createElement("div");
|
|
249
|
+
div.className = "stream-log-line";
|
|
250
|
+
div.textContent = line;
|
|
251
|
+
logEl.appendChild(div);
|
|
252
|
+
logEl.scrollTop = logEl.scrollHeight; // keep the newest line in view
|
|
253
|
+
};
|
|
254
|
+
return {
|
|
255
|
+
reset() {
|
|
256
|
+
startedAt = Date.now();
|
|
257
|
+
if (logEl) logEl.textContent = "";
|
|
258
|
+
},
|
|
259
|
+
pushEvent(ev: { event: string; data: string }) {
|
|
260
|
+
append(logLineForPhase(ev, Date.now() - startedAt));
|
|
261
|
+
},
|
|
262
|
+
finish(result: StreamTerminalResult) {
|
|
263
|
+
append(terminalLogLine(result, Date.now() - startedAt).line);
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
type LogConsole = ReturnType<typeof makeLogConsole>;
|
|
268
|
+
|
|
269
|
+
// Consume a streamed (SSE) or buffered (JSON) response uniformly. In stream
|
|
270
|
+
// mode each `phase` event feeds the shared log console + the optional `onPhase`
|
|
271
|
+
// (a flow's own stage-rail), and the terminal `result` event's payload is
|
|
272
|
+
// returned; in JSON mode (a buffering proxy, or a non-streaming server) the whole
|
|
273
|
+
// body is read once and treated as that terminal payload — so every flow completes
|
|
274
|
+
// honestly either way. Never throws for a well-formed response; the caller keeps
|
|
275
|
+
// its own try/catch for network faults. `res.ok`/`res.status` stay authoritative
|
|
276
|
+
// for the caller (a stream is always HTTP 200; the route front-loads its non-2xx
|
|
277
|
+
// auth/validation refusals as plain JSON BEFORE any stream).
|
|
278
|
+
const streamWithLog = async (
|
|
279
|
+
res: any,
|
|
280
|
+
logc?: LogConsole | null,
|
|
281
|
+
onPhase?: (ev: { event: string; data: string }) => void,
|
|
282
|
+
): Promise<{ data: any }> => {
|
|
283
|
+
const ct = res.headers.get("content-type") || "";
|
|
284
|
+
if (res.body && ct.indexOf("text/event-stream") !== -1) {
|
|
285
|
+
const reader = res.body.getReader();
|
|
286
|
+
const decoder = new TextDecoder();
|
|
287
|
+
let buf = "";
|
|
288
|
+
let terminal: any = undefined;
|
|
289
|
+
for (;;) {
|
|
290
|
+
const { value, done } = await reader.read();
|
|
291
|
+
if (done) break;
|
|
292
|
+
buf += decoder.decode(value, { stream: true });
|
|
293
|
+
const parsed = parseSseChunk(buf);
|
|
294
|
+
buf = parsed.rest;
|
|
295
|
+
for (const ev of parsed.events) {
|
|
296
|
+
if (ev.event === "phase") {
|
|
297
|
+
if (logc) logc.pushEvent(ev);
|
|
298
|
+
if (onPhase) onPhase(ev);
|
|
299
|
+
} else if (ev.event === "result") {
|
|
300
|
+
try { terminal = JSON.parse(ev.data); } catch { terminal = {}; }
|
|
301
|
+
if (logc) logc.finish(terminal);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { data: terminal ?? {} };
|
|
306
|
+
}
|
|
307
|
+
const data = await res.json().catch(() => ({}));
|
|
308
|
+
if (logc) logc.finish(data);
|
|
309
|
+
return { data };
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
const STATUS_CHIP: Record<string, string> = {
|
|
314
|
+
queued: "",
|
|
315
|
+
reconciling: "blue",
|
|
316
|
+
ready: "green",
|
|
317
|
+
blocked: "red",
|
|
318
|
+
};
|
|
319
|
+
// r04 — humanize the raw candidate-status word for the queue chip. The raw
|
|
320
|
+
// status still drives all logic; only the displayed label is merchant-facing
|
|
321
|
+
// (dev words like "reconciling" never reach the operator).
|
|
322
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
323
|
+
queued: "Waiting",
|
|
324
|
+
reconciling: "Checking",
|
|
325
|
+
ready: "Ready",
|
|
326
|
+
blocked: "Needs attention",
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// ---- ?pr=N deep link ---------------------------------------------------
|
|
330
|
+
// A deep link like /admin#publish?pr=42 asks us to surface a specific PR's
|
|
331
|
+
// row once the queue has loaded. The param rides in the hash fragment
|
|
332
|
+
// (after the tab anchor) because that's how the admin shell routes tabs;
|
|
333
|
+
// fall back to a real query string too. Resolve exactly once — so the
|
|
334
|
+
// scroll/highlight never re-fires on the reloads that follow every ship
|
|
335
|
+
// action — and until it resolves, keep an honest "not in the queue yet"
|
|
336
|
+
// callout up rather than silently doing nothing.
|
|
337
|
+
|
|
338
|
+
const readRequestedPr = () => {
|
|
339
|
+
const hash = window.location.hash || "";
|
|
340
|
+
const q = hash.indexOf("?");
|
|
341
|
+
let raw = new URLSearchParams(q >= 0 ? hash.slice(q + 1) : "").get("pr");
|
|
342
|
+
if (raw == null) raw = new URLSearchParams(window.location.search).get("pr");
|
|
343
|
+
if (raw == null) return null;
|
|
344
|
+
const n = Number.parseInt(raw, 10);
|
|
345
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const requestedPr = readRequestedPr();
|
|
349
|
+
let prDeeplinkResolved = false;
|
|
350
|
+
|
|
351
|
+
const applyPrDeeplink = () => {
|
|
352
|
+
if (requestedPr == null || prDeeplinkResolved) return;
|
|
353
|
+
const callout = root.querySelector("[data-publish-pr-callout]");
|
|
354
|
+
const row = queueBody.querySelector('tr[data-pr-number="' + requestedPr + '"]');
|
|
355
|
+
if (!row) {
|
|
356
|
+
if (callout) {
|
|
357
|
+
callout.hidden = false;
|
|
358
|
+
callout.textContent = "PR #" + requestedPr + " isn't in the queue yet.";
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
prDeeplinkResolved = true;
|
|
363
|
+
if (callout) {
|
|
364
|
+
callout.hidden = true;
|
|
365
|
+
callout.textContent = "";
|
|
366
|
+
}
|
|
367
|
+
row.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
368
|
+
row.classList.remove("publish-row-highlight");
|
|
369
|
+
void row.offsetWidth; // restart the flash animation if re-applied
|
|
370
|
+
row.classList.add("publish-row-highlight");
|
|
371
|
+
window.setTimeout(() => row.classList.remove("publish-row-highlight"), 2600);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// ---- Open change queue -------------------------------------------------
|
|
375
|
+
|
|
376
|
+
// r01: the SINGLE state hero. Computed from the SAME /api/changes payload the
|
|
377
|
+
// queue + aggregate already have — no duplicate read. It folds in the merge-
|
|
378
|
+
// doctor health summary (B1, mergeHealthBanner) and the platform-fault
|
|
379
|
+
// escalation (B3, mergeSupportEscalation), plus the queue count and ship plan,
|
|
380
|
+
// and expresses whichever is the most urgent true state as one sentence + one
|
|
381
|
+
// CTA (publishHeroState orders the precedence). There is no separate B1/B3
|
|
382
|
+
// banner any more — a conflict/platform blocker turns THIS element red/urgent.
|
|
383
|
+
const HERO_ICON: Record<string, string> = {
|
|
384
|
+
blocker: "#i-alert",
|
|
385
|
+
warn: "#i-alert",
|
|
386
|
+
attention: "#i-box",
|
|
387
|
+
ready: "#i-export",
|
|
388
|
+
calm: "#i-check",
|
|
389
|
+
};
|
|
390
|
+
const scrollToPanel = (sel: string) => {
|
|
391
|
+
const el = document.getElementById(sel);
|
|
392
|
+
if (el) el.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
393
|
+
};
|
|
394
|
+
const renderHero = (data?: any) => {
|
|
395
|
+
if (!heroEl) return;
|
|
396
|
+
const payload = data || {};
|
|
397
|
+
const changes = Array.isArray(payload.changes) ? payload.changes : [];
|
|
398
|
+
const agg = payload.aggregate || null;
|
|
399
|
+
const stagedSha = agg && agg.aggregateSha;
|
|
400
|
+
const liveId = payload.current && payload.current.versionId;
|
|
401
|
+
const view = publishHeroState({
|
|
402
|
+
changeCount: changes.length,
|
|
403
|
+
health: mergeHealthBanner(payload),
|
|
404
|
+
escalation: mergeSupportEscalation(payload),
|
|
405
|
+
plan: payload.plan || null,
|
|
406
|
+
aheadCount: (agg && agg.includedPrs ? agg.includedPrs.length : 0),
|
|
407
|
+
matchesLive: Boolean(stagedSha && liveId && stagedSha === liveId),
|
|
408
|
+
canShip,
|
|
409
|
+
publicSiteHost,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
(heroEl as any).dataset.tone = view.tone;
|
|
413
|
+
if (heroIconEl) {
|
|
414
|
+
const use = heroIconEl.querySelector("use");
|
|
415
|
+
if (use) use.setAttribute("href", HERO_ICON[view.tone] || "#i-check");
|
|
416
|
+
}
|
|
417
|
+
if (heroKickerEl) (heroKickerEl as any).hidden = !view.urgent;
|
|
418
|
+
if (heroHeadlineEl) heroHeadlineEl.textContent = view.headline;
|
|
419
|
+
if (heroDetailEl) heroDetailEl.textContent = view.detail;
|
|
420
|
+
|
|
421
|
+
// ONE CTA: a mailto link for the "report to support" escalation, a button
|
|
422
|
+
// for every in-page action, nothing for the calm caught-up state.
|
|
423
|
+
const cta = view.cta;
|
|
424
|
+
if (cta.kind === "report-support") {
|
|
425
|
+
if (heroCtaEl) (heroCtaEl as any).hidden = true;
|
|
426
|
+
if (heroCtaLinkEl) {
|
|
427
|
+
heroCtaLinkEl.textContent = cta.label;
|
|
428
|
+
if (cta.href) heroCtaLinkEl.setAttribute("href", cta.href);
|
|
429
|
+
(heroCtaLinkEl as any).hidden = false;
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
if (heroCtaLinkEl) (heroCtaLinkEl as any).hidden = true;
|
|
433
|
+
if (heroCtaEl) {
|
|
434
|
+
if (cta.kind === "none") {
|
|
435
|
+
(heroCtaEl as any).hidden = true;
|
|
436
|
+
(heroCtaEl as any).onclick = null;
|
|
437
|
+
} else {
|
|
438
|
+
heroCtaEl.textContent = cta.label;
|
|
439
|
+
(heroCtaEl as any).hidden = false;
|
|
440
|
+
(heroCtaEl as any).onclick = () => {
|
|
441
|
+
if (cta.kind === "publish") publishAggregate(heroCtaEl);
|
|
442
|
+
else if (cta.kind === "review-queue") scrollToPanel("publish-queue");
|
|
443
|
+
else if (cta.kind === "goto-release") scrollToPanel("publish-next-release");
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
(heroEl as any).hidden = false;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// B2: per-row merge-doctor finding. Reads THIS row's dominant finding out of
|
|
452
|
+
// the map computed once per render (mergeRowFindings over the same payload the
|
|
453
|
+
// queue already holds — no extra fetch, no per-row analysis) and renders its
|
|
454
|
+
// ownership tag as a chip. When the finding carries an action it LIGHTS UP the
|
|
455
|
+
// row's EXISTING mapped button (resolve → the conflict-recovery button, build/
|
|
456
|
+
// retry → the build button) via a "primed" class + the remedy as its tooltip —
|
|
457
|
+
// it does NOT re-declare the API call. A finding with no inline button to prime
|
|
458
|
+
// (e.g. a promotable-yet-stalled platform retry) gets a fallback CTA that still
|
|
459
|
+
// calls the SAME existing action function. An informational finding (action:
|
|
460
|
+
// null) shows the chip only; a healthy row has no finding, so no chip.
|
|
461
|
+
const rowFindingFor = (findings: any, change: any) => {
|
|
462
|
+
const byChange =
|
|
463
|
+
change.changeId != null ? findings.get(mergeRowKey(change.changeId, null)) : undefined;
|
|
464
|
+
return (
|
|
465
|
+
byChange ||
|
|
466
|
+
(change.prNumber != null ? findings.get(mergeRowKey(null, change.prNumber)) : undefined)
|
|
467
|
+
);
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const applyRowFinding = (actionsCell: any, change: any, findings: any, buttons: any = {}) => {
|
|
471
|
+
const finding = rowFindingFor(findings, change);
|
|
472
|
+
if (!finding) return;
|
|
473
|
+
const tag = chip(finding.ownershipLabel, finding.tone);
|
|
474
|
+
tag.classList.add("publish-finding-chip");
|
|
475
|
+
tag.title = finding.detail;
|
|
476
|
+
actionsCell.prepend(tag);
|
|
477
|
+
if (!finding.cta) return; // informational-only diagnosis — chip, no button
|
|
478
|
+
const mapped = finding.cta.kind === "resolve" ? buttons.resolve : buttons.build;
|
|
479
|
+
if (mapped) {
|
|
480
|
+
mapped.classList.add("publish-cta-primed");
|
|
481
|
+
mapped.title = finding.remedy;
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// No inline button maps to this finding — add a CTA that reuses the SAME
|
|
485
|
+
// existing action function (never a re-declared fetch). Only when the viewer
|
|
486
|
+
// can actually ship; otherwise the chip alone diagnoses it (the row already
|
|
487
|
+
// carries a sign-in hint), never a dead disabled duplicate.
|
|
488
|
+
if (!canShip) return;
|
|
489
|
+
const cta = document.createElement("button");
|
|
490
|
+
cta.type = "button";
|
|
491
|
+
cta.className = "btn publish-cta-primed";
|
|
492
|
+
cta.textContent = finding.cta.label;
|
|
493
|
+
cta.title = finding.remedy;
|
|
494
|
+
cta.addEventListener("click", () =>
|
|
495
|
+
finding.cta.kind === "resolve"
|
|
496
|
+
? openConflictRecovery(change, cta)
|
|
497
|
+
: buildPreview(change, cta),
|
|
498
|
+
);
|
|
499
|
+
actionsCell.append(cta);
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const renderQueue = (payload?: any) => {
|
|
503
|
+
const changes = Array.isArray(payload.changes) ? payload.changes : [];
|
|
504
|
+
const rowFindings = mergeRowFindings(payload);
|
|
505
|
+
// Ledger truth (fb-1787916141762-owjzis): which changes are ALREADY integrated
|
|
506
|
+
// into the staged aggregate, read straight from aggregate.includedPrs (the
|
|
507
|
+
// ledger), DECOUPLED from any per-row mergeable/rebuild signal. A change in
|
|
508
|
+
// this set has merged; its row must never offer conflict-recovery (which would
|
|
509
|
+
// try to refresh an already-merged candidate and error "No open candidate").
|
|
510
|
+
const stagedChangeIds = new Set<string>();
|
|
511
|
+
const stagedPrNumbers = new Set<number>();
|
|
512
|
+
for (const p of (payload.aggregate && payload.aggregate.includedPrs) || []) {
|
|
513
|
+
if (p && typeof p.changeId === "string") stagedChangeIds.add(p.changeId);
|
|
514
|
+
if (p && typeof p.prNumber === "number") stagedPrNumbers.add(p.prNumber);
|
|
515
|
+
}
|
|
516
|
+
const isIntegrated = (change: any) =>
|
|
517
|
+
stagedChangeIds.has(change.changeId) ||
|
|
518
|
+
(change.prNumber != null && stagedPrNumbers.has(change.prNumber));
|
|
519
|
+
// A change already staged into the next release has, by that act, been
|
|
520
|
+
// APPROVED — this panel asks "What needs your approval?", so it must not list
|
|
521
|
+
// it. It reappears here only if it's pulled back out of the next release (the
|
|
522
|
+
// "Next release" panel below owns that Undo/revert). So the review queue shows
|
|
523
|
+
// only changes NOT yet in the aggregate; the integrated ones live in "Next
|
|
524
|
+
// release". (Full `changes` is still used for the top "Open changes" metric,
|
|
525
|
+
// which counts everything waiting for review OR go-live.)
|
|
526
|
+
const pending = changes.filter((change: any) => !isIntegrated(change));
|
|
527
|
+
// rux1: surface a forge-listing failure as a visible strip — never let a
|
|
528
|
+
// degraded (short) list read as a genuine empty queue.
|
|
529
|
+
if (degradedStrip) degradedStrip.hidden = payload.degraded !== true;
|
|
530
|
+
queueBody.textContent = "";
|
|
531
|
+
if (queueCount) queueCount.textContent = String(changes.length);
|
|
532
|
+
if (queueChip) {
|
|
533
|
+
queueChip.textContent = pending.length === 1 ? "1 open" : pending.length + " open";
|
|
534
|
+
queueChip.className = "chip" + (pending.length ? " blue" : "");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!pending.length) {
|
|
538
|
+
const tr = document.createElement("tr");
|
|
539
|
+
// Distinguish "nothing here at all" from "everything open is already added" —
|
|
540
|
+
// the latter is the common state once a merchant has staged their changes, and
|
|
541
|
+
// "No changes waiting" would read as if their work vanished.
|
|
542
|
+
const td = cell(
|
|
543
|
+
subtle(
|
|
544
|
+
changes.length
|
|
545
|
+
? "Nothing waiting for your approval — every open change is already in your next " +
|
|
546
|
+
"release (see below). Pull one back out there if you want to review it again."
|
|
547
|
+
: "No changes waiting. Make an edit in your store and submit it — it'll show up here to " +
|
|
548
|
+
"review and add to your next release.",
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
td.colSpan = 6;
|
|
552
|
+
tr.append(td);
|
|
553
|
+
queueBody.append(tr);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
for (const change of pending) {
|
|
558
|
+
const tr = document.createElement("tr");
|
|
559
|
+
if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
|
|
560
|
+
|
|
561
|
+
// Change id + branch / PR + preview link.
|
|
562
|
+
const idCell = document.createElement("td");
|
|
563
|
+
const idStrong = document.createElement("strong");
|
|
564
|
+
idStrong.textContent = change.changeId || "—";
|
|
565
|
+
idCell.append(idStrong);
|
|
566
|
+
const meta = [];
|
|
567
|
+
if (change.branch) meta.push(change.branch);
|
|
568
|
+
if (change.prNumber != null) meta.push("PR #" + change.prNumber);
|
|
569
|
+
if (meta.length) idCell.append(subtle(meta.join(" · ")));
|
|
570
|
+
// Owner-facing "who did this" — accept takes priority over submit/build (a
|
|
571
|
+
// candidate that's been accepted-to-preview is more meaningfully
|
|
572
|
+
// "acted on" than merely submitted or built). Reject never shows here: a rejected
|
|
573
|
+
// candidate is closed and has already left the open queue.
|
|
574
|
+
const actorRecord = change.actors && (change.actors.accept || change.actors.submit || change.actors.build);
|
|
575
|
+
if (actorRecord) {
|
|
576
|
+
const label = change.actors.accept
|
|
577
|
+
? "Added by "
|
|
578
|
+
: change.actors.submit
|
|
579
|
+
? "Submitted by "
|
|
580
|
+
: "Built by ";
|
|
581
|
+
idCell.append(subtle(label + (actorRecord.name || actorRecord.email)));
|
|
582
|
+
}
|
|
583
|
+
// Prefer the synthesized friendly per-PR route (handles every state);
|
|
584
|
+
// fall back to any server-provided previewUrl for change-first rows
|
|
585
|
+
// that have no PR number yet.
|
|
586
|
+
const previewUrl = candidatePrUrl(appDomain, change.prNumber) || change.previewUrl;
|
|
587
|
+
if (previewUrl) {
|
|
588
|
+
const preview = document.createElement("a");
|
|
589
|
+
preview.href = previewUrl;
|
|
590
|
+
preview.target = "_blank";
|
|
591
|
+
preview.rel = "noopener";
|
|
592
|
+
preview.textContent = "Open preview";
|
|
593
|
+
const wrap = document.createElement("span");
|
|
594
|
+
wrap.className = "subtle";
|
|
595
|
+
wrap.append(preview);
|
|
596
|
+
idCell.append(wrap);
|
|
597
|
+
}
|
|
598
|
+
// r08: copy a shareable, view-only preview link for THIS candidate to hand
|
|
599
|
+
// to a non-technical stakeholder (no sign-in). Shown only when a share token
|
|
600
|
+
// was minted and the row has a PR number to point at.
|
|
601
|
+
const candidateShareUrl = candidatePreviewShareUrl(change.prNumber);
|
|
602
|
+
if (candidateShareUrl) {
|
|
603
|
+
const copyBtn = document.createElement("button");
|
|
604
|
+
copyBtn.type = "button";
|
|
605
|
+
copyBtn.className = "btn btn-compact publish-copy-preview";
|
|
606
|
+
const lbl = document.createElement("span");
|
|
607
|
+
lbl.textContent = "Copy preview link";
|
|
608
|
+
copyBtn.append(copyIcon(), lbl);
|
|
609
|
+
copyBtn.addEventListener("click", () => copyPreviewLink(candidateShareUrl, lbl));
|
|
610
|
+
const copyWrap = document.createElement("span");
|
|
611
|
+
copyWrap.className = "subtle";
|
|
612
|
+
copyWrap.append(copyBtn);
|
|
613
|
+
idCell.append(copyWrap);
|
|
614
|
+
}
|
|
615
|
+
tr.append(idCell);
|
|
616
|
+
|
|
617
|
+
// Reconcile status. u13 DISPLAY HONESTY: when the server could not confirm
|
|
618
|
+
// this row's live state against the forge (liveStateConfirmed === false — the
|
|
619
|
+
// forge listing failed), its stored label may be stale (e.g. a merged PR still
|
|
620
|
+
// reads "ready"). Render "State unknown" instead of asserting the stale label
|
|
621
|
+
// as current fact; keep the last-known label as a subtle, clearly-hedged note.
|
|
622
|
+
let statusCell;
|
|
623
|
+
if (change.liveStateConfirmed === false) {
|
|
624
|
+
statusCell = cell(chip("State unknown", "amber", CHIP_GLYPH.amber));
|
|
625
|
+
statusCell.append(
|
|
626
|
+
subtle("Status couldn't be confirmed — last known: " +
|
|
627
|
+
(STATUS_LABEL[change.status] || change.status || "unknown")),
|
|
628
|
+
);
|
|
629
|
+
} else {
|
|
630
|
+
const tone = STATUS_CHIP[change.status] ?? "amber";
|
|
631
|
+
statusCell = cell(
|
|
632
|
+
chip(STATUS_LABEL[change.status] || change.status || "unknown", tone, CHIP_GLYPH[tone]),
|
|
633
|
+
);
|
|
634
|
+
if (change.lastError) statusCell.append(subtle(change.lastError));
|
|
635
|
+
}
|
|
636
|
+
tr.append(statusCell);
|
|
637
|
+
|
|
638
|
+
// Evidence / compliance verdict.
|
|
639
|
+
const checks = (change.evidence && change.evidence.checks) || [];
|
|
640
|
+
const failing = checks.filter((c: any) => c.status === "fail");
|
|
641
|
+
const promotable = Boolean(change.evidence && change.evidence.promotable);
|
|
642
|
+
const checksCell = cell(
|
|
643
|
+
promotable
|
|
644
|
+
? chip("Checks passed", "green", CHIP_GLYPH.green)
|
|
645
|
+
: chip("Checks blocked", "amber", CHIP_GLYPH.amber),
|
|
646
|
+
);
|
|
647
|
+
if (failing.length) {
|
|
648
|
+
checksCell.append(subtle("Failing: " + failing.map((c: any) => c.label || c.id).join(", ")));
|
|
649
|
+
} else if (!checks.length) {
|
|
650
|
+
checksCell.append(subtle("No checks reported yet"));
|
|
651
|
+
}
|
|
652
|
+
tr.append(checksCell);
|
|
653
|
+
|
|
654
|
+
// Head sha + age.
|
|
655
|
+
const headCell = cell(shortSha(change.headSha));
|
|
656
|
+
headCell.classList.add("publish-mono");
|
|
657
|
+
tr.append(headCell);
|
|
658
|
+
tr.append(cell(age(change.updatedAt)));
|
|
659
|
+
|
|
660
|
+
// Actions.
|
|
661
|
+
const actions = document.createElement("td");
|
|
662
|
+
actions.className = "action publish-actions";
|
|
663
|
+
|
|
664
|
+
// A synthetic not-built forge PR can be reviewed at the lifecycle level
|
|
665
|
+
// without paying to materialize it. Candidate-shaped branches carry a
|
|
666
|
+
// derived changeId, so the operator may Reject immediately; Build preview
|
|
667
|
+
// remains available when visual review is useful.
|
|
668
|
+
if (change.built === false || change.status === "not-built") {
|
|
669
|
+
let notBuiltBuild: any;
|
|
670
|
+
if (canShip) {
|
|
671
|
+
const build = document.createElement("button");
|
|
672
|
+
build.type = "button";
|
|
673
|
+
build.className = "btn primary";
|
|
674
|
+
build.title = "Materialize this PR's hosted preview on demand (does not go live)";
|
|
675
|
+
// rux7: if a build for this PR is mid-flight, a poll re-render must
|
|
676
|
+
// reflect the spinner/disabled state (not a fresh clickable button
|
|
677
|
+
// that would let the operator double-submit). The in-flight closure
|
|
678
|
+
// re-renders on completion, clearing this.
|
|
679
|
+
if (change.prNumber != null && inflightBuilds.has(change.prNumber)) {
|
|
680
|
+
build.textContent = "Building…";
|
|
681
|
+
build.disabled = true;
|
|
682
|
+
build.dataset.building = "true";
|
|
683
|
+
} else {
|
|
684
|
+
build.textContent = "Build preview";
|
|
685
|
+
}
|
|
686
|
+
build.addEventListener("click", () => buildPreview(change, build));
|
|
687
|
+
actions.append(build);
|
|
688
|
+
notBuiltBuild = build;
|
|
689
|
+
if (change.changeId) {
|
|
690
|
+
const reject = document.createElement("button");
|
|
691
|
+
reject.type = "button";
|
|
692
|
+
reject.className = "btn";
|
|
693
|
+
reject.textContent = "Reject";
|
|
694
|
+
reject.disabled = busyChanges.has(change.changeId);
|
|
695
|
+
reject.title = "Close this change without building or publishing it";
|
|
696
|
+
reject.addEventListener("click", () => rejectChange(change, reject));
|
|
697
|
+
actions.append(reject);
|
|
698
|
+
}
|
|
699
|
+
} else {
|
|
700
|
+
actions.append(subtle("Owner/admin sign-in required to build or reject"));
|
|
701
|
+
}
|
|
702
|
+
applyRowFinding(actions, change, rowFindings, { build: notBuiltBuild });
|
|
703
|
+
tr.append(actions);
|
|
704
|
+
queueBody.append(tr);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// rux7: while an integrate/reject for this change is in flight,
|
|
709
|
+
// keep its mutating actions disabled across a poll re-render so the
|
|
710
|
+
// operator can't double-submit against a freshly-minted button.
|
|
711
|
+
const changeBusy = busyChanges.has(change.changeId);
|
|
712
|
+
|
|
713
|
+
// A candidate that is still waiting for its reconciliation/evidence — or
|
|
714
|
+
// whose build FAILED (blocked) — has never become eligible for Accept. A
|
|
715
|
+
// disabled "Accept to preview" is a dead CTA; the safe recovery already
|
|
716
|
+
// exists at POST /api/preview/build, so make it the primary action: it
|
|
717
|
+
// re-materializes this exact PR head and re-runs its checks (does not merge
|
|
718
|
+
// or publish). `blocked` is included so a build-failed candidate keeps a
|
|
719
|
+
// live "Rebuild" nudge and never sits on a dead end (fail-visible, rule 3).
|
|
720
|
+
const buildFailed = change.status === "blocked";
|
|
721
|
+
const needsChecks = !promotable &&
|
|
722
|
+
(change.status === "queued" || change.status === "reconciling" || buildFailed);
|
|
723
|
+
|
|
724
|
+
// rux6 Review: expand the row into a single "review moment" pane —
|
|
725
|
+
// preview iframe + evidence chips + diff + Accept/Reject — so a reviewer
|
|
726
|
+
// never leaves the queue. Supersedes the standalone Diff button (the dg7
|
|
727
|
+
// diff now lives inside the pane, reusing renderDiff). The row's own
|
|
728
|
+
// Accept/Reject buttons below are unchanged.
|
|
729
|
+
const reviewBtn = document.createElement("button");
|
|
730
|
+
reviewBtn.type = "button";
|
|
731
|
+
reviewBtn.className = "btn";
|
|
732
|
+
reviewBtn.textContent = "Review";
|
|
733
|
+
reviewBtn.setAttribute("aria-expanded", "false");
|
|
734
|
+
reviewBtn.title = "Open one pane to preview, diff, and act on this change";
|
|
735
|
+
reviewBtn.addEventListener("click", () => toggleReview(change, reviewBtn, tr));
|
|
736
|
+
actions.append(reviewBtn);
|
|
737
|
+
let runChecksBuild: any;
|
|
738
|
+
if (needsChecks) {
|
|
739
|
+
const build = document.createElement("button");
|
|
740
|
+
build.type = "button";
|
|
741
|
+
build.className = "btn primary";
|
|
742
|
+
build.title = buildFailed
|
|
743
|
+
? "This change's preview build failed — rebuild it (re-materializes this PR head and re-runs checks; does not merge or go live)"
|
|
744
|
+
: "Re-run this PR's preview build and checks (does not merge or go live)";
|
|
745
|
+
if (change.prNumber != null && inflightBuilds.has(change.prNumber)) {
|
|
746
|
+
build.textContent = buildFailed ? "Rebuilding…" : "Running checks…";
|
|
747
|
+
build.disabled = true;
|
|
748
|
+
build.dataset.building = "true";
|
|
749
|
+
} else {
|
|
750
|
+
build.textContent = buildFailed ? "Rebuild" : "Run checks";
|
|
751
|
+
build.disabled = !canShip || changeBusy;
|
|
752
|
+
}
|
|
753
|
+
build.addEventListener("click", () => buildPreview(change, build));
|
|
754
|
+
actions.append(build);
|
|
755
|
+
runChecksBuild = build;
|
|
756
|
+
}
|
|
757
|
+
// b11 SPLIT: the candidate-queue primary action is now ACCEPT TO PREVIEW
|
|
758
|
+
// (b08 integrate = advance the shared preview aggregate), NOT go-live.
|
|
759
|
+
// Integrate flips NO live channel, so it has NO paywall (entitled) gate —
|
|
760
|
+
// it holds only the ship-on-behalf capability floor. Going live is the
|
|
761
|
+
// aggregate card's Publish action, once the aggregate is green.
|
|
762
|
+
const accept = document.createElement("button");
|
|
763
|
+
accept.type = "button";
|
|
764
|
+
accept.className = "btn primary";
|
|
765
|
+
// A ready candidate with passed evidence but `mergeable:false` is a
|
|
766
|
+
// forge-settled conflict with the current preview tip. Make the existing
|
|
767
|
+
// automatic rebuild/re-integrate recovery reachable from the queue rather
|
|
768
|
+
// than presenting a generic, disabled "not ready" action. Other false
|
|
769
|
+
// mergeability states remain disabled: a rebuild cannot fix an incomplete
|
|
770
|
+
// reconcile or failed evidence.
|
|
771
|
+
// A change already in the staged aggregate has integrated — its stored
|
|
772
|
+
// `mergeable` can read false once the forge settles the just-merged PR
|
|
773
|
+
// against the tip, but that is NOT a conflict to recover. Read integration
|
|
774
|
+
// truth from the ledger and never route it to recovery.
|
|
775
|
+
const alreadyIntegrated = isIntegrated(change);
|
|
776
|
+
const needsPreviewUpdate =
|
|
777
|
+
!alreadyIntegrated && change.status === "ready" && promotable && change.mergeable === false;
|
|
778
|
+
// Only ONE primary action at a time: while checks are pending, "Run checks"
|
|
779
|
+
// above is the whole story — a second disabled "Run checks first" button
|
|
780
|
+
// next to it said nothing new and just looked broken.
|
|
781
|
+
if (!needsChecks) {
|
|
782
|
+
if (alreadyIntegrated) {
|
|
783
|
+
accept.textContent = "Already added";
|
|
784
|
+
accept.disabled = true;
|
|
785
|
+
accept.title = "This change is already in your next release.";
|
|
786
|
+
} else {
|
|
787
|
+
accept.textContent = needsPreviewUpdate ? "Update change" : "Add to release";
|
|
788
|
+
accept.disabled = !canShip || (!needsPreviewUpdate && !change.mergeable) || changeBusy;
|
|
789
|
+
accept.title = !canShip
|
|
790
|
+
? "Requires a ship-on-behalf grant on your account — ask the store owner"
|
|
791
|
+
: needsPreviewUpdate
|
|
792
|
+
? "Preview changed while you worked — update this change, then add it"
|
|
793
|
+
: !change.mergeable
|
|
794
|
+
? "This change isn't ready to add yet"
|
|
795
|
+
: "Add this change to your next release (doesn't go live yet)";
|
|
796
|
+
accept.addEventListener("click", () =>
|
|
797
|
+
needsPreviewUpdate ? openConflictRecovery(change, accept) : integrateCandidate(change, accept),
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
actions.append(accept);
|
|
801
|
+
}
|
|
802
|
+
const reject = document.createElement("button");
|
|
803
|
+
reject.type = "button";
|
|
804
|
+
reject.className = "btn";
|
|
805
|
+
reject.textContent = "Reject";
|
|
806
|
+
reject.disabled = !canShip || changeBusy;
|
|
807
|
+
reject.title = canShip
|
|
808
|
+
? "Close this change without publishing"
|
|
809
|
+
: "Requires a ship-on-behalf grant on your account — ask the store owner";
|
|
810
|
+
reject.addEventListener("click", () => rejectChange(change, reject));
|
|
811
|
+
actions.append(reject);
|
|
812
|
+
// B2: light up this row's mapped button — the build button when the row's
|
|
813
|
+
// finding is a build/retry, the conflict-recovery "Update change" accept
|
|
814
|
+
// button when it's a resolve.
|
|
815
|
+
applyRowFinding(actions, change, rowFindings, {
|
|
816
|
+
build: runChecksBuild,
|
|
817
|
+
resolve: needsPreviewUpdate ? accept : undefined,
|
|
818
|
+
});
|
|
819
|
+
tr.append(actions);
|
|
820
|
+
|
|
821
|
+
queueBody.append(tr);
|
|
822
|
+
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
// "See all" closed/rejected history. A rejected change leaves the open queue
|
|
827
|
+
// (server filters terminal status out of `changes`); the server now also returns
|
|
828
|
+
// the closed ones as `closedChanges` so a reject is recoverable-from-history
|
|
829
|
+
// rather than a black hole. Read-only: id + who-closed-it + a still-reachable
|
|
830
|
+
// preview link. The whole panel is HIDDEN when there are none (no empty chrome).
|
|
831
|
+
const renderClosed = (payload?: any) => {
|
|
832
|
+
if (!closedBody || !closedPanel) return;
|
|
833
|
+
const closed = Array.isArray(payload && payload.closedChanges) ? payload.closedChanges : [];
|
|
834
|
+
closedBody.textContent = "";
|
|
835
|
+
if (closedChip) closedChip.textContent = String(closed.length);
|
|
836
|
+
closedPanel.hidden = closed.length === 0;
|
|
837
|
+
if (!closed.length) return;
|
|
838
|
+
|
|
839
|
+
for (const change of closed) {
|
|
840
|
+
const tr = document.createElement("tr");
|
|
841
|
+
if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
|
|
842
|
+
|
|
843
|
+
// Change id + branch/PR + who closed it + a still-reachable preview link.
|
|
844
|
+
const idCell = document.createElement("td");
|
|
845
|
+
const idStrong = document.createElement("strong");
|
|
846
|
+
idStrong.textContent = change.changeId || "—";
|
|
847
|
+
idCell.append(idStrong);
|
|
848
|
+
const meta = [];
|
|
849
|
+
if (change.branch) meta.push(change.branch);
|
|
850
|
+
if (change.prNumber != null) meta.push("PR #" + change.prNumber);
|
|
851
|
+
if (meta.length) idCell.append(subtle(meta.join(" · ")));
|
|
852
|
+
// "Closed by" — the reject actor when known (fall back to the last actor
|
|
853
|
+
// who touched it, so the row is never anonymous when we have any attribution).
|
|
854
|
+
const closer = change.actors && (change.actors.reject || change.actors.accept || change.actors.build);
|
|
855
|
+
if (closer) idCell.append(subtle("Closed by " + (closer.name || closer.email)));
|
|
856
|
+
const previewUrl = candidatePrUrl(appDomain, change.prNumber) || change.previewUrl;
|
|
857
|
+
if (previewUrl) {
|
|
858
|
+
const preview = document.createElement("a");
|
|
859
|
+
preview.href = previewUrl;
|
|
860
|
+
preview.target = "_blank";
|
|
861
|
+
preview.rel = "noopener";
|
|
862
|
+
preview.textContent = "Open preview";
|
|
863
|
+
const wrap = document.createElement("span");
|
|
864
|
+
wrap.className = "subtle";
|
|
865
|
+
wrap.append(preview);
|
|
866
|
+
idCell.append(wrap);
|
|
867
|
+
}
|
|
868
|
+
tr.append(idCell);
|
|
869
|
+
|
|
870
|
+
// Closed (relative time of the last projection update = when it was closed).
|
|
871
|
+
tr.append(cell(age(change.updatedAt)));
|
|
872
|
+
|
|
873
|
+
// Head sha.
|
|
874
|
+
const headCell = cell(shortSha(change.headSha));
|
|
875
|
+
headCell.classList.add("publish-mono");
|
|
876
|
+
tr.append(headCell);
|
|
877
|
+
|
|
878
|
+
closedBody.append(tr);
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const renderCurrent = (current?: any, shippedBy?: any) => {
|
|
883
|
+
// r02: the "Live version" metric is a HUMAN label — "v42" linked to the live
|
|
884
|
+
// store — not a raw SHA. The SHA is demoted to a "Technical details"
|
|
885
|
+
// disclosure. The counter (current.versionNumber) is derived from the
|
|
886
|
+
// promotion history server-side; a version with no number (unpromoted seed)
|
|
887
|
+
// falls back to its short SHA rather than fabricating one.
|
|
888
|
+
const linkEl = root.querySelector("[data-publish-live-version-link]");
|
|
889
|
+
const noneEl = root.querySelector("[data-publish-live-version-none]");
|
|
890
|
+
const techEl = root.querySelector("[data-publish-live-tech]");
|
|
891
|
+
const shaEl = root.querySelector("[data-publish-live-sha]");
|
|
892
|
+
const sourceEl = root.querySelector("[data-publish-live-source]");
|
|
893
|
+
const atEl = root.querySelector("[data-publish-live-at]");
|
|
894
|
+
const shippedByEl = root.querySelector("[data-publish-live-shipped-by]");
|
|
895
|
+
if (current && current.versionId) {
|
|
896
|
+
const label =
|
|
897
|
+
current.versionNumber != null ? "v" + current.versionNumber : shortSha(current.versionId);
|
|
898
|
+
if (linkEl) {
|
|
899
|
+
linkEl.textContent = label;
|
|
900
|
+
linkEl.setAttribute("href", ds.liveHref || "/");
|
|
901
|
+
(linkEl as any).hidden = false;
|
|
902
|
+
}
|
|
903
|
+
if (noneEl) (noneEl as any).hidden = true;
|
|
904
|
+
if (shaEl) shaEl.textContent = shortSha(current.versionId);
|
|
905
|
+
if (techEl) (techEl as any).hidden = false;
|
|
906
|
+
if (sourceEl) {
|
|
907
|
+
sourceEl.textContent =
|
|
908
|
+
(current.source === "commit" ? "Published from a change" : "Published version") +
|
|
909
|
+
(current.artifactCount != null ? " · " + current.artifactCount + " files" : "");
|
|
910
|
+
}
|
|
911
|
+
if (atEl) atEl.textContent = current.createdAt ? age(current.createdAt) : "—";
|
|
912
|
+
// Owner-facing "who did this" — see changeActorAttribution.ts. Absent for
|
|
913
|
+
// any version published before this attribution existed, which is honest,
|
|
914
|
+
// not a bug.
|
|
915
|
+
if (shippedByEl) {
|
|
916
|
+
shippedByEl.textContent = shippedBy ? "Shipped by " + (shippedBy.name || shippedBy.email) : "";
|
|
917
|
+
}
|
|
918
|
+
} else {
|
|
919
|
+
if (linkEl) (linkEl as any).hidden = true;
|
|
920
|
+
if (noneEl) {
|
|
921
|
+
noneEl.textContent = "Not live yet";
|
|
922
|
+
(noneEl as any).hidden = false;
|
|
923
|
+
}
|
|
924
|
+
if (techEl) (techEl as any).hidden = true;
|
|
925
|
+
if (shaEl) shaEl.textContent = "—";
|
|
926
|
+
if (sourceEl) sourceEl.textContent = "Nothing has been published for this store";
|
|
927
|
+
if (atEl) atEl.textContent = "—";
|
|
928
|
+
if (shippedByEl) shippedByEl.textContent = "";
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
// ---- rux7: auto-refresh (poll GET /api/changes) ------------------------
|
|
933
|
+
// Keep the queue + aggregate live without a manual reload: poll on an
|
|
934
|
+
// interval that BACKS OFF while nothing changes (grow the delay up to a cap)
|
|
935
|
+
// and SNAPS BACK to the base the moment something does. Pause entirely while
|
|
936
|
+
// the tab is hidden (document.hidden) — a backgrounded admin tab must not
|
|
937
|
+
// hammer the endpoint — and refresh promptly on return. A poll re-render
|
|
938
|
+
// reuses renderQueue/renderAggregate, which already preserve open diff panes
|
|
939
|
+
// and in-flight actions (inflightBuilds / busyChanges / aggregatePublishing),
|
|
940
|
+
// and applyPrDeeplink stays a one-time effect (prDeeplinkResolved), so the
|
|
941
|
+
// ?pr scroll never re-fires on a tick.
|
|
942
|
+
const POLL_BASE_MS = 8000;
|
|
943
|
+
const POLL_MAX_MS = 60000;
|
|
944
|
+
const POLL_GROWTH = 1.6;
|
|
945
|
+
let pollDelay = POLL_BASE_MS;
|
|
946
|
+
let pollTimer: any = null;
|
|
947
|
+
let pollBusy = false;
|
|
948
|
+
let lastChangesSignature: string | null = null;
|
|
949
|
+
|
|
950
|
+
// A compact projection of everything the queue/aggregate render VISIBLY off,
|
|
951
|
+
// so a no-op poll (identical data) neither re-renders nor interrupts a text
|
|
952
|
+
// selection — it just backs off. Relative ages are intentionally excluded;
|
|
953
|
+
// they drift at most one backoff interval and refresh on any real change.
|
|
954
|
+
const changesSignature = (data?: any) => {
|
|
955
|
+
const cur = data.current || {};
|
|
956
|
+
const agg = data.aggregate || {};
|
|
957
|
+
const run = agg.activeRun || agg.lastRun || {};
|
|
958
|
+
const plan = data.plan || {};
|
|
959
|
+
const rows = (Array.isArray(data.changes) ? data.changes : []).map((c: any) => [
|
|
960
|
+
c.changeId,
|
|
961
|
+
c.prNumber,
|
|
962
|
+
c.status,
|
|
963
|
+
c.headSha,
|
|
964
|
+
c.mergeable,
|
|
965
|
+
c.built,
|
|
966
|
+
c.lastError || null,
|
|
967
|
+
c.title || null,
|
|
968
|
+
c.description || null,
|
|
969
|
+
c.intent || null,
|
|
970
|
+
c.verification || null,
|
|
971
|
+
c.riskRollback || null,
|
|
972
|
+
!!(c.evidence && c.evidence.promotable),
|
|
973
|
+
c.actors ? Object.keys(c.actors).sort().join(",") : "",
|
|
974
|
+
]);
|
|
975
|
+
const summary = data.releaseSummary || (agg.releaseSummary) || null;
|
|
976
|
+
return JSON.stringify({
|
|
977
|
+
degraded: data.degraded === true,
|
|
978
|
+
current: [cur.versionId || null, cur.createdAt || null, cur.artifactCount ?? null],
|
|
979
|
+
shippedBy: (data.shippedBy && data.shippedBy.email) || null,
|
|
980
|
+
aggregate: [
|
|
981
|
+
agg.queueState || null,
|
|
982
|
+
agg.aggregateSha || null,
|
|
983
|
+
run.state || null,
|
|
984
|
+
run.finishedAt || null,
|
|
985
|
+
// include per-PR identity + title + stats so enrichment (a title/scope
|
|
986
|
+
// arriving) re-renders
|
|
987
|
+
(agg.includedPrs || []).map((p: any) => [
|
|
988
|
+
p.prNumber ?? p.changeId ?? null, p.headSha || null, p.title || null,
|
|
989
|
+
p.fileCount ?? null, p.linesAdded ?? null, p.linesRemoved ?? null,
|
|
990
|
+
Array.isArray(p.diff) ? p.diff.length : 0,
|
|
991
|
+
]),
|
|
992
|
+
],
|
|
993
|
+
summary: summary ? [summary.lead || summary.text || null, (summary.highlights || []).length] : null,
|
|
994
|
+
scope: data.stagedScope
|
|
995
|
+
? [data.stagedScope.files, data.stagedScope.added, data.stagedScope.removed]
|
|
996
|
+
: null,
|
|
997
|
+
plan: [plan.ok === true, plan.pinnedSha || null, plan.artifactDigest || null, plan.alreadyShipped === true],
|
|
998
|
+
rows,
|
|
999
|
+
});
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
const clearPoll = () => {
|
|
1003
|
+
if (pollTimer) {
|
|
1004
|
+
clearTimeout(pollTimer);
|
|
1005
|
+
pollTimer = null;
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
const schedulePoll = () => {
|
|
1010
|
+
clearPoll();
|
|
1011
|
+
// Paused while the browser tab is hidden (visibilitychange resumes) OR
|
|
1012
|
+
// while the Publish admin-tab is not the active one (the IntersectionObserver
|
|
1013
|
+
// resumes it on activation) — an inactive tab must not poll heavy reads.
|
|
1014
|
+
if (document.hidden || !tabActive) return;
|
|
1015
|
+
pollTimer = setTimeout(runPoll, pollDelay);
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
// One poll tick: fetch, and only re-render when the projection actually
|
|
1019
|
+
// changed. Returns an outcome the caller uses to size the next interval.
|
|
1020
|
+
const pollChanges = async () => {
|
|
1021
|
+
let res;
|
|
1022
|
+
try {
|
|
1023
|
+
res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
|
|
1024
|
+
} catch {
|
|
1025
|
+
return "error"; // network blip — keep the last good view, just back off
|
|
1026
|
+
}
|
|
1027
|
+
if (res.status === 401 || res.status === 403) return "auth"; // session gone
|
|
1028
|
+
const data = await res.json().catch(() => null);
|
|
1029
|
+
if (!res.ok || !data) return "error";
|
|
1030
|
+
const sig = changesSignature(data);
|
|
1031
|
+
currentStateKey = sig; // heavy panels cache against this fingerprint
|
|
1032
|
+
if (sig === lastChangesSignature) return "unchanged";
|
|
1033
|
+
lastChangesSignature = sig;
|
|
1034
|
+
renderCurrent(data.current, data.shippedBy);
|
|
1035
|
+
renderQueue(data);
|
|
1036
|
+
renderClosed(data);
|
|
1037
|
+
applyPrDeeplink();
|
|
1038
|
+
renderAggregate(data);
|
|
1039
|
+
renderHero(data);
|
|
1040
|
+
return "changed";
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
const runPoll = async () => {
|
|
1044
|
+
pollTimer = null;
|
|
1045
|
+
if (document.hidden || !tabActive || pollBusy) {
|
|
1046
|
+
schedulePoll();
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
pollBusy = true;
|
|
1050
|
+
let outcome;
|
|
1051
|
+
try {
|
|
1052
|
+
outcome = await pollChanges();
|
|
1053
|
+
} finally {
|
|
1054
|
+
pollBusy = false;
|
|
1055
|
+
}
|
|
1056
|
+
if (outcome === "auth") {
|
|
1057
|
+
clearPoll(); // stop polling — a sign-in is required; a manual refresh retries
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
// Branch retention rides the SAME cadence as the queue so its rows reflect
|
|
1061
|
+
// live forge state, not a frozen page-load snapshot (b1: a stale "PR #11
|
|
1062
|
+
// open / Keep" 30+ min after the PR closed) — but ONLY while the branches
|
|
1063
|
+
// panel is actually on screen (lazy: don't fetch a panel the operator
|
|
1064
|
+
// isn't looking at). loadBranches coalesces overlapping ticks and owns its
|
|
1065
|
+
// own no-change guard and unreachable fallback.
|
|
1066
|
+
if (branchesVisible) loadBranches();
|
|
1067
|
+
pollDelay =
|
|
1068
|
+
outcome === "changed"
|
|
1069
|
+
? POLL_BASE_MS
|
|
1070
|
+
: Math.min(POLL_MAX_MS, Math.round(pollDelay * POLL_GROWTH));
|
|
1071
|
+
schedulePoll();
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
const loadQueue = async () => {
|
|
1075
|
+
try {
|
|
1076
|
+
const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
|
|
1077
|
+
if (res.status === 401 || res.status === 403) {
|
|
1078
|
+
if (queueChip) queueChip.textContent = "Sign-in required";
|
|
1079
|
+
if (degradedStrip) degradedStrip.hidden = true;
|
|
1080
|
+
if (heroEl) (heroEl as any).hidden = true;
|
|
1081
|
+
queueBody.textContent = "";
|
|
1082
|
+
const tr = document.createElement("tr");
|
|
1083
|
+
const td = cell(subtle("Sign in as a store owner or admin to view the publish queue."));
|
|
1084
|
+
td.colSpan = 6;
|
|
1085
|
+
tr.append(td);
|
|
1086
|
+
queueBody.append(tr);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
const data = await res.json();
|
|
1090
|
+
if (!res.ok) {
|
|
1091
|
+
setStatus(statusEl, data.error || "Could not load the change queue (" + res.status + ")", "err");
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
renderCurrent(data.current, data.shippedBy);
|
|
1095
|
+
renderQueue(data);
|
|
1096
|
+
renderClosed(data);
|
|
1097
|
+
applyPrDeeplink();
|
|
1098
|
+
renderAggregate(data);
|
|
1099
|
+
renderHero(data);
|
|
1100
|
+
// rux7: this is fresh authoritative data — resync the poll clock so the
|
|
1101
|
+
// next tick dedupes against it, and reset the cadence to responsive
|
|
1102
|
+
// (a manual refresh / a just-completed action likely means more coming).
|
|
1103
|
+
lastChangesSignature = changesSignature(data);
|
|
1104
|
+
currentStateKey = lastChangesSignature; // heavy panels cache against this
|
|
1105
|
+
pollDelay = POLL_BASE_MS;
|
|
1106
|
+
schedulePoll();
|
|
1107
|
+
} catch {
|
|
1108
|
+
setStatus(statusEl, "Could not load the change queue — network error", "err");
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
// ---- b11: the aggregate card + exact-digest Publish ---------------------
|
|
1113
|
+
// Reads the aggregate/runs/plan projection GET /api/changes now returns
|
|
1114
|
+
// (AggregateCardView + AggregateShipPlanView, built by aggregateView.ts):
|
|
1115
|
+
// - the aggregate's queueState + build sha + reviewed digest,
|
|
1116
|
+
// - the COMBINED EVIDENCE = the last integration run's honest state +
|
|
1117
|
+
// statusMessage (never re-derived),
|
|
1118
|
+
// - the included-PR batch,
|
|
1119
|
+
// - the b09 ship PLAN (pinned SHA/digest/PRs/rollback/paywall + run link),
|
|
1120
|
+
// shown inline so the confirm never has to POST to preview it.
|
|
1121
|
+
|
|
1122
|
+
const aggPanel = root.querySelector("[data-publish-aggregate]");
|
|
1123
|
+
const AGG_STATE_CHIP: Record<string, string> = {
|
|
1124
|
+
empty: "",
|
|
1125
|
+
queued: "amber",
|
|
1126
|
+
integrating: "blue",
|
|
1127
|
+
green: "green",
|
|
1128
|
+
red: "red",
|
|
1129
|
+
shipped: "green",
|
|
1130
|
+
};
|
|
1131
|
+
// r04 — the Next-release chip in merchant words; the raw queueState still drives
|
|
1132
|
+
// logic, only the label is humanized (no "integrating"/"green"/"red" jargon).
|
|
1133
|
+
const AGG_STATE_LABEL: Record<string, string> = {
|
|
1134
|
+
empty: "No changes yet",
|
|
1135
|
+
queued: "Waiting",
|
|
1136
|
+
integrating: "Building…",
|
|
1137
|
+
green: "Ready to publish",
|
|
1138
|
+
red: "Needs attention",
|
|
1139
|
+
shipped: "Published",
|
|
1140
|
+
};
|
|
1141
|
+
// The plan we last rendered — the exact-digest guard the Publish POST sends
|
|
1142
|
+
// back so a stale plan (queue moved / artifact changed) is refused, never
|
|
1143
|
+
// shipped blind.
|
|
1144
|
+
let currentPlan: any = null;
|
|
1145
|
+
|
|
1146
|
+
const runLink = (revHref?: any, label?: any) => {
|
|
1147
|
+
if (!revHref) return null;
|
|
1148
|
+
const a = document.createElement("a");
|
|
1149
|
+
a.href = revHref;
|
|
1150
|
+
a.target = "_blank";
|
|
1151
|
+
a.rel = "noopener";
|
|
1152
|
+
a.textContent = label || "Open build";
|
|
1153
|
+
return a;
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
const renderAggregate = (data?: any) => {
|
|
1157
|
+
if (!aggPanel) return;
|
|
1158
|
+
const agg = data.aggregate || null;
|
|
1159
|
+
const plan = data.plan || null;
|
|
1160
|
+
currentPlan = plan && plan.ok ? plan : null;
|
|
1161
|
+
|
|
1162
|
+
// AI-assembled release summary — plain-language "what am I publishing?".
|
|
1163
|
+
// OPTIONAL server enrichment: data.releaseSummary { lead|text, highlights?:
|
|
1164
|
+
// [{title, detail}] }. Absent → the whole card stays hidden and the panel
|
|
1165
|
+
// degrades to the technical chain below (graceful fallback, never a stub).
|
|
1166
|
+
const summaryWrap = aggPanel.querySelector("[data-aggregate-summary]");
|
|
1167
|
+
if (summaryWrap) {
|
|
1168
|
+
const summary = data.releaseSummary || (agg && agg.releaseSummary) || null;
|
|
1169
|
+
const lead = summary && (summary.lead || summary.text);
|
|
1170
|
+
if (!lead) {
|
|
1171
|
+
summaryWrap.hidden = true;
|
|
1172
|
+
} else {
|
|
1173
|
+
summaryWrap.hidden = false;
|
|
1174
|
+
const leadEl = summaryWrap.querySelector("[data-aggregate-summary-lead]");
|
|
1175
|
+
const pointsEl = summaryWrap.querySelector("[data-aggregate-summary-points]");
|
|
1176
|
+
if (leadEl) leadEl.textContent = lead;
|
|
1177
|
+
if (pointsEl) {
|
|
1178
|
+
pointsEl.textContent = "";
|
|
1179
|
+
const points = Array.isArray(summary.highlights) ? summary.highlights : [];
|
|
1180
|
+
for (const pt of points) {
|
|
1181
|
+
const li = document.createElement("li");
|
|
1182
|
+
const tick = document.createElement("span");
|
|
1183
|
+
tick.className = "agg-tick";
|
|
1184
|
+
tick.setAttribute("aria-hidden", "true");
|
|
1185
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
1186
|
+
const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
|
|
1187
|
+
use.setAttribute("href", "#i-check");
|
|
1188
|
+
svg.append(use);
|
|
1189
|
+
tick.append(svg);
|
|
1190
|
+
const text = document.createElement("span");
|
|
1191
|
+
if (pt && typeof pt === "object" && pt.title) {
|
|
1192
|
+
const b = document.createElement("b");
|
|
1193
|
+
b.textContent = pt.title;
|
|
1194
|
+
text.append(b);
|
|
1195
|
+
if (pt.detail) text.append(document.createTextNode(" — " + pt.detail));
|
|
1196
|
+
} else {
|
|
1197
|
+
text.textContent = typeof pt === "string" ? pt : (pt && pt.detail) || "";
|
|
1198
|
+
}
|
|
1199
|
+
li.append(tick, text);
|
|
1200
|
+
pointsEl.append(li);
|
|
1201
|
+
}
|
|
1202
|
+
pointsEl.hidden = points.length === 0;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const chipEl = aggPanel.querySelector("[data-aggregate-chip]");
|
|
1208
|
+
const shaEl = aggPanel.querySelector("[data-aggregate-sha]");
|
|
1209
|
+
const runLinkEl = aggPanel.querySelector("[data-aggregate-run-link]");
|
|
1210
|
+
const evEl = aggPanel.querySelector("[data-aggregate-evidence]");
|
|
1211
|
+
const evDetailEl = aggPanel.querySelector("[data-aggregate-evidence-detail]");
|
|
1212
|
+
const digestEl = aggPanel.querySelector("[data-aggregate-digest]");
|
|
1213
|
+
const prsWrap = aggPanel.querySelector("[data-aggregate-prs-wrap]");
|
|
1214
|
+
const prsTitle = aggPanel.querySelector("[data-aggregate-prs-title]");
|
|
1215
|
+
const deltaNote = aggPanel.querySelector("[data-aggregate-delta-note]");
|
|
1216
|
+
const scopeEl = aggPanel.querySelector("[data-aggregate-scope]");
|
|
1217
|
+
const prsList = aggPanel.querySelector("[data-aggregate-prs]");
|
|
1218
|
+
const publishBtn = aggPanel.querySelector("[data-aggregate-publish]");
|
|
1219
|
+
const publishDetail = aggPanel.querySelector("[data-aggregate-publish-detail]");
|
|
1220
|
+
const retryRow = aggPanel.querySelector("[data-aggregate-retry-row]");
|
|
1221
|
+
const retryBtn = aggPanel.querySelector("[data-aggregate-retry-build]");
|
|
1222
|
+
|
|
1223
|
+
const state = (agg && agg.queueState) || "empty";
|
|
1224
|
+
// u15 — only offer a retry while the aggregate is actually red; a poll
|
|
1225
|
+
// refresh landing mid-retry must not re-enable a button whose click is
|
|
1226
|
+
// already in flight (rux7's own guard, mirrored here).
|
|
1227
|
+
if (retryRow) retryRow.hidden = state !== "red";
|
|
1228
|
+
if (retryBtn) retryBtn.disabled = aggregateRetrying;
|
|
1229
|
+
if (chipEl) {
|
|
1230
|
+
const tone = AGG_STATE_CHIP[state] || "";
|
|
1231
|
+
paintChip(chipEl, AGG_STATE_LABEL[state] || state, tone, CHIP_GLYPH[tone]);
|
|
1232
|
+
}
|
|
1233
|
+
// r11 — the human release name (aggregate.name), shown beside "Next release".
|
|
1234
|
+
// Display-only: absent/null → hidden, and the section header reads as before.
|
|
1235
|
+
const nameEl = aggPanel.querySelector("[data-aggregate-name]");
|
|
1236
|
+
if (nameEl) {
|
|
1237
|
+
const releaseName = agg && agg.name;
|
|
1238
|
+
if (releaseName) {
|
|
1239
|
+
nameEl.textContent = releaseName;
|
|
1240
|
+
(nameEl as any).hidden = false;
|
|
1241
|
+
} else {
|
|
1242
|
+
nameEl.textContent = "";
|
|
1243
|
+
(nameEl as any).hidden = true;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
if (shaEl) shaEl.textContent = agg && agg.aggregateSha ? shortSha(agg.aggregateSha) : "—";
|
|
1247
|
+
// Build-link honesty — the card's own sha is always the last-GREEN
|
|
1248
|
+
// materialized sha (a red cycle leaves it untouched), so it's renderable
|
|
1249
|
+
// whenever present: opening it reaches the last-good preview even while the
|
|
1250
|
+
// current cycle is red/in-flight. When the aggregate has NEVER gone green
|
|
1251
|
+
// there is no renderable sha — surface "build failed — rebuild" or "no build
|
|
1252
|
+
// yet", never a link that 404s (rules 1 & 3).
|
|
1253
|
+
const cardSha = agg && agg.aggregateSha;
|
|
1254
|
+
const buildLink = classifyBuildLink({
|
|
1255
|
+
sha: cardSha,
|
|
1256
|
+
built: Boolean(cardSha),
|
|
1257
|
+
building: Boolean(agg && (agg.activeRun || agg.queueState === "integrating")),
|
|
1258
|
+
attempted: Boolean(agg && (agg.lastRun || agg.queueState === "red")),
|
|
1259
|
+
});
|
|
1260
|
+
if (runLinkEl) {
|
|
1261
|
+
runLinkEl.textContent = "";
|
|
1262
|
+
if (buildLink.available && agg && agg.revHref) {
|
|
1263
|
+
runLinkEl.append(runLink(agg.revHref, buildLink.label)!);
|
|
1264
|
+
} else if (buildLink.kind === "failed") {
|
|
1265
|
+
runLinkEl.append(document.createTextNode(buildLink.note + " "));
|
|
1266
|
+
if (canShip && buildLink.action) {
|
|
1267
|
+
const rebuild = document.createElement("button");
|
|
1268
|
+
rebuild.type = "button";
|
|
1269
|
+
rebuild.className = "btn btn-compact publish-cta-primed";
|
|
1270
|
+
rebuild.textContent = buildLink.action.label;
|
|
1271
|
+
rebuild.disabled = aggregateRetrying;
|
|
1272
|
+
rebuild.addEventListener("click", () => retryBuild(rebuild));
|
|
1273
|
+
runLinkEl.append(rebuild);
|
|
1274
|
+
}
|
|
1275
|
+
} else {
|
|
1276
|
+
runLinkEl.append(document.createTextNode(buildLink.note));
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// "Open preview" next to the Next-release header — a one-click hop to the
|
|
1280
|
+
// current preview build. Same honesty: shown only when a genuinely-built
|
|
1281
|
+
// revision exists, so it can never lead to a 404.
|
|
1282
|
+
const openPreviewEl = aggPanel.querySelector("[data-aggregate-open-preview]");
|
|
1283
|
+
if (openPreviewEl) {
|
|
1284
|
+
if (buildLink.available && agg && agg.revHref) {
|
|
1285
|
+
openPreviewEl.setAttribute("href", agg.revHref);
|
|
1286
|
+
(openPreviewEl as any).hidden = false;
|
|
1287
|
+
} else {
|
|
1288
|
+
(openPreviewEl as any).hidden = true;
|
|
1289
|
+
}
|
|
1290
|
+
// The Next-release header is now a <summary> (collapsible panel). This link
|
|
1291
|
+
// lives inside it, so a click would ALSO toggle the panel — stop that so
|
|
1292
|
+
// "Open preview" only opens the preview. Idempotent-guarded so re-renders
|
|
1293
|
+
// don't stack listeners.
|
|
1294
|
+
if (!(openPreviewEl as any).__noToggleBound) {
|
|
1295
|
+
openPreviewEl.addEventListener("click", (e: Event) => e.stopPropagation());
|
|
1296
|
+
(openPreviewEl as any).__noToggleBound = true;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Combined evidence = the last (or in-flight) integration run's honest state.
|
|
1301
|
+
const lastRun = agg && (agg.activeRun || agg.lastRun);
|
|
1302
|
+
if (evEl) {
|
|
1303
|
+
if (!lastRun) {
|
|
1304
|
+
evEl.textContent = "No runs yet";
|
|
1305
|
+
evEl.dataset.state = "none";
|
|
1306
|
+
} else if (lastRun.state === "passed") {
|
|
1307
|
+
evEl.textContent = "Passed";
|
|
1308
|
+
evEl.dataset.state = "passed";
|
|
1309
|
+
} else if (lastRun.state === "failed") {
|
|
1310
|
+
evEl.textContent = "Failed";
|
|
1311
|
+
evEl.dataset.state = "failed";
|
|
1312
|
+
} else {
|
|
1313
|
+
evEl.textContent = "Integrating…";
|
|
1314
|
+
evEl.dataset.state = "running";
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
if (evDetailEl) {
|
|
1318
|
+
evDetailEl.textContent = "";
|
|
1319
|
+
if (lastRun) {
|
|
1320
|
+
const parts = [];
|
|
1321
|
+
if (lastRun.statusMessage) parts.push(lastRun.statusMessage);
|
|
1322
|
+
else if (lastRun.state === "passed") parts.push("Combined evidence passed.");
|
|
1323
|
+
else if (lastRun.state === "failed") parts.push("Combined evidence did not pass.");
|
|
1324
|
+
if (lastRun.finishedAt) parts.push(age(lastRun.finishedAt));
|
|
1325
|
+
evDetailEl.append(document.createTextNode(parts.join(" · ")));
|
|
1326
|
+
// A run's sha is renderable IFF that run PASSED — a failed run's sha never
|
|
1327
|
+
// materialized, so linking it 404s (the exact Open-Build trap). Link only a
|
|
1328
|
+
// passed run; a failed run shows its rebuild affordance via runLinkEl above.
|
|
1329
|
+
const runBuild = classifyBuildLink({
|
|
1330
|
+
sha: lastRun.aggregateSha,
|
|
1331
|
+
built: lastRun.state === "passed",
|
|
1332
|
+
building: lastRun.state === "running" || lastRun.state === "pending",
|
|
1333
|
+
attempted: true,
|
|
1334
|
+
});
|
|
1335
|
+
if (runBuild.available && lastRun.revHref) {
|
|
1336
|
+
evDetailEl.append(document.createTextNode(" · "));
|
|
1337
|
+
evDetailEl.append(runLink(lastRun.revHref, "Run build")!);
|
|
1338
|
+
}
|
|
1339
|
+
} else {
|
|
1340
|
+
evDetailEl.append(document.createTextNode("The last integration run's verdict."));
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Reviewed digest: only known once the plan pins it (b09). Never fabricated.
|
|
1345
|
+
if (digestEl) {
|
|
1346
|
+
digestEl.textContent = currentPlan ? shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) : "—";
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// u14 — the STAGED delta vs live. The included-PR batch is the "missing
|
|
1350
|
+
// middle": what's in the staged preview but NOT yet on the live site.
|
|
1351
|
+
// `includedPrs` never clears on ship (a shipped aggregate still lists its
|
|
1352
|
+
// PRs), so "ahead of live" MUST key on the shas — staged aggregateSha vs the
|
|
1353
|
+
// live version id — not on the list length alone.
|
|
1354
|
+
const prs = (agg && agg.includedPrs) || [];
|
|
1355
|
+
const stagedSha = agg && agg.aggregateSha;
|
|
1356
|
+
const liveId = data.current && data.current.versionId;
|
|
1357
|
+
const matchesLive = Boolean(stagedSha && liveId && stagedSha === liveId);
|
|
1358
|
+
const aheadCount = prs.length;
|
|
1359
|
+
|
|
1360
|
+
if (prsWrap) prsWrap.hidden = prs.length === 0;
|
|
1361
|
+
if (prsTitle) {
|
|
1362
|
+
prsTitle.textContent =
|
|
1363
|
+
aheadCount && !matchesLive
|
|
1364
|
+
? "Staged changes — " + aheadCount + " ahead of live"
|
|
1365
|
+
: "Included changes";
|
|
1366
|
+
}
|
|
1367
|
+
if (deltaNote) {
|
|
1368
|
+
let msg = "";
|
|
1369
|
+
if (matchesLive) {
|
|
1370
|
+
msg = "This staged build is exactly what's live — nothing is waiting to publish.";
|
|
1371
|
+
} else if (!liveId) {
|
|
1372
|
+
msg = "Nothing is live yet — publishing makes this staged build your first version.";
|
|
1373
|
+
} else if (aheadCount) {
|
|
1374
|
+
msg =
|
|
1375
|
+
"In the staged preview (" + shortSha(stagedSha) + ") but not on your live site (" +
|
|
1376
|
+
shortSha(liveId) + ") until you publish.";
|
|
1377
|
+
}
|
|
1378
|
+
deltaNote.textContent = msg;
|
|
1379
|
+
deltaNote.hidden = !msg;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Staged → live delta strip: the staged aggregate sha ahead of the live
|
|
1383
|
+
// pointer. Shown only when there IS a delta (ahead of a real live version).
|
|
1384
|
+
const deltaStrip = aggPanel.querySelector("[data-aggregate-delta-strip]");
|
|
1385
|
+
if (deltaStrip) {
|
|
1386
|
+
const showStrip = Boolean(aheadCount && !matchesLive && stagedSha && liveId);
|
|
1387
|
+
deltaStrip.hidden = !showStrip;
|
|
1388
|
+
if (showStrip) {
|
|
1389
|
+
const stagedEl = deltaStrip.querySelector("[data-agg-delta-staged]");
|
|
1390
|
+
const liveEl = deltaStrip.querySelector("[data-agg-delta-live]");
|
|
1391
|
+
const countEl = deltaStrip.querySelector("[data-agg-delta-count]");
|
|
1392
|
+
if (stagedEl) stagedEl.textContent = shortSha(stagedSha);
|
|
1393
|
+
if (liveEl) liveEl.textContent = shortSha(liveId);
|
|
1394
|
+
if (countEl) countEl.textContent = aheadCount + " ahead";
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// Top-level scope-of-change: "Scope · N files · +A −R" across the staged batch.
|
|
1399
|
+
// A risk-at-a-glance measure; hidden when no staged change carries size stats.
|
|
1400
|
+
if (scopeEl) {
|
|
1401
|
+
const scope = data.stagedScope;
|
|
1402
|
+
const hasScope = Boolean(scope && (scope.files > 0 || scope.added > 0 || scope.removed > 0));
|
|
1403
|
+
scopeEl.textContent = "";
|
|
1404
|
+
scopeEl.hidden = !hasScope;
|
|
1405
|
+
if (hasScope) {
|
|
1406
|
+
const label = document.createElement("span");
|
|
1407
|
+
label.className = "agg-scope-label";
|
|
1408
|
+
label.textContent = "Scope";
|
|
1409
|
+
scopeEl.append(label);
|
|
1410
|
+
const files = document.createElement("span");
|
|
1411
|
+
files.textContent = scope.files + (scope.files === 1 ? " file" : " files");
|
|
1412
|
+
scopeEl.append(files);
|
|
1413
|
+
if (scope.added || scope.removed) {
|
|
1414
|
+
scopeEl.append(linesNode(scope.added || 0, scope.removed || 0));
|
|
1415
|
+
}
|
|
1416
|
+
if (scope.partial) {
|
|
1417
|
+
const partial = document.createElement("span");
|
|
1418
|
+
partial.className = "agg-scope-partial";
|
|
1419
|
+
partial.textContent = "partial";
|
|
1420
|
+
partial.title = "Some staged changes had no size stats — the totals cover the rest.";
|
|
1421
|
+
scopeEl.append(partial);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Merges what used to be two separate "Included changes" lists (this staged-
|
|
1427
|
+
// delta table + the Publish plan's own duplicate <details> list) into ONE:
|
|
1428
|
+
// `agg.includedPrs` is already server-enriched with the same title/description/
|
|
1429
|
+
// fileCount/lines/diff the plan's list used (see enrichIncludedPrs in
|
|
1430
|
+
// api/changes/index.ts) — no separate cross-reference against `plan.includedPrs`
|
|
1431
|
+
// is needed here, the row already carries everything.
|
|
1432
|
+
if (prsList) {
|
|
1433
|
+
prsList.textContent = "";
|
|
1434
|
+
for (const pr of prs) {
|
|
1435
|
+
const tr = document.createElement("tr");
|
|
1436
|
+
tr.className = "agg-included-row";
|
|
1437
|
+
|
|
1438
|
+
// Change: PR # + title (+ description underneath, + diff disclosure if any).
|
|
1439
|
+
const changeCell = document.createElement("td");
|
|
1440
|
+
const prEl = document.createElement("div");
|
|
1441
|
+
prEl.className = "agg-included-pr";
|
|
1442
|
+
prEl.textContent = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
|
|
1443
|
+
changeCell.append(prEl);
|
|
1444
|
+
if (pr.title) {
|
|
1445
|
+
const t = document.createElement("div");
|
|
1446
|
+
t.className = "agg-included-title";
|
|
1447
|
+
t.textContent = pr.title;
|
|
1448
|
+
changeCell.append(t);
|
|
1449
|
+
}
|
|
1450
|
+
const description = pr.description || pr.summary;
|
|
1451
|
+
if (description) {
|
|
1452
|
+
const desc = document.createElement("p");
|
|
1453
|
+
desc.className = "agg-included-desc";
|
|
1454
|
+
desc.textContent = description;
|
|
1455
|
+
changeCell.append(desc);
|
|
1456
|
+
}
|
|
1457
|
+
// A trivial change carries its own bounded diff — offer it in a "View diff"
|
|
1458
|
+
// disclosure (for a tiny change the diff IS the summary). Lines are colored
|
|
1459
|
+
// by their leading +/- marker.
|
|
1460
|
+
if (Array.isArray(pr.diff) && pr.diff.length) {
|
|
1461
|
+
const details = document.createElement("details");
|
|
1462
|
+
details.className = "agg-diff";
|
|
1463
|
+
const summaryEl = document.createElement("summary");
|
|
1464
|
+
summaryEl.textContent = "View diff";
|
|
1465
|
+
details.append(summaryEl);
|
|
1466
|
+
for (const file of pr.diff) {
|
|
1467
|
+
const fileEl = document.createElement("div");
|
|
1468
|
+
fileEl.className = "agg-diff-file";
|
|
1469
|
+
const path = document.createElement("div");
|
|
1470
|
+
path.className = "agg-diff-path publish-mono";
|
|
1471
|
+
path.textContent = file.path;
|
|
1472
|
+
fileEl.append(path);
|
|
1473
|
+
if (file.patch) {
|
|
1474
|
+
const pre = document.createElement("pre");
|
|
1475
|
+
pre.className = "agg-diff-patch publish-mono";
|
|
1476
|
+
for (const line of String(file.patch).split("\n")) {
|
|
1477
|
+
const ln = document.createElement("span");
|
|
1478
|
+
ln.className =
|
|
1479
|
+
line.startsWith("+") && !line.startsWith("+++") ? "agg-diff-add"
|
|
1480
|
+
: line.startsWith("-") && !line.startsWith("---") ? "agg-diff-del"
|
|
1481
|
+
: line.startsWith("@@") ? "agg-diff-hunk" : "";
|
|
1482
|
+
ln.textContent = line + "\n";
|
|
1483
|
+
pre.append(ln);
|
|
1484
|
+
}
|
|
1485
|
+
fileEl.append(pre);
|
|
1486
|
+
} else {
|
|
1487
|
+
const binary = document.createElement("div");
|
|
1488
|
+
binary.className = "subtle";
|
|
1489
|
+
binary.textContent = "(no line-level diff available)";
|
|
1490
|
+
fileEl.append(binary);
|
|
1491
|
+
}
|
|
1492
|
+
details.append(fileEl);
|
|
1493
|
+
}
|
|
1494
|
+
changeCell.append(details);
|
|
1495
|
+
}
|
|
1496
|
+
tr.append(changeCell);
|
|
1497
|
+
|
|
1498
|
+
// Head sha.
|
|
1499
|
+
const shaCell = document.createElement("td");
|
|
1500
|
+
const sha = document.createElement("span");
|
|
1501
|
+
sha.className = "publish-mono sha-token";
|
|
1502
|
+
sha.textContent = shortSha(pr.headSha);
|
|
1503
|
+
shaCell.append(sha);
|
|
1504
|
+
tr.append(shaCell);
|
|
1505
|
+
|
|
1506
|
+
// Files changed.
|
|
1507
|
+
const filesCell = document.createElement("td");
|
|
1508
|
+
const fileCount = pr.fileCount != null ? pr.fileCount : pr.filesChanged;
|
|
1509
|
+
filesCell.textContent = fileCount != null ? String(fileCount) : "—";
|
|
1510
|
+
tr.append(filesCell);
|
|
1511
|
+
|
|
1512
|
+
// Lines added/removed (green/red).
|
|
1513
|
+
const linesCell = document.createElement("td");
|
|
1514
|
+
if (typeof pr.linesAdded === "number" || typeof pr.linesRemoved === "number") {
|
|
1515
|
+
linesCell.append(linesNode(pr.linesAdded, pr.linesRemoved));
|
|
1516
|
+
} else {
|
|
1517
|
+
linesCell.textContent = "—";
|
|
1518
|
+
}
|
|
1519
|
+
tr.append(linesCell);
|
|
1520
|
+
|
|
1521
|
+
// Integrated when/by.
|
|
1522
|
+
const whenCell = document.createElement("td");
|
|
1523
|
+
const bits = [];
|
|
1524
|
+
if (pr.integratedAt) bits.push("added " + age(pr.integratedAt));
|
|
1525
|
+
if (pr.integratedBy && pr.integratedBy.email) bits.push("by " + pr.integratedBy.email);
|
|
1526
|
+
whenCell.textContent = bits.length ? bits.join(" ") : "—";
|
|
1527
|
+
tr.append(whenCell);
|
|
1528
|
+
|
|
1529
|
+
// Actions: u14 Undo — per-change revert: roll THIS change OUT of the staged
|
|
1530
|
+
// preview (a revert commit + rebuild); the live site is untouched. Only for
|
|
1531
|
+
// a PR-numbered, still-ahead-of-live change; gated on ship capability.
|
|
1532
|
+
const actionsCell = document.createElement("td");
|
|
1533
|
+
actionsCell.className = "action";
|
|
1534
|
+
if (canShip && pr.prNumber != null && !matchesLive) {
|
|
1535
|
+
const undo = document.createElement("button");
|
|
1536
|
+
undo.type = "button";
|
|
1537
|
+
undo.className = "btn btn-compact";
|
|
1538
|
+
undo.textContent = "Undo";
|
|
1539
|
+
undo.title =
|
|
1540
|
+
"Revert PR #" + pr.prNumber + " out of the staged preview (your live site is untouched)";
|
|
1541
|
+
undo.addEventListener("click", () => revertStaged(pr, undo));
|
|
1542
|
+
actionsCell.append(undo);
|
|
1543
|
+
}
|
|
1544
|
+
tr.append(actionsCell);
|
|
1545
|
+
|
|
1546
|
+
prsList.append(tr);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
renderPlan(data.plan);
|
|
1551
|
+
|
|
1552
|
+
// The Publish button: enabled only for a shippable, paywall-cleared plan and
|
|
1553
|
+
// a viewer who can ship. A paywalled plan is SHOWN (upsell), never hidden. Bug 3
|
|
1554
|
+
// publish-safety: computePublishGuard ALSO hard-blocks a red aggregate (the
|
|
1555
|
+
// latest rebuild failed) regardless of a possibly-stale shippable plan, and
|
|
1556
|
+
// surfaces a "serving stale build" indicator — see publish-guard.ts.
|
|
1557
|
+
const servingBuildAtLabel = (() => {
|
|
1558
|
+
const iso = data.current && data.current.createdAt;
|
|
1559
|
+
if (!iso) return null;
|
|
1560
|
+
const d = new Date(iso);
|
|
1561
|
+
return Number.isNaN(d.getTime()) ? null : d.toLocaleString();
|
|
1562
|
+
})();
|
|
1563
|
+
const guard = computePublishGuard({
|
|
1564
|
+
queueState: (agg && agg.queueState) || null,
|
|
1565
|
+
aggregatePublishing,
|
|
1566
|
+
canShip,
|
|
1567
|
+
shippable: Boolean(currentPlan),
|
|
1568
|
+
paywalled: Boolean(currentPlan && currentPlan.paywall && !currentPlan.paywall.allowed),
|
|
1569
|
+
alreadyShipped: Boolean(currentPlan && currentPlan.alreadyShipped),
|
|
1570
|
+
aheadCount,
|
|
1571
|
+
servingBuildAtLabel,
|
|
1572
|
+
});
|
|
1573
|
+
if (publishBtn) {
|
|
1574
|
+
// rux7: never re-enable the Publish button while its POST is in flight —
|
|
1575
|
+
// a poll refresh landing mid-publish must not open a double-submit (folded
|
|
1576
|
+
// into the guard's `aggregatePublishing` input).
|
|
1577
|
+
publishBtn.disabled = guard.disabled;
|
|
1578
|
+
publishBtn.title = guard.title;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// The collapsed destination panel answers the release question in its
|
|
1582
|
+
// summary: green only when the staged release is already live; otherwise
|
|
1583
|
+
// expose the action that advances live to that release. Public-site parity
|
|
1584
|
+
// with the *old* live pointer must never paint this summary green.
|
|
1585
|
+
const liveReleaseState: any = root.querySelector("[data-live-release-state]");
|
|
1586
|
+
const liveReleasePublish: any = root.querySelector("[data-live-release-publish]");
|
|
1587
|
+
if (liveReleaseState && liveReleasePublish) {
|
|
1588
|
+
const liveReleaseView = liveReleaseSummaryState({
|
|
1589
|
+
matchesLive,
|
|
1590
|
+
hasStagedRelease: Boolean(stagedSha),
|
|
1591
|
+
publishDisabled: guard.disabled,
|
|
1592
|
+
publishTitle: guard.title,
|
|
1593
|
+
});
|
|
1594
|
+
if (liveReleaseView.kind === "current") {
|
|
1595
|
+
paintChip(liveReleaseState, liveReleaseView.label, "green", CHIP_GLYPH.green);
|
|
1596
|
+
liveReleaseState.hidden = false;
|
|
1597
|
+
liveReleasePublish.hidden = true;
|
|
1598
|
+
} else if (liveReleaseView.kind === "publish") {
|
|
1599
|
+
liveReleaseState.hidden = true;
|
|
1600
|
+
liveReleasePublish.hidden = false;
|
|
1601
|
+
liveReleasePublish.textContent = liveReleaseView.label;
|
|
1602
|
+
liveReleasePublish.disabled = liveReleaseView.disabled;
|
|
1603
|
+
liveReleasePublish.title = liveReleaseView.title;
|
|
1604
|
+
} else {
|
|
1605
|
+
paintChip(liveReleaseState, liveReleaseView.label, "", "");
|
|
1606
|
+
liveReleaseState.hidden = false;
|
|
1607
|
+
liveReleasePublish.hidden = true;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
if (publishDetail) {
|
|
1611
|
+
if (guard.staleIndicator) {
|
|
1612
|
+
// A failed rebuild is holding new work back — lead with the honest stale
|
|
1613
|
+
// notice instead of the normal "publishes the exact build" copy.
|
|
1614
|
+
publishDetail.textContent = guard.staleIndicator;
|
|
1615
|
+
} else if (currentPlan && currentPlan.alreadyShipped) {
|
|
1616
|
+
publishDetail.textContent =
|
|
1617
|
+
"This reviewed build (" + shortSha(currentPlan.pinnedSha) + ") is already live. ";
|
|
1618
|
+
const view = document.createElement("a");
|
|
1619
|
+
view.href = ds.liveHref || "/";
|
|
1620
|
+
view.target = "_blank";
|
|
1621
|
+
view.rel = "noopener";
|
|
1622
|
+
view.textContent = "View your live store →";
|
|
1623
|
+
publishDetail.append(view);
|
|
1624
|
+
} else if (currentPlan) {
|
|
1625
|
+
publishDetail.textContent = publicSiteHost
|
|
1626
|
+
? "Publishes the exact build you reviewed (" + shortSha(currentPlan.pinnedSha) +
|
|
1627
|
+
") to the hosted store immediately, then automatically republishes " + publicSiteHost + "."
|
|
1628
|
+
: "Publishes the exact build you reviewed (" + shortSha(currentPlan.pinnedSha) + ") — no rebuild.";
|
|
1629
|
+
} else {
|
|
1630
|
+
publishDetail.textContent =
|
|
1631
|
+
"Publishes the exact build you reviewed — no rebuild. Available once your next release is ready and its checks pass.";
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// u14 — orient the owner between the staged aggregate (above) and the live
|
|
1636
|
+
// versions list (below): how many staged changes are still unpublished and
|
|
1637
|
+
// what Publish does to the versions timeline. Owned here (its OWN element) so
|
|
1638
|
+
// this render never races renderVersions' own poll.
|
|
1639
|
+
const versionsOrientation = root.querySelector("[data-versions-orientation]");
|
|
1640
|
+
if (versionsOrientation) {
|
|
1641
|
+
if (aheadCount && !matchesLive) {
|
|
1642
|
+
versionsOrientation.textContent =
|
|
1643
|
+
aheadCount + " staged change" + (aheadCount === 1 ? " is" : "s are") +
|
|
1644
|
+
" not yet published — Publish live creates the next version here, and the current version becomes your rollback target.";
|
|
1645
|
+
versionsOrientation.hidden = false;
|
|
1646
|
+
} else {
|
|
1647
|
+
versionsOrientation.hidden = true;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
|
|
1652
|
+
const renderPlan = (plan?: any) => {
|
|
1653
|
+
const planBox = aggPanel && aggPanel.querySelector("[data-aggregate-plan]");
|
|
1654
|
+
if (!planBox) return;
|
|
1655
|
+
const chipEl = planBox.querySelector("[data-aggregate-plan-chip]");
|
|
1656
|
+
const grid = planBox.querySelector("[data-aggregate-plan-grid]");
|
|
1657
|
+
const note = planBox.querySelector("[data-aggregate-plan-note]");
|
|
1658
|
+
if (grid) grid.textContent = "";
|
|
1659
|
+
|
|
1660
|
+
// No plan at all (degenerate: no store bound) — hide the box.
|
|
1661
|
+
if (!plan) {
|
|
1662
|
+
planBox.hidden = true;
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
planBox.hidden = false;
|
|
1666
|
+
|
|
1667
|
+
if (!plan.ok) {
|
|
1668
|
+
// An honest refusal — show WHY it isn't ready, never a fake plan.
|
|
1669
|
+
paintChip(chipEl, "Not ready to publish", "amber", CHIP_GLYPH.amber);
|
|
1670
|
+
if (note) note.textContent = plan.message || ("Not ready to publish (" + (plan.reason || "refused") + ").");
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const paywalled = plan.paywall && !plan.paywall.allowed;
|
|
1675
|
+
if (chipEl) {
|
|
1676
|
+
const label = plan.alreadyShipped ? "Already live" : paywalled ? "Upgrade required" : "Ready to publish";
|
|
1677
|
+
const tone = plan.alreadyShipped ? "green" : paywalled ? "amber" : "green";
|
|
1678
|
+
paintChip(chipEl, label, tone, CHIP_GLYPH[tone]);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
const row = (term?: any, value?: any, mono?: any) => {
|
|
1682
|
+
const dt = document.createElement("dt");
|
|
1683
|
+
dt.textContent = term;
|
|
1684
|
+
const dd = document.createElement("dd");
|
|
1685
|
+
if (value instanceof Node) dd.append(value);
|
|
1686
|
+
else {
|
|
1687
|
+
dd.textContent = value;
|
|
1688
|
+
if (mono) dd.className = "publish-mono";
|
|
1689
|
+
}
|
|
1690
|
+
grid.append(dt, dd);
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
row("Reviewed build", shortSha(plan.pinnedSha), true);
|
|
1694
|
+
row("Reviewed digest", shortSha(plan.artifactDigest.replace(/^sha256:/, "")), true);
|
|
1695
|
+
const runCell = document.createElement("span");
|
|
1696
|
+
runCell.append(document.createTextNode(plan.integrationRunId));
|
|
1697
|
+
const planRunLink = runLink(plan.pinnedRevHref, "Open build");
|
|
1698
|
+
if (planRunLink) {
|
|
1699
|
+
runCell.append(document.createTextNode(" · "));
|
|
1700
|
+
runCell.append(planRunLink);
|
|
1701
|
+
}
|
|
1702
|
+
row("Integration run", runCell);
|
|
1703
|
+
if (plan.rollbackTarget) {
|
|
1704
|
+
row("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true);
|
|
1705
|
+
} else {
|
|
1706
|
+
row("Rollback to", "None (first publish)");
|
|
1707
|
+
}
|
|
1708
|
+
row("Subscription", plan.paywall && plan.paywall.allowed ? "Enabled" : "Not enabled");
|
|
1709
|
+
// Included changes: rendered ONCE, in the staged-delta table above (prsList) —
|
|
1710
|
+
// the plan's own includedPrs is the same canonical set, so no second list here.
|
|
1711
|
+
|
|
1712
|
+
if (note) {
|
|
1713
|
+
note.textContent = paywalled
|
|
1714
|
+
? (plan.paywall.message || "Publishing to your live store requires the paid plan.")
|
|
1715
|
+
: plan.alreadyShipped
|
|
1716
|
+
? "This reviewed build is already live — nothing to publish."
|
|
1717
|
+
: "Publish sends the exact build you reviewed to your live store. No rebuild.";
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
|
|
1721
|
+
// The exact-digest Publish (b10 ship = b09 orchestrator.ship). Confirms the
|
|
1722
|
+
// pinned plan, then POSTs { confirmGoLive, expectedAggregateSha,
|
|
1723
|
+
// expectedArtifactDigest } — the reviewed-digest guard: a stale plan (queue
|
|
1724
|
+
// moved / artifact changed) is refused by the server, never shipped blind.
|
|
1725
|
+
// ── Publish-live ceremony (shipoff) ──────────────────────────────────────
|
|
1726
|
+
// The go-live counterpart of the accept liftoff below (shares its shell,
|
|
1727
|
+
// styles, planRow + celebrate helpers — referenced at runtime, defined a few
|
|
1728
|
+
// sections down). One honest in-flight state: ship is a single POST that
|
|
1729
|
+
// promotes the pinned digest, so no staged theater — just plan → publishing →
|
|
1730
|
+
// outcome with a real "View your live store" link.
|
|
1731
|
+
const shipoff = root.querySelector("[data-shipoff]");
|
|
1732
|
+
const shipoffCard = shipoff && shipoff.querySelector(".liftoff-card");
|
|
1733
|
+
const shipoffEls: any = shipoff
|
|
1734
|
+
? {
|
|
1735
|
+
plan: shipoff.querySelector("[data-shipoff-plan]"),
|
|
1736
|
+
flight: shipoff.querySelector("[data-shipoff-flight]"),
|
|
1737
|
+
outcome: shipoff.querySelector("[data-shipoff-outcome]"),
|
|
1738
|
+
log: shipoff.querySelector("[data-shipoff-log]"),
|
|
1739
|
+
grid: shipoff.querySelector("[data-shipoff-grid]"),
|
|
1740
|
+
proceed: shipoff.querySelector("[data-shipoff-proceed]"),
|
|
1741
|
+
cancel: shipoff.querySelector("[data-shipoff-cancel]"),
|
|
1742
|
+
live: shipoff.querySelector("[data-shipoff-live]"),
|
|
1743
|
+
elapsed: shipoff.querySelector("[data-shipoff-elapsed]"),
|
|
1744
|
+
hide: shipoff.querySelector("[data-shipoff-hide]"),
|
|
1745
|
+
seal: shipoff.querySelector("[data-shipoff-seal]"),
|
|
1746
|
+
outcomeTitle: shipoff.querySelector("[data-shipoff-outcome-title]"),
|
|
1747
|
+
outcomeDetail: shipoff.querySelector("[data-shipoff-outcome-detail]"),
|
|
1748
|
+
failure: shipoff.querySelector("[data-shipoff-failure]"),
|
|
1749
|
+
failureDetail: shipoff.querySelector("[data-shipoff-failure-detail]"),
|
|
1750
|
+
copy: shipoff.querySelector("[data-shipoff-copy]"),
|
|
1751
|
+
view: shipoff.querySelector("[data-shipoff-view]"),
|
|
1752
|
+
undo: shipoff.querySelector("[data-shipoff-undo]"),
|
|
1753
|
+
done: shipoff.querySelector("[data-shipoff-done]"),
|
|
1754
|
+
confetti: shipoff.querySelector("[data-shipoff-confetti]"),
|
|
1755
|
+
}
|
|
1756
|
+
: null;
|
|
1757
|
+
let shipoffTimer: any = null;
|
|
1758
|
+
// r12 — the element that opened the publish ceremony, so closing returns focus
|
|
1759
|
+
// to it (the Publish button or the hero CTA) rather than dropping it to <body>.
|
|
1760
|
+
let shipoffOpenedFrom: any = null;
|
|
1761
|
+
const shipLog = makeLogConsole(shipoffEls && shipoffEls.log);
|
|
1762
|
+
let shipFailureCopyText = "";
|
|
1763
|
+
// A paste-ready brief for a coding agent when a publish-live did not complete —
|
|
1764
|
+
// the honest reason + the pinned build the human was shipping (mirrors
|
|
1765
|
+
// assembleBuildFailureCopy's framing).
|
|
1766
|
+
const assembleShipFailureCopy = (plan?: any, data?: any, message?: string) => {
|
|
1767
|
+
const tenant = appDomain || repo;
|
|
1768
|
+
return [
|
|
1769
|
+
"In my " + tenant + " store, publishing the reviewed build " +
|
|
1770
|
+
(plan && plan.pinnedSha ? shortSha(plan.pinnedSha) + " " : "") +
|
|
1771
|
+
"to my live store did not complete. Diagnose why from the detail below, then tell me the exact fix.",
|
|
1772
|
+
"",
|
|
1773
|
+
"What publish reported:",
|
|
1774
|
+
message || "(no detail was reported)",
|
|
1775
|
+
data && data.state ? "\nOutcome state: " + data.state : "",
|
|
1776
|
+
data && data.reason ? "Reason: " + data.reason : "",
|
|
1777
|
+
"\nContext: this is a Token of Trust storefront tenant. Go-live pins the exact reviewed " +
|
|
1778
|
+
"aggregate digest and promotes it to the live channel — it never rebuilds. A refusal " +
|
|
1779
|
+
"(not_green / paywall / sha_mismatch / bundle_unstaged) leaves the live store unchanged.",
|
|
1780
|
+
]
|
|
1781
|
+
.filter((l) => l !== "")
|
|
1782
|
+
.join("\n");
|
|
1783
|
+
};
|
|
1784
|
+
if (shipoffEls && shipoffEls.copy) {
|
|
1785
|
+
shipoffEls.copy.addEventListener("click", () => {
|
|
1786
|
+
navigator.clipboard.writeText(shipFailureCopyText).then(
|
|
1787
|
+
() => {
|
|
1788
|
+
shipoffEls.copy.textContent = "Copied";
|
|
1789
|
+
setTimeout(() => { shipoffEls.copy.textContent = "Copy for your AI agent"; }, 1600);
|
|
1790
|
+
},
|
|
1791
|
+
() => { /* clipboard denied — the detail is selectable text */ },
|
|
1792
|
+
);
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
const closeShipoff = () => {
|
|
1796
|
+
if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
|
|
1797
|
+
if (shipoff) shipoff.hidden = true;
|
|
1798
|
+
if (shipoffEls) shipoffEls.confetti.textContent = "";
|
|
1799
|
+
if (shipoffOpenedFrom && document.contains(shipoffOpenedFrom)) shipoffOpenedFrom.focus();
|
|
1800
|
+
shipoffOpenedFrom = null;
|
|
1801
|
+
};
|
|
1802
|
+
const showShipoffPhase = (phase?: any) => {
|
|
1803
|
+
if (!shipoffEls) return;
|
|
1804
|
+
shipoffEls.plan.hidden = phase !== "plan";
|
|
1805
|
+
shipoffEls.flight.hidden = phase !== "flight";
|
|
1806
|
+
shipoffEls.outcome.hidden = phase !== "outcome";
|
|
1807
|
+
if (shipoffCard) shipoffCard.dataset.mode = phase;
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
const buildPublishPlanText = (plan?: any) => {
|
|
1811
|
+
const lines = ["Publish plan (exact reviewed digest — no rebuild):"];
|
|
1812
|
+
lines.push(" tenant: " + (appDomain || repo));
|
|
1813
|
+
lines.push(" build: " + shortSha(plan.pinnedSha));
|
|
1814
|
+
lines.push(" digest: " + plan.artifactDigest);
|
|
1815
|
+
lines.push(" changes: " + (plan.includedPrs || []).length + " included");
|
|
1816
|
+
if (plan.rollbackTarget) lines.push(" rollback: " + shortSha(plan.rollbackTarget.aggregateSha));
|
|
1817
|
+
lines.push(" effect: publish this exact digest to your LIVE store.");
|
|
1818
|
+
return lines.join("\n");
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1821
|
+
const publishAggregate = (button?: any) => {
|
|
1822
|
+
if (!currentPlan) return;
|
|
1823
|
+
const plan = currentPlan;
|
|
1824
|
+
shipoffOpenedFrom = button || null; // r12: return focus here on close
|
|
1825
|
+
if (!shipoff || !shipoffEls) {
|
|
1826
|
+
// No-markup fallback only — the ceremony IS the confirm now.
|
|
1827
|
+
if (!window.confirm(buildPublishPlanText(plan) + "\n\nPublish live?")) return;
|
|
1828
|
+
runShip(plan, button);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
shipoffEls.grid.textContent = "";
|
|
1832
|
+
shipoffEls.grid.append(
|
|
1833
|
+
...planRow("Store", appDomain || repo),
|
|
1834
|
+
...planRow("Build", shortSha(plan.pinnedSha), true),
|
|
1835
|
+
...planRow("Digest", shortDigest(plan.artifactDigest), true),
|
|
1836
|
+
...planRow("Changes", (plan.includedPrs || []).length + " included"),
|
|
1837
|
+
...(plan.rollbackTarget
|
|
1838
|
+
? planRow("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true)
|
|
1839
|
+
: []),
|
|
1840
|
+
);
|
|
1841
|
+
showShipoffPhase("plan");
|
|
1842
|
+
shipoff.hidden = false;
|
|
1843
|
+
shipoffEls.proceed.focus();
|
|
1844
|
+
shipoffEls.cancel.onclick = () => closeShipoff();
|
|
1845
|
+
shipoffEls.done.onclick = () => closeShipoff();
|
|
1846
|
+
shipoffEls.proceed.onclick = () => runShip(plan, button);
|
|
1847
|
+
};
|
|
1848
|
+
|
|
1849
|
+
const runShip = async (plan?: any, button?: any) => {
|
|
1850
|
+
if (button) button.disabled = true;
|
|
1851
|
+
aggregatePublishing = true; // rux7: hold the button disabled across polls
|
|
1852
|
+
setStatus(aggStatusEl, "Publishing the reviewed build (" + shortSha(plan.pinnedSha) + ") live…");
|
|
1853
|
+
const startedAt = Date.now();
|
|
1854
|
+
if (shipoffEls) {
|
|
1855
|
+
showShipoffPhase("flight");
|
|
1856
|
+
shipLog.reset();
|
|
1857
|
+
if (shipoffEls.failure) shipoffEls.failure.hidden = true;
|
|
1858
|
+
shipoffEls.live.textContent = "Pinning the exact reviewed build to your live channel…";
|
|
1859
|
+
shipoffEls.elapsed.textContent = "0:00";
|
|
1860
|
+
if (shipoffEls.hide) shipoffEls.hide.onclick = () => { shipoff.hidden = true; }; // stream keeps running
|
|
1861
|
+
shipoffTimer = setInterval(() => {
|
|
1862
|
+
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
1863
|
+
shipoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
1864
|
+
}, 1000);
|
|
1865
|
+
}
|
|
1866
|
+
const outcome = (state?: any, title?: any, detail?: any, showView?: any, failureDetail?: any) => {
|
|
1867
|
+
if (!shipoffEls) return;
|
|
1868
|
+
if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
|
|
1869
|
+
shipoffEls.seal.dataset.state = state;
|
|
1870
|
+
shipoffEls.outcomeTitle.textContent = title;
|
|
1871
|
+
shipoffEls.outcomeDetail.textContent = detail;
|
|
1872
|
+
shipoffEls.view.hidden = !showView;
|
|
1873
|
+
if (showView) shipoffEls.view.href = ds.liveHref || "/";
|
|
1874
|
+
// r10: the Undo affordance is wired only on a successful ship (below); reset
|
|
1875
|
+
// it hidden for every outcome so a failure never shows a stale Undo.
|
|
1876
|
+
if (shipoffEls.undo) shipoffEls.undo.hidden = true;
|
|
1877
|
+
// A failure gets the honest detail + a "Copy for your AI agent" brief (same
|
|
1878
|
+
// affordance as the build flight); a clean success hides it.
|
|
1879
|
+
if (shipoffEls.failure) {
|
|
1880
|
+
if (failureDetail) {
|
|
1881
|
+
shipoffEls.failure.hidden = false;
|
|
1882
|
+
shipoffEls.failureDetail.textContent = failureDetail;
|
|
1883
|
+
} else {
|
|
1884
|
+
shipoffEls.failure.hidden = true;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
if (shipoff.hidden) shipoff.hidden = false; // re-surface if backgrounded
|
|
1888
|
+
showShipoffPhase("outcome");
|
|
1889
|
+
shipoffEls.done.focus();
|
|
1890
|
+
if (state === "ok") celebrate(shipoffEls.confetti);
|
|
1891
|
+
};
|
|
1892
|
+
try {
|
|
1893
|
+
const res = await fetch(ds.shipEndpoint, {
|
|
1894
|
+
method: "POST",
|
|
1895
|
+
headers: {
|
|
1896
|
+
"content-type": "application/json",
|
|
1897
|
+
// Real-time server-pushed progress (pin → promote → verify → record) over
|
|
1898
|
+
// SSE; falls back to buffered JSON for a non-streaming proxy.
|
|
1899
|
+
accept: "text/event-stream",
|
|
1900
|
+
"x-tot-capability": "ship-on-behalf",
|
|
1901
|
+
},
|
|
1902
|
+
body: JSON.stringify({
|
|
1903
|
+
aggregateId: plan.aggregateId,
|
|
1904
|
+
// The HUMAN go-live gate + the reviewed-digest guards (b09 ship input).
|
|
1905
|
+
confirmGoLive: true,
|
|
1906
|
+
expectedAggregateSha: plan.pinnedSha,
|
|
1907
|
+
expectedArtifactDigest: plan.artifactDigest,
|
|
1908
|
+
}),
|
|
1909
|
+
});
|
|
1910
|
+
const { data } = await streamWithLog(res, shipLog);
|
|
1911
|
+
// b09 AggregateShipResult: the ONLY live claim is state === "shipped".
|
|
1912
|
+
if (data.state === "shipped") {
|
|
1913
|
+
setStatus(
|
|
1914
|
+
aggStatusEl,
|
|
1915
|
+
"Published live · build " + shortSha(data.pinnedSha) + " (receipt " + (data.receiptId || "—") + ").",
|
|
1916
|
+
"ok",
|
|
1917
|
+
);
|
|
1918
|
+
void autoSweepBranches();
|
|
1919
|
+
outcome(
|
|
1920
|
+
"ok",
|
|
1921
|
+
"Your store is live",
|
|
1922
|
+
"Build " + shortSha(data.pinnedSha) + " — exactly what you reviewed — is now serving" +
|
|
1923
|
+
(data.receiptId ? " (receipt " + data.receiptId + ")." : "."),
|
|
1924
|
+
true,
|
|
1925
|
+
);
|
|
1926
|
+
// r10: right after publishing, offer a one-click Undo that jumps to the
|
|
1927
|
+
// rollback row for the version this release just REPLACED (the plan's
|
|
1928
|
+
// rollback target = the prior live version), pre-targeted. Reuses the Live
|
|
1929
|
+
// Site rollback action; only offered when there IS a prior version.
|
|
1930
|
+
const undoTarget = plan.rollbackTarget && plan.rollbackTarget.aggregateSha;
|
|
1931
|
+
if (shipoffEls && shipoffEls.undo && undoTarget) {
|
|
1932
|
+
shipoffEls.undo.hidden = false;
|
|
1933
|
+
shipoffEls.undo.onclick = () => {
|
|
1934
|
+
closeShipoff();
|
|
1935
|
+
focusRollbackTarget(undoTarget);
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
} else {
|
|
1939
|
+
let message;
|
|
1940
|
+
if (data.state === "promote_failed" || data.state === "record_failed") {
|
|
1941
|
+
// Honest partial-failure truth — recoverable, never a false "shipped".
|
|
1942
|
+
message = data.message || "Publish did not complete (" + data.state + "). Retry.";
|
|
1943
|
+
} else if (data.reason === "paywall") {
|
|
1944
|
+
message = data.message || "Publishing to your live store is part of the paid plan.";
|
|
1945
|
+
} else {
|
|
1946
|
+
message = data.message || data.error || "Publish refused (" + (data.reason || data.status || res.status) + ")";
|
|
1947
|
+
}
|
|
1948
|
+
setStatus(aggStatusEl, message, "err");
|
|
1949
|
+
shipFailureCopyText = assembleShipFailureCopy(plan, data, message);
|
|
1950
|
+
outcome("err", "Publish did not complete", message + " Your live store is unchanged.", false, message);
|
|
1951
|
+
// C5 — a refused/failed publish is an aggregate-level event (most tellingly
|
|
1952
|
+
// "not green"); append the health-banner roll-up of what's keeping it red to
|
|
1953
|
+
// the outcome detail (best-effort, additive).
|
|
1954
|
+
surfaceDoctorHint((p) => shipFailureDoctor(p), [shipoffEls && shipoffEls.outcomeDetail, aggStatusEl]);
|
|
1955
|
+
}
|
|
1956
|
+
} catch {
|
|
1957
|
+
setStatus(aggStatusEl, "Publish failed — network error", "err");
|
|
1958
|
+
outcome(
|
|
1959
|
+
"err",
|
|
1960
|
+
"Publish did not complete",
|
|
1961
|
+
"Network error mid-publish — refresh to see the honest state; your live store serves the last published version.",
|
|
1962
|
+
false,
|
|
1963
|
+
);
|
|
1964
|
+
} finally {
|
|
1965
|
+
aggregatePublishing = false;
|
|
1966
|
+
if (button) button.disabled = false;
|
|
1967
|
+
loadQueue();
|
|
1968
|
+
loadVersions({ force: true }); // a publish changed the versions list
|
|
1969
|
+
}
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1972
|
+
const aggStatusEl = aggPanel && aggPanel.querySelector("[data-aggregate-status]");
|
|
1973
|
+
aggPanel
|
|
1974
|
+
?.querySelector("[data-aggregate-publish]")
|
|
1975
|
+
?.addEventListener("click", (event: any) => publishAggregate(event.currentTarget));
|
|
1976
|
+
root
|
|
1977
|
+
.querySelector("[data-live-release-publish]")
|
|
1978
|
+
?.addEventListener("click", (event: any) => {
|
|
1979
|
+
// This action lives inside <summary>; publishing must not also toggle the
|
|
1980
|
+
// disclosure open or closed.
|
|
1981
|
+
event.preventDefault();
|
|
1982
|
+
event.stopPropagation();
|
|
1983
|
+
publishAggregate(event.currentTarget);
|
|
1984
|
+
});
|
|
1985
|
+
root
|
|
1986
|
+
.querySelector("[data-publish-stale-site-link]")
|
|
1987
|
+
?.addEventListener("click", () => {
|
|
1988
|
+
// The destination panel is collapsed by default; an alert promising the
|
|
1989
|
+
// full breakdown must reveal it before the anchor scroll lands.
|
|
1990
|
+
const details: any = root.querySelector(".live-site-details");
|
|
1991
|
+
if (details) details.open = true;
|
|
1992
|
+
});
|
|
1993
|
+
aggPanel
|
|
1994
|
+
?.querySelector("[data-aggregate-retry-build]")
|
|
1995
|
+
?.addEventListener("click", (event: any) => retryBuild(event.currentTarget));
|
|
1996
|
+
// r08: the Next-release "Copy preview link" — shown only when a share token was
|
|
1997
|
+
// minted. The shared preview URL is static (token + canonical domain), so it's
|
|
1998
|
+
// wired once. stopPropagation keeps a copy from also toggling the collapsible.
|
|
1999
|
+
const aggCopyPreviewBtn: any = aggPanel && aggPanel.querySelector("[data-aggregate-copy-preview]");
|
|
2000
|
+
if (aggCopyPreviewBtn && sharedPreviewUrl()) {
|
|
2001
|
+
aggCopyPreviewBtn.hidden = false;
|
|
2002
|
+
const lbl = aggCopyPreviewBtn.querySelector("[data-copy-preview-label]");
|
|
2003
|
+
aggCopyPreviewBtn.addEventListener("click", (e: Event) => {
|
|
2004
|
+
e.stopPropagation();
|
|
2005
|
+
copyPreviewLink(sharedPreviewUrl(), lbl);
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// ── Accept-to-preview LIFTOFF ─────────────────────────────────────────────
|
|
2010
|
+
// The plan confirm + live integration flight + outcome ceremony (replaces
|
|
2011
|
+
// the old window.confirm plan popup). Same b11 contract as before: accept =
|
|
2012
|
+
// `POST /api/changes/integrate` (b08) → integrate into the protected
|
|
2013
|
+
// `preview` aggregate, rebuild + combined-evidence it (b05). Flips NO live
|
|
2014
|
+
// channel — going live is the aggregate card's Publish.
|
|
2015
|
+
//
|
|
2016
|
+
// HONESTY RULE for the stage rail: a stage only advances on a REAL signal —
|
|
2017
|
+
// the outcome POST resolving, or GET /api/changes polling showing the
|
|
2018
|
+
// candidate inside `aggregate.includedPrs` (merge landed) / an `activeRun`
|
|
2019
|
+
// (build+evidence running, statusMessage relayed verbatim). No timers ever
|
|
2020
|
+
// fake progress.
|
|
2021
|
+
const liftoff = root.querySelector("[data-liftoff]");
|
|
2022
|
+
const liftoffCard = liftoff && liftoff.querySelector(".liftoff-card");
|
|
2023
|
+
const liftoffEls: any = liftoff
|
|
2024
|
+
? {
|
|
2025
|
+
plan: liftoff.querySelector("[data-liftoff-plan]"),
|
|
2026
|
+
flight: liftoff.querySelector("[data-liftoff-flight]"),
|
|
2027
|
+
outcome: liftoff.querySelector("[data-liftoff-outcome]"),
|
|
2028
|
+
log: liftoff.querySelector("[data-liftoff-log]"),
|
|
2029
|
+
title: liftoff.querySelector("[data-liftoff-title]"),
|
|
2030
|
+
grid: liftoff.querySelector("[data-liftoff-grid]"),
|
|
2031
|
+
proceed: liftoff.querySelector("[data-liftoff-proceed]"),
|
|
2032
|
+
cancel: liftoff.querySelector("[data-liftoff-cancel]"),
|
|
2033
|
+
flightTitle: liftoff.querySelector("[data-liftoff-flight-title]"),
|
|
2034
|
+
live: liftoff.querySelector("[data-liftoff-live]"),
|
|
2035
|
+
elapsed: liftoff.querySelector("[data-liftoff-elapsed]"),
|
|
2036
|
+
hide: liftoff.querySelector("[data-liftoff-hide]"),
|
|
2037
|
+
seal: liftoff.querySelector("[data-liftoff-seal]"),
|
|
2038
|
+
outcomeTitle: liftoff.querySelector("[data-liftoff-outcome-title]"),
|
|
2039
|
+
outcomeDetail: liftoff.querySelector("[data-liftoff-outcome-detail]"),
|
|
2040
|
+
review: liftoff.querySelector("[data-liftoff-review]"),
|
|
2041
|
+
rebuild: liftoff.querySelector("[data-liftoff-rebuild]"),
|
|
2042
|
+
done: liftoff.querySelector("[data-liftoff-done]"),
|
|
2043
|
+
confetti: liftoff.querySelector("[data-liftoff-confetti]"),
|
|
2044
|
+
conflict: liftoff.querySelector("[data-liftoff-conflict]"),
|
|
2045
|
+
conflictCommands: liftoff.querySelector("[data-conflict-commands]"),
|
|
2046
|
+
conflictCopy: liftoff.querySelector("[data-conflict-copy]"),
|
|
2047
|
+
conflictCopyPrompt: liftoff.querySelector("[data-conflict-copy-prompt]"),
|
|
2048
|
+
conflictRaw: liftoff.querySelector("[data-conflict-raw]"),
|
|
2049
|
+
// u4 — the automate-first recovery surface layered over the expert fallback.
|
|
2050
|
+
conflictSummary: liftoff.querySelector("[data-conflict-summary]"),
|
|
2051
|
+
conflictAuto: liftoff.querySelector("[data-conflict-auto]"),
|
|
2052
|
+
conflictPrimaryStatus: liftoff.querySelector("[data-conflict-primary-status]"),
|
|
2053
|
+
conflictCards: liftoff.querySelector("[data-conflict-cards]"),
|
|
2054
|
+
conflictFiles: liftoff.querySelector("[data-conflict-files]"),
|
|
2055
|
+
conflictCardsStatus: liftoff.querySelector("[data-conflict-cards-status]"),
|
|
2056
|
+
conflictCardButtons: liftoff.querySelectorAll("[data-conflict-card]"),
|
|
2057
|
+
conflictExpert: liftoff.querySelector("[data-conflict-expert]"),
|
|
2058
|
+
}
|
|
2059
|
+
: null;
|
|
2060
|
+
|
|
2061
|
+
// The terminal recovery for a merge-refused candidate: verify the checkout
|
|
2062
|
+
// actually backs THIS change (`tot pr` marks the current checkout's candidate
|
|
2063
|
+
// "(active)" — rebasing the wrong clone/branch is the classic footgun), then
|
|
2064
|
+
// rebase onto the moved preview and re-submit. The open PR updates in place
|
|
2065
|
+
// on re-push. DELIBERATELY comment-free: a live paste-through showed zsh
|
|
2066
|
+
// (no interactivecomments) chokes on `#` lines — every word of guidance
|
|
2067
|
+
// lives in the prose above the block, the block is pure paste-safe commands.
|
|
2068
|
+
const conflictCommandsFor = () => ["tot pr", "git fetch origin", "git rebase origin/preview", "tot preview"].join("\n");
|
|
2069
|
+
|
|
2070
|
+
// The same recovery shaped as a PROMPT for the operator's coding agent
|
|
2071
|
+
// (Claude Code, etc.) — the multi-step, judgment-required version. Every
|
|
2072
|
+
// step below earned its place in a live 2026-08-20 recovery run:
|
|
2073
|
+
// the git-status preflight (an ABANDONED half-rebase with committed
|
|
2074
|
+
// conflict markers was lurking and poisoned the first attempt), the
|
|
2075
|
+
// two-stage auth self-heal (tot login fixes the session, NOT the token
|
|
2076
|
+
// baked into the remote URL — u10 credential-helper removes this), the
|
|
2077
|
+
// conflict-intent framing, and the never-`--fork-candidate` rule.
|
|
2078
|
+
const conflictPromptFor = (change?: any, rawMessage?: any) => {
|
|
2079
|
+
const tenant = appDomain || repo;
|
|
2080
|
+
const pr = change && change.prNumber != null ? "PR #" + change.prNumber : "my open candidate PR";
|
|
2081
|
+
return [
|
|
2082
|
+
"In my " + tenant + " store checkout on this machine, " + pr + " could not be integrated: " +
|
|
2083
|
+
"the shared `preview` branch changed after my change branched, and the two now conflict. " +
|
|
2084
|
+
"Bring my change up to date and re-submit it.",
|
|
2085
|
+
"",
|
|
2086
|
+
"1. Run `git status` first. If a rebase/merge is already in progress (a previous recovery attempt may have been abandoned), inspect it, then `git rebase --abort` to start clean — and check the tracked files for committed `<<<<<<<` conflict markers while you're there.",
|
|
2087
|
+
"2. Run `tot pr` and confirm " + pr + " is listed and marked (active) for this checkout — if it isn't, stop and tell me before touching anything.",
|
|
2088
|
+
"3. Run `git fetch origin`, then `git rebase origin/preview`. On an authentication failure: `tot login`, retry; if fetch STILL fails, the token embedded in the remote URL has rotated — replace the remote with a freshly minted one: `git remote set-url origin \"$(tot clone " + tenant + " --remote-only --print-remote)\"` (this matches how the checkout was originally configured; ask me first if you're unsure).",
|
|
2089
|
+
"4. Resolve any conflicts git flags, preserving the intent of my change on the lines it edited; keep preview's version everywhere else. Show me the resolution if the same lines were edited on both sides. Never commit a file still containing conflict markers; finish with `git rebase --continue` — a half-done rebase must never be left behind.",
|
|
2090
|
+
"5. Re-submit with `tot preview` (plain — never `--fork-candidate`, which would fork a separate PR). " + pr + " updates in place.",
|
|
2091
|
+
"6. Confirm the push succeeded and the submit output reports the preview building. Then end by reminding me how to FINISH adding it once the build is ready: back in the store admin's Publish tab (" + adminPublishUrl() + "), " + pr + " → Add to release" +
|
|
2092
|
+
(change && change.prNumber != null
|
|
2093
|
+
? " — or from this terminal: `tot accept --tenant " + tenant + " --pr " + change.prNumber + "`."
|
|
2094
|
+
: "."),
|
|
2095
|
+
rawMessage ? "\nThe system's refusal, for reference: " + rawMessage : "",
|
|
2096
|
+
].join("\n");
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
// The Publish tab's own URL — where the operator copying this prompt is
|
|
2100
|
+
// standing right now — so the agent's closing hand-back can name it exactly.
|
|
2101
|
+
const adminPublishUrl = () => window.location.origin + window.location.pathname + "#publish";
|
|
2102
|
+
|
|
2103
|
+
// ── u4: automate-first conflict recovery ──────────────────────────────────
|
|
2104
|
+
// The per-change resolve route (u3): POST /api/changes/<changeId>/resolve.
|
|
2105
|
+
// Derived off changesEndpoint so it inherits the tenant surface's basePath.
|
|
2106
|
+
const resolveEndpointFor = (changeId?: any) =>
|
|
2107
|
+
ds.changesEndpoint + "/" + encodeURIComponent(changeId) + "/resolve";
|
|
2108
|
+
|
|
2109
|
+
|
|
2110
|
+
// Reveal the three option cards, naming the exact files diff3 could not
|
|
2111
|
+
// auto-combine (never a bare list with no next step).
|
|
2112
|
+
const revealConflictCards = (unresolved?: any) => {
|
|
2113
|
+
if (!liftoffEls.conflictCards) return;
|
|
2114
|
+
liftoffEls.conflictCards.hidden = false;
|
|
2115
|
+
if (liftoffEls.conflictFiles) {
|
|
2116
|
+
liftoffEls.conflictFiles.textContent = "";
|
|
2117
|
+
(unresolved || []).forEach((f: any) => {
|
|
2118
|
+
const li = document.createElement("li");
|
|
2119
|
+
li.textContent = f;
|
|
2120
|
+
liftoffEls.conflictFiles.append(li);
|
|
2121
|
+
});
|
|
2122
|
+
liftoffEls.conflictFiles.hidden = (unresolved || []).length === 0;
|
|
2123
|
+
}
|
|
2124
|
+
};
|
|
2125
|
+
|
|
2126
|
+
// Open the collapsed expert/terminal fallback and bring it into view — the
|
|
2127
|
+
// honest destination for a same-line overlap only a human/agent can combine,
|
|
2128
|
+
// and the graceful landing when automation isn't deployed (501).
|
|
2129
|
+
const revealExpertFallback = () => {
|
|
2130
|
+
if (liftoffEls.conflictExpert) {
|
|
2131
|
+
liftoffEls.conflictExpert.open = true;
|
|
2132
|
+
liftoffEls.conflictExpert.scrollIntoView({ block: "nearest" });
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
2135
|
+
|
|
2136
|
+
const setConflictStatus = (el?: any, message?: any, state?: any) => {
|
|
2137
|
+
if (!el) return;
|
|
2138
|
+
el.hidden = !message;
|
|
2139
|
+
el.textContent = message || "";
|
|
2140
|
+
if (state) el.dataset.state = state;
|
|
2141
|
+
else delete el.dataset.state;
|
|
2142
|
+
};
|
|
2143
|
+
|
|
2144
|
+
// POST /resolve with `strategy`, then act on the classified outcome. From the
|
|
2145
|
+
// primary action `source` is "primary" (a clean rebuild reveals cards on the
|
|
2146
|
+
// rare overlap); from a card it's "card" (a merge card that re-hits the overlap
|
|
2147
|
+
// escalates to the expert fallback rather than looping). A clean rebuild ALWAYS
|
|
2148
|
+
// auto-re-integrates exactly once via the existing runIntegration flow.
|
|
2149
|
+
const resolveThenReintegrate = async (change?: any, strategy?: any, button?: any, source?: any) => {
|
|
2150
|
+
const statusEl2 = source === "card" ? liftoffEls.conflictCardsStatus : liftoffEls.conflictPrimaryStatus;
|
|
2151
|
+
const controls = [liftoffEls.conflictAuto, ...Array.from(liftoffEls.conflictCardButtons || [])].filter(Boolean);
|
|
2152
|
+
controls.forEach((b) => { b.disabled = true; });
|
|
2153
|
+
setConflictStatus(statusEl2, "Updating your change…");
|
|
2154
|
+
try {
|
|
2155
|
+
const res = await fetch(resolveEndpointFor(change.changeId), {
|
|
2156
|
+
method: "POST",
|
|
2157
|
+
headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
|
|
2158
|
+
body: JSON.stringify({ repo, strategy }),
|
|
2159
|
+
});
|
|
2160
|
+
const body = await res.json().catch(() => ({}));
|
|
2161
|
+
const action = classifyResolveResponse(change, res.status, body);
|
|
2162
|
+
if (action.kind === "reintegrate") {
|
|
2163
|
+
setConflictStatus(statusEl2, "");
|
|
2164
|
+
// Auto-re-integrate ONCE with the refreshed change (new PR/head).
|
|
2165
|
+
runIntegration(action.change, button);
|
|
2166
|
+
} else if (action.kind === "already-integrated") {
|
|
2167
|
+
// The change already merged/integrated — recovery is a clean no-op success,
|
|
2168
|
+
// NOT an error (fb-1787916141762-owjzis). Close the panel and refresh the
|
|
2169
|
+
// queue so the now-stale row drops off; never reveal the expert fallback.
|
|
2170
|
+
setConflictStatus(statusEl2, "This change is already in your next release — nothing to recover.", "ok");
|
|
2171
|
+
if (liftoff) liftoff.hidden = true;
|
|
2172
|
+
loadQueue();
|
|
2173
|
+
} else if (action.kind === "cards") {
|
|
2174
|
+
if (source === "card") {
|
|
2175
|
+
// A merge card that STILL overlaps — no safe auto-combine exists; hand
|
|
2176
|
+
// the specific files to the expert/terminal path.
|
|
2177
|
+
setConflictStatus(
|
|
2178
|
+
statusEl2,
|
|
2179
|
+
"These files need a hand-merge — see “Do it manually instead” below.",
|
|
2180
|
+
"err",
|
|
2181
|
+
);
|
|
2182
|
+
revealConflictCards(action.unresolved);
|
|
2183
|
+
revealExpertFallback();
|
|
2184
|
+
} else {
|
|
2185
|
+
setConflictStatus(statusEl2, "");
|
|
2186
|
+
revealConflictCards(action.unresolved);
|
|
2187
|
+
}
|
|
2188
|
+
} else if (action.kind === "unavailable") {
|
|
2189
|
+
setConflictStatus(
|
|
2190
|
+
statusEl2,
|
|
2191
|
+
"Automatic update isn't available on this store yet — use the manual steps below.",
|
|
2192
|
+
"err",
|
|
2193
|
+
);
|
|
2194
|
+
revealExpertFallback();
|
|
2195
|
+
} else {
|
|
2196
|
+
setConflictStatus(statusEl2, action.message, "err");
|
|
2197
|
+
revealExpertFallback();
|
|
2198
|
+
}
|
|
2199
|
+
} catch {
|
|
2200
|
+
setConflictStatus(statusEl2, "Couldn't reach the server — try the manual steps below.", "err");
|
|
2201
|
+
revealExpertFallback();
|
|
2202
|
+
} finally {
|
|
2203
|
+
controls.forEach((b) => { b.disabled = false; });
|
|
2204
|
+
}
|
|
2205
|
+
};
|
|
2206
|
+
|
|
2207
|
+
// Show/clear the merge-conflict guidance inside the outcome phase. Every
|
|
2208
|
+
// outcome path calls this — visible ONLY for the merge_failed refusal, so a
|
|
2209
|
+
// conflict block from a previous run can never leak into an unrelated outcome.
|
|
2210
|
+
const setConflictGuidance = (visible?: any, rawMessage?: any, change?: any) => {
|
|
2211
|
+
if (!liftoffEls || !liftoffEls.conflict) return;
|
|
2212
|
+
liftoffEls.conflict.hidden = !visible;
|
|
2213
|
+
if (!visible) return;
|
|
2214
|
+
liftoffEls.conflictCommands.textContent = conflictCommandsFor();
|
|
2215
|
+
// The AI prompt is rebuilt per outcome (change + raw message vary) and
|
|
2216
|
+
// stashed on the element; the one-time click handler reads the stash.
|
|
2217
|
+
liftoffEls.conflict.dataset.prompt = conflictPromptFor(change, rawMessage || "");
|
|
2218
|
+
// The forge's own words, kept small for support/diagnosis — the headline
|
|
2219
|
+
// above already says what happened in product language.
|
|
2220
|
+
liftoffEls.conflictRaw.textContent = rawMessage || "";
|
|
2221
|
+
const wireCopy = (btn?: any, label?: any, getText?: any) => {
|
|
2222
|
+
if (!btn || btn.dataset.wired) return;
|
|
2223
|
+
btn.dataset.wired = "1";
|
|
2224
|
+
btn.addEventListener("click", () => {
|
|
2225
|
+
navigator.clipboard.writeText(getText()).then(
|
|
2226
|
+
() => {
|
|
2227
|
+
btn.textContent = "Copied";
|
|
2228
|
+
setTimeout(() => { btn.textContent = label; }, 1600);
|
|
2229
|
+
},
|
|
2230
|
+
() => { /* clipboard denied — the commands are selectable text */ },
|
|
2231
|
+
);
|
|
2232
|
+
});
|
|
2233
|
+
};
|
|
2234
|
+
wireCopy(liftoffEls.conflictCopy, "Copy commands", () => liftoffEls.conflictCommands.textContent);
|
|
2235
|
+
wireCopy(liftoffEls.conflictCopyPrompt, "Copy prompt for your AI agent", () => liftoffEls.conflict.dataset.prompt || "");
|
|
2236
|
+
|
|
2237
|
+
// u4 — reset the automate-first surface for THIS outcome so a prior run's
|
|
2238
|
+
// cards/status/expanded-expert never leak in, then bind the primary action +
|
|
2239
|
+
// option cards to the current change (`.onclick` overwrites, never stacks).
|
|
2240
|
+
if (liftoffEls.conflictCards) liftoffEls.conflictCards.hidden = true;
|
|
2241
|
+
if (liftoffEls.conflictFiles) liftoffEls.conflictFiles.textContent = "";
|
|
2242
|
+
if (liftoffEls.conflictExpert) liftoffEls.conflictExpert.open = false;
|
|
2243
|
+
setConflictStatus(liftoffEls.conflictPrimaryStatus, "");
|
|
2244
|
+
setConflictStatus(liftoffEls.conflictCardsStatus, "");
|
|
2245
|
+
if (liftoffEls.conflictSummary) {
|
|
2246
|
+
// The only structured collision data that survives to the client is the
|
|
2247
|
+
// forge's refusal text (integrate flattens u2's {files,competing} to a
|
|
2248
|
+
// string) + the diff3 `unresolved` list on the resolve response. Name the
|
|
2249
|
+
// competing PR when the refusal mentions one; else stay generic.
|
|
2250
|
+
const prRef = /pr[ #]*([0-9]+)/i.exec(rawMessage || "");
|
|
2251
|
+
liftoffEls.conflictSummary.textContent = prRef
|
|
2252
|
+
? "Another change (PR #" + prRef[1] + ") reached the store first and touched the same content."
|
|
2253
|
+
: "";
|
|
2254
|
+
liftoffEls.conflictSummary.hidden = !prRef;
|
|
2255
|
+
}
|
|
2256
|
+
if (liftoffEls.conflictAuto) {
|
|
2257
|
+
liftoffEls.conflictAuto.onclick = () =>
|
|
2258
|
+
resolveThenReintegrate(change, "merge", liftoffEls.conflictAuto, "primary");
|
|
2259
|
+
}
|
|
2260
|
+
(liftoffEls.conflictCardButtons || []).forEach((card: any) => {
|
|
2261
|
+
card.onclick = () =>
|
|
2262
|
+
resolveThenReintegrate(change, card.dataset.strategy, card, "card");
|
|
2263
|
+
});
|
|
2264
|
+
};
|
|
2265
|
+
|
|
2266
|
+
// A settled conflict is visible in the queue before an integration POST.
|
|
2267
|
+
// Open the same recovery surface normally shown after `merge_failed`, without
|
|
2268
|
+
// claiming that an integration was attempted or changing the aggregate.
|
|
2269
|
+
const openConflictRecovery = (change?: any, button?: any) => {
|
|
2270
|
+
if (!liftoff || !liftoffEls) {
|
|
2271
|
+
setStatus(
|
|
2272
|
+
statusEl,
|
|
2273
|
+
"This change needs updating against the latest preview before it can be added.",
|
|
2274
|
+
"err",
|
|
2275
|
+
);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
liftoffOpenedFrom = button || null;
|
|
2279
|
+
const target = change.prNumber != null ? "PR #" + change.prNumber : "This change";
|
|
2280
|
+
liftoffEls.seal.dataset.state = "err";
|
|
2281
|
+
liftoffEls.outcomeTitle.textContent = "Preview changed while you worked";
|
|
2282
|
+
liftoffEls.outcomeDetail.textContent =
|
|
2283
|
+
target + " passed its checks, but preview changed after it was created and the two now " +
|
|
2284
|
+
"conflict. Nothing was added — update this change automatically, then review the result.";
|
|
2285
|
+
liftoffEls.review.hidden = true;
|
|
2286
|
+
setConflictGuidance(true, "", change);
|
|
2287
|
+
showPhase("outcome");
|
|
2288
|
+
liftoff.hidden = false;
|
|
2289
|
+
if (liftoffEls.conflictAuto) liftoffEls.conflictAuto.focus();
|
|
2290
|
+
};
|
|
2291
|
+
|
|
2292
|
+
const liftoffStage = (name?: any) =>
|
|
2293
|
+
liftoff && liftoff.querySelector('[data-liftoff-stage="' + name + '"]');
|
|
2294
|
+
const LIFTOFF_STAGES = ["merge", "build", "evidence", "green"];
|
|
2295
|
+
const setStage = (name?: any, state?: any) => {
|
|
2296
|
+
const el = liftoffStage(name);
|
|
2297
|
+
if (el) el.dataset.state = state; // pending | active | done | fail
|
|
2298
|
+
};
|
|
2299
|
+
// Advance the rail so everything before `name` is done and `name` is active.
|
|
2300
|
+
const railTo = (name?: any) => {
|
|
2301
|
+
const idx = LIFTOFF_STAGES.indexOf(name);
|
|
2302
|
+
LIFTOFF_STAGES.forEach((s, i) => {
|
|
2303
|
+
const el = liftoffStage(s);
|
|
2304
|
+
const cur = el && el.dataset.state;
|
|
2305
|
+
if (cur === "fail") return; // never un-fail
|
|
2306
|
+
setStage(s, i < idx ? "done" : i === idx ? "active" : "pending");
|
|
2307
|
+
});
|
|
2308
|
+
};
|
|
2309
|
+
|
|
2310
|
+
let liftoffTimer: any = null;
|
|
2311
|
+
let liftoffPoll: any = null;
|
|
2312
|
+
let liftoffOpenedFrom: any = null;
|
|
2313
|
+
const liftoffLog = makeLogConsole(liftoffEls && liftoffEls.log);
|
|
2314
|
+
// Map a server-pushed integrate phase onto the four-stage rail — the SAME rail,
|
|
2315
|
+
// now driven by REAL boundaries instead of a guessing poll: `merging` → Merge,
|
|
2316
|
+
// the aggregate rebuild's `reading`/`publishing` → Rebuild, `checking` → Evidence.
|
|
2317
|
+
const railFromIntegratePhase = (ev: { event: string; data: string }) => {
|
|
2318
|
+
if (ev.event !== "phase") return;
|
|
2319
|
+
let phase = "";
|
|
2320
|
+
try { phase = JSON.parse(ev.data).phase || ""; } catch { /* ignore */ }
|
|
2321
|
+
if (phase === "merging") railTo("merge");
|
|
2322
|
+
else if (phase === "reading" || phase === "publishing") railTo("build");
|
|
2323
|
+
else if (phase === "checking") railTo("evidence");
|
|
2324
|
+
};
|
|
2325
|
+
const stopLiftoffLoops = () => {
|
|
2326
|
+
if (liftoffTimer) { clearInterval(liftoffTimer); liftoffTimer = null; }
|
|
2327
|
+
if (liftoffPoll) { clearInterval(liftoffPoll); liftoffPoll = null; }
|
|
2328
|
+
};
|
|
2329
|
+
const closeLiftoff = () => {
|
|
2330
|
+
stopLiftoffLoops();
|
|
2331
|
+
if (liftoff) liftoff.hidden = true;
|
|
2332
|
+
if (liftoffEls) liftoffEls.confetti.textContent = "";
|
|
2333
|
+
if (liftoffOpenedFrom && document.contains(liftoffOpenedFrom)) liftoffOpenedFrom.focus();
|
|
2334
|
+
liftoffOpenedFrom = null;
|
|
2335
|
+
};
|
|
2336
|
+
const showPhase = (phase?: any) => {
|
|
2337
|
+
if (!liftoffEls) return;
|
|
2338
|
+
liftoffEls.plan.hidden = phase !== "plan";
|
|
2339
|
+
liftoffEls.flight.hidden = phase !== "flight";
|
|
2340
|
+
liftoffEls.outcome.hidden = phase !== "outcome";
|
|
2341
|
+
if (liftoffCard) liftoffCard.dataset.mode = phase;
|
|
2342
|
+
};
|
|
2343
|
+
|
|
2344
|
+
const planRow = (dt?: any, dd?: any, mono?: any) => {
|
|
2345
|
+
const t = document.createElement("dt");
|
|
2346
|
+
t.textContent = dt;
|
|
2347
|
+
const d = document.createElement("dd");
|
|
2348
|
+
d.textContent = dd;
|
|
2349
|
+
if (mono) d.className = "publish-mono";
|
|
2350
|
+
return [t, d];
|
|
2351
|
+
};
|
|
2352
|
+
|
|
2353
|
+
const celebrate = (container?: any) => {
|
|
2354
|
+
if (!container) return;
|
|
2355
|
+
if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
2356
|
+
const colors = ["#0f9d8f", "#25b8a8", "#f4b942", "#5b8def", "#e2634f"];
|
|
2357
|
+
for (let i = 0; i < 42; i++) {
|
|
2358
|
+
const p = document.createElement("i");
|
|
2359
|
+
p.style.left = Math.random() * 100 + "%";
|
|
2360
|
+
p.style.background = colors[i % colors.length]!;
|
|
2361
|
+
p.style.animationDelay = Math.random() * 0.35 + "s";
|
|
2362
|
+
p.style.animationDuration = 0.9 + Math.random() * 0.9 + "s";
|
|
2363
|
+
p.style.setProperty("--liftoff-drift", (Math.random() * 160 - 80).toFixed(0) + "px");
|
|
2364
|
+
p.style.setProperty("--liftoff-spin", (Math.random() * 640 - 320).toFixed(0) + "deg");
|
|
2365
|
+
container.append(p);
|
|
2366
|
+
}
|
|
2367
|
+
setTimeout(() => { container.textContent = ""; }, 2400);
|
|
2368
|
+
};
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
const openLiftoff = (change?: any, button?: any) => {
|
|
2372
|
+
if (!liftoff || !liftoffEls) return false;
|
|
2373
|
+
liftoffOpenedFrom = button || null;
|
|
2374
|
+
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
2375
|
+
liftoffEls.title.textContent = "Add " + target + " to your next release";
|
|
2376
|
+
liftoffEls.grid.textContent = "";
|
|
2377
|
+
liftoffEls.grid.append(
|
|
2378
|
+
...planRow("Store", appDomain || repo),
|
|
2379
|
+
...(change.prNumber != null ? planRow("Pull request", "#" + change.prNumber) : []),
|
|
2380
|
+
...planRow("Change", change.changeId, true),
|
|
2381
|
+
...(change.headSha ? planRow("Head", shortSha(change.headSha), true) : []),
|
|
2382
|
+
);
|
|
2383
|
+
LIFTOFF_STAGES.forEach((s) => setStage(s, "pending"));
|
|
2384
|
+
liftoffEls.live.textContent = "Working…";
|
|
2385
|
+
liftoffEls.elapsed.textContent = "0:00";
|
|
2386
|
+
showPhase("plan");
|
|
2387
|
+
liftoff.hidden = false;
|
|
2388
|
+
liftoffEls.proceed.focus();
|
|
2389
|
+
|
|
2390
|
+
liftoffEls.cancel.onclick = () => closeLiftoff();
|
|
2391
|
+
liftoffEls.done.onclick = () => closeLiftoff();
|
|
2392
|
+
liftoffEls.hide.onclick = () => { liftoff.hidden = true; }; // flight keeps running
|
|
2393
|
+
liftoffEls.proceed.onclick = () => runIntegration(change, button);
|
|
2394
|
+
return true;
|
|
2395
|
+
};
|
|
2396
|
+
|
|
2397
|
+
// Esc: close in plan/outcome; during flight just hide (the POST keeps going).
|
|
2398
|
+
// Handles BOTH ceremonies (accept liftoff + publish shipoff).
|
|
2399
|
+
document.addEventListener("keydown", (ev) => {
|
|
2400
|
+
if (ev.key !== "Escape") return;
|
|
2401
|
+
if (liftoff && !liftoff.hidden) {
|
|
2402
|
+
const mode = liftoffCard && liftoffCard.dataset.mode;
|
|
2403
|
+
if (mode === "flight") liftoff.hidden = true;
|
|
2404
|
+
else closeLiftoff();
|
|
2405
|
+
}
|
|
2406
|
+
if (shipoff && !shipoff.hidden) {
|
|
2407
|
+
const mode = shipoffCard && shipoffCard.dataset.mode;
|
|
2408
|
+
if (mode === "flight") shipoff.hidden = true;
|
|
2409
|
+
else closeShipoff();
|
|
2410
|
+
}
|
|
2411
|
+
if (buildoff && !buildoff.hidden) {
|
|
2412
|
+
const mode = buildoffCard && buildoffCard.dataset.mode;
|
|
2413
|
+
if (mode === "flight") buildoff.hidden = true; // stream keeps running
|
|
2414
|
+
else closeBuildoff();
|
|
2415
|
+
}
|
|
2416
|
+
if (rebuildoff && !rebuildoff.hidden) {
|
|
2417
|
+
const mode = rebuildoffCard && rebuildoffCard.dataset.mode;
|
|
2418
|
+
if (mode === "flight") rebuildoff.hidden = true; // stream keeps running
|
|
2419
|
+
else closeRebuildoff();
|
|
2420
|
+
}
|
|
2421
|
+
});
|
|
2422
|
+
|
|
2423
|
+
// r12 — focus trap. While a modal ceremony is open (aria-modal), Tab must cycle
|
|
2424
|
+
// WITHIN its card, never escaping to the page behind it. Only the visible phase's
|
|
2425
|
+
// controls are tabbable (the hidden phases' buttons fail the offsetParent test), and
|
|
2426
|
+
// a backgrounded ("Continue in background", hidden) dialog is not trapped.
|
|
2427
|
+
document.addEventListener("keydown", (ev) => {
|
|
2428
|
+
if (ev.key !== "Tab") return;
|
|
2429
|
+
const open: any = [liftoff, shipoff, buildoff, rebuildoff].find((d: any) => d && !d.hidden);
|
|
2430
|
+
if (!open) return;
|
|
2431
|
+
const card: any = open.querySelector(".liftoff-card");
|
|
2432
|
+
if (!card) return;
|
|
2433
|
+
const nodes = Array.from(
|
|
2434
|
+
card.querySelectorAll(
|
|
2435
|
+
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])',
|
|
2436
|
+
),
|
|
2437
|
+
).filter((el: any) => el.offsetParent !== null || el === document.activeElement) as any[];
|
|
2438
|
+
if (!nodes.length) {
|
|
2439
|
+
ev.preventDefault();
|
|
2440
|
+
card.focus();
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
const first = nodes[0];
|
|
2444
|
+
const last = nodes[nodes.length - 1];
|
|
2445
|
+
const active = document.activeElement;
|
|
2446
|
+
if (ev.shiftKey) {
|
|
2447
|
+
if (active === first || active === card || !card.contains(active)) {
|
|
2448
|
+
ev.preventDefault();
|
|
2449
|
+
last.focus();
|
|
2450
|
+
}
|
|
2451
|
+
} else if (active === last || active === card || !card.contains(active)) {
|
|
2452
|
+
ev.preventDefault();
|
|
2453
|
+
first.focus();
|
|
2454
|
+
}
|
|
2455
|
+
});
|
|
2456
|
+
|
|
2457
|
+
// Render one classified IntegrateOutcomeView onto the liftoff outcome phase —
|
|
2458
|
+
// the SINGLE place the honest result becomes UI. It reads the derived view
|
|
2459
|
+
// (never re-deciding from raw fields), so "Integration did not land" can only
|
|
2460
|
+
// appear when the merge genuinely did not land; a merge that landed with a
|
|
2461
|
+
// failed build reads "Integrated — preview build failed" and offers Rebuild;
|
|
2462
|
+
// an indeterminate outcome reads "unknown" and offers Re-check. Never a 404
|
|
2463
|
+
// review link (only shown when a genuinely-built revision exists).
|
|
2464
|
+
const applyIntegrateOutcome = (view: IntegrateOutcomeView, change?: any) => {
|
|
2465
|
+
if (!liftoffEls) return;
|
|
2466
|
+
// A successful seal (green OR already-integrated) shows every stage done; a
|
|
2467
|
+
// failure marks its honest failing stage.
|
|
2468
|
+
if (view.seal === "ok") {
|
|
2469
|
+
LIFTOFF_STAGES.forEach((s) => setStage(s, "done"));
|
|
2470
|
+
} else if (view.failStage) {
|
|
2471
|
+
setStage(view.failStage, "fail");
|
|
2472
|
+
}
|
|
2473
|
+
liftoffEls.seal.dataset.state = view.seal;
|
|
2474
|
+
liftoffEls.outcomeTitle.textContent = view.title;
|
|
2475
|
+
liftoffEls.outcomeDetail.textContent = view.detail;
|
|
2476
|
+
setConflictGuidance(
|
|
2477
|
+
view.conflictGuidance,
|
|
2478
|
+
view.conflictGuidance ? view.detail : "",
|
|
2479
|
+
change,
|
|
2480
|
+
);
|
|
2481
|
+
// Review deep-link — shown only for a genuinely-built revision (rule 3: no 404).
|
|
2482
|
+
if (view.review.show && view.review.sha) {
|
|
2483
|
+
liftoffEls.review.href =
|
|
2484
|
+
"/" + encodeURIComponent(appDomain) + "/preview/rev/" + view.review.sha + "/";
|
|
2485
|
+
liftoffEls.review.hidden = false;
|
|
2486
|
+
} else {
|
|
2487
|
+
liftoffEls.review.hidden = true;
|
|
2488
|
+
}
|
|
2489
|
+
// The concrete next-step action button (rebuild / re-check). Conflict recovery
|
|
2490
|
+
// owns its own primary button inside the conflict panel, so no duplicate here.
|
|
2491
|
+
const rebuildBtn = liftoffEls.rebuild;
|
|
2492
|
+
if (rebuildBtn) {
|
|
2493
|
+
const act = view.action;
|
|
2494
|
+
const inlineAction =
|
|
2495
|
+
act && (act.kind === "rebuild" || act.kind === "recheck") ? act : null;
|
|
2496
|
+
if (inlineAction) {
|
|
2497
|
+
rebuildBtn.hidden = false;
|
|
2498
|
+
rebuildBtn.textContent = inlineAction.label;
|
|
2499
|
+
rebuildBtn.onclick = () => {
|
|
2500
|
+
closeLiftoff();
|
|
2501
|
+
if (inlineAction.kind === "rebuild") retryBuild(rebuildBtn);
|
|
2502
|
+
else loadQueue();
|
|
2503
|
+
};
|
|
2504
|
+
} else {
|
|
2505
|
+
rebuildBtn.hidden = true;
|
|
2506
|
+
rebuildBtn.onclick = null;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
if (liftoff.hidden) liftoff.hidden = false; // surface even if backgrounded
|
|
2510
|
+
showPhase("outcome");
|
|
2511
|
+
liftoffEls.done.focus();
|
|
2512
|
+
if (view.ok) celebrate(liftoffEls.confetti);
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
// b08 integrate returns an IntegrateOutcome — { ok, queueState, runState,
|
|
2516
|
+
// aggregateSha, pointerMoved, statusMessage, reason? } — NEVER a bare
|
|
2517
|
+
// merged:true. Render it honestly via classifyIntegrateOutcome: green advances
|
|
2518
|
+
// the aggregate; a landed-but-red merge says the build failed (not "did not
|
|
2519
|
+
// land"); an indeterminate result is an explicit unknown, never a silent pass.
|
|
2520
|
+
const runIntegration = async (change?: any, button?: any) => {
|
|
2521
|
+
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
2522
|
+
if (button) button.disabled = true;
|
|
2523
|
+
busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
|
|
2524
|
+
setStatus(statusEl, "Adding " + change.changeId + " to your next release…");
|
|
2525
|
+
const startedAt = Date.now();
|
|
2526
|
+
if (liftoffEls) {
|
|
2527
|
+
showPhase("flight");
|
|
2528
|
+
liftoffLog.reset();
|
|
2529
|
+
liftoffEls.flightTitle.textContent = "Adding " + target + " to your next release";
|
|
2530
|
+
railTo("merge");
|
|
2531
|
+
liftoffEls.live.textContent = "Adding your change to the next release…";
|
|
2532
|
+
liftoffTimer = setInterval(() => {
|
|
2533
|
+
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
2534
|
+
liftoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
2535
|
+
}, 1000);
|
|
2536
|
+
}
|
|
2537
|
+
try {
|
|
2538
|
+
const res = await fetch(ds.integrateEndpoint, {
|
|
2539
|
+
method: "POST",
|
|
2540
|
+
headers: {
|
|
2541
|
+
"content-type": "application/json",
|
|
2542
|
+
// Real-time server-pushed progress (merge → rebuild → evidence) over SSE;
|
|
2543
|
+
// falls back to buffered JSON for a non-streaming proxy.
|
|
2544
|
+
accept: "text/event-stream",
|
|
2545
|
+
// Mirror of the server-side capability model (UX): the server
|
|
2546
|
+
// re-derives capability and stays authoritative.
|
|
2547
|
+
"x-tot-capability": "ship-on-behalf",
|
|
2548
|
+
},
|
|
2549
|
+
body: JSON.stringify({
|
|
2550
|
+
repo,
|
|
2551
|
+
changeId: change.changeId,
|
|
2552
|
+
prNumber: change.prNumber ?? undefined,
|
|
2553
|
+
expectedHeadSha: change.headSha || undefined,
|
|
2554
|
+
}),
|
|
2555
|
+
});
|
|
2556
|
+
// Drive the stage-rail + scrolling log from the REAL streamed phases (no
|
|
2557
|
+
// more client-side guessing poll); the terminal `result` carries the honest
|
|
2558
|
+
// IntegrateOutcome the branches below render.
|
|
2559
|
+
const { data } = await streamWithLog(res, liftoffLog, railFromIntegratePhase);
|
|
2560
|
+
stopLiftoffLoops();
|
|
2561
|
+
// HONEST RESULT — derived from the actual merge/build signals, never a
|
|
2562
|
+
// separate optimistic flag. `aggregateSha` is the discriminator: the
|
|
2563
|
+
// orchestrator writes it IFF the forge merge landed, so a landed merge
|
|
2564
|
+
// whose build failed reads "Integrated — preview build failed" (with a
|
|
2565
|
+
// Rebuild), and only a genuinely-unlanded merge reads "did not land".
|
|
2566
|
+
const view = classifyIntegrateOutcome({
|
|
2567
|
+
httpOk: res.ok,
|
|
2568
|
+
ok: data.ok === true,
|
|
2569
|
+
aggregateSha: data.aggregateSha ?? null,
|
|
2570
|
+
queueState: data.queueState ?? null,
|
|
2571
|
+
reason: data.reason ?? null,
|
|
2572
|
+
statusMessage: data.statusMessage ?? null,
|
|
2573
|
+
error: data.error ?? null,
|
|
2574
|
+
target,
|
|
2575
|
+
});
|
|
2576
|
+
setStatus(statusEl, view.detail, view.seal);
|
|
2577
|
+
if (view.ok) void autoSweepBranches();
|
|
2578
|
+
if (view.seal === "err") {
|
|
2579
|
+
// C5 — append the merge-doctor's read of THIS candidate's failure to the
|
|
2580
|
+
// outcome detail (best-effort, additive; the view's copy stands on its own).
|
|
2581
|
+
surfaceDoctorHint(
|
|
2582
|
+
(p) => integrateFailureDoctor(p, { changeId: change.changeId, prNumber: change.prNumber }),
|
|
2583
|
+
[liftoffEls && liftoffEls.outcomeDetail, statusEl],
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
applyIntegrateOutcome(view, change);
|
|
2587
|
+
} catch {
|
|
2588
|
+
stopLiftoffLoops();
|
|
2589
|
+
// A network fault means we DON'T KNOW whether the merge landed — unknown =
|
|
2590
|
+
// error, never a silent "did not land". Re-check the queue, don't assume.
|
|
2591
|
+
const view = classifyIntegrateOutcome({ httpOk: false, networkError: true, target });
|
|
2592
|
+
setStatus(statusEl, view.detail, "err");
|
|
2593
|
+
applyIntegrateOutcome(view, change);
|
|
2594
|
+
} finally {
|
|
2595
|
+
busyChanges.delete(change.changeId);
|
|
2596
|
+
if (button) button.disabled = false;
|
|
2597
|
+
loadQueue();
|
|
2598
|
+
}
|
|
2599
|
+
};
|
|
2600
|
+
|
|
2601
|
+
const integrateCandidate = (change?: any, button?: any) => {
|
|
2602
|
+
// The liftoff dialog IS the plan confirm now; window.confirm stays only as
|
|
2603
|
+
// the no-dialog fallback (markup missing = fail open to the old popup).
|
|
2604
|
+
if (openLiftoff(change, button)) return;
|
|
2605
|
+
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
2606
|
+
const plan =
|
|
2607
|
+
"Add to release plan:\n store: " + (appDomain || repo) +
|
|
2608
|
+
(change.prNumber != null ? "\n PR: #" + change.prNumber : "") +
|
|
2609
|
+
"\n change id: " + change.changeId +
|
|
2610
|
+
(change.headSha ? "\n head sha: " + shortSha(change.headSha) : "") +
|
|
2611
|
+
"\n effect: add " + target + " to your next release, then rebuild the preview and " +
|
|
2612
|
+
"re-run its checks. Does NOT go live — you publish your release separately.";
|
|
2613
|
+
if (!window.confirm(plan + "\n\nProceed?")) return;
|
|
2614
|
+
runIntegration(change, button);
|
|
2615
|
+
};
|
|
2616
|
+
|
|
2617
|
+
const rejectChange = async (change?: any, button?: any) => {
|
|
2618
|
+
const reason = window.prompt(
|
|
2619
|
+
"Reject " + change.changeId +
|
|
2620
|
+
" — this closes the PR and removes its hosted preview and candidate branch. " +
|
|
2621
|
+
"Add a short note for the submitter:",
|
|
2622
|
+
);
|
|
2623
|
+
if (reason == null) return;
|
|
2624
|
+
if (!reason.trim()) {
|
|
2625
|
+
setStatus(statusEl, "A reject note is required.", "err");
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
button.disabled = true;
|
|
2629
|
+
busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
|
|
2630
|
+
setStatus(statusEl, "Rejecting " + change.changeId + "…");
|
|
2631
|
+
try {
|
|
2632
|
+
const res = await fetch(
|
|
2633
|
+
ds.changesEndpoint + "/" + encodeURIComponent(change.changeId) + "/reject",
|
|
2634
|
+
{
|
|
2635
|
+
method: "POST",
|
|
2636
|
+
headers: {
|
|
2637
|
+
"content-type": "application/json",
|
|
2638
|
+
"x-tot-capability": "ship-on-behalf",
|
|
2639
|
+
},
|
|
2640
|
+
body: JSON.stringify({ repo, reason: reason.trim() }),
|
|
2641
|
+
},
|
|
2642
|
+
);
|
|
2643
|
+
const data = await res.json().catch(() => ({}));
|
|
2644
|
+
if (res.ok) {
|
|
2645
|
+
setStatus(
|
|
2646
|
+
statusEl,
|
|
2647
|
+
data.cleanupWarning
|
|
2648
|
+
? "Change " + change.changeId + " rejected. " + data.cleanupWarning + "."
|
|
2649
|
+
: "Change " +
|
|
2650
|
+
change.changeId +
|
|
2651
|
+
" rejected; its PR, preview, and candidate branch are cleaned up.",
|
|
2652
|
+
data.cleanupWarning ? "err" : "ok",
|
|
2653
|
+
);
|
|
2654
|
+
void autoSweepBranches();
|
|
2655
|
+
} else {
|
|
2656
|
+
setStatus(statusEl, data.error || "Reject failed (" + res.status + ")", "err");
|
|
2657
|
+
}
|
|
2658
|
+
} catch {
|
|
2659
|
+
setStatus(statusEl, "Reject failed — network error", "err");
|
|
2660
|
+
} finally {
|
|
2661
|
+
busyChanges.delete(change.changeId);
|
|
2662
|
+
button.disabled = false;
|
|
2663
|
+
loadQueue();
|
|
2664
|
+
}
|
|
2665
|
+
};
|
|
2666
|
+
|
|
2667
|
+
// u14 — per-change REVERT (Undo): roll ONE change OUT of the shared staged
|
|
2668
|
+
// preview. Appends a revert commit + rebuilds the `preview` aggregate; it NEVER
|
|
2669
|
+
// touches the live channel (no paywall, `/api/changes/revert`). The outcome is
|
|
2670
|
+
// rendered HONESTLY per the queue's reason — `already_reverted` / `target_not_found`
|
|
2671
|
+
// are calm informational states (the change is simply already out of staging), not
|
|
2672
|
+
// errors, so they never flash red.
|
|
2673
|
+
const revertStaged = async (pr?: any, button?: any) => {
|
|
2674
|
+
const target = pr.prNumber != null ? "PR #" + pr.prNumber : pr.changeId;
|
|
2675
|
+
const plan =
|
|
2676
|
+
"Undo " + target + " from the staged preview?\n\n" +
|
|
2677
|
+
" tenant: " + (appDomain || repo) + "\n" +
|
|
2678
|
+
" target: " + target + "\n" +
|
|
2679
|
+
" effect: append a revert commit to the staged preview and rebuild — the change\n" +
|
|
2680
|
+
" comes OUT of staging. Your live site is untouched.\n\n" +
|
|
2681
|
+
"Proceed?";
|
|
2682
|
+
if (!window.confirm(plan)) return;
|
|
2683
|
+
button.disabled = true;
|
|
2684
|
+
setStatus(statusEl, "Reverting " + target + " out of the staged preview…");
|
|
2685
|
+
try {
|
|
2686
|
+
const res = await fetch(ds.changesEndpoint + "/revert", {
|
|
2687
|
+
method: "POST",
|
|
2688
|
+
headers: {
|
|
2689
|
+
"content-type": "application/json",
|
|
2690
|
+
"x-tot-capability": "ship-on-behalf",
|
|
2691
|
+
},
|
|
2692
|
+
body: JSON.stringify({ repo, prNumber: pr.prNumber }),
|
|
2693
|
+
});
|
|
2694
|
+
const data = await res.json().catch(() => ({}));
|
|
2695
|
+
if (res.ok && data.ok) {
|
|
2696
|
+
setStatus(statusEl, "Reverted " + target + " — the staged preview is rebuilding.", "ok");
|
|
2697
|
+
} else if (res.ok && (data.reason === "already_reverted" || data.reason === "target_not_found")) {
|
|
2698
|
+
// Not an error: the change is already out of staging (or was never in it).
|
|
2699
|
+
setStatus(
|
|
2700
|
+
statusEl,
|
|
2701
|
+
data.reason === "already_reverted"
|
|
2702
|
+
? target + " was already removed from the staged preview."
|
|
2703
|
+
: target + " isn't in the staged preview — nothing to undo.",
|
|
2704
|
+
);
|
|
2705
|
+
} else {
|
|
2706
|
+
setStatus(statusEl, data.error || data.statusMessage || "Undo failed (" + res.status + ")", "err");
|
|
2707
|
+
}
|
|
2708
|
+
} catch {
|
|
2709
|
+
setStatus(statusEl, "Undo failed — network error", "err");
|
|
2710
|
+
} finally {
|
|
2711
|
+
button.disabled = false;
|
|
2712
|
+
loadQueue();
|
|
2713
|
+
}
|
|
2714
|
+
};
|
|
2715
|
+
|
|
2716
|
+
// u15 — retry a DEAD aggregate rebuild, no new merge: the last candidate's
|
|
2717
|
+
// PR already merged into preview, but the build/evidence step that follows
|
|
2718
|
+
// died (e.g. a transient MCP error). `POST /api/changes/rebuild` re-runs
|
|
2719
|
+
// ONLY that step against the sha the forge already carries — never a
|
|
2720
|
+
// second merge attempt, never a membership change. It streams its
|
|
2721
|
+
// reading/publishing/checking phases into the SAME shared scrolling live-log
|
|
2722
|
+
// console the other flights use, with Continue-in-background + a Copy-for-AI
|
|
2723
|
+
// failure brief.
|
|
2724
|
+
const rebuildoff: any = root.querySelector("[data-rebuildoff]");
|
|
2725
|
+
const rebuildoffCard = rebuildoff && rebuildoff.querySelector(".liftoff-card");
|
|
2726
|
+
const rebuildoffEls: any = rebuildoff
|
|
2727
|
+
? {
|
|
2728
|
+
flight: rebuildoff.querySelector("[data-rebuildoff-flight]"),
|
|
2729
|
+
outcome: rebuildoff.querySelector("[data-rebuildoff-outcome]"),
|
|
2730
|
+
log: rebuildoff.querySelector("[data-rebuildoff-log]"),
|
|
2731
|
+
live: rebuildoff.querySelector("[data-rebuildoff-live]"),
|
|
2732
|
+
elapsed: rebuildoff.querySelector("[data-rebuildoff-elapsed]"),
|
|
2733
|
+
hide: rebuildoff.querySelector("[data-rebuildoff-hide]"),
|
|
2734
|
+
seal: rebuildoff.querySelector("[data-rebuildoff-seal]"),
|
|
2735
|
+
outcomeTitle: rebuildoff.querySelector("[data-rebuildoff-outcome-title]"),
|
|
2736
|
+
outcomeDetail: rebuildoff.querySelector("[data-rebuildoff-outcome-detail]"),
|
|
2737
|
+
failure: rebuildoff.querySelector("[data-rebuildoff-failure]"),
|
|
2738
|
+
failureDetail: rebuildoff.querySelector("[data-rebuildoff-failure-detail]"),
|
|
2739
|
+
copy: rebuildoff.querySelector("[data-rebuildoff-copy]"),
|
|
2740
|
+
done: rebuildoff.querySelector("[data-rebuildoff-done]"),
|
|
2741
|
+
}
|
|
2742
|
+
: null;
|
|
2743
|
+
let rebuildoffTimer: any = null;
|
|
2744
|
+
let rebuildoffOpenedFrom: any = null; // r12: return focus here on close
|
|
2745
|
+
let rebuildFailureCopyText = "";
|
|
2746
|
+
const rebuildLog = makeLogConsole(rebuildoffEls && rebuildoffEls.log);
|
|
2747
|
+
const stopRebuildoffTimer = () => {
|
|
2748
|
+
if (rebuildoffTimer) { clearInterval(rebuildoffTimer); rebuildoffTimer = null; }
|
|
2749
|
+
};
|
|
2750
|
+
const closeRebuildoff = () => {
|
|
2751
|
+
stopRebuildoffTimer();
|
|
2752
|
+
if (rebuildoff) rebuildoff.hidden = true;
|
|
2753
|
+
if (rebuildoffOpenedFrom && document.contains(rebuildoffOpenedFrom)) rebuildoffOpenedFrom.focus();
|
|
2754
|
+
rebuildoffOpenedFrom = null;
|
|
2755
|
+
};
|
|
2756
|
+
const showRebuildoffPhase = (phase?: any) => {
|
|
2757
|
+
if (!rebuildoffEls) return;
|
|
2758
|
+
rebuildoffEls.flight.hidden = phase !== "flight";
|
|
2759
|
+
rebuildoffEls.outcome.hidden = phase !== "outcome";
|
|
2760
|
+
if (rebuildoffCard) rebuildoffCard.dataset.mode = phase;
|
|
2761
|
+
};
|
|
2762
|
+
if (rebuildoffEls) {
|
|
2763
|
+
rebuildoffEls.done.onclick = () => closeRebuildoff();
|
|
2764
|
+
if (rebuildoffEls.hide) rebuildoffEls.hide.onclick = () => { rebuildoff.hidden = true; }; // stream keeps running
|
|
2765
|
+
if (rebuildoffEls.copy) {
|
|
2766
|
+
rebuildoffEls.copy.addEventListener("click", () => {
|
|
2767
|
+
navigator.clipboard.writeText(rebuildFailureCopyText).then(
|
|
2768
|
+
() => {
|
|
2769
|
+
rebuildoffEls.copy.textContent = "Copied";
|
|
2770
|
+
setTimeout(() => { rebuildoffEls.copy.textContent = "Copy for your AI agent"; }, 1600);
|
|
2771
|
+
},
|
|
2772
|
+
() => { /* clipboard denied — the detail is selectable text */ },
|
|
2773
|
+
);
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
const retryBuild = async (button?: any) => {
|
|
2779
|
+
if (!window.confirm(
|
|
2780
|
+
"Retry the build for " + (appDomain || repo) + "?\n\n" +
|
|
2781
|
+
" effect: rebuild your next release and re-run its checks —\n" +
|
|
2782
|
+
" nothing new is added.\n\n" +
|
|
2783
|
+
"Proceed?",
|
|
2784
|
+
)) return;
|
|
2785
|
+
button.disabled = true;
|
|
2786
|
+
rebuildoffOpenedFrom = button || null; // r12: return focus here on close
|
|
2787
|
+
aggregateRetrying = true; // rux7-style: hold disabled across a poll landing mid-retry
|
|
2788
|
+
setStatus(aggStatusEl, "Retrying the build…");
|
|
2789
|
+
const startedAt = Date.now();
|
|
2790
|
+
if (rebuildoffEls) {
|
|
2791
|
+
showRebuildoffPhase("flight");
|
|
2792
|
+
rebuildLog.reset();
|
|
2793
|
+
if (rebuildoffEls.failure) rebuildoffEls.failure.hidden = true;
|
|
2794
|
+
rebuildoffEls.live.textContent = "Rebuilding your next release preview…";
|
|
2795
|
+
rebuildoffEls.elapsed.textContent = "0:00";
|
|
2796
|
+
rebuildoff.hidden = false;
|
|
2797
|
+
stopRebuildoffTimer();
|
|
2798
|
+
rebuildoffTimer = setInterval(() => {
|
|
2799
|
+
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
2800
|
+
rebuildoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
2801
|
+
}, 1000);
|
|
2802
|
+
}
|
|
2803
|
+
const outcome = (state?: any, title?: any, detail?: any, failureDetail?: any) => {
|
|
2804
|
+
stopRebuildoffTimer();
|
|
2805
|
+
if (!rebuildoffEls) return;
|
|
2806
|
+
rebuildoffEls.seal.dataset.state = state;
|
|
2807
|
+
rebuildoffEls.outcomeTitle.textContent = title;
|
|
2808
|
+
rebuildoffEls.outcomeDetail.textContent = detail;
|
|
2809
|
+
if (rebuildoffEls.failure) {
|
|
2810
|
+
if (failureDetail) {
|
|
2811
|
+
rebuildoffEls.failure.hidden = false;
|
|
2812
|
+
rebuildoffEls.failureDetail.textContent = failureDetail;
|
|
2813
|
+
} else {
|
|
2814
|
+
rebuildoffEls.failure.hidden = true;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
if (rebuildoff.hidden) rebuildoff.hidden = false; // re-surface if backgrounded
|
|
2818
|
+
showRebuildoffPhase("outcome");
|
|
2819
|
+
rebuildoffEls.done.focus();
|
|
2820
|
+
};
|
|
2821
|
+
try {
|
|
2822
|
+
const res = await fetch(ds.changesEndpoint + "/rebuild", {
|
|
2823
|
+
method: "POST",
|
|
2824
|
+
headers: {
|
|
2825
|
+
"content-type": "application/json",
|
|
2826
|
+
// Real-time server-pushed progress (reading → publishing → checking) over
|
|
2827
|
+
// SSE; falls back to buffered JSON for a non-streaming proxy.
|
|
2828
|
+
accept: "text/event-stream",
|
|
2829
|
+
"x-tot-capability": "ship-on-behalf",
|
|
2830
|
+
},
|
|
2831
|
+
body: JSON.stringify({ repo }),
|
|
2832
|
+
});
|
|
2833
|
+
const { data } = await streamWithLog(res, rebuildLog);
|
|
2834
|
+
if (data.ok) {
|
|
2835
|
+
setStatus(aggStatusEl, "Build retry passed — your next release is ready.", "ok");
|
|
2836
|
+
outcome("ok", "Your next release is ready", "The rebuild passed all your store's checks. Nothing is live yet.");
|
|
2837
|
+
} else if (data.reason === "nothing_to_rebuild") {
|
|
2838
|
+
setStatus(aggStatusEl, "Nothing to rebuild — there's no earlier build to retry yet.");
|
|
2839
|
+
outcome(
|
|
2840
|
+
"ok",
|
|
2841
|
+
"Nothing to rebuild",
|
|
2842
|
+
"There's no earlier preview build for this store to retry yet.",
|
|
2843
|
+
);
|
|
2844
|
+
} else {
|
|
2845
|
+
const detail =
|
|
2846
|
+
data.statusMessage || data.error || "your next release did not pass its checks (" + (data.reason || data.queueState) + ")";
|
|
2847
|
+
setStatus(aggStatusEl, "Build retry did not pass: " + detail, "err");
|
|
2848
|
+
rebuildFailureCopyText = [
|
|
2849
|
+
"In my " + (appDomain || repo) + " store, retrying the preview build did not pass. " +
|
|
2850
|
+
"Diagnose why from the detail below, then tell me the exact fix.",
|
|
2851
|
+
"",
|
|
2852
|
+
"What the rebuild reported:",
|
|
2853
|
+
detail,
|
|
2854
|
+
"\nContext: this re-runs ONLY the aggregate rebuild + combined evidence against the " +
|
|
2855
|
+
"last merged commit on the shared `preview` branch — no new merge, no membership change.",
|
|
2856
|
+
].join("\n");
|
|
2857
|
+
outcome("err", "The rebuild didn't pass", detail + " Your next release is unchanged.", detail);
|
|
2858
|
+
surfaceDoctorHint((p) => shipFailureDoctor(p), [rebuildoffEls && rebuildoffEls.outcomeDetail, aggStatusEl]);
|
|
2859
|
+
}
|
|
2860
|
+
} catch {
|
|
2861
|
+
setStatus(aggStatusEl, "Retry failed — network error", "err");
|
|
2862
|
+
outcome(
|
|
2863
|
+
"err",
|
|
2864
|
+
"The rebuild didn't finish",
|
|
2865
|
+
"Network error mid-rebuild — refresh the queue to see your next release's honest state.",
|
|
2866
|
+
);
|
|
2867
|
+
} finally {
|
|
2868
|
+
aggregateRetrying = false;
|
|
2869
|
+
button.disabled = false;
|
|
2870
|
+
loadQueue();
|
|
2871
|
+
}
|
|
2872
|
+
};
|
|
2873
|
+
|
|
2874
|
+
// ---- rux5: build-on-demand for a synthetic not-built PR ------------------
|
|
2875
|
+
// Mirror of the integrate plan affordance: state EXACTLY what build
|
|
2876
|
+
// does — materialize this PR's hosted preview at its forge head — and that it
|
|
2877
|
+
// is INERT (flips no live channel). POSTs { pr, headSha?, baseSha? }; the row
|
|
2878
|
+
// already carries the head sha GET /api/changes resolved via forge PR-read, and
|
|
2879
|
+
// the server re-resolves it authoritatively when the body omits it. Shows an
|
|
2880
|
+
// in-progress state (disabled + "Building…") while the build runs, then reloads
|
|
2881
|
+
// the queue so the freshly-built PR reappears as a built candidate.
|
|
2882
|
+
|
|
2883
|
+
const buildPreviewPlan = (change?: any) => {
|
|
2884
|
+
const lines = ["Build preview plan:", " tenant: " + (appDomain || repo)];
|
|
2885
|
+
lines.push(" PR: #" + change.prNumber);
|
|
2886
|
+
if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
|
|
2887
|
+
lines.push(
|
|
2888
|
+
" effect: materialize this PR's hosted preview at its head — INERT " +
|
|
2889
|
+
"(does NOT go live).",
|
|
2890
|
+
);
|
|
2891
|
+
return lines.join("\n");
|
|
2892
|
+
};
|
|
2893
|
+
|
|
2894
|
+
// ── Build-flight dialog (real-time SSE build/check progress) ─────────────
|
|
2895
|
+
// Mirrors the accept liftoff's flight → outcome ceremony (same liftoff-* markup)
|
|
2896
|
+
// but is driven by the phase events `POST /api/preview/build` streams over SSE.
|
|
2897
|
+
// "Continue in background" hides the dialog while the stream keeps running; on a
|
|
2898
|
+
// FAILURE the outcome shows the honest detail + a "Copy for your AI agent" button.
|
|
2899
|
+
const buildoff: any = root.querySelector("[data-buildoff]");
|
|
2900
|
+
const buildoffCard = buildoff && buildoff.querySelector(".liftoff-card");
|
|
2901
|
+
const buildoffEls: any = buildoff
|
|
2902
|
+
? {
|
|
2903
|
+
flight: buildoff.querySelector("[data-buildoff-flight]"),
|
|
2904
|
+
outcome: buildoff.querySelector("[data-buildoff-outcome]"),
|
|
2905
|
+
log: buildoff.querySelector("[data-buildoff-log]"),
|
|
2906
|
+
flightTitle: buildoff.querySelector("[data-buildoff-flight-title]"),
|
|
2907
|
+
live: buildoff.querySelector("[data-buildoff-live]"),
|
|
2908
|
+
elapsed: buildoff.querySelector("[data-buildoff-elapsed]"),
|
|
2909
|
+
hide: buildoff.querySelector("[data-buildoff-hide]"),
|
|
2910
|
+
seal: buildoff.querySelector("[data-buildoff-seal]"),
|
|
2911
|
+
outcomeTitle: buildoff.querySelector("[data-buildoff-outcome-title]"),
|
|
2912
|
+
outcomeDetail: buildoff.querySelector("[data-buildoff-outcome-detail]"),
|
|
2913
|
+
failure: buildoff.querySelector("[data-buildoff-failure]"),
|
|
2914
|
+
failureDetail: buildoff.querySelector("[data-buildoff-failure-detail]"),
|
|
2915
|
+
copy: buildoff.querySelector("[data-buildoff-copy]"),
|
|
2916
|
+
review: buildoff.querySelector("[data-buildoff-review]"),
|
|
2917
|
+
done: buildoff.querySelector("[data-buildoff-done]"),
|
|
2918
|
+
}
|
|
2919
|
+
: null;
|
|
2920
|
+
|
|
2921
|
+
let buildoffTimer: any = null;
|
|
2922
|
+
let buildoffOpenedFrom: any = null;
|
|
2923
|
+
let buildFailureCopyText = "";
|
|
2924
|
+
const buildLog = makeLogConsole(buildoffEls && buildoffEls.log);
|
|
2925
|
+
const stopBuildoffTimer = () => {
|
|
2926
|
+
if (buildoffTimer) { clearInterval(buildoffTimer); buildoffTimer = null; }
|
|
2927
|
+
};
|
|
2928
|
+
const closeBuildoff = () => {
|
|
2929
|
+
stopBuildoffTimer();
|
|
2930
|
+
if (buildoff) buildoff.hidden = true;
|
|
2931
|
+
if (buildoffOpenedFrom && document.contains(buildoffOpenedFrom)) buildoffOpenedFrom.focus();
|
|
2932
|
+
buildoffOpenedFrom = null;
|
|
2933
|
+
};
|
|
2934
|
+
const showBuildoffPhase = (phase?: any) => {
|
|
2935
|
+
if (!buildoffEls) return;
|
|
2936
|
+
buildoffEls.flight.hidden = phase !== "flight";
|
|
2937
|
+
buildoffEls.outcome.hidden = phase !== "outcome";
|
|
2938
|
+
if (buildoffCard) buildoffCard.dataset.mode = phase;
|
|
2939
|
+
};
|
|
2940
|
+
const setBuildRail = (stageState?: any) => {
|
|
2941
|
+
if (!buildoff) return;
|
|
2942
|
+
BUILD_STAGES.forEach((s) => {
|
|
2943
|
+
const el = buildoff.querySelector('[data-buildoff-stage="' + s + '"]');
|
|
2944
|
+
if (el) el.dataset.state = stageState[s];
|
|
2945
|
+
});
|
|
2946
|
+
};
|
|
2947
|
+
if (buildoffEls) {
|
|
2948
|
+
// Copy-for-AI wiring (once): the honest failure brief for a coding agent.
|
|
2949
|
+
buildoffEls.copy.addEventListener("click", () => {
|
|
2950
|
+
navigator.clipboard.writeText(buildFailureCopyText).then(
|
|
2951
|
+
() => {
|
|
2952
|
+
buildoffEls.copy.textContent = "Copied";
|
|
2953
|
+
setTimeout(() => { buildoffEls.copy.textContent = "Copy for your AI agent"; }, 1600);
|
|
2954
|
+
},
|
|
2955
|
+
() => { /* clipboard denied — the detail is selectable text */ },
|
|
2956
|
+
);
|
|
2957
|
+
});
|
|
2958
|
+
buildoffEls.done.onclick = () => closeBuildoff();
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
// Stream the build over SSE, reflecting each phase on the flight rail. Falls back
|
|
2962
|
+
// to a one-shot JSON read if the response wasn't an event-stream (a buffering
|
|
2963
|
+
// proxy, or a server that doesn't stream) so the action always completes honestly.
|
|
2964
|
+
const streamBuild = async (change?: any, button?: any) => {
|
|
2965
|
+
const label = "PR #" + change.prNumber;
|
|
2966
|
+
if (change.prNumber != null) inflightBuilds.add(change.prNumber);
|
|
2967
|
+
if (button) {
|
|
2968
|
+
button.disabled = true;
|
|
2969
|
+
button.dataset.building = "true";
|
|
2970
|
+
button.textContent = "Building…";
|
|
2971
|
+
}
|
|
2972
|
+
setStatus(statusEl, "Building preview for " + label + "…");
|
|
2973
|
+
|
|
2974
|
+
let progress: BuildProgress = initialBuildProgress();
|
|
2975
|
+
if (buildoffEls) {
|
|
2976
|
+
buildoffOpenedFrom = button || null;
|
|
2977
|
+
buildoffEls.flightTitle.textContent = "Building preview for " + label;
|
|
2978
|
+
setBuildRail(progress.stageState);
|
|
2979
|
+
buildLog.reset();
|
|
2980
|
+
buildoffEls.live.textContent = "Starting…";
|
|
2981
|
+
buildoffEls.elapsed.textContent = "0:00";
|
|
2982
|
+
if (buildoffEls.failure) buildoffEls.failure.hidden = true;
|
|
2983
|
+
if (buildoffEls.review) buildoffEls.review.hidden = true;
|
|
2984
|
+
showBuildoffPhase("flight");
|
|
2985
|
+
buildoff.hidden = false;
|
|
2986
|
+
const startedAt = Date.now();
|
|
2987
|
+
stopBuildoffTimer();
|
|
2988
|
+
buildoffTimer = setInterval(() => {
|
|
2989
|
+
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
2990
|
+
buildoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
2991
|
+
}, 1000);
|
|
2992
|
+
buildoffEls.hide.onclick = () => { buildoff.hidden = true; }; // stream keeps running
|
|
2993
|
+
buildoffEls.done.onclick = () => closeBuildoff();
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
const applyEvent = (ev: any) => {
|
|
2997
|
+
if (ev && ev.event === "phase") buildLog.pushEvent(ev);
|
|
2998
|
+
progress = reduceBuildProgress(progress, ev);
|
|
2999
|
+
if (buildoffEls) {
|
|
3000
|
+
setBuildRail(progress.stageState);
|
|
3001
|
+
buildoffEls.live.textContent = progress.liveLabel;
|
|
3002
|
+
}
|
|
3003
|
+
};
|
|
3004
|
+
|
|
3005
|
+
const renderOutcome = (result: BuildResult) => {
|
|
3006
|
+
stopBuildoffTimer();
|
|
3007
|
+
buildLog.finish(result as StreamTerminalResult);
|
|
3008
|
+
if (result.ok) {
|
|
3009
|
+
setStatus(
|
|
3010
|
+
statusEl,
|
|
3011
|
+
"Built preview for " + label + " — it now appears as a built change in the queue.",
|
|
3012
|
+
"ok",
|
|
3013
|
+
);
|
|
3014
|
+
} else if (result.outcome === "blocked") {
|
|
3015
|
+
setStatus(
|
|
3016
|
+
statusEl,
|
|
3017
|
+
"Build did not complete for " + label + ": " + (buildFailureDetail(result) || "blocked"),
|
|
3018
|
+
"err",
|
|
3019
|
+
);
|
|
3020
|
+
} else {
|
|
3021
|
+
setStatus(
|
|
3022
|
+
statusEl,
|
|
3023
|
+
(result.errors && result.errors.join("; ")) || result.error || "Build failed",
|
|
3024
|
+
"err",
|
|
3025
|
+
);
|
|
3026
|
+
}
|
|
3027
|
+
if (!buildoffEls) return;
|
|
3028
|
+
if (result.ok) {
|
|
3029
|
+
buildoffEls.seal.dataset.state = "ok";
|
|
3030
|
+
buildoffEls.outcomeTitle.textContent = "Preview built";
|
|
3031
|
+
buildoffEls.outcomeDetail.textContent =
|
|
3032
|
+
label + " is materialized — it now appears as a built change in the queue, ready to review.";
|
|
3033
|
+
if (buildoffEls.failure) buildoffEls.failure.hidden = true;
|
|
3034
|
+
if (buildoffEls.review) {
|
|
3035
|
+
if (result.previewUrl) {
|
|
3036
|
+
buildoffEls.review.href = result.previewUrl;
|
|
3037
|
+
buildoffEls.review.hidden = false;
|
|
3038
|
+
} else {
|
|
3039
|
+
buildoffEls.review.hidden = true;
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
} else {
|
|
3043
|
+
buildoffEls.seal.dataset.state = "err";
|
|
3044
|
+
buildoffEls.outcomeTitle.textContent =
|
|
3045
|
+
result.outcome === "blocked" ? "A check blocked this build" : "The build didn't finish";
|
|
3046
|
+
buildoffEls.outcomeDetail.textContent =
|
|
3047
|
+
result.outcome === "blocked"
|
|
3048
|
+
? label + " built, but a check blocked it. Here's exactly what stopped it — copy it for your AI agent to fix:"
|
|
3049
|
+
: label + " couldn't be built. Here's what the build reported — copy it for your AI agent to diagnose:";
|
|
3050
|
+
if (buildoffEls.review) buildoffEls.review.hidden = true;
|
|
3051
|
+
buildFailureCopyText = assembleBuildFailureCopy({
|
|
3052
|
+
tenant: appDomain || repo,
|
|
3053
|
+
prNumber: change.prNumber,
|
|
3054
|
+
changeId: change.changeId,
|
|
3055
|
+
result,
|
|
3056
|
+
});
|
|
3057
|
+
if (buildoffEls.failure) {
|
|
3058
|
+
buildoffEls.failure.hidden = false;
|
|
3059
|
+
buildoffEls.failureDetail.textContent = buildFailureDetail(result) || "(no detail was reported)";
|
|
3060
|
+
}
|
|
3061
|
+
// Re-surface the dialog if it was dismissed to the background — a failure
|
|
3062
|
+
// needs the operator's eyes, unlike a clean success they already walked away from.
|
|
3063
|
+
if (buildoff.hidden) buildoff.hidden = false;
|
|
3064
|
+
}
|
|
3065
|
+
showBuildoffPhase("outcome");
|
|
3066
|
+
if (!buildoff.hidden) buildoffEls.done.focus();
|
|
3067
|
+
};
|
|
3068
|
+
|
|
3069
|
+
try {
|
|
3070
|
+
const res = await fetch(ds.buildEndpoint, {
|
|
3071
|
+
method: "POST",
|
|
3072
|
+
headers: {
|
|
3073
|
+
"content-type": "application/json",
|
|
3074
|
+
accept: "text/event-stream",
|
|
3075
|
+
// Mirror of the server-side capability model (UX); the server
|
|
3076
|
+
// re-derives capability (buildGate) and stays authoritative.
|
|
3077
|
+
"x-tot-capability": "ship-on-behalf",
|
|
3078
|
+
},
|
|
3079
|
+
body: JSON.stringify({
|
|
3080
|
+
pr: change.prNumber,
|
|
3081
|
+
// A row for an ALREADY-KNOWN candidate must carry its real changeId, or
|
|
3082
|
+
// the server falls back to synthesizing "pr-<N>" and writes/reads a
|
|
3083
|
+
// SEPARATE phantom record — the real candidate's ReviewEnvironment never
|
|
3084
|
+
// advances no matter how many times "Run checks" is clicked. Omit it only
|
|
3085
|
+
// for a genuinely not-yet-built row (change.changeId is null there).
|
|
3086
|
+
changeId: change.changeId || undefined,
|
|
3087
|
+
headSha: change.headSha || undefined,
|
|
3088
|
+
baseSha: change.baseSha || undefined,
|
|
3089
|
+
}),
|
|
3090
|
+
});
|
|
3091
|
+
const ct = res.headers.get("content-type") || "";
|
|
3092
|
+
if (res.body && ct.indexOf("text/event-stream") !== -1) {
|
|
3093
|
+
const reader = res.body.getReader();
|
|
3094
|
+
const decoder = new TextDecoder();
|
|
3095
|
+
let buf = "";
|
|
3096
|
+
for (;;) {
|
|
3097
|
+
const { value, done } = await reader.read();
|
|
3098
|
+
if (done) break;
|
|
3099
|
+
buf += decoder.decode(value, { stream: true });
|
|
3100
|
+
const parsed = parseSseChunk(buf);
|
|
3101
|
+
buf = parsed.rest;
|
|
3102
|
+
for (const ev of parsed.events) applyEvent(ev);
|
|
3103
|
+
if (progress.terminal) break;
|
|
3104
|
+
}
|
|
3105
|
+
if (!progress.terminal) {
|
|
3106
|
+
// Stream ended without a terminal result — treat as an honest failure.
|
|
3107
|
+
applyEvent({
|
|
3108
|
+
event: "result",
|
|
3109
|
+
data: JSON.stringify({ ok: false, outcome: "failed", errors: ["the build stream ended unexpectedly"] }),
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
3112
|
+
renderOutcome(progress.result as BuildResult);
|
|
3113
|
+
} else {
|
|
3114
|
+
// Non-streaming fallback: read the JSON body the endpoint returns to
|
|
3115
|
+
// non-streaming callers and normalize it into the same outcome shape.
|
|
3116
|
+
const data = await res.json().catch(() => ({}) as any);
|
|
3117
|
+
renderOutcome({
|
|
3118
|
+
ok: !!data.ok,
|
|
3119
|
+
outcome: data.outcome || (data.ok ? "ok" : res.status === 422 ? "blocked" : "failed"),
|
|
3120
|
+
tenantId: data.tenantId,
|
|
3121
|
+
changeId: data.changeId,
|
|
3122
|
+
headSha: data.headSha,
|
|
3123
|
+
previewUrl: data.previewUrl,
|
|
3124
|
+
errors: data.errors,
|
|
3125
|
+
error: data.error,
|
|
3126
|
+
findings: data.findings,
|
|
3127
|
+
});
|
|
3128
|
+
}
|
|
3129
|
+
} catch {
|
|
3130
|
+
setStatus(statusEl, "Build failed — network error", "err");
|
|
3131
|
+
if (buildoffEls) {
|
|
3132
|
+
stopBuildoffTimer();
|
|
3133
|
+
buildoffEls.seal.dataset.state = "err";
|
|
3134
|
+
buildoffEls.outcomeTitle.textContent = "The build didn't finish";
|
|
3135
|
+
buildoffEls.outcomeDetail.textContent =
|
|
3136
|
+
"Network error mid-build — refresh the queue to see the honest state.";
|
|
3137
|
+
if (buildoffEls.failure) buildoffEls.failure.hidden = true;
|
|
3138
|
+
if (buildoffEls.review) buildoffEls.review.hidden = true;
|
|
3139
|
+
if (buildoff.hidden) buildoff.hidden = false;
|
|
3140
|
+
showBuildoffPhase("outcome");
|
|
3141
|
+
}
|
|
3142
|
+
} finally {
|
|
3143
|
+
if (change.prNumber != null) inflightBuilds.delete(change.prNumber);
|
|
3144
|
+
if (button) {
|
|
3145
|
+
button.disabled = false;
|
|
3146
|
+
delete button.dataset.building;
|
|
3147
|
+
}
|
|
3148
|
+
loadQueue();
|
|
3149
|
+
}
|
|
3150
|
+
};
|
|
3151
|
+
|
|
3152
|
+
const buildPreview = async (change?: any, button?: any) => {
|
|
3153
|
+
if (!window.confirm(buildPreviewPlan(change) + "\n\nBuild the preview now?")) return;
|
|
3154
|
+
await streamBuild(change, button);
|
|
3155
|
+
};
|
|
3156
|
+
|
|
3157
|
+
// ---- rux6: unified review pane -----------------------------------------
|
|
3158
|
+
// "One review moment": expanding a candidate row reveals a single pane with
|
|
3159
|
+
// the live preview (iframed SAME-ORIGIN — the /<tenant>/preview/pr/<N> route
|
|
3160
|
+
// serves `frame-ancestors 'self'` + `X-Frame-Options: SAMEORIGIN`, so a
|
|
3161
|
+
// same-origin admin embed is permitted; a cross-origin embed is refused,
|
|
3162
|
+
// which is why the pane also always offers an "Open in new tab" link), the
|
|
3163
|
+
// evidence chips the row already carries (change.evidence), the dg7 diff
|
|
3164
|
+
// (reusing renderDiff below — GET /api/changes/<id>/diff, which never invents
|
|
3165
|
+
// a diff for a non-renderable candidate), and the SAME Accept-to-preview /
|
|
3166
|
+
// Reject actions. The reviewer never leaves the queue to review and act; the
|
|
3167
|
+
// server stays authoritative for every action (this is UI consolidation only).
|
|
3168
|
+
|
|
3169
|
+
const diffBase = ds.diffEndpointBase;
|
|
3170
|
+
|
|
3171
|
+
const reviewRowFor = (tr?: any) => {
|
|
3172
|
+
const next = tr.nextElementSibling;
|
|
3173
|
+
return next && next.dataset && next.dataset.publishReviewRow === tr.dataset.changeRowId
|
|
3174
|
+
? next
|
|
3175
|
+
: null;
|
|
3176
|
+
};
|
|
3177
|
+
|
|
3178
|
+
const closeReview = (tr?: any, button?: any) => {
|
|
3179
|
+
const existing = reviewRowFor(tr);
|
|
3180
|
+
if (existing) existing.remove();
|
|
3181
|
+
if (button) {
|
|
3182
|
+
button.textContent = "Review";
|
|
3183
|
+
button.setAttribute("aria-expanded", "false");
|
|
3184
|
+
}
|
|
3185
|
+
};
|
|
3186
|
+
|
|
3187
|
+
// Evidence chips from the data the row already carries (change.evidence):
|
|
3188
|
+
// an overall promotable verdict + one chip per reported check.
|
|
3189
|
+
const renderEvidenceChips = (container?: any, change?: any) => {
|
|
3190
|
+
const checks = (change.evidence && change.evidence.checks) || [];
|
|
3191
|
+
const promotable = Boolean(change.evidence && change.evidence.promotable);
|
|
3192
|
+
container.append(
|
|
3193
|
+
promotable
|
|
3194
|
+
? chip("Checks passed", "green", CHIP_GLYPH.green)
|
|
3195
|
+
: chip("Checks blocked", "amber", CHIP_GLYPH.amber),
|
|
3196
|
+
);
|
|
3197
|
+
for (const c of checks) {
|
|
3198
|
+
const tone = c.status === "pass" ? "green" : c.status === "fail" ? "red" : "amber";
|
|
3199
|
+
const label = (c.label || c.id || "check") + (c.status && c.status !== "pass" ? " · " + c.status : "");
|
|
3200
|
+
const el = chip(label, tone, CHIP_GLYPH[tone]);
|
|
3201
|
+
if (c.detail) el.title = c.detail;
|
|
3202
|
+
container.append(el);
|
|
3203
|
+
}
|
|
3204
|
+
if (!checks.length) container.append(subtle("No checks reported yet"));
|
|
3205
|
+
};
|
|
3206
|
+
|
|
3207
|
+
// Build the split pane. Returns the pane node + the diff container so the
|
|
3208
|
+
// caller can populate the diff asynchronously (reusing renderDiff).
|
|
3209
|
+
const buildReviewPane = (change?: any) => {
|
|
3210
|
+
const pane = document.createElement("div");
|
|
3211
|
+
pane.className = "review-pane";
|
|
3212
|
+
|
|
3213
|
+
// Preview column — same-origin iframe of the friendly per-PR route (it
|
|
3214
|
+
// handles every state: ready render, or the not-built page). Falls back to
|
|
3215
|
+
// any server-provided previewUrl for a change-first row with no PR number.
|
|
3216
|
+
const previewUrl = candidatePrUrl(appDomain, change.prNumber) || change.previewUrl;
|
|
3217
|
+
const previewCol = document.createElement("div");
|
|
3218
|
+
previewCol.className = "review-preview";
|
|
3219
|
+
const pHead = document.createElement("div");
|
|
3220
|
+
pHead.className = "review-pane-head";
|
|
3221
|
+
const pTitle = document.createElement("b");
|
|
3222
|
+
pTitle.textContent = "Preview";
|
|
3223
|
+
pHead.append(pTitle);
|
|
3224
|
+
if (previewUrl) {
|
|
3225
|
+
const openLink = document.createElement("a");
|
|
3226
|
+
openLink.href = previewUrl;
|
|
3227
|
+
openLink.target = "_blank";
|
|
3228
|
+
openLink.rel = "noopener";
|
|
3229
|
+
openLink.textContent = "Open in new tab ↗";
|
|
3230
|
+
pHead.append(openLink);
|
|
3231
|
+
}
|
|
3232
|
+
previewCol.append(pHead);
|
|
3233
|
+
if (previewUrl) {
|
|
3234
|
+
const frame = document.createElement("iframe");
|
|
3235
|
+
frame.className = "review-frame";
|
|
3236
|
+
frame.src = previewUrl;
|
|
3237
|
+
frame.loading = "lazy";
|
|
3238
|
+
frame.title =
|
|
3239
|
+
change.prNumber != null
|
|
3240
|
+
? "Preview of PR #" + change.prNumber
|
|
3241
|
+
: "Preview of " + change.changeId;
|
|
3242
|
+
previewCol.append(frame);
|
|
3243
|
+
} else {
|
|
3244
|
+
previewCol.append(subtle("No preview is available for this change yet."));
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
// Detail column — evidence chips + diff + the same Accept/Reject actions.
|
|
3248
|
+
const detailCol = document.createElement("div");
|
|
3249
|
+
detailCol.className = "review-detail";
|
|
3250
|
+
|
|
3251
|
+
const summaryValues = [
|
|
3252
|
+
["Intent", change.intent],
|
|
3253
|
+
["User-visible effect", change.description],
|
|
3254
|
+
["Verification", change.verification],
|
|
3255
|
+
["Risk / rollback", change.riskRollback],
|
|
3256
|
+
].filter((entry) => typeof entry[1] === "string" && entry[1].trim());
|
|
3257
|
+
if ((typeof change.title === "string" && change.title.trim()) || summaryValues.length) {
|
|
3258
|
+
const summarySection = document.createElement("div");
|
|
3259
|
+
summarySection.className = "review-section";
|
|
3260
|
+
const summaryTitle = document.createElement("b");
|
|
3261
|
+
summaryTitle.textContent = change.title && change.title.trim()
|
|
3262
|
+
? change.title.trim()
|
|
3263
|
+
: "About this change";
|
|
3264
|
+
summarySection.append(summaryTitle);
|
|
3265
|
+
for (const [label, value] of summaryValues) {
|
|
3266
|
+
const line = document.createElement("div");
|
|
3267
|
+
const labelEl = document.createElement("strong");
|
|
3268
|
+
labelEl.textContent = label + ": ";
|
|
3269
|
+
line.append(labelEl, document.createTextNode(value.trim()));
|
|
3270
|
+
summarySection.append(line);
|
|
3271
|
+
}
|
|
3272
|
+
detailCol.append(summarySection);
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
const evSection = document.createElement("div");
|
|
3276
|
+
evSection.className = "review-section";
|
|
3277
|
+
const evTitle = document.createElement("b");
|
|
3278
|
+
evTitle.textContent = "Checks";
|
|
3279
|
+
const evChips = document.createElement("div");
|
|
3280
|
+
evChips.className = "review-chips";
|
|
3281
|
+
renderEvidenceChips(evChips, change);
|
|
3282
|
+
evSection.append(evTitle, evChips);
|
|
3283
|
+
detailCol.append(evSection);
|
|
3284
|
+
|
|
3285
|
+
const diffSection = document.createElement("div");
|
|
3286
|
+
diffSection.className = "review-section";
|
|
3287
|
+
const diffTitle = document.createElement("b");
|
|
3288
|
+
diffTitle.textContent = "Diff";
|
|
3289
|
+
const diffTarget = document.createElement("div");
|
|
3290
|
+
diffTarget.className = "review-diff-target";
|
|
3291
|
+
diffTarget.append(subtle("Loading the diff…"));
|
|
3292
|
+
diffSection.append(diffTitle, diffTarget);
|
|
3293
|
+
detailCol.append(diffSection);
|
|
3294
|
+
|
|
3295
|
+
// The SAME actions as the row — same handlers, same gates. Not a new authz
|
|
3296
|
+
// path: integrateCandidate/rejectChange POST to the same endpoints, and the
|
|
3297
|
+
// server re-authorizes.
|
|
3298
|
+
const actionRow = document.createElement("div");
|
|
3299
|
+
actionRow.className = "review-actions";
|
|
3300
|
+
const accept = document.createElement("button");
|
|
3301
|
+
accept.type = "button";
|
|
3302
|
+
accept.className = "btn primary";
|
|
3303
|
+
const needsPreviewUpdate =
|
|
3304
|
+
change.status === "ready" &&
|
|
3305
|
+
Boolean(change.evidence && change.evidence.promotable) &&
|
|
3306
|
+
change.mergeable === false;
|
|
3307
|
+
const needsChecks = !Boolean(change.evidence && change.evidence.promotable) &&
|
|
3308
|
+
(change.status === "queued" || change.status === "reconciling");
|
|
3309
|
+
if (needsChecks) {
|
|
3310
|
+
const build = document.createElement("button");
|
|
3311
|
+
build.type = "button";
|
|
3312
|
+
build.className = "btn primary";
|
|
3313
|
+
build.textContent = "Run checks";
|
|
3314
|
+
build.disabled = !canShip || (change.prNumber != null && inflightBuilds.has(change.prNumber));
|
|
3315
|
+
build.title = "Re-run this PR's preview build and checks (does not merge or go live)";
|
|
3316
|
+
build.addEventListener("click", () => buildPreview(change, build));
|
|
3317
|
+
actionRow.append(build);
|
|
3318
|
+
}
|
|
3319
|
+
// Only ONE primary action at a time — see the queue-row fix above.
|
|
3320
|
+
if (!needsChecks) {
|
|
3321
|
+
accept.textContent = needsPreviewUpdate ? "Update change" : "Add to release";
|
|
3322
|
+
accept.disabled = !canShip || (!needsPreviewUpdate && !change.mergeable);
|
|
3323
|
+
accept.title = !canShip
|
|
3324
|
+
? "Requires a ship-on-behalf grant on your account — ask the store owner"
|
|
3325
|
+
: needsPreviewUpdate
|
|
3326
|
+
? "Preview changed while you worked — update this change, then add it"
|
|
3327
|
+
: !change.mergeable
|
|
3328
|
+
? "This change isn't ready to add yet"
|
|
3329
|
+
: "Add this change to your next release (doesn't go live yet)";
|
|
3330
|
+
accept.addEventListener("click", () =>
|
|
3331
|
+
needsPreviewUpdate ? openConflictRecovery(change, accept) : integrateCandidate(change, accept),
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
3334
|
+
const reject = document.createElement("button");
|
|
3335
|
+
reject.type = "button";
|
|
3336
|
+
reject.className = "btn";
|
|
3337
|
+
reject.textContent = "Reject";
|
|
3338
|
+
reject.disabled = !canShip;
|
|
3339
|
+
reject.title = canShip
|
|
3340
|
+
? "Close this change without publishing"
|
|
3341
|
+
: "Requires an owner or admin sign-in";
|
|
3342
|
+
reject.addEventListener("click", () => rejectChange(change, reject));
|
|
3343
|
+
actionRow.append(...(needsChecks ? [] : [accept]), reject);
|
|
3344
|
+
detailCol.append(actionRow);
|
|
3345
|
+
|
|
3346
|
+
pane.append(previewCol, detailCol);
|
|
3347
|
+
return { pane, diffTarget };
|
|
3348
|
+
};
|
|
3349
|
+
|
|
3350
|
+
// A candidate's diff at a fixed head sha is immutable, so re-opening a review
|
|
3351
|
+
// pane for the same changeId@headSha serves from cache without re-fetching.
|
|
3352
|
+
const diffCache = createKeyedCache();
|
|
3353
|
+
const loadReviewDiff = async (change?: any, diffTarget?: any) => {
|
|
3354
|
+
const cacheKey = change.changeId + "@" + (change.headSha || "");
|
|
3355
|
+
const cached = diffCache.get(cacheKey);
|
|
3356
|
+
if (cached) {
|
|
3357
|
+
diffTarget.textContent = "";
|
|
3358
|
+
renderDiff(diffTarget, cached);
|
|
3359
|
+
return;
|
|
3360
|
+
}
|
|
3361
|
+
try {
|
|
3362
|
+
const res = await fetch(diffBase + "/" + encodeURIComponent(change.changeId) + "/diff", {
|
|
3363
|
+
headers: { accept: "application/json" },
|
|
3364
|
+
});
|
|
3365
|
+
const data = await res.json().catch(() => ({}));
|
|
3366
|
+
diffTarget.textContent = "";
|
|
3367
|
+
if (!res.ok) {
|
|
3368
|
+
diffTarget.append(subtle(data.error || "Could not load the diff (" + res.status + ")"));
|
|
3369
|
+
} else {
|
|
3370
|
+
const diff = data.diff || {};
|
|
3371
|
+
diffCache.set(cacheKey, diff);
|
|
3372
|
+
renderDiff(diffTarget, diff);
|
|
3373
|
+
}
|
|
3374
|
+
} catch {
|
|
3375
|
+
diffTarget.textContent = "";
|
|
3376
|
+
diffTarget.append(subtle("Could not load the diff — network error"));
|
|
3377
|
+
}
|
|
3378
|
+
};
|
|
3379
|
+
|
|
3380
|
+
const toggleReview = (change?: any, button?: any, tr?: any) => {
|
|
3381
|
+
if (!tr.dataset.changeRowId) tr.dataset.changeRowId = change.changeId;
|
|
3382
|
+
if (reviewRowFor(tr)) {
|
|
3383
|
+
closeReview(tr, button);
|
|
3384
|
+
return;
|
|
3385
|
+
}
|
|
3386
|
+
const detailTr = document.createElement("tr");
|
|
3387
|
+
detailTr.dataset.publishReviewRow = tr.dataset.changeRowId;
|
|
3388
|
+
const td = document.createElement("td");
|
|
3389
|
+
td.colSpan = 6;
|
|
3390
|
+
td.className = "publish-review-cell";
|
|
3391
|
+
const { pane, diffTarget } = buildReviewPane(change);
|
|
3392
|
+
td.append(pane);
|
|
3393
|
+
detailTr.append(td);
|
|
3394
|
+
tr.after(detailTr);
|
|
3395
|
+
button.textContent = "Hide review";
|
|
3396
|
+
button.setAttribute("aria-expanded", "true");
|
|
3397
|
+
loadReviewDiff(change, diffTarget);
|
|
3398
|
+
};
|
|
3399
|
+
|
|
3400
|
+
const CHANGE_MARK: Record<string, string> = { added: "+", changed: "~", removed: "−" };
|
|
3401
|
+
|
|
3402
|
+
const renderDiff = (td?: any, diff?: any) => {
|
|
3403
|
+
td.textContent = "";
|
|
3404
|
+
const head = document.createElement("div");
|
|
3405
|
+
head.className = "publish-diff-head";
|
|
3406
|
+
|
|
3407
|
+
// Authority: honest label of whether the diff describes the exact head.
|
|
3408
|
+
const authority = diff.authority || {};
|
|
3409
|
+
const authTone = authority.authoritative ? "green" : authority.stale ? "amber" : "";
|
|
3410
|
+
const authChip = chip(
|
|
3411
|
+
authority.authoritative
|
|
3412
|
+
? "Reviewed at head"
|
|
3413
|
+
: authority.stale
|
|
3414
|
+
? "Last-good preview (stale vs head)"
|
|
3415
|
+
: authority.missing
|
|
3416
|
+
? "No evidence yet"
|
|
3417
|
+
: "Head",
|
|
3418
|
+
authTone,
|
|
3419
|
+
CHIP_GLYPH[authTone],
|
|
3420
|
+
);
|
|
3421
|
+
head.append(authChip);
|
|
3422
|
+
|
|
3423
|
+
if (diff.headSha) {
|
|
3424
|
+
const sha = document.createElement("span");
|
|
3425
|
+
sha.className = "publish-mono subtle";
|
|
3426
|
+
sha.textContent = "head " + shortSha(diff.headSha);
|
|
3427
|
+
head.append(sha);
|
|
3428
|
+
}
|
|
3429
|
+
if (diff.revHref) {
|
|
3430
|
+
const rev = document.createElement("a");
|
|
3431
|
+
rev.href = diff.revHref;
|
|
3432
|
+
rev.target = "_blank";
|
|
3433
|
+
rev.rel = "noopener";
|
|
3434
|
+
rev.textContent = "Open reviewed revision";
|
|
3435
|
+
head.append(rev);
|
|
3436
|
+
}
|
|
3437
|
+
td.append(head);
|
|
3438
|
+
|
|
3439
|
+
if (!diff.renderable) {
|
|
3440
|
+
// Honest: a candidate that can't render has NO diff — say why (dg3), no fake.
|
|
3441
|
+
const reason = document.createElement("p");
|
|
3442
|
+
reason.className = "note publish-diff-blocked";
|
|
3443
|
+
reason.textContent =
|
|
3444
|
+
diff.blockedReason || "This change has no preview to compare yet.";
|
|
3445
|
+
td.append(reason);
|
|
3446
|
+
return;
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
const files = Array.isArray(diff.files) ? diff.files : [];
|
|
3450
|
+
if (!files.length) {
|
|
3451
|
+
td.append(subtle("No file changes vs the current live version."));
|
|
3452
|
+
return;
|
|
3453
|
+
}
|
|
3454
|
+
const list = document.createElement("ul");
|
|
3455
|
+
list.className = "publish-diff-list";
|
|
3456
|
+
for (const f of files) {
|
|
3457
|
+
const li = document.createElement("li");
|
|
3458
|
+
li.className = "publish-diff-" + f.change;
|
|
3459
|
+
const mark = document.createElement("span");
|
|
3460
|
+
mark.className = "publish-diff-mark";
|
|
3461
|
+
mark.textContent = CHANGE_MARK[f.change] || "•";
|
|
3462
|
+
const path = document.createElement("code");
|
|
3463
|
+
path.textContent = f.path;
|
|
3464
|
+
li.append(mark, path);
|
|
3465
|
+
list.append(li);
|
|
3466
|
+
}
|
|
3467
|
+
td.append(list);
|
|
3468
|
+
};
|
|
3469
|
+
|
|
3470
|
+
// ---- dg7: versions / promotion history + rollback ----------------------
|
|
3471
|
+
// The promotion history (u2/u3 promotion_status) with the tenant Gitea SHA,
|
|
3472
|
+
// its /rev link (u5), and the current-live marker; a prior version rolls back
|
|
3473
|
+
// THROUGH the one orchestrator (/api/changes/rollback → u8), rendering only the
|
|
3474
|
+
// HONEST terminal state (rolled_back only when verified).
|
|
3475
|
+
|
|
3476
|
+
const versionsPanel = root.querySelector("[data-publish-versions]");
|
|
3477
|
+
const versionsBody = versionsPanel?.querySelector("[data-versions-body]");
|
|
3478
|
+
const versionsChip = versionsPanel?.querySelector("[data-versions-chip]");
|
|
3479
|
+
const versionsStatus = versionsPanel?.querySelector("[data-versions-status]");
|
|
3480
|
+
const versionsRollbackNote = versionsPanel?.querySelector("[data-versions-rollback-note]");
|
|
3481
|
+
// Hosted and static-publish stores both render the same instant "Roll back" UX
|
|
3482
|
+
// below — every rollback is an instant KV pointer flip.
|
|
3483
|
+
|
|
3484
|
+
// r10: the post-publish "Undo" deep-link. focusRollbackTarget stashes the
|
|
3485
|
+
// version-id to undo, opens the Live Site + Rollback disclosures, forces a fresh
|
|
3486
|
+
// versions read, and scrolls the panel into view; renderVersions then applies the
|
|
3487
|
+
// pending focus once that row exists (applyRollbackFocus) — highlighting it and
|
|
3488
|
+
// focusing its "Roll back" button so the operator lands pre-targeted.
|
|
3489
|
+
let pendingRollbackFocus: string | null = null;
|
|
3490
|
+
const openLiveSiteRollback = () => {
|
|
3491
|
+
const liveSite: any = root.querySelector(".live-site-details");
|
|
3492
|
+
if (liveSite) liveSite.open = true;
|
|
3493
|
+
const versionsDetails: any = versionsPanel && versionsPanel.querySelector(".versions-details");
|
|
3494
|
+
if (versionsDetails) versionsDetails.open = true;
|
|
3495
|
+
};
|
|
3496
|
+
const applyRollbackFocus = () => {
|
|
3497
|
+
if (!pendingRollbackFocus || !versionsBody) return;
|
|
3498
|
+
let target: any = null;
|
|
3499
|
+
versionsBody.querySelectorAll("tr[data-version-id]").forEach((r: any) => {
|
|
3500
|
+
if (r.dataset.versionId === pendingRollbackFocus) target = r;
|
|
3501
|
+
});
|
|
3502
|
+
if (!target) return; // not rendered yet — a pending fetch will re-run this
|
|
3503
|
+
pendingRollbackFocus = null;
|
|
3504
|
+
target.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
3505
|
+
target.classList.remove("publish-row-highlight");
|
|
3506
|
+
void target.offsetWidth; // restart the flash if re-applied
|
|
3507
|
+
target.classList.add("publish-row-highlight");
|
|
3508
|
+
window.setTimeout(() => target.classList.remove("publish-row-highlight"), 2600);
|
|
3509
|
+
const btn: any = target.querySelector("button");
|
|
3510
|
+
if (btn) btn.focus();
|
|
3511
|
+
};
|
|
3512
|
+
const focusRollbackTarget = (versionId?: string) => {
|
|
3513
|
+
if (!versionId) return;
|
|
3514
|
+
pendingRollbackFocus = versionId;
|
|
3515
|
+
openLiveSiteRollback();
|
|
3516
|
+
loadVersions({ force: true }); // fresh read: the replaced version is now a prior row
|
|
3517
|
+
const panel = document.getElementById("publish-live-site");
|
|
3518
|
+
if (panel) panel.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
3519
|
+
};
|
|
3520
|
+
|
|
3521
|
+
const renderVersions = (view?: any) => {
|
|
3522
|
+
if (!versionsBody) return;
|
|
3523
|
+
const versions = Array.isArray(view.versions) ? view.versions : [];
|
|
3524
|
+
versionsBody.textContent = "";
|
|
3525
|
+
|
|
3526
|
+
if (versionsChip) {
|
|
3527
|
+
versionsChip.textContent = versions.length === 1 ? "1 version" : versions.length + " versions";
|
|
3528
|
+
versionsChip.className = "chip" + (versions.length ? " blue" : "");
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
// u14 — orient a store with exactly ONE live version: rollback needs a PRIOR
|
|
3532
|
+
// target, which only exists once a SECOND version is published. Only fill the
|
|
3533
|
+
// status line if nothing else has claimed it (an action/error message wins).
|
|
3534
|
+
if (versionsStatus && versions.length === 1 && !versionsStatus.textContent) {
|
|
3535
|
+
versionsStatus.textContent = "Rollback targets appear here once a second version is published.";
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
// Every rollback is an instant KV pointer flip, so there is no CI-latency
|
|
3539
|
+
// note to show.
|
|
3540
|
+
if (versionsRollbackNote) versionsRollbackNote.hidden = true;
|
|
3541
|
+
|
|
3542
|
+
if (!versions.length) {
|
|
3543
|
+
const tr = document.createElement("tr");
|
|
3544
|
+
const td = cell(
|
|
3545
|
+
subtle(
|
|
3546
|
+
"Nothing published yet. When you publish your next release it becomes your first live " +
|
|
3547
|
+
"version — and any earlier versions you can roll back to will appear here.",
|
|
3548
|
+
),
|
|
3549
|
+
);
|
|
3550
|
+
td.colSpan = 6;
|
|
3551
|
+
tr.append(td);
|
|
3552
|
+
versionsBody.append(tr);
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
for (const v of versions) {
|
|
3557
|
+
const tr = document.createElement("tr");
|
|
3558
|
+
// r10: tag the row with its version id so the post-publish "Undo" can deep-
|
|
3559
|
+
// link straight to the replaced version's rollback row (applyRollbackFocus).
|
|
3560
|
+
tr.dataset.versionId = v.versionId;
|
|
3561
|
+
|
|
3562
|
+
// Version identity: the merchant-facing "v42" counter is the primary label;
|
|
3563
|
+
// the tenant Gitea SHA is demoted to a "Technical details" disclosure below
|
|
3564
|
+
// (r02 — a raw SHA is meaningless to a merchant). Falls back to the short SHA
|
|
3565
|
+
// only when a version carries no number (an unpromoted seed), never faked.
|
|
3566
|
+
const idCell = document.createElement("td");
|
|
3567
|
+
const strong = document.createElement("strong");
|
|
3568
|
+
// r11 — the human release name leads when present; the "v42" counter falls
|
|
3569
|
+
// back when it's null (and rides along as a subtle secondary when a name shows).
|
|
3570
|
+
const releaseName = v.name || null;
|
|
3571
|
+
strong.textContent = releaseName || (v.number != null ? "v" + v.number : shortSha(v.versionId));
|
|
3572
|
+
idCell.append(strong);
|
|
3573
|
+
if (releaseName && v.number != null) idCell.append(subtle("v" + v.number));
|
|
3574
|
+
if (v.source) idCell.append(subtle(v.source === "commit" ? "From a commit" : "Content hash"));
|
|
3575
|
+
const tech = document.createElement("details");
|
|
3576
|
+
tech.className = "ver-tech";
|
|
3577
|
+
const techSummary = document.createElement("summary");
|
|
3578
|
+
techSummary.textContent = "Technical details";
|
|
3579
|
+
tech.append(techSummary);
|
|
3580
|
+
const shaSpan = document.createElement("span");
|
|
3581
|
+
shaSpan.className = "publish-mono subtle";
|
|
3582
|
+
shaSpan.textContent = shortSha(v.versionId);
|
|
3583
|
+
tech.append(shaSpan);
|
|
3584
|
+
idCell.append(tech);
|
|
3585
|
+
|
|
3586
|
+
// "What shipped" — the SAME human summary the staged Publish console shows,
|
|
3587
|
+
// now describing the RELEASE: a plain-language lead + an openable list of the
|
|
3588
|
+
// PRs this version shipped (from its release receipt). OPTIONAL server
|
|
3589
|
+
// enrichment: absent (an older version with no linked receipt) → the cell
|
|
3590
|
+
// stays just the sha + source, exactly as before (graceful fallback).
|
|
3591
|
+
const summary = v.releaseSummary || null;
|
|
3592
|
+
if (summary && (summary.lead || summary.text)) {
|
|
3593
|
+
const lead = document.createElement("p");
|
|
3594
|
+
lead.className = "ver-summary";
|
|
3595
|
+
lead.textContent = summary.lead || summary.text;
|
|
3596
|
+
idCell.append(lead);
|
|
3597
|
+
}
|
|
3598
|
+
const changes = Array.isArray(v.changes) ? v.changes : [];
|
|
3599
|
+
if (changes.length) {
|
|
3600
|
+
const details = document.createElement("details");
|
|
3601
|
+
details.className = "agg-included-details ver-changes";
|
|
3602
|
+
const sm = document.createElement("summary");
|
|
3603
|
+
const smLead = document.createElement("span");
|
|
3604
|
+
smLead.className = "agg-included-lead";
|
|
3605
|
+
smLead.textContent = "What shipped";
|
|
3606
|
+
const badge = document.createElement("span");
|
|
3607
|
+
badge.className = "agg-included-badge";
|
|
3608
|
+
badge.textContent = String(changes.length);
|
|
3609
|
+
const chev = document.createElement("span");
|
|
3610
|
+
chev.className = "agg-chev";
|
|
3611
|
+
chev.setAttribute("aria-hidden", "true");
|
|
3612
|
+
const chevSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
3613
|
+
const chevPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
3614
|
+
chevPath.setAttribute("d", "M9 6l6 6-6 6");
|
|
3615
|
+
chevSvg.setAttribute("viewBox", "0 0 24 24");
|
|
3616
|
+
chevSvg.setAttribute("fill", "none");
|
|
3617
|
+
chevSvg.setAttribute("stroke", "currentColor");
|
|
3618
|
+
chevSvg.setAttribute("stroke-width", "2.2");
|
|
3619
|
+
chevSvg.append(chevPath);
|
|
3620
|
+
chev.append(chevSvg);
|
|
3621
|
+
sm.append(smLead, badge, chev);
|
|
3622
|
+
details.append(sm);
|
|
3623
|
+
const ul = document.createElement("ul");
|
|
3624
|
+
ul.className = "agg-included-list";
|
|
3625
|
+
for (const ch of changes) ul.append(changeSummaryItem(ch));
|
|
3626
|
+
details.append(ul);
|
|
3627
|
+
idCell.append(details);
|
|
3628
|
+
}
|
|
3629
|
+
tr.append(idCell);
|
|
3630
|
+
|
|
3631
|
+
// Digest (static current only — never fabricated for priors).
|
|
3632
|
+
const digestCell = cell(v.digest ? shortSha(v.digest) : "—");
|
|
3633
|
+
digestCell.classList.add("publish-mono");
|
|
3634
|
+
if (!v.digest) digestCell.classList.add("subtle");
|
|
3635
|
+
tr.append(digestCell);
|
|
3636
|
+
|
|
3637
|
+
tr.append(cell(age(v.promotedAt)));
|
|
3638
|
+
|
|
3639
|
+
// Preview: the u5 immutable /rev/<sha> link.
|
|
3640
|
+
const revCell = document.createElement("td");
|
|
3641
|
+
if (v.revHref) {
|
|
3642
|
+
const a = document.createElement("a");
|
|
3643
|
+
a.href = v.revHref;
|
|
3644
|
+
a.target = "_blank";
|
|
3645
|
+
a.rel = "noopener";
|
|
3646
|
+
a.textContent = "Revision";
|
|
3647
|
+
revCell.append(a);
|
|
3648
|
+
} else {
|
|
3649
|
+
revCell.append(subtle("—"));
|
|
3650
|
+
}
|
|
3651
|
+
tr.append(revCell);
|
|
3652
|
+
|
|
3653
|
+
// State: current live vs prior.
|
|
3654
|
+
tr.append(
|
|
3655
|
+
cell(
|
|
3656
|
+
v.isCurrent
|
|
3657
|
+
? chip("Current live", "green", CHIP_GLYPH.green)
|
|
3658
|
+
: chip("Prior", "", CHIP_GLYPH[""]),
|
|
3659
|
+
),
|
|
3660
|
+
);
|
|
3661
|
+
|
|
3662
|
+
// Action: roll back to a PRIOR version (never the current one).
|
|
3663
|
+
const act = document.createElement("td");
|
|
3664
|
+
act.className = "action publish-actions";
|
|
3665
|
+
if (!v.isCurrent) {
|
|
3666
|
+
const rollbackBtn = document.createElement("button");
|
|
3667
|
+
rollbackBtn.type = "button";
|
|
3668
|
+
rollbackBtn.className = "btn";
|
|
3669
|
+
rollbackBtn.textContent = "Roll back";
|
|
3670
|
+
rollbackBtn.disabled = !canShip;
|
|
3671
|
+
rollbackBtn.title = !canShip
|
|
3672
|
+
? "Requires an owner or ship-on-behalf sign-in"
|
|
3673
|
+
: "Roll the live site back to this version";
|
|
3674
|
+
rollbackBtn.addEventListener("click", () => rollbackVersion(v, rollbackBtn));
|
|
3675
|
+
act.append(rollbackBtn);
|
|
3676
|
+
}
|
|
3677
|
+
tr.append(act);
|
|
3678
|
+
|
|
3679
|
+
versionsBody.append(tr);
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
// r10: land a pending post-publish "Undo" on the replaced version's row now
|
|
3683
|
+
// that the rows exist.
|
|
3684
|
+
applyRollbackFocus();
|
|
3685
|
+
};
|
|
3686
|
+
|
|
3687
|
+
// Published versions only change when something is published, which moves the
|
|
3688
|
+
// change-queue fingerprint (currentStateKey). Cache the view against that key
|
|
3689
|
+
// so re-viewing the panel or a re-render doesn't re-fire the read; refetch
|
|
3690
|
+
// only when the fingerprint changes or on an explicit refresh (force).
|
|
3691
|
+
const versionsCache = createKeyedCache();
|
|
3692
|
+
const loadVersionsImpl = async () => {
|
|
3693
|
+
if (!versionsBody) return;
|
|
3694
|
+
const key = currentStateKey;
|
|
3695
|
+
if (key != null && versionsCache.has(key)) {
|
|
3696
|
+
renderVersions(versionsCache.get(key));
|
|
3697
|
+
return;
|
|
3698
|
+
}
|
|
3699
|
+
try {
|
|
3700
|
+
const res = await fetch(ds.versionsEndpoint, { headers: { accept: "application/json" } });
|
|
3701
|
+
if (res.status === 401 || res.status === 403) {
|
|
3702
|
+
if (versionsChip) versionsChip.textContent = "Sign-in required";
|
|
3703
|
+
versionsBody.textContent = "";
|
|
3704
|
+
const tr = document.createElement("tr");
|
|
3705
|
+
const td = cell(subtle("Sign in as a store owner or admin to view published versions."));
|
|
3706
|
+
td.colSpan = 6;
|
|
3707
|
+
tr.append(td);
|
|
3708
|
+
versionsBody.append(tr);
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
const data = await res.json().catch(() => ({}));
|
|
3712
|
+
if (!res.ok) {
|
|
3713
|
+
setStatus(versionsStatus, data.error || "Could not load versions (" + res.status + ")", "err");
|
|
3714
|
+
return;
|
|
3715
|
+
}
|
|
3716
|
+
const view = data.view || {};
|
|
3717
|
+
if (key != null) versionsCache.set(key, view);
|
|
3718
|
+
renderVersions(view);
|
|
3719
|
+
} catch {
|
|
3720
|
+
setStatus(versionsStatus, "Could not load versions — network error", "err");
|
|
3721
|
+
}
|
|
3722
|
+
};
|
|
3723
|
+
const versionsCoalescer = createCoalescer(loadVersionsImpl);
|
|
3724
|
+
const loadVersions = (opts?: any) => {
|
|
3725
|
+
// force (explicit refresh / post-action) drops the cached entry so the
|
|
3726
|
+
// next run re-fetches even if the fingerprint hasn't caught up yet.
|
|
3727
|
+
if (opts && opts.force && currentStateKey != null) versionsCache.delete(currentStateKey);
|
|
3728
|
+
return versionsCoalescer.trigger();
|
|
3729
|
+
};
|
|
3730
|
+
|
|
3731
|
+
// Interpret the rollback response into ONE honest terminal state. Kept local
|
|
3732
|
+
// (NOT imported from lib/publish/shipWorkspace's interpretRollbackResponse):
|
|
3733
|
+
// that module pulls lib/preview value dependencies, which must not be dragged
|
|
3734
|
+
// into this client bundle. `rolledBack` is
|
|
3735
|
+
// true ONLY for a verified `rolled_back` — a `publish_pending` is NEVER a
|
|
3736
|
+
// rolled-back claim.
|
|
3737
|
+
const interpretRollback = (httpOk?: any, body?: any) => {
|
|
3738
|
+
const toVersionId = typeof body.toVersionId === "string" ? body.toVersionId : null;
|
|
3739
|
+
if (typeof body.state === "string") {
|
|
3740
|
+
const state = ["rolled_back", "publish_pending", "publish_failed", "blocked"].includes(body.state)
|
|
3741
|
+
? body.state
|
|
3742
|
+
: "error";
|
|
3743
|
+
return {
|
|
3744
|
+
state,
|
|
3745
|
+
rolledBack: state === "rolled_back",
|
|
3746
|
+
message: body.message || body.error || "Rollback returned state " + state + ".",
|
|
3747
|
+
};
|
|
3748
|
+
}
|
|
3749
|
+
if (httpOk && body.rolledBack === true) {
|
|
3750
|
+
return {
|
|
3751
|
+
state: "rolled_back",
|
|
3752
|
+
rolledBack: true,
|
|
3753
|
+
message: toVersionId ? "Rolled back live to " + shortSha(toVersionId) + "." : "Rolled back live.",
|
|
3754
|
+
};
|
|
3755
|
+
}
|
|
3756
|
+
return {
|
|
3757
|
+
state: "blocked",
|
|
3758
|
+
rolledBack: false,
|
|
3759
|
+
message: (body.error || "Rollback failed") + (body.reason ? " (" + body.reason + ")" : ""),
|
|
3760
|
+
};
|
|
3761
|
+
};
|
|
3762
|
+
|
|
3763
|
+
const rollbackVersion = async (version?: any, button?: any) => {
|
|
3764
|
+
// Every rollback is an instant KV pointer flip — the confirm/status copy is
|
|
3765
|
+
// the same for a hosted and a static-publish store.
|
|
3766
|
+
const confirmMsg =
|
|
3767
|
+
"Roll the live store back to version " + shortSha(version.versionId) +
|
|
3768
|
+
"? This publishes that prior version to the live site.";
|
|
3769
|
+
if (!window.confirm(confirmMsg)) return;
|
|
3770
|
+
button.disabled = true;
|
|
3771
|
+
setStatus(versionsStatus, "Rolling back to " + shortSha(version.versionId) + "…");
|
|
3772
|
+
try {
|
|
3773
|
+
// The console dispatches by version id only; a static prior version's
|
|
3774
|
+
// archive digest lives only in S3 (CI resolves it). Pass a digest ONLY when
|
|
3775
|
+
// we honestly have one (never for a static prior — its digest is always null).
|
|
3776
|
+
const payload: any = { targetVersionId: version.versionId };
|
|
3777
|
+
if (version.digest) payload.targetDigest = version.digest;
|
|
3778
|
+
const res = await fetch(ds.rollbackEndpoint, {
|
|
3779
|
+
method: "POST",
|
|
3780
|
+
headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
|
|
3781
|
+
body: JSON.stringify(payload),
|
|
3782
|
+
});
|
|
3783
|
+
const data = await res.json().catch(() => ({}));
|
|
3784
|
+
const outcome = interpretRollback(res.ok, data);
|
|
3785
|
+
setStatus(versionsStatus, outcome.message, outcome.rolledBack ? "ok" : outcome.state === "publish_pending" ? "ok" : "err");
|
|
3786
|
+
} catch {
|
|
3787
|
+
setStatus(versionsStatus, "Rollback failed — network error", "err");
|
|
3788
|
+
} finally {
|
|
3789
|
+
button.disabled = false;
|
|
3790
|
+
loadVersions({ force: true }); // a rollback changed the live version
|
|
3791
|
+
loadQueue();
|
|
3792
|
+
}
|
|
3793
|
+
};
|
|
3794
|
+
|
|
3795
|
+
// ---- Domain section ----------------------------------------------------
|
|
3796
|
+
|
|
3797
|
+
const domainPanel = root.querySelector("[data-publish-domain]");
|
|
3798
|
+
let domainPollTimer: any = null;
|
|
3799
|
+
|
|
3800
|
+
const renderDomain = (payload?: any) => {
|
|
3801
|
+
if (!domainPanel) return;
|
|
3802
|
+
const domain = payload.domain || {};
|
|
3803
|
+
const stateEl = domainPanel.querySelector("[data-domain-state]");
|
|
3804
|
+
const stateChip = domainPanel.querySelector("[data-domain-state-chip]");
|
|
3805
|
+
const sinceEl = domainPanel.querySelector("[data-domain-since]");
|
|
3806
|
+
const captureEl = domainPanel.querySelector("[data-domain-capture]");
|
|
3807
|
+
const runEl = domainPanel.querySelector("[data-domain-run]");
|
|
3808
|
+
const runDetailEl = domainPanel.querySelector("[data-domain-run-detail]");
|
|
3809
|
+
const rollbackBtn = domainPanel.querySelector("[data-domain-rollback]");
|
|
3810
|
+
const rollbackDetail = domainPanel.querySelector("[data-domain-rollback-detail]");
|
|
3811
|
+
const connected = domain.state === "cloudfront";
|
|
3812
|
+
|
|
3813
|
+
if (stateEl) {
|
|
3814
|
+
stateEl.textContent = connected
|
|
3815
|
+
? "Storefront CDN (cloudfront)"
|
|
3816
|
+
: "Previous host (pantheon)";
|
|
3817
|
+
}
|
|
3818
|
+
if (stateChip) {
|
|
3819
|
+
const tone = connected ? "green" : "amber";
|
|
3820
|
+
paintChip(stateChip, connected ? "Connected" : "Not connected", tone, CHIP_GLYPH[tone]);
|
|
3821
|
+
}
|
|
3822
|
+
if (sinceEl) {
|
|
3823
|
+
sinceEl.textContent = domain.since
|
|
3824
|
+
? "Since " + new Date(domain.since).toLocaleString()
|
|
3825
|
+
: "Current host for " + (payload.appDomain || ds.appDomain);
|
|
3826
|
+
}
|
|
3827
|
+
if (captureEl) {
|
|
3828
|
+
captureEl.textContent = domain.priorCapturedAt
|
|
3829
|
+
? "Captured " + new Date(domain.priorCapturedAt).toLocaleString()
|
|
3830
|
+
: "None captured";
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
const run = domain.lastRun;
|
|
3834
|
+
// "dispatched" = awaiting the CI completion callback (terminal-only wire).
|
|
3835
|
+
const inFlight = run && run.status === "dispatched";
|
|
3836
|
+
if (runEl) {
|
|
3837
|
+
runEl.textContent = run
|
|
3838
|
+
? run.action + (run.rehearsal ? " (rehearsal)" : "") + " · " + run.status
|
|
3839
|
+
: "None";
|
|
3840
|
+
}
|
|
3841
|
+
if (runDetailEl) {
|
|
3842
|
+
runDetailEl.textContent = "";
|
|
3843
|
+
if (run) {
|
|
3844
|
+
const parts = [];
|
|
3845
|
+
if (run.actor) parts.push("by " + run.actor);
|
|
3846
|
+
if (run.startedAt) parts.push(age(run.startedAt));
|
|
3847
|
+
runDetailEl.append(document.createTextNode(parts.join(" · ")));
|
|
3848
|
+
if (run.runUrl) {
|
|
3849
|
+
const link = document.createElement("a");
|
|
3850
|
+
link.href = run.runUrl;
|
|
3851
|
+
link.target = "_blank";
|
|
3852
|
+
link.rel = "noopener";
|
|
3853
|
+
link.textContent = "View run";
|
|
3854
|
+
runDetailEl.append(document.createTextNode(" · "));
|
|
3855
|
+
runDetailEl.append(link);
|
|
3856
|
+
}
|
|
3857
|
+
if (run.status === "failed" && run.error) {
|
|
3858
|
+
const err = document.createElement("span");
|
|
3859
|
+
err.className = "publish-error-detail";
|
|
3860
|
+
err.textContent = run.error;
|
|
3861
|
+
runDetailEl.append(document.createElement("br"));
|
|
3862
|
+
runDetailEl.append(err);
|
|
3863
|
+
}
|
|
3864
|
+
} else {
|
|
3865
|
+
runDetailEl.textContent = "Connect and rollback runs appear here.";
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
if (rollbackBtn) {
|
|
3869
|
+
rollbackBtn.disabled =
|
|
3870
|
+
!canShip || inFlight || !domain.priorRecordsKey;
|
|
3871
|
+
}
|
|
3872
|
+
if (rollbackDetail && domain.priorCapturedAt) {
|
|
3873
|
+
rollbackDetail.textContent =
|
|
3874
|
+
"Restores the DNS records captured " +
|
|
3875
|
+
new Date(domain.priorCapturedAt).toLocaleString() + ".";
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
// Poll while a run is in flight so completion lands without a manual refresh.
|
|
3879
|
+
if (domainPollTimer) {
|
|
3880
|
+
clearTimeout(domainPollTimer);
|
|
3881
|
+
domainPollTimer = null;
|
|
3882
|
+
}
|
|
3883
|
+
if (inFlight) domainPollTimer = setTimeout(loadDomain, 15000);
|
|
3884
|
+
};
|
|
3885
|
+
|
|
3886
|
+
const loadDomain = async () => {
|
|
3887
|
+
if (!domainPanel) return;
|
|
3888
|
+
try {
|
|
3889
|
+
const res = await fetch(ds.domainStatusEndpoint, {
|
|
3890
|
+
headers: { accept: "application/json" },
|
|
3891
|
+
});
|
|
3892
|
+
if (!res.ok) return;
|
|
3893
|
+
renderDomain(await res.json());
|
|
3894
|
+
} catch {
|
|
3895
|
+
/* leave the last rendered state */
|
|
3896
|
+
}
|
|
3897
|
+
// u9: refresh the readiness itemization alongside the status.
|
|
3898
|
+
loadReadiness();
|
|
3899
|
+
};
|
|
3900
|
+
|
|
3901
|
+
// ---- Cutover readiness (u9) --------------------------------------------
|
|
3902
|
+
// Reads the SAME server gate `tot go-live` reads (/api/domain/readiness) and
|
|
3903
|
+
// itemizes the preconditions. The Connect button additionally requires the
|
|
3904
|
+
// server to report ready — the display and the enforcement can't diverge.
|
|
3905
|
+
const readinessBox = domainPanel?.querySelector("[data-domain-readiness]");
|
|
3906
|
+
const connectBtn = domainPanel?.querySelector("[data-domain-connect]");
|
|
3907
|
+
let domainReady = false;
|
|
3908
|
+
|
|
3909
|
+
const renderReadiness = (payload?: any) => {
|
|
3910
|
+
if (!readinessBox) return;
|
|
3911
|
+
const readiness = (payload && payload.readiness) || {};
|
|
3912
|
+
const checks = Array.isArray(readiness.checks) ? readiness.checks : [];
|
|
3913
|
+
domainReady = readiness.ready === true;
|
|
3914
|
+
readinessBox.hidden = false;
|
|
3915
|
+
|
|
3916
|
+
const chip = readinessBox.querySelector("[data-domain-readiness-chip]");
|
|
3917
|
+
if (chip) {
|
|
3918
|
+
const tone = domainReady ? "green" : "amber";
|
|
3919
|
+
paintChip(chip, domainReady ? "Ready" : "Not ready", tone, CHIP_GLYPH[tone]);
|
|
3920
|
+
}
|
|
3921
|
+
const list = readinessBox.querySelector("[data-domain-readiness-list]");
|
|
3922
|
+
if (list) {
|
|
3923
|
+
list.textContent = "";
|
|
3924
|
+
for (const c of checks) {
|
|
3925
|
+
const li = document.createElement("li");
|
|
3926
|
+
li.className = c.ok ? "ok" : "blocked";
|
|
3927
|
+
// Header row: mark + bold label. The detail and remedy render on their
|
|
3928
|
+
// OWN lines below (not as inline siblings of the label) so the label and
|
|
3929
|
+
// its description never run together as one string.
|
|
3930
|
+
const head = document.createElement("div");
|
|
3931
|
+
head.className = "domain-readiness-item-head";
|
|
3932
|
+
const mark = document.createElement("span");
|
|
3933
|
+
mark.className = "domain-readiness-mark";
|
|
3934
|
+
mark.textContent = c.ok ? "✓" : "✗";
|
|
3935
|
+
const label = document.createElement("b");
|
|
3936
|
+
label.textContent = c.label || c.id || "";
|
|
3937
|
+
head.append(mark, label);
|
|
3938
|
+
li.append(head);
|
|
3939
|
+
if (c.detail) {
|
|
3940
|
+
const detail = document.createElement("p");
|
|
3941
|
+
detail.className = "domain-readiness-detail";
|
|
3942
|
+
detail.textContent = c.detail;
|
|
3943
|
+
li.append(detail);
|
|
3944
|
+
}
|
|
3945
|
+
// Blocked checks carry a "how to clear this" remedy from the SAME gate
|
|
3946
|
+
// `tot go-live` reads — the guide lives with the enforcement, never a
|
|
3947
|
+
// divergent side note.
|
|
3948
|
+
if (!c.ok && c.remedy) {
|
|
3949
|
+
const remedy = document.createElement("p");
|
|
3950
|
+
remedy.className = "domain-readiness-remedy";
|
|
3951
|
+
remedy.textContent = "→ " + c.remedy;
|
|
3952
|
+
li.append(remedy);
|
|
3953
|
+
}
|
|
3954
|
+
list.append(li);
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
const note = readinessBox.querySelector("[data-domain-readiness-note]");
|
|
3958
|
+
if (note) {
|
|
3959
|
+
note.textContent = payload && payload.ownerCapable === false
|
|
3960
|
+
? "Apex cutover is owner-only — sign in as the store owner to connect the domain."
|
|
3961
|
+
: domainReady
|
|
3962
|
+
? "All preconditions are green — the cutover is permitted."
|
|
3963
|
+
: "Clear the failing preconditions above before connecting the apex.";
|
|
3964
|
+
}
|
|
3965
|
+
// Gate the Connect button on server readiness in ADDITION to owner (the
|
|
3966
|
+
// markup already disables it for non-owners); never enable it when blocked.
|
|
3967
|
+
if (connectBtn && !connectBtn.dataset.ownerLocked) {
|
|
3968
|
+
connectBtn.disabled = !domainReady;
|
|
3969
|
+
}
|
|
3970
|
+
};
|
|
3971
|
+
|
|
3972
|
+
const loadReadiness = async () => {
|
|
3973
|
+
if (!readinessBox || !ds.domainReadinessEndpoint) return;
|
|
3974
|
+
try {
|
|
3975
|
+
const res = await fetch(ds.domainReadinessEndpoint, {
|
|
3976
|
+
headers: { accept: "application/json", "x-tot-capability": "ship-on-behalf" },
|
|
3977
|
+
});
|
|
3978
|
+
if (!res.ok) return;
|
|
3979
|
+
renderReadiness(await res.json());
|
|
3980
|
+
} catch {
|
|
3981
|
+
/* leave the last rendered readiness */
|
|
3982
|
+
}
|
|
3983
|
+
};
|
|
3984
|
+
|
|
3985
|
+
const domainStatusEl = domainPanel?.querySelector("[data-domain-status]");
|
|
3986
|
+
|
|
3987
|
+
const domainAction = async (body?: any, button?: any) => {
|
|
3988
|
+
button.disabled = true;
|
|
3989
|
+
setStatus(domainStatusEl, (body.action === "connect" ? "Connecting" : "Rolling back") + "…");
|
|
3990
|
+
try {
|
|
3991
|
+
const res = await fetch(ds.domainDispatchEndpoint, {
|
|
3992
|
+
method: "POST",
|
|
3993
|
+
headers: {
|
|
3994
|
+
"content-type": "application/json",
|
|
3995
|
+
"x-tot-capability": "ship-on-behalf",
|
|
3996
|
+
},
|
|
3997
|
+
body: JSON.stringify(body),
|
|
3998
|
+
});
|
|
3999
|
+
const data = await res.json().catch(() => ({}));
|
|
4000
|
+
if (res.ok && data.dispatched) {
|
|
4001
|
+
setStatus(
|
|
4002
|
+
domainStatusEl,
|
|
4003
|
+
(body.action === "connect" ? "Connect" : "Rollback") +
|
|
4004
|
+
(body.rehearsal ? " rehearsal" : "") +
|
|
4005
|
+
" dispatched — the run status updates below as it progresses.",
|
|
4006
|
+
"ok",
|
|
4007
|
+
);
|
|
4008
|
+
} else {
|
|
4009
|
+
setStatus(domainStatusEl, data.error || "Request failed (" + res.status + ")", "err");
|
|
4010
|
+
}
|
|
4011
|
+
} catch {
|
|
4012
|
+
setStatus(domainStatusEl, "Request failed — network error", "err");
|
|
4013
|
+
} finally {
|
|
4014
|
+
button.disabled = false;
|
|
4015
|
+
loadDomain();
|
|
4016
|
+
}
|
|
4017
|
+
};
|
|
4018
|
+
|
|
4019
|
+
domainPanel?.querySelector("[data-domain-connect]")?.addEventListener("click", (event: any) => {
|
|
4020
|
+
const confirmInput = domainPanel.querySelector("[data-domain-confirm]");
|
|
4021
|
+
const rehearsal = Boolean(domainPanel.querySelector("[data-domain-rehearsal]")?.checked);
|
|
4022
|
+
domainAction(
|
|
4023
|
+
{
|
|
4024
|
+
action: "connect",
|
|
4025
|
+
confirmDomain: (confirmInput?.value || "").trim(),
|
|
4026
|
+
rehearsal,
|
|
4027
|
+
},
|
|
4028
|
+
event.currentTarget,
|
|
4029
|
+
);
|
|
4030
|
+
});
|
|
4031
|
+
|
|
4032
|
+
domainPanel?.querySelector("[data-domain-rollback]")?.addEventListener("click", (event: any) => {
|
|
4033
|
+
if (
|
|
4034
|
+
!window.confirm(
|
|
4035
|
+
"Roll back " + ds.appDomain + " to the previous host? DNS returns to the captured records in about a minute.",
|
|
4036
|
+
)
|
|
4037
|
+
) {
|
|
4038
|
+
return;
|
|
4039
|
+
}
|
|
4040
|
+
domainAction({ action: "rollback" }, event.currentTarget);
|
|
4041
|
+
});
|
|
4042
|
+
|
|
4043
|
+
// ---- b17: branch retention ----------------------------------------------
|
|
4044
|
+
// Renders the GET /api/changes/branches dry-run classification (already
|
|
4045
|
+
// projected server-side by branchRetentionView.ts — this script only
|
|
4046
|
+
// paints it, same division of labor as renderQueue/renderVersions above)
|
|
4047
|
+
// and drives the guarded delete. The server (candidate_delete, b13)
|
|
4048
|
+
// re-classifies fresh on every delete, so the confirm below is the HUMAN
|
|
4049
|
+
// gate, not the safety guarantee — the safety guarantee is server-side.
|
|
4050
|
+
|
|
4051
|
+
const branchesPanel = root.querySelector("[data-publish-branches]");
|
|
4052
|
+
const branchesBody = branchesPanel?.querySelector("[data-branches-body]");
|
|
4053
|
+
const branchesChip = branchesPanel?.querySelector("[data-branches-chip]");
|
|
4054
|
+
const branchesStatus = branchesPanel?.querySelector("[data-branches-status]");
|
|
4055
|
+
const branchesActions = branchesPanel?.querySelector("[data-branches-actions]");
|
|
4056
|
+
const branchesSweepBtn = branchesPanel?.querySelector("[data-branches-sweep]");
|
|
4057
|
+
|
|
4058
|
+
// Kept local (NOT imported from branchRetentionView): that module pulls a
|
|
4059
|
+
// lib/forge value dependency, which must not be dragged into this client bundle.
|
|
4060
|
+
// Shown when a live classification fetch fails — the table must NEVER keep the
|
|
4061
|
+
// last-known chips (a confident stale label is exactly the b1 bug).
|
|
4062
|
+
const FORGE_UNREACHABLE_LABEL = "State unknown (forge unreachable)";
|
|
4063
|
+
|
|
4064
|
+
// The last classification the panel rendered from a SUCCESSFUL fetch. Kept so a
|
|
4065
|
+
// later fetch failure can re-stamp those same branches as "state unknown" (never
|
|
4066
|
+
// leave the stale confident chips), and so an unchanged poll can skip re-render.
|
|
4067
|
+
let lastBranchesRows: any[] = [];
|
|
4068
|
+
let lastBranchesSignature: string | null = null;
|
|
4069
|
+
// Refs the "Sweep dead branches" action deletes, as pre-computed server-side
|
|
4070
|
+
// (view.sweepableRefs): Dead + delete-eligible + reachable. The UI never re-derives
|
|
4071
|
+
// the delete set — it confirms these, then loops the guarded delete.
|
|
4072
|
+
let lastSweepableRefs: any[] = [];
|
|
4073
|
+
|
|
4074
|
+
const buildCleanupPlan = (row?: any) => {
|
|
4075
|
+
return [
|
|
4076
|
+
"Delete branch plan:",
|
|
4077
|
+
" repo: " + (appDomain || repo),
|
|
4078
|
+
" branch: " + row.ref,
|
|
4079
|
+
" tip sha: " + row.shortSha,
|
|
4080
|
+
" classification: " + row.classificationLabel,
|
|
4081
|
+
" reason: " + row.reason,
|
|
4082
|
+
"",
|
|
4083
|
+
"This deletes the Git BRANCH only. Reject already closes the PR and removes",
|
|
4084
|
+
"its hosted candidate artifacts. The server re-classifies this",
|
|
4085
|
+
"branch fresh before deleting; a moved tip or a branch that is no longer",
|
|
4086
|
+
"integrated/closed-or-rejected is refused, not silently skipped.",
|
|
4087
|
+
].join("\n");
|
|
4088
|
+
};
|
|
4089
|
+
|
|
4090
|
+
// One guarded delete round-trip. Shared by the per-row "Clean up", the bulk
|
|
4091
|
+
// "Sweep dead branches", and the per-row orphan "Delete this branch" — the server
|
|
4092
|
+
// re-checks eligibility fresh on every ref. `opts` opts into the explicit HUMAN
|
|
4093
|
+
// delete (explicitConfirm + expectedClassification) for an orphan the default
|
|
4094
|
+
// eligibility path refuses; omitted, this is the default guarded delete.
|
|
4095
|
+
const postDeleteRef = async (ref?: any, expectedSha?: any, opts: any = {}) => {
|
|
4096
|
+
const body: any = { ref, expectedSha };
|
|
4097
|
+
if (opts.explicitConfirm) body.explicitConfirm = true;
|
|
4098
|
+
if (opts.expectedClassification) body.expectedClassification = opts.expectedClassification;
|
|
4099
|
+
const res = await fetch(ds.branchesEndpoint, {
|
|
4100
|
+
method: "POST",
|
|
4101
|
+
headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
|
|
4102
|
+
body: JSON.stringify(body),
|
|
4103
|
+
});
|
|
4104
|
+
const data = await res.json().catch(() => ({}));
|
|
4105
|
+
return { ok: res.ok && data.ok, status: res.status, data };
|
|
4106
|
+
};
|
|
4107
|
+
|
|
4108
|
+
// Old branches should clean themselves up — a non-developer shouldn't need to
|
|
4109
|
+
// find and click "Sweep dead branches" for routine housekeeping. Runs silently
|
|
4110
|
+
// (no confirm dialog, no visible status) after a successful accept/publish;
|
|
4111
|
+
// the manual sweep in the advanced panel below still exists for anyone who
|
|
4112
|
+
// wants to trigger or inspect it directly.
|
|
4113
|
+
const autoSweepBranches = async () => {
|
|
4114
|
+
if (!canShip) return;
|
|
4115
|
+
try {
|
|
4116
|
+
const res = await fetch(ds.branchesEndpoint, { headers: { accept: "application/json" } });
|
|
4117
|
+
if (!res.ok) return;
|
|
4118
|
+
const view = await res.json().catch(() => null);
|
|
4119
|
+
if (!view) return;
|
|
4120
|
+
const rows = Array.isArray(view.rows) ? view.rows : [];
|
|
4121
|
+
const sweepable = Array.isArray(view.sweepableRefs) ? view.sweepableRefs : [];
|
|
4122
|
+
if (!sweepable.length) return;
|
|
4123
|
+
const rowsByRef = new Map<string, any>(rows.map((r: any): [string, any] => [r.ref, r]));
|
|
4124
|
+
for (const ref of sweepable) {
|
|
4125
|
+
const row = rowsByRef.get(ref);
|
|
4126
|
+
if (!row) continue;
|
|
4127
|
+
await postDeleteRef(row.ref, row.sha).catch(() => {});
|
|
4128
|
+
}
|
|
4129
|
+
loadBranches(); // refresh the advanced panel if it happens to be open
|
|
4130
|
+
} catch {
|
|
4131
|
+
// Best-effort — a manual sweep in the advanced panel still works.
|
|
4132
|
+
}
|
|
4133
|
+
};
|
|
4134
|
+
|
|
4135
|
+
const cleanupBranch = async (row?: any, button?: any) => {
|
|
4136
|
+
if (!window.confirm(buildCleanupPlan(row) + "\n\nDelete this branch?")) return;
|
|
4137
|
+
button.disabled = true;
|
|
4138
|
+
setStatus(branchesStatus, "Deleting " + row.shortRef + "…");
|
|
4139
|
+
try {
|
|
4140
|
+
const { ok, status, data } = await postDeleteRef(row.ref, row.sha);
|
|
4141
|
+
if (ok) {
|
|
4142
|
+
setStatus(
|
|
4143
|
+
branchesStatus,
|
|
4144
|
+
data.alreadyAbsent
|
|
4145
|
+
? row.shortRef + " was already gone (nothing to delete)."
|
|
4146
|
+
: "Deleted " + row.shortRef + ".",
|
|
4147
|
+
"ok",
|
|
4148
|
+
);
|
|
4149
|
+
} else {
|
|
4150
|
+
setStatus(branchesStatus, data.error || "Delete refused (" + status + ")", "err");
|
|
4151
|
+
}
|
|
4152
|
+
} catch {
|
|
4153
|
+
setStatus(branchesStatus, "Delete failed — network error", "err");
|
|
4154
|
+
} finally {
|
|
4155
|
+
button.disabled = false;
|
|
4156
|
+
loadBranches();
|
|
4157
|
+
}
|
|
4158
|
+
};
|
|
4159
|
+
|
|
4160
|
+
// Per-row "Delete this branch": the deliberate, one-at-a-time HUMAN delete of an
|
|
4161
|
+
// ORPHAN — a branch the auto/bulk "Sweep" and one-click "Clean up" both refuse
|
|
4162
|
+
// (it is quarantined out of the sweep, and never eligibleForDelete). This is the
|
|
4163
|
+
// ONLY path that removes one. It routes through candidate_delete's guarded explicit
|
|
4164
|
+
// path (explicitConfirm + expectedClassification), which re-verifies the ref is
|
|
4165
|
+
// still an orphan at the same tip server-side and refuses (classification_changed /
|
|
4166
|
+
// sha_mismatch) if it drifted since this row was read — so the confirm below is the
|
|
4167
|
+
// human gate, the server re-check is the safety guarantee.
|
|
4168
|
+
const buildOrphanDeletePlan = (row?: any) => {
|
|
4169
|
+
return [
|
|
4170
|
+
"Delete ORPHANED branch:",
|
|
4171
|
+
" repo: " + (appDomain || repo),
|
|
4172
|
+
" branch: " + row.ref,
|
|
4173
|
+
" tip sha: " + row.shortSha,
|
|
4174
|
+
" classification: orphan",
|
|
4175
|
+
" reason: " + row.reason,
|
|
4176
|
+
"",
|
|
4177
|
+
"An orphan has no open PR and no integration record — nothing else will ever",
|
|
4178
|
+
"clean it up (the scheduled sweep and one-click cleanup both skip it). This",
|
|
4179
|
+
"deletes the Git BRANCH only — it never evicts a hosted preview build. The",
|
|
4180
|
+
"server re-checks this branch fresh before deleting; if it is no longer an",
|
|
4181
|
+
"orphan, or its tip has moved, the delete is refused — not silently skipped.",
|
|
4182
|
+
].join("\n");
|
|
4183
|
+
};
|
|
4184
|
+
|
|
4185
|
+
const deleteOrphanBranch = async (row?: any, button?: any) => {
|
|
4186
|
+
if (!window.confirm(buildOrphanDeletePlan(row) + "\n\nPermanently delete this orphaned branch?")) return;
|
|
4187
|
+
button.disabled = true;
|
|
4188
|
+
setStatus(branchesStatus, "Deleting orphan " + row.shortRef + "…");
|
|
4189
|
+
try {
|
|
4190
|
+
const { ok, status, data } = await postDeleteRef(row.ref, row.sha, {
|
|
4191
|
+
explicitConfirm: true,
|
|
4192
|
+
expectedClassification: row.classification,
|
|
4193
|
+
});
|
|
4194
|
+
if (ok) {
|
|
4195
|
+
setStatus(
|
|
4196
|
+
branchesStatus,
|
|
4197
|
+
data.alreadyAbsent
|
|
4198
|
+
? row.shortRef + " was already gone (nothing to delete)."
|
|
4199
|
+
: "Deleted orphaned branch " + row.shortRef + ".",
|
|
4200
|
+
"ok",
|
|
4201
|
+
);
|
|
4202
|
+
} else {
|
|
4203
|
+
setStatus(branchesStatus, data.error || "Delete refused (" + status + ")", "err");
|
|
4204
|
+
}
|
|
4205
|
+
} catch {
|
|
4206
|
+
setStatus(branchesStatus, "Delete failed — network error", "err");
|
|
4207
|
+
} finally {
|
|
4208
|
+
button.disabled = false;
|
|
4209
|
+
loadBranches();
|
|
4210
|
+
}
|
|
4211
|
+
};
|
|
4212
|
+
|
|
4213
|
+
// Bulk "Sweep dead branches": one confirm showing EXACTLY the refs it will delete,
|
|
4214
|
+
// then a guarded delete per ref (each re-checked fresh server-side — a ref that has
|
|
4215
|
+
// moved or is no longer eligible is refused, never silently skipped).
|
|
4216
|
+
const sweepDeadBranches = async () => {
|
|
4217
|
+
const rowsByRef = new Map<string, any>(lastBranchesRows.map((r: any): [string, any] => [r.ref, r]));
|
|
4218
|
+
const targets = lastSweepableRefs.map((ref) => rowsByRef.get(ref)).filter(Boolean);
|
|
4219
|
+
if (!targets.length) return;
|
|
4220
|
+
const preview = [
|
|
4221
|
+
"Sweep dead branches — this deletes " + targets.length + " branch(es):",
|
|
4222
|
+
"",
|
|
4223
|
+
...targets.map((r: any) => " • " + r.ref + " @ " + r.shortSha + " (" + r.classificationLabel + ")"),
|
|
4224
|
+
"",
|
|
4225
|
+
"Each deletes the Git BRANCH only (never a hosted preview build). The server",
|
|
4226
|
+
"re-checks every ref fresh before deleting; anything no longer eligible is",
|
|
4227
|
+
"refused, not silently skipped.",
|
|
4228
|
+
].join("\n");
|
|
4229
|
+
if (!window.confirm(preview + "\n\nDelete these branches?")) return;
|
|
4230
|
+
branchesSweepBtn.disabled = true;
|
|
4231
|
+
let deleted = 0;
|
|
4232
|
+
let refused = 0;
|
|
4233
|
+
for (const r of targets) {
|
|
4234
|
+
setStatus(branchesStatus, "Sweeping " + r.shortRef + "…");
|
|
4235
|
+
try {
|
|
4236
|
+
const { ok } = await postDeleteRef(r.ref, r.sha);
|
|
4237
|
+
if (ok) deleted++;
|
|
4238
|
+
else refused++;
|
|
4239
|
+
} catch {
|
|
4240
|
+
refused++;
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
setStatus(
|
|
4244
|
+
branchesStatus,
|
|
4245
|
+
"Swept " + deleted + " branch(es)" + (refused ? ", " + refused + " refused" : "") + ".",
|
|
4246
|
+
refused ? "err" : "ok",
|
|
4247
|
+
);
|
|
4248
|
+
branchesSweepBtn.disabled = false;
|
|
4249
|
+
loadBranches();
|
|
4250
|
+
};
|
|
4251
|
+
|
|
4252
|
+
// Show/enable the sweep button only when there is a Dead+eligible set to sweep.
|
|
4253
|
+
const updateSweepButton = () => {
|
|
4254
|
+
if (!branchesActions || !branchesSweepBtn) return;
|
|
4255
|
+
const n = lastSweepableRefs.length;
|
|
4256
|
+
branchesActions.hidden = n === 0;
|
|
4257
|
+
branchesSweepBtn.disabled = !canShip || n === 0;
|
|
4258
|
+
branchesSweepBtn.title = canShip ? "" : "Requires an owner/admin sign-in.";
|
|
4259
|
+
branchesSweepBtn.textContent = n ? "Sweep dead branches (" + n + ")" : "Sweep dead branches";
|
|
4260
|
+
};
|
|
4261
|
+
branchesSweepBtn?.addEventListener("click", sweepDeadBranches);
|
|
4262
|
+
|
|
4263
|
+
// Paint the table body from a set of rows. `unreachable` re-stamps every row
|
|
4264
|
+
// as "state unknown" and withdraws its action — used when a live fetch fails,
|
|
4265
|
+
// so the operator never sees a confident classification the forge can no
|
|
4266
|
+
// longer confirm (the b1 bug: a stale "PR #11 open / Keep" long after close).
|
|
4267
|
+
const renderBranchRows = (rows?: any, { unreachable = false }: any = {}) => {
|
|
4268
|
+
branchesBody.textContent = "";
|
|
4269
|
+
if (!rows.length) {
|
|
4270
|
+
const tr = document.createElement("tr");
|
|
4271
|
+
const td = cell(
|
|
4272
|
+
subtle(
|
|
4273
|
+
unreachable
|
|
4274
|
+
? "Branch state unknown — the forge could not be reached."
|
|
4275
|
+
: "No candidate branches found for this repo.",
|
|
4276
|
+
),
|
|
4277
|
+
);
|
|
4278
|
+
td.colSpan = 7;
|
|
4279
|
+
tr.append(td);
|
|
4280
|
+
branchesBody.append(tr);
|
|
4281
|
+
return;
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
for (const row of rows) {
|
|
4285
|
+
const rowUnknown = unreachable || row.forgeUnreachable === true;
|
|
4286
|
+
const tr = document.createElement("tr");
|
|
4287
|
+
|
|
4288
|
+
const branchCell = document.createElement("td");
|
|
4289
|
+
const branchStrong = document.createElement("strong");
|
|
4290
|
+
branchStrong.textContent = row.shortRef;
|
|
4291
|
+
branchCell.append(branchStrong, subtle(row.ref + " @ " + row.shortSha));
|
|
4292
|
+
|
|
4293
|
+
// Describe the branch by WHAT it changes — the SAME candidate_summary the
|
|
4294
|
+
// staged Publish console + versions list read. OPTIONAL server enrichment:
|
|
4295
|
+
// absent (no PR, or the summary read missed it) → the cell stays just the
|
|
4296
|
+
// ref, exactly as before (graceful fallback). Never shown on an unreachable
|
|
4297
|
+
// row: its classification is precisely what couldn't be confirmed.
|
|
4298
|
+
if (!rowUnknown && (row.prTitle || row.prDescription)) {
|
|
4299
|
+
if (row.prTitle) {
|
|
4300
|
+
const t = document.createElement("p");
|
|
4301
|
+
t.className = "branch-pr-title";
|
|
4302
|
+
t.textContent = row.prTitle;
|
|
4303
|
+
branchCell.append(t);
|
|
4304
|
+
}
|
|
4305
|
+
if (row.prDescription) {
|
|
4306
|
+
const d = document.createElement("p");
|
|
4307
|
+
d.className = "branch-pr-desc";
|
|
4308
|
+
d.textContent = row.prDescription;
|
|
4309
|
+
branchCell.append(d);
|
|
4310
|
+
}
|
|
4311
|
+
const fileCount = typeof row.fileCount === "number" ? row.fileCount : null;
|
|
4312
|
+
const hasLines = typeof row.linesAdded === "number" || typeof row.linesRemoved === "number";
|
|
4313
|
+
if (fileCount != null || hasLines || row.aiDrafted) {
|
|
4314
|
+
const meta = document.createElement("div");
|
|
4315
|
+
meta.className = "agg-included-meta branch-pr-meta";
|
|
4316
|
+
if (fileCount != null) {
|
|
4317
|
+
const f = document.createElement("span");
|
|
4318
|
+
f.textContent = fileCount + (fileCount === 1 ? " file" : " files");
|
|
4319
|
+
meta.append(f);
|
|
4320
|
+
}
|
|
4321
|
+
if (hasLines) meta.append(linesNode(row.linesAdded, row.linesRemoved));
|
|
4322
|
+
if (row.aiDrafted) {
|
|
4323
|
+
const ai = document.createElement("span");
|
|
4324
|
+
ai.className = "branch-pr-ai";
|
|
4325
|
+
ai.textContent = "AI summary";
|
|
4326
|
+
meta.append(ai);
|
|
4327
|
+
}
|
|
4328
|
+
branchCell.append(meta);
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
tr.append(branchCell);
|
|
4332
|
+
|
|
4333
|
+
// Protected rows collapse to one line — branch + classification + a single
|
|
4334
|
+
// status. Nobody acts on them, so the PR/tip/age/evidence detail is just noise.
|
|
4335
|
+
if (row.compact && !rowUnknown) {
|
|
4336
|
+
tr.append(cell(chip(row.classificationLabel, row.tone || "")));
|
|
4337
|
+
const statusTd = cell(subtle(row.reason || "Protected — permanent"));
|
|
4338
|
+
statusTd.colSpan = 5;
|
|
4339
|
+
tr.append(statusTd);
|
|
4340
|
+
branchesBody.append(tr);
|
|
4341
|
+
continue;
|
|
4342
|
+
}
|
|
4343
|
+
|
|
4344
|
+
tr.append(
|
|
4345
|
+
cell(
|
|
4346
|
+
rowUnknown
|
|
4347
|
+
? chip(FORGE_UNREACHABLE_LABEL, "amber")
|
|
4348
|
+
: chip(row.classificationLabel, row.tone || ""),
|
|
4349
|
+
),
|
|
4350
|
+
);
|
|
4351
|
+
|
|
4352
|
+
const prCell = document.createElement("td");
|
|
4353
|
+
if (row.prNumber != null) {
|
|
4354
|
+
// Link to the INTERNAL friendly PR route (/<tenant>/preview/pr/<N>), never
|
|
4355
|
+
// the server-provided `row.prUrl` — that is the raw Gitea forge html_url the
|
|
4356
|
+
// MCP candidate_list projection emits (<forge-host>/<org>/<repo>/pulls/N),
|
|
4357
|
+
// and surfacing a raw forge link to an owner is exactly the boundary
|
|
4358
|
+
// noGiteaLinks.test.ts guards (the source scan can't catch a host that
|
|
4359
|
+
// arrives in data, so we refuse it here at render). Mirrors the candidate
|
|
4360
|
+
// queue, which already builds its preview link this way.
|
|
4361
|
+
const prHref = candidatePrUrl(appDomain, row.prNumber);
|
|
4362
|
+
if (prHref) {
|
|
4363
|
+
const a = document.createElement("a");
|
|
4364
|
+
a.href = prHref;
|
|
4365
|
+
a.target = "_blank";
|
|
4366
|
+
a.rel = "noopener";
|
|
4367
|
+
a.textContent = "PR #" + row.prNumber;
|
|
4368
|
+
prCell.append(a);
|
|
4369
|
+
} else {
|
|
4370
|
+
prCell.append(document.createTextNode("PR #" + row.prNumber));
|
|
4371
|
+
}
|
|
4372
|
+
prCell.append(subtle((row.prState || "") + (row.integratedIntoAggregate ? " · in aggregate" : "")));
|
|
4373
|
+
} else {
|
|
4374
|
+
prCell.append(subtle("No PR record"));
|
|
4375
|
+
}
|
|
4376
|
+
tr.append(prCell);
|
|
4377
|
+
|
|
4378
|
+
tr.append(cell(row.commitTimestampDisplay));
|
|
4379
|
+
tr.append(cell(row.ageLabel));
|
|
4380
|
+
// Evidence cell shows a summary only when the row actually HAS evidence —
|
|
4381
|
+
// never a "no evidence reported" placeholder in an otherwise empty column.
|
|
4382
|
+
tr.append(row.hasEvidence ? cell(row.evidenceSummary) : cell());
|
|
4383
|
+
|
|
4384
|
+
const actionsCell = document.createElement("td");
|
|
4385
|
+
actionsCell.className = "action";
|
|
4386
|
+
if (rowUnknown) {
|
|
4387
|
+
// No trustworthy classification → offer no delete; the row's own eligibility
|
|
4388
|
+
// is exactly what we could not confirm. A cleanup still re-checks server-side,
|
|
4389
|
+
// but we must not INVITE one off a state we know we could not read.
|
|
4390
|
+
actionsCell.append(subtle("—"));
|
|
4391
|
+
} else if (row.classification === "orphan") {
|
|
4392
|
+
// Orphan (human quarantined OR machine "Dead"): the sweep and one-click "Clean
|
|
4393
|
+
// up" both refuse it, so this deliberate per-row HUMAN delete is its only path
|
|
4394
|
+
// out. Distinct from "Clean up" (styled danger) and re-verified fresh server-side.
|
|
4395
|
+
const btn = document.createElement("button");
|
|
4396
|
+
btn.type = "button";
|
|
4397
|
+
btn.className = "btn branch-delete-orphan";
|
|
4398
|
+
btn.dataset.deleteOrphan = "1";
|
|
4399
|
+
btn.disabled = !canShip;
|
|
4400
|
+
btn.title = canShip ? "" : "Requires an owner/admin sign-in.";
|
|
4401
|
+
btn.textContent = "Delete this branch";
|
|
4402
|
+
btn.addEventListener("click", () => deleteOrphanBranch(row, btn));
|
|
4403
|
+
actionsCell.append(btn);
|
|
4404
|
+
} else if (row.eligibleForDelete) {
|
|
4405
|
+
const btn = document.createElement("button");
|
|
4406
|
+
btn.type = "button";
|
|
4407
|
+
btn.className = "btn";
|
|
4408
|
+
btn.disabled = !canShip;
|
|
4409
|
+
btn.title = canShip ? "" : "Requires an owner/admin sign-in.";
|
|
4410
|
+
btn.textContent = "Clean up";
|
|
4411
|
+
btn.addEventListener("click", () => cleanupBranch(row, btn));
|
|
4412
|
+
actionsCell.append(btn);
|
|
4413
|
+
} else {
|
|
4414
|
+
actionsCell.append(subtle("Keep"));
|
|
4415
|
+
}
|
|
4416
|
+
tr.append(actionsCell);
|
|
4417
|
+
|
|
4418
|
+
branchesBody.append(tr);
|
|
4419
|
+
}
|
|
4420
|
+
};
|
|
4421
|
+
|
|
4422
|
+
// Render a SUCCESSFUL live fetch: cache the rows (so a later failure can stamp
|
|
4423
|
+
// them unknown), skip re-render when nothing changed, and set the summary chip.
|
|
4424
|
+
const renderBranches = (view?: any) => {
|
|
4425
|
+
if (!branchesBody) return;
|
|
4426
|
+
const rows = Array.isArray(view.rows) ? view.rows : [];
|
|
4427
|
+
const sig = JSON.stringify(rows);
|
|
4428
|
+
lastBranchesRows = rows;
|
|
4429
|
+
lastSweepableRefs = Array.isArray(view.sweepableRefs) ? view.sweepableRefs : [];
|
|
4430
|
+
updateSweepButton();
|
|
4431
|
+
if (sig === lastBranchesSignature) return; // no visible change — don't churn the DOM
|
|
4432
|
+
lastBranchesSignature = sig;
|
|
4433
|
+
if (branchesChip) {
|
|
4434
|
+
const needsReview = rows.filter((r: any) => r.quarantine || r.eligibleForDelete).length;
|
|
4435
|
+
branchesChip.textContent = needsReview
|
|
4436
|
+
? needsReview + " to review"
|
|
4437
|
+
: rows.length
|
|
4438
|
+
? rows.length + " branches"
|
|
4439
|
+
: "0 branches";
|
|
4440
|
+
branchesChip.className = "chip" + (needsReview ? " amber" : "");
|
|
4441
|
+
}
|
|
4442
|
+
renderBranchRows(rows);
|
|
4443
|
+
};
|
|
4444
|
+
|
|
4445
|
+
// Render the forge-unreachable state: re-stamp the last-known branches as
|
|
4446
|
+
// "state unknown" (or a single notice if none were ever loaded), never leaving
|
|
4447
|
+
// the stale confident chips a fetch failure would otherwise freeze on screen.
|
|
4448
|
+
const renderBranchesUnreachable = () => {
|
|
4449
|
+
if (!branchesBody) return;
|
|
4450
|
+
lastBranchesSignature = null; // force a real re-render when the forge returns
|
|
4451
|
+
lastSweepableRefs = []; // can't confirm eligibility → nothing is sweepable
|
|
4452
|
+
updateSweepButton();
|
|
4453
|
+
if (branchesChip) {
|
|
4454
|
+
branchesChip.textContent = "State unknown";
|
|
4455
|
+
branchesChip.className = "chip amber";
|
|
4456
|
+
}
|
|
4457
|
+
renderBranchRows(lastBranchesRows, { unreachable: true });
|
|
4458
|
+
};
|
|
4459
|
+
|
|
4460
|
+
const loadBranchesImpl = async () => {
|
|
4461
|
+
if (!branchesBody) return;
|
|
4462
|
+
try {
|
|
4463
|
+
const res = await fetch(ds.branchesEndpoint, { headers: { accept: "application/json" } });
|
|
4464
|
+
if (res.status === 401 || res.status === 403) {
|
|
4465
|
+
if (branchesChip) branchesChip.textContent = "Sign-in required";
|
|
4466
|
+
lastSweepableRefs = [];
|
|
4467
|
+
updateSweepButton();
|
|
4468
|
+
branchesBody.textContent = "";
|
|
4469
|
+
const tr = document.createElement("tr");
|
|
4470
|
+
const td = cell(subtle("Sign in as a store owner or admin to view branch retention."));
|
|
4471
|
+
td.colSpan = 7;
|
|
4472
|
+
tr.append(td);
|
|
4473
|
+
branchesBody.append(tr);
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
const data = await res.json().catch(() => ({}));
|
|
4477
|
+
if (!res.ok) {
|
|
4478
|
+
// A non-2xx means the live classification is UNKNOWN — never keep the last
|
|
4479
|
+
// chips on screen (they may now be lies). Stamp the branches "state unknown".
|
|
4480
|
+
setStatus(branchesStatus, data.error || "Could not load branch classification (" + res.status + ")", "err");
|
|
4481
|
+
renderBranchesUnreachable();
|
|
4482
|
+
return;
|
|
4483
|
+
}
|
|
4484
|
+
renderBranches(data.report || {});
|
|
4485
|
+
} catch {
|
|
4486
|
+
setStatus(branchesStatus, "Could not load branch classification — network error", "err");
|
|
4487
|
+
renderBranchesUnreachable();
|
|
4488
|
+
}
|
|
4489
|
+
};
|
|
4490
|
+
// Coalesce overlapping branch reads: a poll tick, a reveal, and a post-action
|
|
4491
|
+
// refresh can all fire at once — only one runs, with at most one trailing run.
|
|
4492
|
+
const branchesCoalescer = createCoalescer(loadBranchesImpl);
|
|
4493
|
+
const loadBranches = () => branchesCoalescer.trigger();
|
|
4494
|
+
|
|
4495
|
+
// ---- Wiring ------------------------------------------------------------
|
|
4496
|
+
|
|
4497
|
+
// Progressive + lazy: the tab shell is already server-rendered; each heavy
|
|
4498
|
+
// panel fires its first read only when it (or the tab) actually becomes
|
|
4499
|
+
// visible, and the queue poll only runs while the tab is active. An
|
|
4500
|
+
// IntersectionObserver is the single visibility signal — a panel inside a
|
|
4501
|
+
// hidden admin-tab (display:none) never intersects, so nothing fetches until
|
|
4502
|
+
// the operator opens the Publish tab and scrolls the panel into view.
|
|
4503
|
+
// r05 — restore each collapsible section's open/closed state from localStorage
|
|
4504
|
+
// (per store, so one store's layout never bleeds into another) and persist it on
|
|
4505
|
+
// every toggle. When nothing is stored the section keeps its SSR default. Native
|
|
4506
|
+
// <details> carry no aria-expanded of their own, so we mirror it onto the summary
|
|
4507
|
+
// and keep it in sync — a later a11y bundle asserts against it.
|
|
4508
|
+
const setupCollapsePersistence = () => {
|
|
4509
|
+
let storage: Storage | null;
|
|
4510
|
+
try {
|
|
4511
|
+
storage = typeof window !== "undefined" ? window.localStorage : null;
|
|
4512
|
+
} catch {
|
|
4513
|
+
storage = null; // localStorage access can throw (private mode / disabled)
|
|
4514
|
+
}
|
|
4515
|
+
const sections: Array<{ id: CollapseSectionId; selector: string }> = [
|
|
4516
|
+
{ id: "review-queue", selector: ".publish-queue-details" },
|
|
4517
|
+
{ id: "closed-changes", selector: ".closed-changes-details" },
|
|
4518
|
+
{ id: "next-release", selector: ".agg-console-details" },
|
|
4519
|
+
{ id: "live-site", selector: ".live-site-details" },
|
|
4520
|
+
{ id: "rollback", selector: ".versions-details" },
|
|
4521
|
+
];
|
|
4522
|
+
for (const { id, selector } of sections) {
|
|
4523
|
+
const details: any = root.querySelector(selector);
|
|
4524
|
+
if (!details) continue;
|
|
4525
|
+
const summary = details.querySelector("summary");
|
|
4526
|
+
const syncAria = () => {
|
|
4527
|
+
if (summary) summary.setAttribute("aria-expanded", details.open ? "true" : "false");
|
|
4528
|
+
};
|
|
4529
|
+
if (storage) {
|
|
4530
|
+
const stored = readPersistedOpen(storage, appDomain, id);
|
|
4531
|
+
if (stored !== null) details.open = stored; // otherwise keep the SSR default
|
|
4532
|
+
}
|
|
4533
|
+
syncAria();
|
|
4534
|
+
details.addEventListener("toggle", () => {
|
|
4535
|
+
if (storage) writePersistedOpen(storage, appDomain, id, details.open);
|
|
4536
|
+
syncAria();
|
|
4537
|
+
});
|
|
4538
|
+
}
|
|
4539
|
+
};
|
|
4540
|
+
|
|
4541
|
+
const revealDomainOnce = (() => {
|
|
4542
|
+
let done = false;
|
|
4543
|
+
return () => {
|
|
4544
|
+
if (done) return;
|
|
4545
|
+
done = true;
|
|
4546
|
+
loadDomain();
|
|
4547
|
+
};
|
|
4548
|
+
})();
|
|
4549
|
+
|
|
4550
|
+
const onTabActivate = () => {
|
|
4551
|
+
if (tabActive) return;
|
|
4552
|
+
tabActive = true;
|
|
4553
|
+
// loadQueue renders + resyncs the poll clock + (re)starts the cadence.
|
|
4554
|
+
loadQueue();
|
|
4555
|
+
};
|
|
4556
|
+
const onTabDeactivate = () => {
|
|
4557
|
+
if (!tabActive) return;
|
|
4558
|
+
tabActive = false;
|
|
4559
|
+
clearPoll(); // an inactive tab must not keep polling heavy reads
|
|
4560
|
+
};
|
|
4561
|
+
|
|
4562
|
+
const observeVisibility = () => {
|
|
4563
|
+
if (typeof IntersectionObserver !== "function") {
|
|
4564
|
+
// No IO (very old/headless env): fall back to eager load — correctness
|
|
4565
|
+
// over efficiency. Behaves like the pre-progressive-render tab.
|
|
4566
|
+
tabActive = true;
|
|
4567
|
+
branchesVisible = true;
|
|
4568
|
+
loadQueue();
|
|
4569
|
+
loadVersions();
|
|
4570
|
+
revealDomainOnce();
|
|
4571
|
+
loadBranches();
|
|
4572
|
+
return;
|
|
4573
|
+
}
|
|
4574
|
+
const io = new IntersectionObserver(
|
|
4575
|
+
(entries) => {
|
|
4576
|
+
for (const e of entries) {
|
|
4577
|
+
const visible = e.isIntersecting;
|
|
4578
|
+
if (e.target === root) {
|
|
4579
|
+
if (visible) onTabActivate();
|
|
4580
|
+
else onTabDeactivate();
|
|
4581
|
+
} else if (e.target === versionsPanel) {
|
|
4582
|
+
if (visible) loadVersions();
|
|
4583
|
+
} else if (e.target === branchesPanel) {
|
|
4584
|
+
branchesVisible = visible;
|
|
4585
|
+
if (visible) loadBranches();
|
|
4586
|
+
} else if (e.target === domainPanel) {
|
|
4587
|
+
if (visible) revealDomainOnce();
|
|
4588
|
+
}
|
|
4589
|
+
}
|
|
4590
|
+
},
|
|
4591
|
+
{ rootMargin: "200px" },
|
|
4592
|
+
);
|
|
4593
|
+
io.observe(root);
|
|
4594
|
+
if (versionsPanel) io.observe(versionsPanel);
|
|
4595
|
+
if (branchesPanel) io.observe(branchesPanel);
|
|
4596
|
+
if (domainPanel) io.observe(domainPanel);
|
|
4597
|
+
};
|
|
4598
|
+
|
|
4599
|
+
root.querySelector("[data-publish-refresh]")?.addEventListener("click", () => {
|
|
4600
|
+
setStatus(statusEl, "");
|
|
4601
|
+
// Explicit refresh: force past every client cache and re-read what's on screen.
|
|
4602
|
+
diffCache.clear();
|
|
4603
|
+
versionsCache.clear();
|
|
4604
|
+
loadQueue();
|
|
4605
|
+
loadVersions({ force: true });
|
|
4606
|
+
loadDomain();
|
|
4607
|
+
loadBranches();
|
|
4608
|
+
});
|
|
4609
|
+
|
|
4610
|
+
// rux7: pause polling while hidden; on return, refresh immediately (loadQueue
|
|
4611
|
+
// re-renders + resyncs the poll clock) rather than waiting out the interval.
|
|
4612
|
+
document.addEventListener("visibilitychange", () => {
|
|
4613
|
+
if (document.hidden) {
|
|
4614
|
+
clearPoll();
|
|
4615
|
+
} else if (tabActive) {
|
|
4616
|
+
// Only resume when the Publish tab is the active one — a background
|
|
4617
|
+
// browser-tab return must not wake a Publish tab the operator left.
|
|
4618
|
+
loadQueue();
|
|
4619
|
+
if (branchesVisible) loadBranches(); // refresh branch truth on return
|
|
4620
|
+
}
|
|
4621
|
+
});
|
|
4622
|
+
|
|
4623
|
+
setupCollapsePersistence();
|
|
4624
|
+
observeVisibility();
|
|
4625
|
+
}
|