Real-time, multiplayer code review with an AI in the room.
ReviewFlow is a self-hostable code-review platform: drop in a snippet, share the link, and watch reviews stream in live while Google Gemini analyses the code in the background. It pairs a Go (Gin) backend with a Svelte 5 frontend over GraphQL and a hand-rolled WebSocket hub.
Built by Souvik Biswas
┌───────────────────────────────┐
│ SvelteKit SPA (Svelte 5) │
│ CodeMirror 6 · diff2html │
│ urql · native WebSocket │
└──────┬───────────────┬────────┘
│ HTTPS │ WSS
▼ ▼
┌──────────────────────────────┐
│ Caddy (edge) │
│ · serves SPA │
│ · reverse-proxy /graphql, │
│ /auth, /ws, /notifications│
│ · Let's Encrypt, CSP, HSTS │
└──────────────┬───────────────┘
│ http :8080
▼
┌──────────────────────────────────────┐
│ Go backend (Gin + gqlgen) │
│ · GraphQL resolvers (snippets, │
│ reviews, AI review field) │
│ · WebSocket room hub (one Run() │
│ goroutine, RWMutex-guarded map) │
│ · JWT auth, GitHub OAuth │
│ · Notifications REST endpoint │
└────┬───────────────────────┬─────────┘
│ │
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ MongoDB 7 │ │ Gemini 2.0 │
│ users │ │ Flash (free) │
│ snippets │ │ · async pipeline│
│ reviews │ │ · structured │
│ ai_reviews │ │ JSON output │
│ notifications│ │ · rate-limited │
└────────────────┘ └──────────────────┘
classDiagram
direction TB
class User {
+ObjectID id
+int64 githubId
+string githubUsername
+string avatarUrl
+Time createdAt
+Time updatedAt
}
class Snippet {
+ObjectID id
+ObjectID authorId
+string title
+string language
+string code
+string previousVersion
+Time createdAt
+Time updatedAt
}
class Review {
+ObjectID id
+ObjectID snippetId
+ObjectID authorId
+string body
+int lineNumber
+ObjectID parentReviewId
+Time createdAt
}
class AIReview {
+ObjectID id
+string snippetId
+string[] suggestions
+string complexity
+string[] refactorHints
+string[] securityFlags
+int qualityScore
+string language
+Time generatedAt
}
class Notification {
+ObjectID id
+ObjectID userId
+ObjectID snippetId
+ObjectID reviewId
+bool read
+Time createdAt
}
class Hub {
-map~string,Client~ rooms
-chan register
-chan unregister
-chan broadcast
-RWMutex mu
+Run()
+BroadcastToRoom(id, msg)
+RoomPresence(id) Presence[]
}
class Client {
-Conn conn
-string snippetId
-string userId
-string username
-chan send
-chan done
+readPump()
+writePump()
}
class AIService {
-GenerativeModel model
-Limiter limiter
-Client db
-Hub hub
+ReviewCode(id, lang, code)
-generateWithRetry(prompt)
-save(review)
-broadcast(review)
}
class AuthHandler {
-Config cfg
-Client db
-HttpClient http
+GitHubLogin(c)
+GitHubCallback(c)
+Me(c)
}
User "1" --> "*" Snippet : authors
User "1" --> "*" Review : writes
User "1" --> "*" Notification : receives
Snippet "1" --> "*" Review : has
Snippet "1" --> "0..1" AIReview : generated for
Review "0..1" --> "0..1" Review : replies to
Hub "1" o-- "*" Client : manages
AIService ..> Hub : broadcasts via
AIService ..> AIReview : produces
AuthHandler ..> User : upserts
sequenceDiagram
actor Browser
participant FE as SvelteKit (CF Pages)
participant BE as Go API (Render)
participant GH as GitHub
Browser->>FE: click "Sign in with GitHub"
FE->>BE: GET /auth/github
BE-->>Browser: Set-Cookie rf_oauth_state + 307 → GitHub
Browser->>GH: GET /login/oauth/authorize?client_id&state
GH-->>Browser: authorization page
Browser->>GH: user clicks Authorize
GH->>BE: GET /auth/github/callback?code&state
BE->>BE: verify state cookie (CSRF check)
BE->>GH: POST /login/oauth/access_token
GH-->>BE: {access_token}
BE->>GH: GET /user Bearer access_token
GH-->>BE: {id, login, avatar_url}
BE->>BE: upsertUser → sign JWT
BE-->>Browser: 307 → /auth/callback?token=JWT
Browser->>FE: load /auth/callback
FE->>FE: localStorage.setItem(token) + resetAuth()
FE->>BE: GET /auth/me Authorization: Bearer JWT
BE-->>FE: {id, githubUsername, avatarUrl}
FE->>Browser: redirect → /dashboard
sequenceDiagram
actor Browser
participant FE as SvelteKit
participant BE as Go API
participant DB as MongoDB
participant Gemini
participant Hub as WS Hub
Browser->>FE: submit snippet form
FE->>BE: POST /graphql createSnippet
BE->>DB: insertOne(snippets)
BE-->>FE: Snippet{id, title, ...} ← returns immediately
FE->>Browser: render snippet page (aiPending=true)
FE->>BE: WS /ws/:snippetId?token=JWT
BE->>Hub: register client in room
Note over BE,Gemini: goroutine — detached from request context
BE->>Gemini: GenerateContent(system prompt + code)
Gemini-->>BE: JSON {suggestions, complexity, refactorHints, ...}
BE->>DB: upsertOne(ai_reviews)
BE->>Hub: BroadcastToRoom(snippetId, ai_review_ready)
Hub->>FE: WS frame {type: ai_review_ready, payload: AIReview}
FE->>Browser: render AI review panel
| Layer | Choice |
|---|---|
| Frontend | SvelteKit (Svelte 5 runes) · TypeScript · Tailwind CSS |
| Editor | CodeMirror 6 · diff2html · jsdiff |
| GraphQL | @urql/svelte on the client · gqlgen on the server |
| Realtime | Native browser WebSocket ↔ gorilla/websocket room hub |
| Backend | Go · Gin · golang-jwt/jwt/v5 · mongo-driver |
| AI | Google Gemini 2.0 Flash via generative-ai-go |
| Rate limit | golang.org/x/time/rate (10 req/min, under free-tier quota) |
| Auth | GitHub OAuth → HttpOnly · SameSite=Strict cookie |
| Database | MongoDB 7 (auth enabled in production) |
| Edge / TLS | Caddy 2 — automatic Let's Encrypt |
| Packaging | Docker · multi-stage builds · distroless final image |
You only need Docker.
# 1. Clone, then configure
git clone https://github.com/<you>/reviewflow.git
cd reviewflow
cp .env.example .env
# 2. (recommended) add a GitHub OAuth app + Gemini key
# GitHub: https://github.com/settings/developers
# Homepage URL: http://localhost:5173
# Authorization callback URL: http://localhost:8080/auth/github/callback
# Gemini key (free tier): https://aistudio.google.com/apikey
# Then edit .env to fill in GITHUB_CLIENT_ID/SECRET, JWT_SECRET, GEMINI_API_KEY
# 3. Start the backend stack (Go + MongoDB, with hot reload via Air)
docker compose up
# -> backend on :8080, /health returns {"status":"ok","db":"connected"}
# 4. In another shell, start the frontend dev server
cd web
cp .env.example .env # PUBLIC_API_URL=http://localhost:8080
npm install
npm run dev # -> http://localhost:5173
Open http://localhost:5173, sign in with GitHub, create a snippet, and a Gemini AI review appears moments later.
| Command | What it does |
|---|---|
docker compose up |
Backend + MongoDB with hot reload |
cd web && npm run dev |
Frontend dev server (Vite) |
cd web && npm run check |
TypeScript + Svelte type check |
go vet ./... && go build ./... |
Backend type / build check |
go test ./internal/ai/... |
Unit tests for the Gemini parser |
go run github.com/99designs/gqlgen generate |
Regenerate GraphQL bindings after schema edit |
.env)| Variable | Required | Default | Purpose |
|---|---|---|---|
PORT |
8080 |
HTTP port the API listens on | |
ENVIRONMENT |
development |
Set to production for Secure cookies + strict logging |
|
CORS_ORIGIN |
http://localhost:5173 |
Browser origin allowed to call the API + open WebSockets | |
MONGO_URI |
✓ | mongodb://mongo:27017 |
Mongo connection string (use creds in prod) |
MONGO_DB |
reviewflow |
Database name | |
JWT_SECRET |
✓ (prod) | dev-insecure-secret-change-me |
HMAC secret for session tokens (openssl rand -hex 32) |
GITHUB_CLIENT_ID |
✓ | GitHub OAuth app id | |
GITHUB_CLIENT_SECRET |
✓ | GitHub OAuth app secret | |
GITHUB_REDIRECT_URL |
http://localhost:8080/auth/github/callback |
Must match the URL registered on the OAuth app | |
FRONTEND_URL |
http://localhost:5173 |
Where the browser is sent after a successful login | |
GEMINI_API_KEY |
Gemini API key — when empty, AI reviews are disabled |
web/.env)| Variable | Required | Default | Purpose |
|---|---|---|---|
PUBLIC_API_URL |
window.location.origin |
Backend base URL. In dev set to http://localhost:8080; in prod leave it blank — the SPA and API share a host behind Caddy. |
.env.prod)| Variable | Purpose |
|---|---|
DOMAIN |
Public hostname served by Caddy |
ACME_EMAIL |
Email for Let's Encrypt account recovery |
MONGO_ROOT_USERNAME / _PASSWORD |
Mongo root credentials (referenced by MONGO_URI) |
See .env.example, web/.env.example, and
.env.prod.example for the canonical templates.
cp .env.prod.example .env.prod
# fill in DOMAIN, ACME_EMAIL, MONGO_ROOT_*, JWT_SECRET, GITHUB_*, GEMINI_API_KEY
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
This brings up:
gcr.io/distroless/static:nonroot
(≈10 MB, no shell, runs as uid 65532)./graphql, /auth/*, /ws/*,
/notifications, and /health to the backend. HTTPS is provisioned
automatically via Let's Encrypt the first time it starts.Caddy ships strict security headers out of the box: HSTS preload, CSP locked
to 'self' (plus Google Fonts and GitHub avatars), X-Frame-Options: DENY,
Referrer-Policy: strict-origin-when-cross-origin.
| Page | Image |
|---|---|
| Landing | |
| Dashboard | |
| Snippet — reviews | |
| Snippet — AI review | |
| Snippet — diff view |
The hub is the heart of ReviewFlow's real-time layer. It lives in
internal/ws and follows a strict single-writer actor
model:
per-client goroutines single hub goroutine
┌─────────────────────────────┐ ┌─────────────────────────┐
│ readPump (1 per client) │ │ Hub.Run() │
│ • parse inbound frames │ │ owns rooms map: │
│ • JSON "ping" → "pong" │ │ snippetId → {clients} │
│ • "typing" → broadcast ──┼──┐ │ │
│ • on exit → unregister ─┼┐ │ │ for { select { │
│ ││ ├───▶│ <-register │
│ writePump (1 per client) ││ │ │ <-unregister │
│ • drains <-send (buf 256)◀┼┼─┼─────┤ <-broadcast │
│ • ticker → WS ping frame ││ │ │ } } │
│ • <-done → close, return ◀┼┼─┼─────┤ │
└──────────────────────────────┘│ │ │ RWMutex; Run is the │
│ │ │ only writer of rooms. │
register / unregister / broadcast │ │ └────────────▲────────────┘
(unbuffered channels) ──────┘ │ │
│ BroadcastToRoom(id, msg)
GraphQL resolvers / Gemini service
Run() mutates the rooms map. Producers block on
unbuffered channels until Run() picks up their intent, so there is no
reorder window.RWMutex read lock; the
writer holds a write lock. The mutex is there to make those reads safe,
not to serialise writes (the single-goroutine discipline does that).readPump write to
send (the pong reply path). The hub therefore never closes send;
it closes a per-client done channel exactly once and calls conn.Close()
to wake both pumps. This avoids the classic send-on-closed-channel panic.userId — multiple tabs collapse to one
viewer, and presence_join / presence_leave fire only on a user's first
and last connection in the room.The full broadcast path is just three hops: a resolver (or the Gemini
service) calls hub.BroadcastToRoom(snippetId, msg), which enqueues a frame
on the broadcast channel. Run() picks it up, takes an RLock to snapshot
the room, and pushes the bytes onto each client's send buffer. Each client's
writePump does the actual conn.WriteMessage. Reviews appear on every
viewer's screen as soon as AddReview returns.
MIT — see LICENSE.