Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
76 changes: 75 additions & 1 deletion grafana/dashboards/gittensory.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -259,5 +333,5 @@
"timezone": "browser",
"title": "Gittensory Self-Host",
"uid": "gittensory-selfhost",
"version": 2
"version": 3
}
18 changes: 18 additions & 0 deletions migrations/0056_orb_events.sql
Original file line number Diff line number Diff line change
@@ -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 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, -- '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 (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
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;
11 changes: 11 additions & 0 deletions migrations/0057_orb_installations.sql
Original file line number Diff line number Diff line change
@@ -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 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, -- NULL = still installed
UNIQUE (installation_id, repo)
);
CREATE INDEX IF NOT EXISTS orb_installations_repo ON orb_installations (repo, removed_at);
148 changes: 148 additions & 0 deletions src/selfhost/orb-collector.ts
Original file line number Diff line number Diff line change
@@ -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=<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 App ID — no PII). */
function instanceId(): string {
return createHash("sha256").update(process.env.GITHUB_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<void> {
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<number> {
if (!orbEnabled()) return 0;
Comment thread
JSONbored marked this conversation as resolved.
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.GITHUB_WEBHOOK_SECRET ?? "";
Comment thread
JSONbored marked this conversation as resolved.
Outdated
const anonymize = (process.env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false";
Comment thread
JSONbored marked this conversation as resolved.

const { results } = await db
.prepare(`SELECT * FROM orb_events WHERE exported_at IS NULL ORDER BY id LIMIT ?`)
.bind(batchSize)
.all<OrbRow>();

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;
}
66 changes: 66 additions & 0 deletions src/selfhost/orb-setup.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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, "&#39;");
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Gittensory Orb setup</title></head>
<body style="font-family:system-ui;max-width:40rem;margin:4rem auto;padding:0 1rem">
<h1>Gittensory Orb setup</h1>
<p>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.</p>
<form action="https://github.com/settings/apps/new" method="post">
<input type="hidden" name="manifest" value='${manifest}'>
<button type="submit" style="padding:.6rem 1.2rem;font-size:1rem;cursor:pointer">Create Gittensory Orb App →</button>
</form>
</body></html>`;
}

/** 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<OrbCredentials> {
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";
}
Loading
Loading