diff --git a/.env.example b/.env.example index d221936ce3..7d0783f98d 100644 --- a/.env.example +++ b/.env.example @@ -160,3 +160,16 @@ GITTENSORY_REVIEW_DRAFT=false # AI_EMBED_MODEL=bge-m3 # embedding model for RAG (openai-compatible /embeddings). MUST be # # 1024-dimensional (e.g. bge-m3 or mxbai-embed-large via Ollama). # # Used only when RAG is enabled (GITTENSORY_REVIEW_RAG + allowlist). + +# --- Gittensory Orb (#1219; opt-in outcome signal collection) --- +# Run GET /orb/setup to create the Orb GitHub App (read-only; separate from the main App). +# Credentials are written to /data/gittensory-orb.env on callback — load them here. +# ORB_APP_ID= # App ID from /orb/setup callback +# ORB_APP_SLUG= # App slug (human-readable name) +# ORB_WEBHOOK_SECRET= # secret from /orb/setup callback — signs /orb/webhook requests +# ORB_PRIVATE_KEY= # PEM from /orb/setup callback (JSON-stringified) +# ORB_ENABLED=false # master switch: set to true to enable collection (default off) +# ORB_AIR_GAP=false # set to true to keep all data local — never send to the collector +# ORB_ANONYMIZE=true # HMAC-hash repo names before export (default true; false = raw names) +# ORB_COLLECTOR_URL=https://orb.gittensory.app/v1/ingest # central collector URL (set by default; override as needed) +# ORB_SETUP_OUTPUT_PATH=/data/gittensory-orb.env # where /orb/setup/callback writes the credentials file diff --git a/grafana/dashboards/gittensory.json b/grafana/dashboards/gittensory.json index e83163cc21..46592a5143 100644 --- a/grafana/dashboards/gittensory.json +++ b/grafana/dashboards/gittensory.json @@ -236,6 +236,80 @@ { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "rate(gittensory_qdrant_errors_total[2m])", "legendFormat": "errors/s" } ] }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 }, + "id": 104, + "title": "Gittensory Orb", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, "unit": "short" } }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 33 }, + "id": 13, + "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "title": "Orb Events Recorded", + "type": "stat", + "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "gittensory_orb_events_recorded_total", "legendFormat": "recorded" }] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, "unit": "short" } }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 33 }, + "id": 14, + "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "title": "Orb Events Exported", + "type": "stat", + "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "gittensory_orb_events_exported_total", "legendFormat": "exported" }] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }, "unit": "short" } }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 33 }, + "id": 15, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "title": "Orb Export Errors", + "type": "stat", + "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "gittensory_orb_export_errors_total or vector(0)", "legendFormat": "errors" }] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, "unit": "short" } }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 33 }, + "id": 16, + "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, + "title": "Orb Installations", + "type": "stat", + "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "gittensory_orb_installs_total", "legendFormat": "installs" }] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 2, "fillOpacity": 10 }, "unit": "ops" } }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 37 }, + "id": 17, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "title": "Orb Event Rate", + "type": "timeseries", + "targets": [ + { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "rate(gittensory_orb_events_recorded_total[5m])", "legendFormat": "recorded/s" }, + { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "rate(gittensory_orb_events_exported_total[5m])", "legendFormat": "exported/s" }, + { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "rate(gittensory_orb_webhook_total[5m])", "legendFormat": "webhooks/s" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 2, "fillOpacity": 10 }, "unit": "short" } }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 37 }, + "id": 18, + "options": { "legend": { "calcs": ["mean", "last"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "title": "Orb Pending vs Exported", + "type": "timeseries", + "targets": [ + { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "gittensory_orb_events_recorded_total - gittensory_orb_events_exported_total", "legendFormat": "pending" }, + { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "gittensory_orb_events_exported_total", "legendFormat": "exported (cumulative)" } + ] + } ], "refresh": "30s", "schemaVersion": 38, @@ -259,5 +333,5 @@ "timezone": "browser", "title": "Gittensory Self-Host", "uid": "gittensory-selfhost", - "version": 2 + "version": 3 } diff --git a/migrations/0056_orb_events.sql b/migrations/0056_orb_events.sql new file mode 100644 index 0000000000..455588afa6 --- /dev/null +++ b/migrations/0056_orb_events.sql @@ -0,0 +1,18 @@ +-- Gittensory Orb (#1219): local outcome-signal store. Records the gate verdict and +-- final outcome (merged / closed) for every PR the engine reviewed. Used by the Orb +-- export job to batch-send calibration signals to the central collector (opt-in) or +-- to keep them local for operator-only analysis (ORB_AIR_GAP=true). +CREATE TABLE IF NOT EXISTS orb_events ( + id INTEGER PRIMARY KEY, + repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, + head_sha TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('merged', 'closed')), + gate_verdict TEXT, -- 'approve' | 'block' | 'comment' | NULL (no review recorded) + time_to_close_ms INTEGER, -- ms from PR open to close; NULL if opened_at unavailable + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + exported_at TEXT, -- NULL = pending export; set when batch-sent to collector + UNIQUE (repo, pr_number, head_sha) -- idempotent: same close event may arrive more than once +); +CREATE INDEX IF NOT EXISTS orb_events_repo_pr ON orb_events (repo, pr_number); +CREATE INDEX IF NOT EXISTS orb_events_export_pending ON orb_events (exported_at) WHERE exported_at IS NULL; diff --git a/migrations/0057_orb_installations.sql b/migrations/0057_orb_installations.sql new file mode 100644 index 0000000000..64059e2d57 --- /dev/null +++ b/migrations/0057_orb_installations.sql @@ -0,0 +1,11 @@ +-- Gittensory Orb (#1219): tracks which repos have the Orb GitHub App installed. +-- `removed_at IS NULL` = currently installed; set on uninstall/removal events. +CREATE TABLE IF NOT EXISTS orb_installations ( + id INTEGER PRIMARY KEY, + installation_id INTEGER NOT NULL, + repo TEXT NOT NULL, + installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + removed_at TEXT, -- NULL = still installed + UNIQUE (installation_id, repo) +); +CREATE INDEX IF NOT EXISTS orb_installations_repo ON orb_installations (repo, removed_at); diff --git a/src/selfhost/orb-collector.ts b/src/selfhost/orb-collector.ts new file mode 100644 index 0000000000..83fec8bb0f --- /dev/null +++ b/src/selfhost/orb-collector.ts @@ -0,0 +1,148 @@ +// Gittensory Orb (#1219) — local outcome-signal collector. Records gate verdict + final PR +// outcome (merged/closed) for every PR the engine reviewed, enabling calibration of gate +// thresholds and AI prompts from real-world feedback signals. +// +// Collection is always local (DB only). Export to the central collector is opt-in: +// ORB_ENABLED=true — activates collection (off by default) +// ORB_COLLECTOR_URL= — endpoint to export batches to (default: https://orb.gittensory.app/v1/ingest) +// ORB_AIR_GAP=true — keep all events local, never send externally +// ORB_ANONYMIZE=true — HMAC-hash repo/owner before export (default: true) +// +// Nothing is ever sent without ORB_ENABLED=true. No diffs, no code, no comments, no user +// identifiers — only aggregate outcome metadata (repo-hash, verdict, outcome, timing). +import { createHash, createHmac } from "node:crypto"; +import { incr } from "./metrics"; + +export interface OrbEvent { + repo: string; + pr_number: number; + head_sha: string; + outcome: "merged" | "closed"; + gate_verdict?: string; + time_to_close_ms?: number; +} + +interface OrbRow { + id: number; + repo: string; + pr_number: number; + head_sha: string; + outcome: string; + gate_verdict: string | null; + time_to_close_ms: number | null; + created_at: string; + exported_at: string | null; +} + +interface OrbExportPayload { + instance_id: string; + events: Array<{ + repo_hash: string; + pr_hash: string; + outcome: string; + gate_verdict: string | null; + time_to_close_ms: number | null; + created_at: string; + }>; +} + +/** Stable instance identifier (hash of the Orb App ID — no PII). */ +function instanceId(): string { + return createHash("sha256").update(process.env.ORB_APP_ID ?? "unknown").digest("hex").slice(0, 16); +} + +/** HMAC a string with the webhook secret for anonymized export. */ +function hmacField(value: string, secret: string): string { + return createHmac("sha256", secret).update(value).digest("hex").slice(0, 24); +} + +/** Returns true only when Orb collection is explicitly enabled. */ +export function orbEnabled(): boolean { + const v = (process.env.ORB_ENABLED ?? "").toLowerCase(); + return v === "true" || v === "1" || v === "yes"; +} + +/** Record a single outcome event in the local DB. No-op when ORB_ENABLED is false. */ +export async function recordOrbEvent(db: D1Database, event: OrbEvent): Promise { + if (!orbEnabled()) return; + try { + await db + .prepare( + `INSERT OR IGNORE INTO orb_events (repo, pr_number, head_sha, outcome, gate_verdict, time_to_close_ms) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .bind(event.repo, event.pr_number, event.head_sha, event.outcome, event.gate_verdict ?? null, event.time_to_close_ms ?? null) + .run(); + incr("gittensory_orb_events_recorded_total"); + } catch { + // best-effort — never let Orb collection crash job processing + } +} + +/** + * Export pending Orb events to the central collector. Called periodically (e.g. hourly). + * Reads up to `batchSize` unexported events, signs and POSTs them, marks them as exported. + * Returns the number of events exported (0 if air-gap, disabled, or nothing pending). + */ +export async function exportOrbBatch( + db: D1Database, + batchSize = 200, + fetchFn: typeof fetch = fetch, +): Promise { + if (!orbEnabled()) return 0; + if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0; + + const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://orb.gittensory.app/v1/ingest"; + const secret = process.env.ORB_WEBHOOK_SECRET ?? ""; + const anonymize = (process.env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false"; + + const { results } = await db + .prepare(`SELECT * FROM orb_events WHERE exported_at IS NULL ORDER BY id LIMIT ?`) + .bind(batchSize) + .all(); + + if (!results || results.length === 0) return 0; + + const payload: OrbExportPayload = { + instance_id: instanceId(), + events: results.map((r) => ({ + repo_hash: anonymize ? hmacField(r.repo, secret) : r.repo, + pr_hash: anonymize ? hmacField(`${r.repo}#${r.pr_number}`, secret) : String(r.pr_number), + outcome: r.outcome, + gate_verdict: r.gate_verdict, + time_to_close_ms: r.time_to_close_ms, + created_at: r.created_at, + })), + }; + + const body = JSON.stringify(payload); + const signature = createHmac("sha256", secret).update(body).digest("hex"); + + try { + const res = await fetchFn(collectorUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-orb-signature": `sha256=${signature}`, + "x-orb-instance": instanceId(), + }, + body, + }); + if (!res.ok) { + incr("gittensory_orb_export_errors_total"); + return 0; + } + } catch { + incr("gittensory_orb_export_errors_total"); + return 0; + } + + // Mark all exported events + const ids = results.map((r) => r.id); + const placeholders = ids.map(() => "?").join(","); + const now = new Date().toISOString(); + await db.prepare(`UPDATE orb_events SET exported_at=? WHERE id IN (${placeholders})`).bind(now, ...ids).run(); + + incr("gittensory_orb_events_exported_total", {}, ids.length); + return ids.length; +} diff --git a/src/selfhost/orb-setup.ts b/src/selfhost/orb-setup.ts new file mode 100644 index 0000000000..1ad8c7c6a4 --- /dev/null +++ b/src/selfhost/orb-setup.ts @@ -0,0 +1,66 @@ +// Gittensory Orb (#1219) setup wizard. Mirrors setup-wizard.ts but for the lightweight +// "Gittensory Orb" GitHub App — pull_requests:read + metadata:read + pull_request + +// installation events only. Creates a separate App so operators can install Orb +// independently of the main review App, and revoke data collection without touching reviews. +// +// Routes (server.ts): GET /orb/setup → form page; GET /orb/setup/callback → exchange code. + +export interface OrbCredentials { + id: number; + slug: string; + webhook_secret: string; + pem: string; +} + +/** Minimal Orb App manifest — read-only permissions, no write capabilities. */ +export function buildOrbManifest(origin: string, state: string): Record { + const base = origin.replace(/\/+$/, ""); + return { + name: "Gittensory Orb", + url: base, + hook_attributes: { url: `${base}/orb/webhook` }, + redirect_url: `${base}/orb/setup/callback?state=${encodeURIComponent(state)}`, + public: false, + default_permissions: { + pull_requests: "read", + metadata: "read", + }, + default_events: ["pull_request", "installation", "installation_repositories"], + }; +} + +/** HTML page with a single button that POSTs the manifest to GitHub's App-creation flow. */ +export function renderOrbSetupPage(origin: string, state: string): string { + const manifest = JSON.stringify(buildOrbManifest(origin, state)).replace(/'/g, "'"); + return `Gittensory Orb setup + +

Gittensory Orb setup

+

This creates a lightweight read-only GitHub App that observes PR outcomes for local calibration +and optional aggregate telemetry. Install it on the same repositories as your main Gittensory App. +GitHub will redirect back here with the credentials — then restart the container to activate collection.

+
+ + +
+`; +} + +/** Exchange a one-time manifest code (from GitHub's callback) for the App's credentials. */ +export async function exchangeOrbManifestCode(code: string, fetchImpl: typeof fetch = fetch): Promise { + const res = await fetchImpl(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, { + method: "POST", + headers: { accept: "application/vnd.github+json", "user-agent": "gittensory-selfhost" }, + }); + if (!res.ok) throw new Error(`orb_manifest_exchange_http_${res.status}`); + return (await res.json()) as OrbCredentials; +} + +/** Serialize Orb credentials as env-file lines for the operator to load. */ +export function orbCredentialsToEnv(creds: OrbCredentials): string { + return [ + `ORB_APP_ID=${creds.id}`, + `ORB_APP_SLUG=${creds.slug}`, + `ORB_WEBHOOK_SECRET=${creds.webhook_secret}`, + `ORB_PRIVATE_KEY=${JSON.stringify(creds.pem)}`, + ].join("\n") + "\n"; +} diff --git a/src/selfhost/orb-webhook.ts b/src/selfhost/orb-webhook.ts new file mode 100644 index 0000000000..6e328960a8 --- /dev/null +++ b/src/selfhost/orb-webhook.ts @@ -0,0 +1,137 @@ +// Gittensory Orb (#1219) webhook dispatcher — handles pull_request + installation events +// from the lightweight Orb GitHub App. Verifies HMAC-SHA256 (x-hub-signature-256), +// tracks per-repo installations, and records PR outcome signals on pull_request.closed. + +import { createHmac, timingSafeEqual } from "node:crypto"; +import { incr } from "./metrics"; +import { recordOrbEvent } from "./orb-collector"; + +/** Verify a GitHub webhook signature (x-hub-signature-256: sha256=). */ +export function verifyOrbSignature(payload: string, sig: string, secret: string): boolean { + if (!sig.startsWith("sha256=")) return false; + const expected = createHmac("sha256", secret).update(payload).digest("hex"); + const actual = sig.slice("sha256=".length); + try { + return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(actual, "hex")); + } catch { + return false; + } +} + +/** Look up the most recent gate verdict for a repo+PR from the review_targets table. */ +export async function lookupGateVerdict(db: D1Database, repo: string, prNumber: number): Promise { + try { + const row = await db + .prepare(`SELECT verdict FROM review_targets WHERE repo = ? AND number = ? ORDER BY updated_at DESC LIMIT 1`) + .bind(repo, prNumber) + .first<{ verdict: string | null }>(); + return row?.verdict ?? null; + } catch { + return null; + } +} + +interface InstallationPayload { + action: string; + installation: { id: number }; + repositories?: Array<{ full_name: string }>; + repositories_added?: Array<{ full_name: string }>; + repositories_removed?: Array<{ full_name: string }>; +} + +interface PullRequestPayload { + action: string; + pull_request: { + number: number; + head: { sha: string }; + merged: boolean; + created_at: string; + closed_at: string | null; + }; + repository: { full_name: string }; +} + +/** Main dispatcher. Returns the HTTP status + body to reply with. */ +export async function handleOrbWebhook( + event: string, + payload: string, + db: D1Database, +): Promise<{ status: number; body: string }> { + incr("gittensory_orb_webhook_total"); + + if (event === "installation" || event === "installation_repositories") { + return handleInstallation(JSON.parse(payload) as InstallationPayload, db); + } + + if (event === "pull_request") { + const body = JSON.parse(payload) as PullRequestPayload; + if (body.action === "closed") return handlePrClosed(body, db); + return { status: 204, body: "" }; + } + + return { status: 204, body: "" }; +} + +async function handleInstallation( + body: InstallationPayload, + db: D1Database, +): Promise<{ status: number; body: string }> { + const installationId = body.installation.id; + const now = new Date().toISOString(); + + if (body.action === "created" || body.action === "added") { + const repos = [...(body.repositories ?? []), ...(body.repositories_added ?? [])]; + for (const r of repos) { + try { + await db + .prepare(`INSERT OR IGNORE INTO orb_installations (installation_id, repo, installed_at) VALUES (?, ?, ?)`) + .bind(installationId, r.full_name, now) + .run(); + } catch { /* best-effort — never crash on install tracking */ } + } + if (repos.length) incr("gittensory_orb_installs_total", {}, repos.length); + } + + if (body.action === "deleted" || body.action === "removed") { + const repos = body.action === "deleted" + ? (body.repositories ?? []) + : (body.repositories_removed ?? []); + for (const r of repos) { + try { + await db + .prepare(`UPDATE orb_installations SET removed_at = ? WHERE installation_id = ? AND repo = ? AND removed_at IS NULL`) + .bind(now, installationId, r.full_name) + .run(); + } catch { /* best-effort */ } + } + } + + return { status: 204, body: "" }; +} + +async function handlePrClosed( + body: PullRequestPayload, + db: D1Database, +): Promise<{ status: number; body: string }> { + const repo = body.repository.full_name; + const prNumber = body.pull_request.number; + const headSha = body.pull_request.head.sha; + const outcome: "merged" | "closed" = body.pull_request.merged ? "merged" : "closed"; + + const closedMs = body.pull_request.closed_at ? new Date(body.pull_request.closed_at).getTime() : null; + const createdMs = body.pull_request.created_at ? new Date(body.pull_request.created_at).getTime() : null; + const timeToCloseMs = closedMs !== null && createdMs !== null ? closedMs - createdMs : undefined; + + const gateVerdict = await lookupGateVerdict(db, repo, prNumber); + + await recordOrbEvent(db, { + repo, + pr_number: prNumber, + head_sha: headSha, + outcome, + ...(gateVerdict !== null ? { gate_verdict: gateVerdict } : {}), + ...(timeToCloseMs !== undefined ? { time_to_close_ms: timeToCloseMs } : {}), + }); + + return { status: 204, body: "" }; +} diff --git a/src/server.ts b/src/server.ts index 97fd351349..6ceb040a3c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,9 @@ import worker from "./index"; import { processJob } from "./queue/processors"; import { createSelfHostAi } from "./selfhost/ai"; import { credentialsToEnv, exchangeManifestCode, renderSetupPage } from "./selfhost/setup-wizard"; +import { exchangeOrbManifestCode, orbCredentialsToEnv, renderOrbSetupPage } from "./selfhost/orb-setup"; +import { handleOrbWebhook, verifyOrbSignature } from "./selfhost/orb-webhook"; +import { orbEnabled, exportOrbBatch } from "./selfhost/orb-collector"; import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter"; import { readiness } from "./selfhost/health"; import { gauge, incr, renderMetrics } from "./selfhost/metrics"; @@ -186,6 +189,9 @@ async function main(): Promise { "gittensory_jobs_failed_total", "gittensory_jobs_dead_total", "gittensory_http_requests_total", "gittensory_webhook_dedup_total", "gittensory_qdrant_queries_total", "gittensory_qdrant_upserts_total", + "gittensory_orb_webhook_total", "gittensory_orb_installs_total", + "gittensory_orb_events_recorded_total", "gittensory_orb_events_exported_total", + "gittensory_orb_export_errors_total", ]) incr(c, undefined, 0); @@ -209,7 +215,7 @@ async function main(): Promise { if ((path === "/setup" || path === "/setup/callback") && !process.env.GITHUB_APP_ID) { // PUBLIC_API_ORIGIN is required: falling back to request.url.origin would let an attacker spoof // the Host header and redirect the App-creation callback to an attacker-controlled domain, where - // they could exchange the code for the App private key and webhook secret. + // they could exchange the one-time code for the App private key and webhook secret. const origin = process.env.PUBLIC_API_ORIGIN; if (!origin) { return new Response( @@ -248,6 +254,55 @@ async function main(): Promise { return new Response(`setup failed: ${error instanceof Error ? error.message : "error"}`, { status: 500 }); } } + // Gittensory Orb setup wizard — only while no Orb App is configured. + if ((path === "/orb/setup" || path === "/orb/setup/callback") && !process.env.ORB_APP_ID) { + // Same guard as the main setup wizard: PUBLIC_API_ORIGIN required to prevent Host-header spoofing. + const origin = process.env.PUBLIC_API_ORIGIN; + if (!origin) { + return new Response( + "PUBLIC_API_ORIGIN must be set before using the Orb setup wizard — add it to your .env file", + { status: 400 }, + ); + } + if (path === "/orb/setup") { + const state = randomUUID(); + return new Response(renderOrbSetupPage(origin, state), { + headers: { + "content-type": "text/html; charset=utf-8", + "Set-Cookie": `orb_setup_state=${state}; Path=/orb/setup; HttpOnly; SameSite=Lax; Max-Age=3600`, + }, + }); + } + const params = new URL(request.url).searchParams; + const code = params.get("code"); + if (!code) return new Response("missing ?code", { status: 400 }); + const stateParam = params.get("state"); + const cookieHeader = request.headers.get("cookie") ?? ""; + const cookieState = cookieHeader.split(";").map((c) => c.trim()).find((c) => c.startsWith("orb_setup_state="))?.slice("orb_setup_state=".length); + if (!stateParam || !cookieState || stateParam !== cookieState) { + return new Response("invalid state parameter", { status: 403 }); + } + try { + const creds = await exchangeOrbManifestCode(code); + const outPath = process.env.ORB_SETUP_OUTPUT_PATH ?? "/data/gittensory-orb.env"; + writeFileSync(outPath, orbCredentialsToEnv(creds), { mode: 0o600 }); + console.log(JSON.stringify({ event: "selfhost_orb_created", slug: creds.slug, app_id: creds.id })); + return new Response(`

Gittensory Orb App created ✓

Credentials written to ${outPath}. Add them to your .env (or load the file), install the Orb App on your repos, and restart the container.

`, { headers: { "content-type": "text/html; charset=utf-8" } }); + } catch (error) { + return new Response(`orb setup failed: ${error instanceof Error ? error.message : "error"}`, { status: 500 }); + } + } + // Orb webhook endpoint — receives pull_request + installation events from the Orb App. + if (path === "/orb/webhook" && request.method === "POST" && process.env.ORB_WEBHOOK_SECRET) { + const payload = await request.text(); + const sig = request.headers.get("x-hub-signature-256") ?? ""; + if (!verifyOrbSignature(payload, sig, process.env.ORB_WEBHOOK_SECRET)) { + return new Response("signature mismatch", { status: 401 }); + } + const event = request.headers.get("x-github-event") ?? ""; + const result = await handleOrbWebhook(event, payload, backend.db); + return new Response(result.body || null, { status: result.status }); + } incr("gittensory_http_requests_total"); // Webhook delivery dedup: return 204 immediately for already-processed delivery IDs. // We mark only AFTER a successful response — failed/rejected webhooks must be retryable. @@ -283,6 +338,17 @@ async function main(): Promise { ); }, intervalMs); + // Orb hourly export — batch-send pending outcome signals to the central collector. + // No-op when ORB_ENABLED is not set or ORB_AIR_GAP=true. + if (orbEnabled()) { + const runExport = () => + exportOrbBatch(backend.db) + .then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); }) + .catch(() => undefined); + void runExport(); // flush any pending events from a previous run at startup + setInterval(runExport, 3_600_000); // then hourly + } + // Graceful shutdown: stop accepting HTTP, let the queue finish, close the backend. let shuttingDown = false; const shutdown = async (signal: string): Promise => { diff --git a/test/unit/selfhost-orb-collector.test.ts b/test/unit/selfhost-orb-collector.test.ts new file mode 100644 index 0000000000..20e0e0dcfa --- /dev/null +++ b/test/unit/selfhost-orb-collector.test.ts @@ -0,0 +1,267 @@ +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter"; +import { exportOrbBatch, orbEnabled, recordOrbEvent } from "../../src/selfhost/orb-collector"; +import { resetMetrics, renderMetrics } from "../../src/selfhost/metrics"; + +/** Spin up an in-memory SQLite DB with the orb_events table (and the _selfhost_migrations + * stub so the adapter resolves without running all 56 migrations). */ +function makeDb(): D1Database { + const raw = new DatabaseSync(":memory:") as never; + const driver = nodeSqliteDriver(raw); + driver.exec(` + CREATE TABLE orb_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, + head_sha TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('merged', 'closed')), + gate_verdict TEXT, + time_to_close_ms INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + exported_at TEXT, + UNIQUE (repo, pr_number, head_sha) + ); + CREATE INDEX orb_events_repo_pr ON orb_events (repo, pr_number); + CREATE INDEX orb_events_export_pending ON orb_events (exported_at) WHERE exported_at IS NULL; + `); + return createD1Adapter(driver); +} + +async function countRows(db: D1Database): Promise { + const r = await db.prepare("SELECT COUNT(*) AS n FROM orb_events").first<{ n: number }>(); + return r?.n ?? 0; +} + +async function allRows(db: D1Database) { + return (await db.prepare("SELECT * FROM orb_events").all()).results; +} + +describe("orbEnabled()", () => { + afterEach(() => { delete process.env.ORB_ENABLED; }); + + it("returns false when ORB_ENABLED is unset (default off)", () => { + delete process.env.ORB_ENABLED; + expect(orbEnabled()).toBe(false); + }); + + it("returns true for 'true', '1', 'yes' (case-insensitive)", () => { + for (const v of ["true", "True", "TRUE", "1", "yes", "Yes"]) { + process.env.ORB_ENABLED = v; + expect(orbEnabled()).toBe(true); + } + }); + + it("returns false for empty string and 'false'", () => { + for (const v of ["", "false", "0", "no"]) { + process.env.ORB_ENABLED = v; + expect(orbEnabled()).toBe(false); + } + }); +}); + +describe("recordOrbEvent()", () => { + beforeEach(() => { resetMetrics(); process.env.ORB_ENABLED = "true"; }); + afterEach(() => { delete process.env.ORB_ENABLED; }); + + it("inserts an event with all fields when ORB_ENABLED=true", async () => { + const db = makeDb(); + await recordOrbEvent(db, { repo: "owner/repo", pr_number: 42, head_sha: "abc123", outcome: "merged", gate_verdict: "approve", time_to_close_ms: 3600000 }); + expect(await countRows(db)).toBe(1); + const [row] = (await allRows(db)) as Array>; + expect(row?.repo).toBe("owner/repo"); + expect(row?.pr_number).toBe(42); + expect(row?.outcome).toBe("merged"); + expect(row?.gate_verdict).toBe("approve"); + expect(row?.time_to_close_ms).toBe(3600000); + expect(row?.exported_at).toBeNull(); + }); + + it("inserts with null gate_verdict and time_to_close_ms when omitted", async () => { + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha", outcome: "closed" }); + const [row] = (await allRows(db)) as Array>; + expect(row?.gate_verdict).toBeNull(); + expect(row?.time_to_close_ms).toBeNull(); + }); + + it("is idempotent — INSERT OR IGNORE prevents duplicates for the same (repo, pr, sha)", async () => { + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha1", outcome: "merged" }); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha1", outcome: "merged" }); + expect(await countRows(db)).toBe(1); + }); + + it("increments gittensory_orb_events_recorded_total on each successful insert", async () => { + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha1", outcome: "merged" }); + await recordOrbEvent(db, { repo: "o/r", pr_number: 2, head_sha: "sha2", outcome: "closed" }); + expect(await renderMetrics()).toMatch(/gittensory_orb_events_recorded_total 2/); + }); + + it("does nothing when ORB_ENABLED=false", async () => { + process.env.ORB_ENABLED = "false"; + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 99, head_sha: "sha", outcome: "merged" }); + expect(await countRows(db)).toBe(0); + }); + + it("swallows DB errors and never throws (best-effort)", async () => { + const brokenDb = { prepare: () => ({ bind: () => ({ run: () => Promise.reject(new Error("disk full")) }) }) } as unknown as D1Database; + await expect(recordOrbEvent(brokenDb, { repo: "o/r", pr_number: 1, head_sha: "sha", outcome: "merged" })).resolves.not.toThrow(); + }); +}); + +describe("exportOrbBatch()", () => { + beforeEach(() => { + resetMetrics(); + process.env.ORB_ENABLED = "true"; + process.env.ORB_WEBHOOK_SECRET = "test-secret"; + process.env.ORB_ANONYMIZE = "true"; + delete process.env.ORB_AIR_GAP; + delete process.env.ORB_COLLECTOR_URL; + }); + afterEach(() => { + delete process.env.ORB_ENABLED; + process.env.ORB_WEBHOOK_SECRET = undefined as unknown as string; + delete process.env.ORB_ANONYMIZE; + delete process.env.ORB_AIR_GAP; + delete process.env.ORB_COLLECTOR_URL; + }); + + it("returns 0 when ORB_ENABLED=false (no-op)", async () => { + process.env.ORB_ENABLED = "false"; + const db = makeDb(); + expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0); + }); + + it("returns 0 when ORB_AIR_GAP=true", async () => { + process.env.ORB_AIR_GAP = "true"; + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha", outcome: "merged" }); + expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0); + }); + + it("returns 0 when there are no pending events", async () => { + const db = makeDb(); + expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0); + }); + + it("exports pending events and marks them as exported", async () => { + const db = makeDb(); + await recordOrbEvent(db, { repo: "owner/repo", pr_number: 1, head_sha: "sha1", outcome: "merged", gate_verdict: "approve" }); + await recordOrbEvent(db, { repo: "owner/repo", pr_number: 2, head_sha: "sha2", outcome: "closed" }); + + let capturedBody: string | undefined; + const fakeFetch = async (_url: string | URL | Request, init?: RequestInit) => { + capturedBody = init?.body as string; + return new Response(null, { status: 200 }); + }; + + const exported = await exportOrbBatch(db, 200, fakeFetch); + expect(exported).toBe(2); + + // Verify the payload is signed and anonymized + const payload = JSON.parse(capturedBody!) as { instance_id: string; events: Array<{ repo_hash: string; pr_hash: string }> }; + expect(payload.events).toHaveLength(2); + // Anonymized: repo_hash must NOT be the raw repo name + expect(payload.events[0]?.repo_hash).not.toBe("owner/repo"); + expect(payload.events[0]?.repo_hash).toHaveLength(24); // HMAC slice + + // Rows are marked as exported + const rows = (await allRows(db)) as Array>; + expect(rows.every((r) => r.exported_at !== null)).toBe(true); + + // Counter incremented + expect(await renderMetrics()).toMatch(/gittensory_orb_events_exported_total 2/); + }); + + it("ORB_ANONYMIZE=false sends raw repo name in payload", async () => { + process.env.ORB_ANONYMIZE = "false"; + const db = makeDb(); + await recordOrbEvent(db, { repo: "owner/repo", pr_number: 1, head_sha: "sha1", outcome: "merged" }); + + let capturedBody: string | undefined; + await exportOrbBatch(db, 200, async (_u, init) => { capturedBody = init?.body as string; return new Response(null, { status: 200 }); }); + const payload = JSON.parse(capturedBody!) as { events: Array<{ repo_hash: string }> }; + expect(payload.events[0]?.repo_hash).toBe("owner/repo"); + }); + + it("does not re-export already-exported events", async () => { + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha1", outcome: "merged" }); + const fakeFetch = vi.fn(async () => new Response(null, { status: 200 })); + await exportOrbBatch(db, 200, fakeFetch); // exports 1 + const second = await exportOrbBatch(db, 200, fakeFetch); // nothing left + expect(second).toBe(0); + expect(fakeFetch).toHaveBeenCalledTimes(1); + }); + + it("returns 0 and increments error counter on HTTP error from collector", async () => { + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha", outcome: "merged" }); + const result = await exportOrbBatch(db, 200, async () => new Response(null, { status: 503 })); + expect(result).toBe(0); + expect(await renderMetrics()).toContain("gittensory_orb_export_errors_total"); + // Event still pending (not marked as exported) + const rows = (await allRows(db)) as Array>; + expect(rows[0]?.exported_at).toBeNull(); + }); + + it("returns 0 and increments error counter when collector is unreachable (network error)", async () => { + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha", outcome: "merged" }); + const result = await exportOrbBatch(db, 200, async () => { throw new Error("ECONNREFUSED"); }); + expect(result).toBe(0); + expect(await renderMetrics()).toContain("gittensory_orb_export_errors_total"); + }); + + it("includes x-orb-signature header with sha256 HMAC", async () => { + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha", outcome: "merged" }); + + let sigHeader: string | undefined; + await exportOrbBatch(db, 200, async (_u, init) => { + sigHeader = (init?.headers as Record)?.["x-orb-signature"]; + return new Response(null, { status: 200 }); + }); + expect(sigHeader).toMatch(/^sha256=[a-f0-9]{64}$/); + }); + + it("uses empty-string HMAC key when ORB_WEBHOOK_SECRET is unset (covers ?? '' branch)", async () => { + delete (process.env as NodeJS.Dict)["ORB_WEBHOOK_SECRET"]; + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha", outcome: "merged" }); + let sigHeader: string | undefined; + const exported = await exportOrbBatch(db, 200, async (_u, init) => { + sigHeader = (init?.headers as Record)?.["x-orb-signature"]; + return new Response(null, { status: 200 }); + }); + expect(exported).toBe(1); + // Signature should still be formed (with empty-string key) + expect(sigHeader).toMatch(/^sha256=[a-f0-9]{64}$/); + }); + + it("defaults ORB_ANONYMIZE to true when unset (covers ?? 'true' branch)", async () => { + delete process.env.ORB_ANONYMIZE; + const db = makeDb(); + await recordOrbEvent(db, { repo: "owner/repo", pr_number: 1, head_sha: "sha1", outcome: "merged" }); + let capturedBody: string | undefined; + await exportOrbBatch(db, 200, async (_u, init) => { capturedBody = init?.body as string; return new Response(null, { status: 200 }); }); + const payload = JSON.parse(capturedBody!) as { events: Array<{ repo_hash: string }> }; + // Default is anonymize=true, so repo name must be hashed + expect(payload.events[0]?.repo_hash).not.toBe("owner/repo"); + expect(payload.events[0]?.repo_hash).toHaveLength(24); + }); + + it("respects batchSize — exports only the first N pending events", async () => { + const db = makeDb(); + for (let i = 1; i <= 5; i++) + await recordOrbEvent(db, { repo: "o/r", pr_number: i, head_sha: `sha${i}`, outcome: "merged" }); + const exported = await exportOrbBatch(db, 3, async () => new Response(null, { status: 200 })); + expect(exported).toBe(3); + // 2 events still pending + const rows = (await allRows(db)) as Array>; + expect(rows.filter((r) => r.exported_at === null)).toHaveLength(2); + }); +}); diff --git a/test/unit/selfhost-orb-setup.test.ts b/test/unit/selfhost-orb-setup.test.ts new file mode 100644 index 0000000000..e55df452d5 --- /dev/null +++ b/test/unit/selfhost-orb-setup.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { + buildOrbManifest, + exchangeOrbManifestCode, + orbCredentialsToEnv, + renderOrbSetupPage, +} from "../../src/selfhost/orb-setup"; + +describe("buildOrbManifest()", () => { + it("sets the webhook URL to /orb/webhook under the origin", () => { + const m = buildOrbManifest("https://gittensory.example.com", "state123"); + expect((m.hook_attributes as { url: string }).url).toBe("https://gittensory.example.com/orb/webhook"); + }); + + it("sets the redirect_url to /orb/setup/callback with encoded state", () => { + const m = buildOrbManifest("https://example.com", "my state"); + expect(m.redirect_url).toBe("https://example.com/orb/setup/callback?state=my%20state"); + }); + + it("strips a trailing slash from the origin", () => { + const m = buildOrbManifest("https://example.com/", "s"); + expect((m.hook_attributes as { url: string }).url).toBe("https://example.com/orb/webhook"); + }); + + it("requests only read permissions (pull_requests + metadata)", () => { + const m = buildOrbManifest("https://example.com", "s"); + const perms = m.default_permissions as Record; + expect(perms.pull_requests).toBe("read"); + expect(perms.metadata).toBe("read"); + // Must not request write permissions + expect(Object.values(perms).every((v) => v === "read")).toBe(true); + }); + + it("subscribes to pull_request, installation, and installation_repositories events", () => { + const m = buildOrbManifest("https://example.com", "s"); + const events = m.default_events as string[]; + expect(events).toContain("pull_request"); + expect(events).toContain("installation"); + expect(events).toContain("installation_repositories"); + }); + + it("sets public: false", () => { + expect(buildOrbManifest("https://example.com", "s").public).toBe(false); + }); +}); + +describe("renderOrbSetupPage()", () => { + it("returns valid HTML containing the manifest JSON", () => { + const html = renderOrbSetupPage("https://example.com", "xyz"); + expect(html).toContain(""); + expect(html).toContain("orb/webhook"); + expect(html).toContain("https://github.com/settings/apps/new"); + expect(html).toContain("Gittensory Orb"); + }); + + it("embeds the manifest in the form input value", () => { + const html = renderOrbSetupPage("https://example.com", "state-abc"); + expect(html).toContain('name="manifest"'); + expect(html).toContain("orb/webhook"); + }); + + it("escapes single quotes in the manifest to prevent attribute injection", () => { + // JSON.stringify naturally won't produce ' but the replace guard must be present + const html = renderOrbSetupPage("https://example.com", "s"); + expect(html).not.toContain("'gittensory"); + }); +}); + +describe("exchangeOrbManifestCode()", () => { + afterEach(() => { vi.restoreAllMocks(); }); + + it("POSTs to the GitHub conversions endpoint and returns parsed credentials", async () => { + const fakeCreds = { id: 999, slug: "gittensory-orb-test", webhook_secret: "sec", pem: "pem-data" }; + const fakeFetch = vi.fn(async () => new Response(JSON.stringify(fakeCreds), { status: 201 })); + const result = await exchangeOrbManifestCode("test-code-xyz", fakeFetch); + expect(result).toEqual(fakeCreds); + const [url, init] = fakeFetch.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toContain("test-code-xyz"); + expect(url).toContain("app-manifests"); + expect(init.method).toBe("POST"); + }); + + it("throws on non-OK HTTP response with status in the message", async () => { + const fakeFetch = vi.fn(async () => new Response("", { status: 422 })); + await expect(exchangeOrbManifestCode("bad-code", fakeFetch)).rejects.toThrow("422"); + }); + + it("URL-encodes the code to prevent injection", async () => { + const fakeFetch = vi.fn(async () => new Response(JSON.stringify({ id: 1, slug: "s", webhook_secret: "w", pem: "p" }), { status: 201 })); + await exchangeOrbManifestCode("code/with/slash", fakeFetch); + const [url] = fakeFetch.mock.calls[0] as unknown as [string]; + expect(url).toContain("code%2Fwith%2Fslash"); + }); +}); + +describe("orbCredentialsToEnv()", () => { + it("produces ORB_APP_ID, ORB_APP_SLUG, ORB_WEBHOOK_SECRET, ORB_PRIVATE_KEY lines", () => { + const env = orbCredentialsToEnv({ id: 42, slug: "orb-slug", webhook_secret: "wh-sec", pem: "BEGIN RSA" }); + expect(env).toContain("ORB_APP_ID=42"); + expect(env).toContain("ORB_APP_SLUG=orb-slug"); + expect(env).toContain("ORB_WEBHOOK_SECRET=wh-sec"); + expect(env).toContain("ORB_PRIVATE_KEY="); + expect(env.endsWith("\n")).toBe(true); + }); + + it("JSON-stringifies the PEM so newlines survive loading as a single env var", () => { + const env = orbCredentialsToEnv({ id: 1, slug: "s", webhook_secret: "w", pem: "line1\nline2" }); + expect(env).toContain('"line1\\nline2"'); + }); +}); diff --git a/test/unit/selfhost-orb-webhook.test.ts b/test/unit/selfhost-orb-webhook.test.ts new file mode 100644 index 0000000000..fd2a71a05d --- /dev/null +++ b/test/unit/selfhost-orb-webhook.test.ts @@ -0,0 +1,322 @@ +import { createHmac } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter"; +import { + handleOrbWebhook, + lookupGateVerdict, + verifyOrbSignature, +} from "../../src/selfhost/orb-webhook"; +import { resetMetrics, renderMetrics } from "../../src/selfhost/metrics"; + +const SECRET = "test-webhook-secret"; + +function sign(payload: string, secret = SECRET): string { + return "sha256=" + createHmac("sha256", secret).update(payload).digest("hex"); +} + +function makeDb(): D1Database { + const driver = nodeSqliteDriver(new DatabaseSync(":memory:") as never); + driver.exec(` + CREATE TABLE orb_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo TEXT NOT NULL, pr_number INTEGER NOT NULL, head_sha TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('merged', 'closed')), + gate_verdict TEXT, time_to_close_ms INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + exported_at TEXT, + UNIQUE (repo, pr_number, head_sha) + ); + CREATE TABLE orb_installations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + installation_id INTEGER NOT NULL, repo TEXT NOT NULL, + installed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + removed_at TEXT, + UNIQUE (installation_id, repo) + ); + CREATE TABLE review_targets ( + id TEXT PRIMARY KEY, + project TEXT NOT NULL, kind TEXT NOT NULL, repo TEXT NOT NULL, + number INTEGER NOT NULL, verdict TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (project, kind, repo, number) + ); + `); + return createD1Adapter(driver); +} + +// ── verifyOrbSignature ──────────────────────────────────────────────────────── + +describe("verifyOrbSignature()", () => { + it("returns true for a correctly signed payload", () => { + const payload = '{"action":"closed"}'; + expect(verifyOrbSignature(payload, sign(payload), SECRET)).toBe(true); + }); + + it("returns false when the signature doesn't match", () => { + expect(verifyOrbSignature("payload", sign("other"), SECRET)).toBe(false); + }); + + it("returns false when the secret is wrong", () => { + const payload = "body"; + expect(verifyOrbSignature(payload, sign(payload, "wrong-secret"), SECRET)).toBe(false); + }); + + it("returns false when the sig header is missing the sha256= prefix", () => { + const payload = "body"; + const raw = createHmac("sha256", SECRET).update(payload).digest("hex"); + expect(verifyOrbSignature(payload, raw, SECRET)).toBe(false); + }); + + it("returns false for empty sig", () => { + expect(verifyOrbSignature("body", "", SECRET)).toBe(false); + }); + + it("returns false when sig hex is malformed/wrong length (timingSafeEqual throws — covers catch branch)", () => { + // "sha256=" prefix passes, but odd-length hex → Buffer.from(..., 'hex') produces + // a different byte length than the 32-byte expected HMAC → timingSafeEqual throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH + expect(verifyOrbSignature("body", "sha256=abc", SECRET)).toBe(false); + }); +}); + +// ── lookupGateVerdict ───────────────────────────────────────────────────────── + +describe("lookupGateVerdict()", () => { + beforeEach(() => { process.env.ORB_ENABLED = "true"; }); + afterEach(() => { delete process.env.ORB_ENABLED; }); + + it("returns the verdict from review_targets for a matching repo+PR", async () => { + const db = makeDb(); + await db.prepare(`INSERT INTO review_targets (id, project, kind, repo, number, verdict, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).bind("t1", "proj", "PR", "owner/repo", 42, "merge", "2024-01-01T00:00:00Z").run(); + expect(await lookupGateVerdict(db, "owner/repo", 42)).toBe("merge"); + }); + + it("returns null when no review_target row exists", async () => { + const db = makeDb(); + expect(await lookupGateVerdict(db, "owner/repo", 99)).toBeNull(); + }); + + it("returns null on DB error (best-effort)", async () => { + const brokenDb = { prepare: () => ({ bind: () => ({ first: () => Promise.reject(new Error("disk full")) }) }) } as unknown as D1Database; + expect(await lookupGateVerdict(brokenDb, "o/r", 1)).toBeNull(); + }); + + it("picks the most recent verdict when multiple rows exist for the same repo+PR", async () => { + const db = makeDb(); + await db.prepare(`INSERT INTO review_targets (id, project, kind, repo, number, verdict, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).bind("t1", "p", "PR", "o/r", 1, "close", "2024-01-01T00:00:00Z").run(); + await db.prepare(`INSERT INTO review_targets (id, project, kind, repo, number, verdict, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).bind("t2", "p2", "PR", "o/r", 1, "merge", "2024-01-02T00:00:00Z").run(); + expect(await lookupGateVerdict(db, "o/r", 1)).toBe("merge"); + }); +}); + +// ── handleOrbWebhook ────────────────────────────────────────────────────────── + +describe("handleOrbWebhook() — pull_request events", () => { + beforeEach(() => { resetMetrics(); process.env.ORB_ENABLED = "true"; }); + afterEach(() => { delete process.env.ORB_ENABLED; }); + + const prPayload = (action: string, merged: boolean, prNumber = 7) => + JSON.stringify({ + action, + pull_request: { + number: prNumber, + head: { sha: "abc123" }, + merged, + created_at: "2024-01-01T00:00:00Z", + closed_at: "2024-01-01T01:00:00Z", + }, + repository: { full_name: "owner/repo" }, + }); + + it("returns 204 for a merged PR and records it in orb_events", async () => { + const db = makeDb(); + const result = await handleOrbWebhook("pull_request", prPayload("closed", true), db); + expect(result.status).toBe(204); + const row = await db.prepare("SELECT outcome FROM orb_events WHERE repo='owner/repo' AND pr_number=7").first<{ outcome: string }>(); + expect(row?.outcome).toBe("merged"); + }); + + it("records outcome='closed' for a non-merged closed PR", async () => { + const db = makeDb(); + await handleOrbWebhook("pull_request", prPayload("closed", false), db); + const row = await db.prepare("SELECT outcome FROM orb_events WHERE repo='owner/repo' AND pr_number=7").first<{ outcome: string }>(); + expect(row?.outcome).toBe("closed"); + }); + + it("calculates time_to_close_ms from created_at and closed_at", async () => { + const db = makeDb(); + await handleOrbWebhook("pull_request", prPayload("closed", true), db); + const row = await db.prepare("SELECT time_to_close_ms FROM orb_events WHERE pr_number=7").first<{ time_to_close_ms: number }>(); + expect(row?.time_to_close_ms).toBe(3600000); // 1 hour + }); + + it("stores the gate verdict from review_targets when present", async () => { + const db = makeDb(); + await db.prepare(`INSERT INTO review_targets (id, project, kind, repo, number, verdict, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).bind("t1", "p", "PR", "owner/repo", 7, "merge", "2024-01-01T00:00:00Z").run(); + await handleOrbWebhook("pull_request", prPayload("closed", true), db); + const row = await db.prepare("SELECT gate_verdict FROM orb_events WHERE pr_number=7").first<{ gate_verdict: string }>(); + expect(row?.gate_verdict).toBe("merge"); + }); + + it("stores null gate_verdict when no review_target exists for the PR", async () => { + const db = makeDb(); + await handleOrbWebhook("pull_request", prPayload("closed", false), db); + const row = await db.prepare("SELECT gate_verdict FROM orb_events WHERE pr_number=7").first<{ gate_verdict: string | null }>(); + expect(row?.gate_verdict).toBeNull(); + }); + + it("records null time_to_close_ms when closed_at or created_at is absent (covers ternary null branches)", async () => { + const db = makeDb(); + const payload = JSON.stringify({ + action: "closed", + pull_request: { number: 5, head: { sha: "sha5" }, merged: true, created_at: null, closed_at: null }, + repository: { full_name: "owner/repo" }, + }); + const result = await handleOrbWebhook("pull_request", payload, db); + expect(result.status).toBe(204); + const row = await db.prepare("SELECT time_to_close_ms FROM orb_events WHERE pr_number=5").first<{ time_to_close_ms: number | null }>(); + expect(row?.time_to_close_ms).toBeNull(); + }); + + it("records null time_to_close_ms when only closed_at is absent", async () => { + const db = makeDb(); + const payload = JSON.stringify({ + action: "closed", + pull_request: { number: 6, head: { sha: "sha6" }, merged: false, created_at: "2024-01-01T00:00:00Z", closed_at: null }, + repository: { full_name: "owner/repo" }, + }); + await handleOrbWebhook("pull_request", payload, db); + const row = await db.prepare("SELECT time_to_close_ms FROM orb_events WHERE pr_number=6").first<{ time_to_close_ms: number | null }>(); + expect(row?.time_to_close_ms).toBeNull(); + }); + + it("returns 204 and does NOT record for non-closed pull_request actions", async () => { + const db = makeDb(); + const result = await handleOrbWebhook("pull_request", prPayload("opened", false), db); + expect(result.status).toBe(204); + const { results } = await db.prepare("SELECT * FROM orb_events").all(); + expect(results).toHaveLength(0); + }); + + it("increments gittensory_orb_webhook_total on every event", async () => { + const db = makeDb(); + await handleOrbWebhook("pull_request", prPayload("closed", true), db); + await handleOrbWebhook("pull_request", prPayload("closed", true, 8), db); + expect(await renderMetrics()).toMatch(/gittensory_orb_webhook_total 2/); + }); +}); + +describe("handleOrbWebhook() — installation events", () => { + beforeEach(() => { resetMetrics(); process.env.ORB_ENABLED = "true"; }); + afterEach(() => { delete process.env.ORB_ENABLED; }); + + it("creates installation records for each repo on 'created' event", async () => { + const db = makeDb(); + const payload = JSON.stringify({ + action: "created", + installation: { id: 100 }, + repositories: [{ full_name: "owner/repo-a" }, { full_name: "owner/repo-b" }], + }); + await handleOrbWebhook("installation", payload, db); + const { results } = await db.prepare("SELECT repo FROM orb_installations").all<{ repo: string }>(); + expect(results.map((r) => r.repo).sort()).toEqual(["owner/repo-a", "owner/repo-b"]); + }); + + it("marks repos as removed on 'deleted' event by setting removed_at", async () => { + const db = makeDb(); + const created = JSON.stringify({ action: "created", installation: { id: 100 }, repositories: [{ full_name: "owner/repo" }] }); + await handleOrbWebhook("installation", created, db); + const deleted = JSON.stringify({ action: "deleted", installation: { id: 100 }, repositories: [{ full_name: "owner/repo" }] }); + await handleOrbWebhook("installation", deleted, db); + const row = await db.prepare("SELECT removed_at FROM orb_installations WHERE repo='owner/repo'").first<{ removed_at: string | null }>(); + expect(row?.removed_at).not.toBeNull(); + }); + + it("handles installation_repositories added event", async () => { + const db = makeDb(); + const payload = JSON.stringify({ + action: "added", + installation: { id: 200 }, + repositories_added: [{ full_name: "owner/new-repo" }], + repositories_removed: [], + }); + await handleOrbWebhook("installation_repositories", payload, db); + const row = await db.prepare("SELECT repo FROM orb_installations WHERE repo='owner/new-repo'").first<{ repo: string }>(); + expect(row?.repo).toBe("owner/new-repo"); + }); + + it("handles installation_repositories removed event", async () => { + const db = makeDb(); + await db.prepare("INSERT INTO orb_installations (installation_id, repo) VALUES (?, ?)").bind(200, "owner/gone-repo").run(); + const payload = JSON.stringify({ + action: "removed", + installation: { id: 200 }, + repositories_added: [], + repositories_removed: [{ full_name: "owner/gone-repo" }], + }); + await handleOrbWebhook("installation_repositories", payload, db); + const row = await db.prepare("SELECT removed_at FROM orb_installations WHERE repo='owner/gone-repo'").first<{ removed_at: string | null }>(); + expect(row?.removed_at).not.toBeNull(); + }); + + it("handles installation_repositories removed event with missing repositories_removed field (covers ?? [] branch)", async () => { + const db = makeDb(); + await db.prepare("INSERT INTO orb_installations (installation_id, repo) VALUES (?, ?)").bind(300, "owner/repo-x").run(); + // No repositories_removed key — falls back to [] + const payload = JSON.stringify({ + action: "removed", + installation: { id: 300 }, + repositories_added: [], + // repositories_removed intentionally absent + }); + const result = await handleOrbWebhook("installation_repositories", payload, db); + expect(result.status).toBe(204); + // No rows should be marked removed (empty list was used) + const row = await db.prepare("SELECT removed_at FROM orb_installations WHERE repo='owner/repo-x'").first<{ removed_at: string | null }>(); + expect(row?.removed_at).toBeNull(); + }); + + it("does not increment installs counter when repositories list is empty (covers if(repos.length) false branch)", async () => { + const db = makeDb(); + const payload = JSON.stringify({ action: "created", installation: { id: 1 }, repositories: [] }); + await handleOrbWebhook("installation", payload, db); + // Counter must not have been incremented + expect(await renderMetrics()).not.toMatch(/gittensory_orb_installs_total [^0]/); + }); + + it("handles deleted event when repositories field is absent (covers repositories ?? [] branch)", async () => { + const db = makeDb(); + await db.prepare("INSERT INTO orb_installations (installation_id, repo) VALUES (?, ?)").bind(400, "owner/to-delete").run(); + // 'deleted' with no repositories key → falls back to [] + const payload = JSON.stringify({ action: "deleted", installation: { id: 400 } }); + const result = await handleOrbWebhook("installation", payload, db); + expect(result.status).toBe(204); + // Nothing removed since repos list was empty + const row = await db.prepare("SELECT removed_at FROM orb_installations WHERE repo='owner/to-delete'").first<{ removed_at: string | null }>(); + expect(row?.removed_at).toBeNull(); + }); + + it("increments gittensory_orb_installs_total for each repo installed", async () => { + const db = makeDb(); + const payload = JSON.stringify({ action: "created", installation: { id: 1 }, repositories: [{ full_name: "o/a" }, { full_name: "o/b" }] }); + await handleOrbWebhook("installation", payload, db); + expect(await renderMetrics()).toMatch(/gittensory_orb_installs_total 2/); + }); + + it("is idempotent — duplicate install events do not create duplicate rows", async () => { + const db = makeDb(); + const payload = JSON.stringify({ action: "created", installation: { id: 1 }, repositories: [{ full_name: "o/r" }] }); + await handleOrbWebhook("installation", payload, db); + await handleOrbWebhook("installation", payload, db); + const { results } = await db.prepare("SELECT * FROM orb_installations").all(); + expect(results).toHaveLength(1); + }); +}); + +describe("handleOrbWebhook() — unknown events", () => { + it("returns 204 for unhandled event types (ping, etc.)", async () => { + const db = makeDb(); + const result = await handleOrbWebhook("ping", '{"zen":"Keep it logically awesome."}', db); + expect(result.status).toBe(204); + }); +});