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",