From 8d3ed4b0a813039877b5f0c5902877d528993b09 Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Sun, 12 Jul 2026 19:05:30 -0700
Subject: [PATCH] refactor(visual): rename R2 buckets, /gittensory/shot route,
and R2 key prefixes to loopover
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Provisions two new live Cloudflare R2 buckets (loopover-review-audit,
loopover-visual-capture-public) replacing gittensory-review-audit and
gittensory-visual-capture-public, and wires the REVIEW_AUDIT R2 binding into
wrangler.jsonc for the first time (it was previously configured only via the
dashboard, undeclared in code) so it actually resolves on the hosted Worker
going forward.
Renames the public screenshot route from /gittensory/shot to /loopover/shot
(src/api/routes.ts, src/auth/rate-limit.ts's cost classification) and the R2
key namespace from gittensory/ to loopover/ throughout capture.ts, shot.ts,
actions-fallback.ts's two fallback-shot namespaces, blob-store.ts, and
s3-blob-store.ts, plus every doc comment describing them.
Per explicit maintainer decision: no data migration, no permanent alias for
old links, and no dual-serving window — old screenshots are not preserved,
this is a straight-forward cutover so everything going forward is correctly
named. The two old buckets are left in place (not deleted) for now.
Closes #5332.
---
src/api/routes.ts | 4 +-
src/auth/rate-limit.ts | 2 +-
src/env.d.ts | 17 +++---
src/review/unified-comment-bridge.ts | 4 +-
src/review/visual/actions-fallback.ts | 4 +-
src/review/visual/capture.ts | 10 ++--
src/review/visual/shot.ts | 12 ++---
src/selfhost/blob-store.ts | 4 +-
src/selfhost/s3-blob-store.ts | 6 +--
src/selfhost/stubs/puppeteer.ts | 2 +-
test/unit/actions-fallback.test.ts | 6 +--
test/unit/auth.test.ts | 2 +-
test/unit/visual-capture.test.ts | 74 +++++++++++++--------------
test/unit/visual-shot.test.ts | 12 ++---
worker-configuration.d.ts | 4 +-
wrangler.jsonc | 14 +++++
16 files changed, 98 insertions(+), 79 deletions(-)
diff --git a/src/api/routes.ts b/src/api/routes.ts
index f38f2387b6..b2538cbc44 100644
--- a/src/api/routes.ts
+++ b/src/api/routes.ts
@@ -1035,9 +1035,9 @@ export function createApp() {
// an allowlisted public host. The route's own Cache-Control headers (per mode) are set inside handleShot;
// the rate-limit middleware classifies it as 'normal' (a sane public class) via routeClassForPath.
// Flag-OFF = TRULY inert: when GITTENSORY_REVIEW_SCREENSHOTS is off nothing references this route (no comment
- // carries a /gittensory/shot URL), so 404 it outright — that removes the on-demand `?url=` render surface
+ // carries a /loopover/shot URL), so 404 it outright — that removes the on-demand `?url=` render surface
// entirely until the feature is deliberately enabled, rather than relying on the host allowlist alone.
- app.get("/gittensory/shot", (c) => {
+ app.get("/loopover/shot", (c) => {
if (!isScreenshotsEnabled(c.env)) return c.notFound();
return handleShot(c.req.raw, c.env, {
...(c.env.PUBLIC_SITE_ORIGIN ? { productionUrl: c.env.PUBLIC_SITE_ORIGIN } : {}),
diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts
index e3ee5c2668..6b4e36e268 100644
--- a/src/auth/rate-limit.ts
+++ b/src/auth/rate-limit.ts
@@ -123,7 +123,7 @@ export function routeClassForPath(path: string): RateLimitClass {
if (path === "/v1/orb/ingest") return "strict";
if (path === "/v1/auth/session" || path === "/v1/auth/logout") return "normal";
if (path.startsWith("/v1/auth/")) return "strict";
- if (path === "/gittensory/shot") return "expensive";
+ if (path === "/loopover/shot") return "expensive";
if (
path.includes("/branch-analysis") ||
path.includes("/v1/agent/") ||
diff --git a/src/env.d.ts b/src/env.d.ts
index 4416a6d542..dfc1cf1efd 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -33,17 +33,20 @@ declare global {
* Workers-AI-safe constant (96) when unset — this override exists for self-host operators tuning
* throughput on their own hardware (e.g. GPU-accelerated Ollama), not to change the hosted default. */
AI_EMBED_BATCH?: string;
- /** Optional self-host review audit + visual-capture blob store. The Node runtime injects a filesystem-backed
- * store when REVIEW_AUDIT_DIR is set, or an S3-compatible-bucket-backed store (an operator's own Cloudflare
- * R2 bucket, or any other S3-compatible provider) when REVIEW_AUDIT_S3_BUCKET + _ENDPOINT +
- * _ACCESS_KEY_ID + _SECRET_ACCESS_KEY are all set (takes priority when both are configured); the
- * Cloudflare API worker no longer binds the review R2 bucket. */
+ /** Review audit + visual-capture blob store. The Cloudflare API worker binds this natively to its own R2
+ * bucket (see wrangler.jsonc's r2_buckets). Self-host has no native binding, so the Node runtime injects a
+ * filesystem-backed store when REVIEW_AUDIT_DIR is set, or an S3-compatible-bucket-backed store (an
+ * operator's own Cloudflare R2 bucket, or any other S3-compatible provider) when REVIEW_AUDIT_S3_BUCKET +
+ * _ENDPOINT + _ACCESS_KEY_ID + _SECRET_ACCESS_KEY are all set (takes priority when both are configured). */
REVIEW_AUDIT?: R2Bucket;
+ /** Reserved R2 binding for a future split of public-facing screenshot storage away from the private
+ * review-audit bucket (see wrangler.jsonc's r2_buckets) — not yet read or written by any code path. */
+ VISUAL_CAPTURE_PUBLIC?: R2Bucket;
/** Public base URL for an S3-compatible REVIEW_AUDIT bucket's own public read access (an R2 `r2.dev` public
* bucket URL, or a custom domain connected to the bucket) -- see src/selfhost/s3-blob-store.ts. When set,
* capture.ts's resolveShotUrl links screenshots DIRECTLY at `${this}/${key}` so GitHub's image proxy (and
* every other viewer) fetches straight from the bucket's own CDN, never touching this instance's
- * PUBLIC_API_ORIGIN at all. Unset (default) ⇒ served through this instance's own /gittensory/shot?key=
+ * PUBLIC_API_ORIGIN at all. Unset (default) ⇒ served through this instance's own /loopover/shot?key=
* proxy route instead, exactly as before -- the bucket still gets used for storage, just not linked to
* directly. Only meaningful alongside a configured REVIEW_AUDIT_S3_* bucket; ignored otherwise. */
REVIEW_AUDIT_S3_PUBLIC_URL?: string;
@@ -298,7 +301,7 @@ declare global {
* isVisualPath). "before" = production (PUBLIC_SITE_ORIGIN); "after" = the PR's preview deploy. Each shot
* is rendered via the optional BROWSER binding, stored through REVIEW_AUDIT when configured, and embedded
* in the unified PR comment as a "Visual preview" table — served either from this instance's own PUBLIC
- * /gittensory/shot route, or, when REVIEW_AUDIT_S3_PUBLIC_URL is set, directly from the operator's own
+ * /loopover/shot route, or, when REVIEW_AUDIT_S3_PUBLIC_URL is set, directly from the operator's own
* S3-compatible bucket instead (see src/selfhost/s3-blob-store.ts). Self-host equivalents are
* BROWSER_WS_ENDPOINT + (REVIEW_AUDIT_DIR or the REVIEW_AUDIT_S3_* bucket vars); degrades gracefully
* (placeholders / dashes) without them. Backend .ts/.md/.json/.py PRs NEVER trigger capture. Capture runs
diff --git a/src/review/unified-comment-bridge.ts b/src/review/unified-comment-bridge.ts
index 54859a31c1..00b8c26836 100644
--- a/src/review/unified-comment-bridge.ts
+++ b/src/review/unified-comment-bridge.ts
@@ -339,7 +339,7 @@ export type UnifiedCommentBridgeArgs = {
/** Headline brand (default "LoopOver review"). */
brand?: string | undefined;
/** Visual before/after capture routes (visual-capture port). When present + non-empty, a "Visual preview"
- * collapsible (a markdown table of
tags pointing at the public /gittensory/shot URLs) is appended.
+ * collapsible (a markdown table of
tags pointing at the public /loopover/shot URLs) is appended.
* Public-safe: only URLs + route paths — no private terms. Default OFF (the processor passes this only
* when screenshotsAllowed + the PR touches web-visible files). */
beforeAfter?: CaptureRoute[] | undefined;
@@ -436,7 +436,7 @@ export function buildVisualFindingsCollapsible(findings: string[]): UnifiedColla
* pixel-diff provider is available and finds a real visual difference — absent on hosted builds and any
* unchanged/no-diff cell, which render as a dash like every other missing shot. Emitted as TRUSTED raw HTML
* (`rawHtml: true`) so the `/
` survive — public-safe by construction: every value is a first-party
- * minted /gittensory/shot URL or a route path (no private rubric / scoring terms), and a stray `"` in a URL
+ * minted /loopover/shot URL or a route path (no private rubric / scoring terms), and a stray `"` in a URL
* is neutralized so it can't break out of the attribute. Returns null when nothing is renderable (no route
* has any shot URL), so the section is omitted rather than shown empty.
*/
diff --git a/src/review/visual/actions-fallback.ts b/src/review/visual/actions-fallback.ts
index 84ab57d5b0..bab735db83 100644
--- a/src/review/visual/actions-fallback.ts
+++ b/src/review/visual/actions-fallback.ts
@@ -148,7 +148,7 @@ export function parseFallbackRunCorrelation(displayTitle: string | undefined | n
// Dispatch in-flight marker -- a persisted R2 sentinel, not a live GitHub API query (#4112 review fix).
// ---------------------------------------------------------------------------------------------------------
-const FALLBACK_DISPATCH_MARKER_NAMESPACE = "gittensory/fallback-dispatch/";
+const FALLBACK_DISPATCH_MARKER_NAMESPACE = "loopover/fallback-dispatch/";
/** The workflow's own `timeout-minutes: 15` (visual-capture-fallback.yml) plus a buffer for GitHub's own
* runner-queueing delay before the job even starts -- a marker older than this is treated as abandoned
@@ -459,7 +459,7 @@ export function fallbackShotFileName(path: string, viewport: "desktop" | "mobile
// fingerprint against (capturePage's own key scheme needs a real "page" url; a fallback shot has none).
// ---------------------------------------------------------------------------------------------------------
-const FALLBACK_SHOT_NAMESPACE = "gittensory/shots/actions-fallback/";
+const FALLBACK_SHOT_NAMESPACE = "loopover/shots/actions-fallback/";
/** The R2 key a fallback-captured shot is stored/read under for one PR head + route + viewport. Pure content
* address (no preview URL involved) -- deterministic so the write side (webhook handler) and the read side
diff --git a/src/review/visual/capture.ts b/src/review/visual/capture.ts
index bc5831bcaf..fb8ae6c625 100644
--- a/src/review/visual/capture.ts
+++ b/src/review/visual/capture.ts
@@ -4,7 +4,7 @@
// after = the PR's preview-deploy URL, discovered the
// provider-agnostic way (Deployments API → commit checks → cloudflare-bot PR comment). Each page is
// rendered once here (in the queue consumer, which has the time budget), stored as a PNG in R2
-// (env.REVIEW_AUDIT), and embedded either as /gittensory/shot?key= (this
+// (env.REVIEW_AUDIT), and embedded either as /loopover/shot?key= (this
// instance's own proxy route) or, when REVIEW_AUDIT_S3_PUBLIC_URL is configured (an operator's own
// publicly-readable S3-compatible bucket — see src/selfhost/s3-blob-store.ts), a direct link at the
// bucket's own public URL — see resolveShotUrl below. Either way, GitHub's image proxy fetches a fast
@@ -31,7 +31,7 @@ import { captureScrollFrames, captureShot, DESKTOP_VIEWPORT, MOBILE_VIEWPORT, ty
import { compareCapturedScreenshots, isVisualDiffAvailable, type VisualDiffOutcome } from "./pixel-diff";
import { encodeScrollGif, isScrollGifAvailable } from "./scroll-gif";
-const NAMESPACE = "gittensory";
+const NAMESPACE = "loopover";
const DEFAULT_ROUTES = ["/"];
// The app-folder segment is a wildcard, not hardcoded to gittensory-ui: metagraphed's UI (apps/ui/src/routes/)
// uses the identical TanStack flat-file convention `routeForFile` below implements, just under a different app
@@ -297,7 +297,7 @@ function routeForFile(raw: string): string {
* The publicly-servable URL for an already-stored REVIEW_AUDIT key. Prefers a direct link at the operator's
* own S3-compatible bucket (REVIEW_AUDIT_S3_PUBLIC_URL) so GitHub's image proxy — and every other viewer —
* fetches straight from that bucket's own CDN, never touching this instance at all. Falls back to this
- * instance's own /gittensory/shot?key= proxy route (today's only option, and still the only option for the
+ * instance's own /loopover/shot?key= proxy route (today's only option, and still the only option for the
* filesystem-backed self-host store, which has no public URL of its own). Empty string when neither is
* configured, matching every call site's existing "no shotBase" degradation.
*/
@@ -310,7 +310,7 @@ function resolveShotUrl(env: Env, key: string): string {
}
/**
- * Render `page`, store the PNG in R2, and return its /gittensory/shot?key= URL. Falls back to an on-demand
+ * Render `page`, store the PNG in R2, and return its /loopover/shot?key= URL. Falls back to an on-demand
* ?url= link if R2 or the render is unavailable; returns {} when there is no page (no preview deploy yet) so
* the cell shows a dash. Reuses an identical cached fingerprint (a deployment_status re-run filling "after"
* cells would otherwise re-render the same screenshot — Browser Rendering is the costliest binding).
@@ -336,7 +336,7 @@ async function capturePage(
themeStorageKey?: string | undefined,
): Promise<{ url?: string | undefined; png?: Uint8Array | undefined }> {
if (!page) return {};
- const shotBase = env.PUBLIC_API_ORIGIN; // this worker's public origin (serves /gittensory/shot)
+ const shotBase = env.PUBLIC_API_ORIGIN; // this worker's public origin (serves /loopover/shot)
// Carries the theme (#3678) and, when set, the storage key (#4109) so a LATER on-demand fetch of this
// exact URL (e.g. a failed/never-persisted render retried by GitHub's image proxy) still requests the
// matching prefers-color-scheme/localStorage forcing, not the default — handleShot's Mode B reads these
diff --git a/src/review/visual/shot.ts b/src/review/visual/shot.ts
index 858ea43c56..c2ca371fdb 100644
--- a/src/review/visual/shot.ts
+++ b/src/review/visual/shot.ts
@@ -4,15 +4,15 @@
// • puppeteer import unchanged (@cloudflare/puppeteer), SSRF guard now isSafeHttpUrl from ../content-lane/safe-url
// • bindings: env.BROWSER (Browser Rendering) + env.REVIEW_AUDIT (R2) — gittensory's R2 binding is
// REVIEW_AUDIT, NOT reviewbot's env.AUDIT.
-// • r2 key prefix default 'gittensory/shots/'; on-demand render allowlist's production host = PUBLIC_SITE_ORIGIN.
+// • r2 key prefix default 'loopover/shots/'; on-demand render allowlist's production host = PUBLIC_SITE_ORIGIN.
// • no reviewbot REVIEWBOT_* secrets / REST fallback — gittensory renders via the BROWSER binding only.
//
// Two modes:
-// GET /gittensory/shot?key= -> stream a pre-rendered PNG from R2 (fast; GitHub's image proxy
+// GET /loopover/shot?key= -> stream a pre-rendered PNG from R2 (fast; GitHub's image proxy
// fetches this static object instead of waiting on a live render).
-// GET /gittensory/shot?url= -> render on demand and return a PNG (host-allowlisted +
+// GET /loopover/shot?url= -> render on demand and return a PNG (host-allowlisted +
// SSRF-guarded). A fallback / manual-check path.
-// GET /gittensory/shot?placeholder=loading|failed|auth -> a static SVG card (no render).
+// GET /loopover/shot?placeholder=loading|failed|auth -> a static SVG card (no render).
//
// Rendering uses the Cloudflare Browser Rendering *binding* (env.BROWSER) via @cloudflare/puppeteer — no
// account API token. Returns null on any failure so callers degrade gracefully (the cell becomes a dash).
@@ -78,7 +78,7 @@ const THEME_STORAGE_WRITE_TIMEOUT_MS = 2_000;
const THEME_STORAGE_RELOAD_TIMEOUT_MS = 20000;
/** Per-call shot-route options: the R2 namespace (key prefix) + the production host for the on-demand render
- * allowlist. Defaults to gittensory so the /gittensory/shot route works with no options. */
+ * allowlist. Defaults to loopover so the /loopover/shot route works with no options. */
export interface ShotOptions {
namespace?: string;
productionUrl?: string;
@@ -443,7 +443,7 @@ export async function captureScrollFrames(env: Env, url: string, viewport: Viewp
export async function handleShot(request: Request, env: Env, opts: ShotOptions = {}): Promise {
const params = new URL(request.url).searchParams;
- const r2Prefix = `${opts.namespace ?? "gittensory"}/shots/`;
+ const r2Prefix = `${opts.namespace ?? "loopover"}/shots/`;
// Mode 0: a placeholder for an "after" cell with no real screenshot yet — the animated spinner (preview
// still building), the static "deploy failed" card (preview won't come), or the auth-wall card.
diff --git a/src/selfhost/blob-store.ts b/src/selfhost/blob-store.ts
index d48df5ff44..71bc611051 100644
--- a/src/selfhost/blob-store.ts
+++ b/src/selfhost/blob-store.ts
@@ -1,5 +1,5 @@
// Self-host blob store (#10). A minimal R2Bucket-compatible store backed by the local filesystem — the persistence
-// the visual-review screenshot path (src/review/visual/capture.ts + the /gittensory/shot serve route) reads/writes
+// the visual-review screenshot path (src/review/visual/capture.ts + the /loopover/shot serve route) reads/writes
// through `env.REVIEW_AUDIT`. The cloud uses the Cloudflare R2 binding; self-host has none, so visual captures
// previously could not be cached/persisted (they degraded to on-demand re-render). This implements only the get/put
// surface those two paths use; every other R2Bucket method is unused on self-host. Node-only (fs import never
@@ -9,7 +9,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, resolve, sep } from "node:path";
/** Build a filesystem-backed REVIEW_AUDIT store rooted at `baseDir`. Keys are app-generated
- * (`gittensory/shots/.png`) and the serve route already prefix-checks + rejects `..`, but the path is
+ * (`loopover/shots/.png`) and the serve route already prefix-checks + rejects `..`, but the path is
* resolved + boundary-checked here too so a key can never escape the base directory. */
export function createFsBlobStore(baseDir: string): R2Bucket {
const base = resolve(baseDir);
diff --git a/src/selfhost/s3-blob-store.ts b/src/selfhost/s3-blob-store.ts
index fb4a0a0bbf..853a242dc5 100644
--- a/src/selfhost/s3-blob-store.ts
+++ b/src/selfhost/s3-blob-store.ts
@@ -5,14 +5,14 @@
//
// Why this exists: the filesystem-backed store (REVIEW_AUDIT_DIR) persists screenshots on the SAME host that
// runs the review container, so the images embedded in a public GitHub PR comment are only reachable through
-// that host's own public origin (PUBLIC_API_ORIGIN) and the /gittensory/shot proxy route -- if an operator
+// that host's own public origin (PUBLIC_API_ORIGIN) and the /loopover/shot proxy route -- if an operator
// keeps their instance behind a private network (Tailscale, a firewall, no public DNS at all), those images
// are unreachable for anyone outside that network, GitHub's own servers included. Storing in a genuinely
// public bucket instead decouples "does my review pipeline run on my own infrastructure" from "are the
// resulting public-facing images reachable by anyone" -- this store still only does get/put/delete; making the
// resulting keys PUBLICLY SERVABLE (a public r2.dev URL, or a custom domain connected to the bucket) is the
// operator's own one-time bucket setup, and `resolveShotUrl` (capture.ts) is what points served links directly
-// at REVIEW_AUDIT_S3_PUBLIC_URL instead of this instance's own /gittensory/shot proxy once it's configured.
+// at REVIEW_AUDIT_S3_PUBLIC_URL instead of this instance's own /loopover/shot proxy once it's configured.
//
// MODULAR + off by default: unset REVIEW_AUDIT_S3_BUCKET (+ _ENDPOINT/_ACCESS_KEY_ID/_SECRET_ACCESS_KEY) ⇒ no
// REVIEW_AUDIT_S3 binding ⇒ server.ts falls back to REVIEW_AUDIT_DIR (or, if that's unset too, on-demand
@@ -38,7 +38,7 @@ export type S3BlobStoreConfig = {
const S3_CLIENT_RETRIES = 3;
/** Build an S3-compatible-bucket-backed REVIEW_AUDIT store. Keys are app-generated
- * (`gittensory/shots/.png`, already validated by the /gittensory/shot serve route's own prefix +
+ * (`loopover/shots/.png`, already validated by the /loopover/shot serve route's own prefix +
* traversal check) and passed straight through as the S3 object key -- no additional encoding beyond the
* URL-path escaping every S3 REST call needs regardless of key shape. */
export function createS3BlobStore(config: S3BlobStoreConfig): R2Bucket {
diff --git a/src/selfhost/stubs/puppeteer.ts b/src/selfhost/stubs/puppeteer.ts
index cc00e61a6c..98c5287754 100644
--- a/src/selfhost/stubs/puppeteer.ts
+++ b/src/selfhost/stubs/puppeteer.ts
@@ -1,6 +1,6 @@
// Self-host replacement for @cloudflare/puppeteer (#980). When BROWSER_WS_ENDPOINT is set, connects to an
// external Chrome-compatible browser (e.g. a `browserless/chrome` sidecar) via puppeteer-core's WebSocket
-// connect API — this makes the /gittensory/shot on-demand render endpoint fully functional. When the env var
+// connect API — this makes the /loopover/shot on-demand render endpoint fully functional. When the env var
// is absent, the functions throw so the caller's `if (!env.BROWSER)` guard (in shot.ts) short-circuits first.
// Install: add `puppeteer-core` to package deps + set BROWSER_WS_ENDPOINT (or set INSTALL_VISUAL_REVIEW=true
// in the Dockerfile and point at a `browserless/chrome:latest` sidecar).
diff --git a/test/unit/actions-fallback.test.ts b/test/unit/actions-fallback.test.ts
index bfd7c79189..73954e8332 100644
--- a/test/unit/actions-fallback.test.ts
+++ b/test/unit/actions-fallback.test.ts
@@ -190,7 +190,7 @@ describe("fallbackShotR2Key", () => {
const a = await fallbackShotR2Key("deadbeef", "/pricing", "desktop");
const b = await fallbackShotR2Key("deadbeef", "/pricing", "desktop");
expect(a).toBe(b);
- expect(a.startsWith("gittensory/shots/actions-fallback/")).toBe(true);
+ expect(a.startsWith("loopover/shots/actions-fallback/")).toBe(true);
expect(a.endsWith(".png")).toBe(true);
});
@@ -431,7 +431,7 @@ describe("isFallbackDispatchInFlight / markFallbackDispatched / clearFallbackDis
const store = memoryFallbackMarkerStore();
const env = createTestEnv({ REVIEW_AUDIT: store });
const fingerprint = await sha256Hex(`${HEAD_SHA}:actions-fallback:dispatch-marker`);
- const key = `gittensory/fallback-dispatch/${fingerprint.slice(0, 40)}.json`;
+ const key = `loopover/fallback-dispatch/${fingerprint.slice(0, 40)}.json`;
await store.put(key, "not json");
await expect(isFallbackDispatchInFlight(env, HEAD_SHA)).resolves.toBe(false);
});
@@ -440,7 +440,7 @@ describe("isFallbackDispatchInFlight / markFallbackDispatched / clearFallbackDis
const store = memoryFallbackMarkerStore();
const env = createTestEnv({ REVIEW_AUDIT: store });
const fingerprint = await sha256Hex(`${HEAD_SHA}:actions-fallback:dispatch-marker`);
- const key = `gittensory/fallback-dispatch/${fingerprint.slice(0, 40)}.json`;
+ const key = `loopover/fallback-dispatch/${fingerprint.slice(0, 40)}.json`;
await store.put(key, JSON.stringify({ someOtherField: true }));
await expect(isFallbackDispatchInFlight(env, HEAD_SHA)).resolves.toBe(false);
});
diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts
index ec7c2d0657..db16524f9d 100644
--- a/test/unit/auth.test.ts
+++ b/test/unit/auth.test.ts
@@ -105,7 +105,7 @@ describe("private-beta auth and rate limiting", () => {
expect(routeClassForPath("/v1/orb/ingest")).toBe("strict"); // open telemetry ingest — abuse-capped per IP
expect(routeClassForPath("/v1/auth/github/device/start")).toBe("strict");
expect(routeClassForPath("/v1/local/branch-analysis")).toBe("expensive");
- expect(routeClassForPath("/gittensory/shot")).toBe("expensive");
+ expect(routeClassForPath("/loopover/shot")).toBe("expensive");
expect(routeClassForPath("/v1/scoring/preview")).toBe("expensive");
expect(routeClassForPath("/v1/upstream/status")).toBe("expensive");
expect(routeClassForPath("/v1/contributors/jsonbored/decision-pack")).toBe("expensive");
diff --git a/test/unit/visual-capture.test.ts b/test/unit/visual-capture.test.ts
index a00b6ef270..a4efa8ab35 100644
--- a/test/unit/visual-capture.test.ts
+++ b/test/unit/visual-capture.test.ts
@@ -56,7 +56,7 @@ function reviewAuditWithBrokenCachedBody(key: string): R2Bucket {
async function shotKey(prNumber: number, slot: "before" | "after", viewportName: "desktop" | "mobile", page: string): Promise {
const fingerprint = await sha256Hex(`${prNumber}:${slot}:${viewportName}:${page}`);
- return `gittensory/shots/${fingerprint.slice(0, 40)}.png`;
+ return `loopover/shots/${fingerprint.slice(0, 40)}.png`;
}
afterEach(() => {
@@ -118,8 +118,8 @@ describe("visual capture preview discovery", () => {
path: "/app",
beforeUrl: undefined,
beforeUrlMobile: undefined,
- afterUrl: "https://worker.example/gittensory/shot?placeholder=failed",
- afterUrlMobile: "https://worker.example/gittensory/shot?placeholder=failed",
+ afterUrl: "https://worker.example/loopover/shot?placeholder=failed",
+ afterUrlMobile: "https://worker.example/loopover/shot?placeholder=failed",
},
]);
expect(latestGitHubRestRateLimitObservation(key)).toEqual({
@@ -158,8 +158,8 @@ describe("visual capture preview discovery", () => {
path: "/app",
beforeUrl: undefined,
beforeUrlMobile: undefined,
- afterUrl: `https://worker.example/gittensory/shot?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=1440&h=900`,
- afterUrlMobile: `https://worker.example/gittensory/shot?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=390&h=844`,
+ afterUrl: `https://worker.example/loopover/shot?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=1440&h=900`,
+ afterUrlMobile: `https://worker.example/loopover/shot?url=${encodeURIComponent("https://pr-42-abc1234.preview.example.com/app")}&w=390&h=844`,
},
]);
});
@@ -492,8 +492,8 @@ describe("buildCapture pixel-diff wiring (#3674)", () => {
{ repoFullName: "owner/repo", prNumber: 2, previewUrl: "https://preview.example.com" },
["apps/gittensory-ui/src/routes/app.index.tsx"],
);
- expect(result.routes[0]?.diffUrl).toContain("/gittensory/shot?key=");
- expect(result.routes[0]?.diffUrlMobile).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.diffUrl).toContain("/loopover/shot?key=");
+ expect(result.routes[0]?.diffUrlMobile).toContain("/loopover/shot?key=");
expect(result.routes[0]?.diffUrl).not.toBe(result.routes[0]?.diffUrlMobile);
} finally {
availableSpy.mockRestore();
@@ -588,7 +588,7 @@ describe("buildCapture pixel-diff wiring (#3674)", () => {
["apps/gittensory-ui/src/routes/app.index.tsx"],
);
- expect(result.routes[0]?.beforeUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.beforeUrl).toContain("/loopover/shot?key=");
expect(result.routes[0]?.diffUrl).toBeUndefined();
});
@@ -610,7 +610,7 @@ describe("buildCapture pixel-diff wiring (#3674)", () => {
["apps/gittensory-ui/src/routes/app.index.tsx"],
);
- expect(result.routes[0]?.beforeUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.beforeUrl).toContain("/loopover/shot?key=");
} finally {
availableSpy.mockRestore();
compareSpy.mockRestore();
@@ -634,7 +634,7 @@ describe("buildCapture pixel-diff wiring (#3674)", () => {
);
expect(captureShotSpy).toHaveBeenCalled();
- expect(result.routes[0]?.beforeUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.beforeUrl).toContain("/loopover/shot?key=");
expect(result.routes[0]?.diffUrl).toBeUndefined();
} finally {
captureShotSpy.mockRestore();
@@ -691,7 +691,7 @@ describe("buildCapture pixel-diff wiring (#3674)", () => {
["apps/gittensory-ui/src/routes/app.index.tsx"],
);
- expect(result.routes[0]?.diffUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.diffUrl).toContain("/loopover/shot?key=");
} finally {
availableSpy.mockRestore();
compareSpy.mockRestore();
@@ -700,7 +700,7 @@ describe("buildCapture pixel-diff wiring (#3674)", () => {
});
describe("buildCapture with REVIEW_AUDIT_S3_PUBLIC_URL configured (direct bucket links)", () => {
- it("links an already-cached shot directly at the bucket instead of this instance's /gittensory/shot proxy", async () => {
+ it("links an already-cached shot directly at the bucket instead of this instance's /loopover/shot proxy", async () => {
const env = createTestEnv({
PUBLIC_API_ORIGIN: "https://worker.example",
PUBLIC_SITE_ORIGIN: "https://prod.example.com",
@@ -716,7 +716,7 @@ describe("buildCapture with REVIEW_AUDIT_S3_PUBLIC_URL configured (direct bucket
["apps/gittensory-ui/src/routes/app.index.tsx"],
);
expect(result.routes[0]?.afterUrl).toBe(`https://pub-abc123.r2.dev/${afterKey}`);
- expect(result.routes[0]?.afterUrl).not.toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.afterUrl).not.toContain("/loopover/shot?key=");
});
it("strips a trailing slash from REVIEW_AUDIT_S3_PUBLIC_URL before joining the key", async () => {
@@ -769,7 +769,7 @@ describe("buildCapture with REVIEW_AUDIT_S3_PUBLIC_URL configured (direct bucket
{ repoFullName: "owner/repo", prNumber: 23, previewUrl: "https://preview.example.com" },
["apps/gittensory-ui/src/routes/app.index.tsx"],
);
- expect(result.routes[0]?.afterUrl).toContain("worker.example/gittensory/shot?url=");
+ expect(result.routes[0]?.afterUrl).toContain("worker.example/loopover/shot?url=");
});
it("uploadDiffImage links directly at the bucket even when PUBLIC_API_ORIGIN is unset (S3 public URL alone is enough)", async () => {
@@ -925,7 +925,7 @@ describe("buildCapture theme matrix (#3678)", () => {
{ themes: ["dark"] },
);
expect(result.routes[0]?.theme).toBe("dark");
- expect(result.routes[0]?.diffUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.diffUrl).toContain("/loopover/shot?key=");
// Same path/PR, but tagged "dark" — must not reuse the untagged diff's fingerprint (theme is part of the key).
const untaggedFingerprint = await sha256Hex(`25:diff:desktop:/app`);
expect(result.routes[0]?.diffUrl).not.toContain(untaggedFingerprint.slice(0, 40));
@@ -984,7 +984,7 @@ describe("buildCapture theme-storage-key wiring (#4109)", () => {
{ themes: ["dark"], themeStorageKey: "theme" },
);
expect(result.routes[0]?.beforeUrl).toBe(
- `https://worker.example/gittensory/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900&theme=dark&themeStorageKey=${encodeURIComponent("theme")}`,
+ `https://worker.example/loopover/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900&theme=dark&themeStorageKey=${encodeURIComponent("theme")}`,
);
});
@@ -997,7 +997,7 @@ describe("buildCapture theme-storage-key wiring (#4109)", () => {
undefined,
{ themeStorageKey: "theme" },
);
- expect(result.routes[0]?.beforeUrl).toBe(`https://worker.example/gittensory/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900`);
+ expect(result.routes[0]?.beforeUrl).toBe(`https://worker.example/loopover/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900`);
});
it("threads the theme storage key into the shot fingerprint too, so it never collides with an untagged-key capture of the same theme", async () => {
@@ -1013,7 +1013,7 @@ describe("buildCapture theme-storage-key wiring (#4109)", () => {
{ themes: ["dark"], themeStorageKey: "theme" },
);
expect(result.routes[0]?.theme).toBe("dark");
- expect(result.routes[0]?.beforeUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.beforeUrl).toContain("/loopover/shot?key=");
// Same PR/path/theme, but tagged with a storage key — must not reuse the untagged-key fingerprint.
const untaggedFingerprint = await sha256Hex(`42:before:desktop:https://prod.example.com/app:dark`);
expect(result.routes[0]?.beforeUrl).not.toContain(untaggedFingerprint.slice(0, 40));
@@ -1040,7 +1040,7 @@ describe("buildCapture theme-storage-key wiring (#4109)", () => {
{ gif: true, themes: ["dark"], themeStorageKey: "theme" },
);
expect(result.routes[0]?.theme).toBe("dark");
- expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.afterGifUrl).toContain("/loopover/shot?key=");
const untaggedFingerprint = await sha256Hex(`43:scrollgif:after:desktop:https://preview.example.com/app:dark`);
expect(result.routes[0]?.afterGifUrl).not.toContain(untaggedFingerprint.slice(0, 40));
expect(captureScrollSpy.mock.calls.some(([, , , opts]) => opts?.themeStorageKey === "theme")).toBe(true);
@@ -1112,8 +1112,8 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => {
);
expect(captureScrollSpy).toHaveBeenCalledTimes(2); // before + after
expect(encodeSpy).toHaveBeenCalledTimes(2);
- expect(result.routes[0]?.beforeGifUrl).toContain("/gittensory/shot?key=");
- expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.beforeGifUrl).toContain("/loopover/shot?key=");
+ expect(result.routes[0]?.afterGifUrl).toContain("/loopover/shot?key=");
expect(result.routes[0]?.beforeGifUrl).not.toBe(result.routes[0]?.afterGifUrl);
} finally {
gifAvailableSpy.mockRestore();
@@ -1171,7 +1171,7 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => {
{ gif: true },
);
expect(captureScrollSpy).toHaveBeenCalledTimes(1); // before only
- expect(result.routes[0]?.beforeGifUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.beforeGifUrl).toContain("/loopover/shot?key=");
expect(result.routes[0]?.afterGifUrl).toBeUndefined();
} finally {
gifAvailableSpy.mockRestore();
@@ -1273,7 +1273,7 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => {
);
expect(captureScrollSpy).toHaveBeenCalledTimes(1); // after only — before has no page to capture
expect(result.routes[0]?.beforeGifUrl).toBeUndefined();
- expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.afterGifUrl).toContain("/loopover/shot?key=");
} finally {
gifAvailableSpy.mockRestore();
captureScrollSpy.mockRestore();
@@ -1321,7 +1321,7 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => {
{ gif: true, themes: ["dark"] },
);
expect(result.routes[0]?.theme).toBe("dark");
- expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.afterGifUrl).toContain("/loopover/shot?key=");
const untaggedFingerprint = await sha256Hex(`39:scrollgif:after:desktop:https://preview.example.com/app`);
expect(result.routes[0]?.afterGifUrl).not.toContain(untaggedFingerprint.slice(0, 40));
} finally {
@@ -1353,7 +1353,7 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => {
{ gif: true },
);
expect(captureScrollSpy).toHaveBeenCalled(); // cache lookup failed -> falls through to a fresh capture
- expect(result.routes[0]?.beforeGifUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.beforeGifUrl).toContain("/loopover/shot?key=");
} finally {
gifAvailableSpy.mockRestore();
captureScrollSpy.mockRestore();
@@ -1403,8 +1403,8 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => {
undefined,
{ gif: true },
);
- expect(result.routes[0]?.beforeGifUrl).toContain("/gittensory/shot?key=");
- expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key=");
+ expect(result.routes[0]?.beforeGifUrl).toContain("/loopover/shot?key=");
+ expect(result.routes[0]?.afterGifUrl).toContain("/loopover/shot?key=");
} finally {
gifAvailableSpy.mockRestore();
captureScrollSpy.mockRestore();
@@ -1414,12 +1414,12 @@ describe("buildCapture scroll-GIF wiring (#3612)", () => {
});
describe("hasSuccessfulBotCapture (#4110)", () => {
- const REAL_BEFORE = "https://api.example/gittensory/shot?key=gittensory%2Fshots%2Fbefore.png";
- const REAL_AFTER = "https://api.example/gittensory/shot?key=gittensory%2Fshots%2Fafter.png";
- const ON_DEMAND_BEFORE = "https://api.example/gittensory/shot?url=https%3A%2F%2Fprod.example%2Fapp&w=1440&h=900";
- const ON_DEMAND_AFTER = "https://api.example/gittensory/shot?url=https%3A%2F%2Fpreview.example%2Fapp&w=1440&h=900";
- const LOADING_PLACEHOLDER = "https://api.example/gittensory/shot?placeholder=loading";
- const FAILED_PLACEHOLDER = "https://api.example/gittensory/shot?placeholder=failed";
+ const REAL_BEFORE = "https://api.example/loopover/shot?key=gittensory%2Fshots%2Fbefore.png";
+ const REAL_AFTER = "https://api.example/loopover/shot?key=gittensory%2Fshots%2Fafter.png";
+ const ON_DEMAND_BEFORE = "https://api.example/loopover/shot?url=https%3A%2F%2Fprod.example%2Fapp&w=1440&h=900";
+ const ON_DEMAND_AFTER = "https://api.example/loopover/shot?url=https%3A%2F%2Fpreview.example%2Fapp&w=1440&h=900";
+ const LOADING_PLACEHOLDER = "https://api.example/loopover/shot?placeholder=loading";
+ const FAILED_PLACEHOLDER = "https://api.example/loopover/shot?placeholder=failed";
function route(overrides: Partial = {}): CaptureRoute {
return { path: "/app", ...overrides };
@@ -1722,7 +1722,7 @@ describe("review.visual.actions_fallback (#4112 GitHub-Actions build-and-serve f
{ actionsFallback: true },
);
- expect(result.routes[0]?.afterUrl).toBe(`https://worker.example/gittensory/shot?key=${encodeURIComponent(key)}`);
+ expect(result.routes[0]?.afterUrl).toBe(`https://worker.example/loopover/shot?key=${encodeURIComponent(key)}`);
});
it("links an already-stored fallback shot directly at the bucket when REVIEW_AUDIT_S3_PUBLIC_URL is configured", async () => {
@@ -1833,18 +1833,18 @@ describe("fetchShotContentBlock (#4111)", () => {
it("returns a base64-encoded image content block on a successful fetch", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 })));
- const block = await fetchShotContentBlock("https://x/gittensory/shot?key=before");
+ const block = await fetchShotContentBlock("https://x/loopover/shot?key=before");
expect(block).toEqual({ type: "image", data: Buffer.from([137, 80, 78, 71]).toString("base64"), mimeType: "image/png" });
});
it("returns undefined on a non-2xx response", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("not found", { status: 404 })));
- await expect(fetchShotContentBlock("https://x/gittensory/shot?key=missing")).resolves.toBeUndefined();
+ await expect(fetchShotContentBlock("https://x/loopover/shot?key=missing")).resolves.toBeUndefined();
});
it("returns undefined (never throws) when fetch itself rejects", async () => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network down"); }));
- await expect(fetchShotContentBlock("https://x/gittensory/shot?key=broken")).resolves.toBeUndefined();
+ await expect(fetchShotContentBlock("https://x/loopover/shot?key=broken")).resolves.toBeUndefined();
});
});
diff --git a/test/unit/visual-shot.test.ts b/test/unit/visual-shot.test.ts
index e093fc5be6..bece73fed7 100644
--- a/test/unit/visual-shot.test.ts
+++ b/test/unit/visual-shot.test.ts
@@ -31,11 +31,11 @@ function env(): Env {
}
function request(url: string): Request {
- return new Request(`https://api.example.test/gittensory/shot?url=${encodeURIComponent(url)}`);
+ return new Request(`https://api.example.test/loopover/shot?url=${encodeURIComponent(url)}`);
}
function shotRequest(query: string): Request {
- return new Request(`https://api.example.test/gittensory/shot?${query}`);
+ return new Request(`https://api.example.test/loopover/shot?${query}`);
}
// Minimal R2 stub: REVIEW_AUDIT.get(key) returns an object whose `.body` is a byte stream, or null.
@@ -628,7 +628,7 @@ describe("visual screenshot placeholder cards", () => {
describe("visual screenshot R2 key serve + traversal guard", () => {
it("streams a stored PNG for a valid key inside the namespace", async () => {
const png = new Uint8Array([10, 20, 30, 40]);
- const key = "gittensory/shots/abc.png";
+ const key = "loopover/shots/abc.png";
const response = await handleShot(shotRequest(`key=${encodeURIComponent(key)}`), r2Env({ [key]: png }));
expect(response.status).toBe(200);
@@ -639,7 +639,7 @@ describe("visual screenshot R2 key serve + traversal guard", () => {
it("serves a .gif key with an image/gif content-type (#3612) — extension-derived, not stored httpMetadata", async () => {
const gif = new Uint8Array([1, 2, 3, 4]);
- const key = "gittensory/shots/abc.gif";
+ const key = "loopover/shots/abc.gif";
const response = await handleShot(shotRequest(`key=${encodeURIComponent(key)}`), r2Env({ [key]: gif }));
expect(response.status).toBe(200);
@@ -649,7 +649,7 @@ describe("visual screenshot R2 key serve + traversal guard", () => {
it("returns 404 for a valid key that is absent from R2", async () => {
const response = await handleShot(
- shotRequest(`key=${encodeURIComponent("gittensory/shots/missing.png")}`),
+ shotRequest(`key=${encodeURIComponent("loopover/shots/missing.png")}`),
r2Env({}),
);
@@ -659,7 +659,7 @@ describe("visual screenshot R2 key serve + traversal guard", () => {
it("rejects a key that traverses with ..", async () => {
const response = await handleShot(
- shotRequest(`key=${encodeURIComponent("gittensory/shots/../../etc/passwd")}`),
+ shotRequest(`key=${encodeURIComponent("loopover/shots/../../etc/passwd")}`),
r2Env({}),
);
diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts
index 34e9be5122..e1947b1df9 100644
--- a/worker-configuration.d.ts
+++ b/worker-configuration.d.ts
@@ -1,7 +1,9 @@
/* eslint-disable */
-// Generated by Wrangler by running `wrangler types` (hash: d5851e3e7ea2bc8cd084f39b4328cb86)
+// Generated by Wrangler by running `wrangler types` (hash: edef2ca99ed4103c1ab8cdf89078e160)
// Runtime types generated with workerd@1.20260701.1 2026-05-28 nodejs_compat
interface __BaseEnv_Env {
+ REVIEW_AUDIT: R2Bucket;
+ VISUAL_CAPTURE_PUBLIC: R2Bucket;
DB: D1Database;
JOBS: Queue;
GITHUB_OAUTH_CLIENT_ID: "Ov23lixYxDzyUKE070sm";
diff --git a/wrangler.jsonc b/wrangler.jsonc
index 1fb51b625f..da88e5fe26 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -235,6 +235,20 @@
"custom_domain": true,
},
],
+ // R2 bucket bindings were previously configured directly via the dashboard, not tracked in code (#5332).
+ // REVIEW_AUDIT backs both the review-audit ledger and the /loopover/shot screenshot cache (key-prefixed);
+ // VISUAL_CAPTURE_PUBLIC is provisioned for a future split of public-facing screenshot storage but is not
+ // yet read/written by any code path.
+ "r2_buckets": [
+ {
+ "binding": "REVIEW_AUDIT",
+ "bucket_name": "loopover-review-audit",
+ },
+ {
+ "binding": "VISUAL_CAPTURE_PUBLIC",
+ "bucket_name": "loopover-visual-capture-public",
+ },
+ ],
"d1_databases": [
{
"binding": "DB",