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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ dist-ssr
coverage
.git
.claude
# The review-enrichment service (REES) is a separate Railway service with its own Dockerfile — keep it out of the engine image.
review-enrichment
.DS_Store
*.tsbuildinfo
# Never ship secrets into the build context
Expand Down
4 changes: 4 additions & 0 deletions review-enrichment/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
*.tsbuildinfo
.DS_Store
23 changes: 23 additions & 0 deletions review-enrichment/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Gittensory review-enrichment service (REES). Lean two-stage Node build; analyzers add CLI tools later (#1477).
# Build context = the review-enrichment/ directory (Railway "Root Directory" = review-enrichment).
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev

FROM node:22-slim AS runtime
# Least privilege: run as a non-root user.
RUN useradd --create-home --uid 10001 rees
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
USER rees
ENV PORT=8080
EXPOSE 8080
# Provide at runtime (NOT baked into the image): REES_SHARED_SECRET (shared bearer with the engine).
CMD ["node", "dist/server.js"]
44 changes: 44 additions & 0 deletions review-enrichment/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Review-enrichment service (REES)

A standalone Railway microservice that produces a structured **review brief** for the gittensory review engine.

The engine reviews PRs by running a headless `claude --print` subprocess with `Bash`/`WebFetch` disallowed and **no
repo checkout**, so it cannot run a linter, hit a CVE database, resolve a dependency tree, or query git history. REES
fills exactly that gap: given a PR it runs heavy/external/historical analysis and returns a pre-rendered, public-safe
brief the engine splices into the prompt next to grounding + RAG. It is strictly **additive and fail-safe** — the engine
treats any timeout/error as "no brief" and proceeds.

## API

| Route | Purpose |
| ----------------- | ------------------------------------------------------------------------------- |
| `GET /health` | Liveness (Railway healthcheck). |
| `GET /ready` | Readiness. |
| `POST /v1/enrich` | `Authorization: Bearer <REES_SHARED_SECRET>` → `EnrichRequest` → `ReviewBrief`. |

See `src/server.ts` for the `EnrichRequest` / `ReviewBrief` contract.

## Analyzers (added behind the contract)

- **#1474** dependency-diff + OSV.dev CVE
- **#1475** SPDX license policy
- **#1476** gitleaks-grade secret scan (value-redacted)
- **#1477** static analysis + complexity (lint/semgrep over the diff)
- **#1478** history (author track record, similar past PRs, linked-issue alignment)

## Run locally

```sh
npm install
REES_SHARED_SECRET=dev npm run build && npm start # listens on :8080
curl localhost:8080/health
curl -XPOST localhost:8080/v1/enrich -H 'authorization: Bearer dev' \
-H 'content-type: application/json' -d '{"repoFullName":"o/r","prNumber":1}'
```

## Deploy (Railway)

Separate service from the engine. Set **Root Directory = `review-enrichment`** so Railway reads this folder's
`railway.json` + `Dockerfile`. Set `REES_SHARED_SECRET` (same value the engine holds) as a service variable — never
commit it. The engine reaches the service over Railway **private networking** (`<service>.railway.internal`); no public
domain is required.
75 changes: 75 additions & 0 deletions review-enrichment/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions review-enrichment/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "gittensory-review-enrichment",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Gittensory review-enrichment service (REES) — heavy/external/historical PR analysis returned as a structured brief the review engine splices into the prompt. Deploys standalone on Railway; see #1473.",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"dev": "node --experimental-strip-types --watch src/server.ts",
"test": "node --test --experimental-strip-types"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"hono": "^4.6.14"
},
"devDependencies": {
"typescript": "^5.7.2",
"@types/node": "^22.10.2"
}
}
15 changes: 15 additions & 0 deletions review-enrichment/railway.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile",
"watchPatterns": ["review-enrichment/**"]
},
"deploy": {
"healthcheckPath": "/health",
"healthcheckTimeout": 60,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 5,
"numReplicas": 1
}
}
17 changes: 17 additions & 0 deletions review-enrichment/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { timingSafeEqual } from "node:crypto";

/**
* Constant-time `Authorization: Bearer <secret>` check. Returns false on a missing/malformed header or any
* mismatch. Length-checks before timingSafeEqual (which throws on unequal-length buffers) — the length leak is
* acceptable for a fixed-length shared secret.
*/
export function verifyBearer(
header: string | undefined,
secret: string,
): boolean {
if (!header || !header.startsWith("Bearer ")) return false;
const token = Buffer.from(header.slice("Bearer ".length));
const expected = Buffer.from(secret);
if (token.length !== expected.length) return false;
return timingSafeEqual(token, expected);
}
103 changes: 103 additions & 0 deletions review-enrichment/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Gittensory review-enrichment service (REES) — #1473 scaffold.
//
// Given a PR (repo, number, headSha, diff, files, short-lived token), this service runs the heavy/external/
// historical analysis the no-checkout `claude --print` reviewer is blind to, and returns a pre-rendered,
// public-safe "review brief" the engine splices into the prompt next to grounding + RAG. The engine treats any
// timeout/error as "no brief" and proceeds — so this service is strictly additive and fully fail-safe.
//
// THIS scaffold ships the contract + transport only: /health, /ready, and an authenticated /v1/enrich that
// returns an empty (non-partial) brief. The analyzers — dependency/CVE (#1474), license (#1475), secret (#1476),
// static+complexity (#1477), history (#1478) — land behind this stable contract, each filling one `findings` key.
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { verifyBearer } from "./auth.js";

/** Engine → service request. The engine already has the diff + files, so the service needs NO repo checkout. */
export interface EnrichRequest {
repoFullName: string;
prNumber: number;
headSha?: string;
baseSha?: string;
title?: string;
body?: string;
author?: string;
files?: Array<{
path: string;
status?: string;
patch?: string;
additions?: number;
deletions?: number;
}>;
diff?: string;
/** Short-lived broker token for OSV/license/history fetches. Never logged. */
githubToken?: string;
budget?: { timeoutMs?: number; maxBriefChars?: number };
analyzers?: string[];
}

/** Service → engine response. `promptSection` is spliced verbatim; `findings` is the structured backing data. */
export interface ReviewBrief {
schemaVersion: 1;
repoFullName: string;
prNumber: number;
headSha: string | null;
generatedAtIso: string;
elapsedMs: number;
partial: boolean;
analyzerStatus: Record<string, "ok" | "degraded" | "skipped">;
findings: Record<string, unknown>;
promptSection: string;
systemSuffix: string;
}

const app = new Hono();

app.get("/health", (c) =>
c.json({ status: "ok", service: "review-enrichment" }),
);
app.get("/ready", (c) => c.json({ ready: true }));

app.post("/v1/enrich", async (c) => {
const start = Date.now();
const secret = process.env.REES_SHARED_SECRET;
// No secret configured ⇒ the service is not ready to authenticate anything; fail closed.
if (!secret) return c.json({ error: "service_not_configured" }, 503);
if (!verifyBearer(c.req.header("authorization"), secret))
return c.json({ error: "unauthorized" }, 401);

const payload = (await c.req
.json()
.catch(() => null)) as EnrichRequest | null;
if (
!payload ||
typeof payload.repoFullName !== "string" ||
typeof payload.prNumber !== "number"
) {
return c.json({ error: "bad_request" }, 400);
}

// Scaffold: no analyzers wired yet (#1474-#1478). Return an empty, non-partial brief so the engine seam
// (#1472) can integrate and smoke-test end-to-end. As analyzers land they populate `findings`/`analyzerStatus`
// and render into `promptSection`.
const brief: ReviewBrief = {
schemaVersion: 1,
repoFullName: payload.repoFullName,
prNumber: payload.prNumber,
headSha: payload.headSha ?? null,
generatedAtIso: new Date().toISOString(),
elapsedMs: Date.now() - start,
partial: false,
analyzerStatus: {},
findings: {},
promptSection: "",
systemSuffix: "",
};
return c.json(brief);
});

const port = Number(process.env.PORT ?? "8080");
serve({ fetch: app.fetch, port }, (info) => {
console.log(JSON.stringify({ event: "rees_listening", port: info.port }));
});

export { app };
16 changes: 16 additions & 0 deletions review-enrichment/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"]
}