Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
2 changes: 1 addition & 1 deletion src/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/") ||
Expand Down
17 changes: 10 additions & 7 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/review/unified-comment-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> tags pointing at the public /gittensory/shot URLs) is appended.
* collapsible (a markdown table of <img> 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;
Expand Down Expand Up @@ -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 `<a>/<img>` 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.
*/
Expand Down
4 changes: 2 additions & 2 deletions src/review/visual/actions-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions src/review/visual/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PUBLIC_API_ORIGIN>/gittensory/shot?key=<r2key> (this
// (env.REVIEW_AUDIT), and embedded either as <PUBLIC_API_ORIGIN>/loopover/shot?key=<r2key> (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
Expand All @@ -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
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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).
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src/review/visual/shot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<r2key> -> stream a pre-rendered PNG from R2 (fast; GitHub's image proxy
// GET /loopover/shot?key=<r2key> -> 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=<page> -> render <page> on demand and return a PNG (host-allowlisted +
// GET /loopover/shot?url=<page> -> render <page> 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).
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Response> {
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.
Expand Down
4 changes: 2 additions & 2 deletions src/selfhost/blob-store.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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/<hash>.png`) and the serve route already prefix-checks + rejects `..`, but the path is
* (`loopover/shots/<hash>.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);
Expand Down
6 changes: 3 additions & 3 deletions src/selfhost/s3-blob-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<hash>.png`, already validated by the /gittensory/shot serve route's own prefix +
* (`loopover/shots/<hash>.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 {
Expand Down
2 changes: 1 addition & 1 deletion src/selfhost/stubs/puppeteer.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
6 changes: 3 additions & 3 deletions test/unit/actions-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand Down
Loading
Loading