# PR Previews (PP) > PP is a self-hosted service that connects to a Gitea instance via webhook. When a PR is opened or updated, PP automatically provisions an AWS EC2 instance, builds and runs the project, and comments a live preview URL back on the PR. Designed for teams and agents iterating on code without needing local environments. ## Purpose - Provide a shareable live preview of any PR before it's merged. - Remove the need for reviewers to pull and run code locally. - Works well in agent-driven workflows where humans review from mobile or external environments. --- ## Tech Stack - **Backend:** `rj-web` from `../shsf` (same setup pattern), logging + auth middleware + Prisma from `../shsf` adapted to this project's needs. - **Frontend:** React + Vite + Tailwind CSS (dark/light mode toggle, responsive). - **Database:** PostgreSQL via Prisma (schema at `./prisma/schema.prisma`). - **CI/CD:** Reuse `../shsf` CI/CD pipeline; build and push Docker image to the same registry under `/pp-previews/core`. ## Project Structure ``` ./backend/ # Node backend (rj-web) ./frontend/ # React + Vite frontend ./prisma/schema.prisma # Prisma schema ./docker-compose.yml # Compose + Dockerfile for PP itself ``` --- ## Implementation Notes You're building this from scratch. Refer to `../shsf` for patterns on rj-web setup, logging, auth middleware, and Prisma client initialization. **Testing:** No automated tests for V1. Build to fully working state locally first. The first real test will be against a live Gitea instance with a real repo. Build as if it will be tested end-to-end immediately. --- ## Data Model (Prisma) ### User PP account. - id, username, passwordHash, isAdmin, isFounder, createdAt - giteaUsername, giteaPAT (encrypted), giteaInstanceUrl (e.g. `https://gitea.example.com`, no trailing slash) - awsAccessKeyId (encrypted), awsSecretAccessKey (encrypted), awsRegion ### RepoConfig Per-repo settings, one per user per repo. A repo can only be configured by one user — first to save wins; others receive an error. - id, userId, repoOwner, repoName - isEnabled (bool) - giteaWebhookId (string, stored after auto-registration so PP can delete/update it later) - denyList (string[], Gitea usernames to skip — e.g. dependabot) - instanceType (string, default from AdminSettings.defaultInstanceType) - inactivityHours (float, default 12, range 0.5–72) - port (int, default 3000 — the port the app listens on inside the EC2 instance) - envVars (JSON key-value) - useDockerCompose (bool) - composeFilePath (string, nullable — path within repo, default `docker-compose.yml`) - aptPackages (string[]) - setupCommands (string[]) - buildCommands (string[]) - postBuildCommands (string[]) - runCommand (string, single — non-compose mode only) ### Preview One record per PR. Persists across pushes; the EC2 instance is reused on each new push (only build + run steps re-run). - id, repoConfigId, prNumber, prTitle - commitSha (updated on each push) - instanceId (AWS EC2 instance ID) - instanceIp (public IP) - port (copied from RepoConfig at deploy time) - status (enum: `PROVISIONING` | `BUILDING` | `RUNNING` | `FAILED` | `STOPPED` | `IGNORED`) - logs (text, appended each redeploy, capped at AdminSettings.logSizeLimitBytes — oldest lines truncated with a `--- logs truncated ---` marker) - pid (int, nullable — non-compose mode only, the background process PID on the EC2 instance) - sshPrivateKey (encrypted — ephemeral, generated per launch, deleted on instance termination) - sshKeyName (string — AWS key pair name, for deletion on terminate) - giteaCommentId (int, nullable — the Gitea comment ID of PP's status comment, for in-place edits) - lastActivityAt (DateTime — updated on each push webhook AND each PR comment webhook) - createdAt, updatedAt, stoppedAt ### Job Durable async job queue for deployments and stops. - id, previewId (nullable), type (enum: `DEPLOY` | `STOP` | `INACTIVITY_STOP`), status (enum: `PENDING` | `RUNNING` | `DONE` | `FAILED`) - payload (JSON), error (text, nullable) - createdAt, startedAt, finishedAt ### WebhookToken Per-user HMAC secret for verifying Gitea webhook payloads. - id, userId, token (random secret), createdAt ### NoConfigComment Tracks which PRs have already received a "no previews configured" comment to avoid repeat spam. - id, userId, repoOwner, repoName, prNumber, createdAt ### AdminSettings Single row global config (seeded on first run). - id - defaultInstanceType (string, default `t2.medium`) - maxConcurrentInstancesPerUser (int, default 5) - logSizeLimitBytes (int, default 1048576 — 1MB) - previewRetentionDays (int, default 30 — STOPPED/FAILED records older than this are purged) - webhookRateLimitPerMinute (int, default 10 — per user) - contactEmail (string — shown in privacy policy footer) --- ## Backend ### Auth - Session-based auth (reuse shsf middleware). - Registration endpoint is disabled — only admins can create users via the admin panel. - First registered user automatically becomes admin and is flagged as `isFounder = true`. The founder cannot be demoted or deleted. ### Webhook Endpoint `POST /webhook/:userId` **Rate limiting:** Each user is limited to `AdminSettings.webhookRateLimitPerMinute` webhook events per minute. Excess requests receive `429 Too Many Requests` and are dropped (not queued). **Signature verification:** Gitea sends `X-Gitea-Signature-256: sha256=`. PP verifies using the user's `WebhookToken.token` before doing anything else. Invalid signature → `401`. **Accepted Gitea events** (set via `X-Gitea-Event` header): - `pull_request` with action `opened` → trigger new deploy - `pull_request` with action `synchronize` → trigger redeploy on existing Preview - `pull_request` with action `reopened` → same as `opened` - `pull_request` with action `closed` → stop and terminate the preview - `issue_comment` with action `created` → parse for `/pp` commands (see PP Commands section) **Webhook handler returns `200 OK` immediately** after enqueueing a job. All processing is async. **Activity tracking:** Update `Preview.lastActivityAt = now` on both `synchronize` and `issue_comment` events. ### PP Commands PP listens for comments on PRs (via `issue_comment` webhook). A comment is a PP command if it starts with `/pp ` as the first line. Commands are only acted on if posted by: the PR author, or a Gitea user with `owner` or `admin` role on the repo (checked via `GET /api/v1/repos/{owner}/{repo}/teams` or collaborator endpoint). | Command | Behaviour | |---|---| | `/pp rebuild` | Re-runs build + start steps on the existing EC2 instance (same as a new push but without a git pull). | | `/pp stop` | Stops and terminates the preview instance. Sets status to `STOPPED`. | | `/pp start` | If `STOPPED`: re-provisions a new EC2 and deploys. If `IGNORED`: un-ignores AND immediately triggers a fresh deploy. | | `/pp logs` | PP posts a new comment with the last 50 lines of `Preview.logs` in a fenced code block. | | `/pp ignore` | Sets `Preview.status = IGNORED`. Future pushes and commands (except `/pp start`) are silently ignored. If no Preview exists yet, creates one in `IGNORED` state so future pushes are skipped. | PP does **not** react to its own comments (check that the commenter's username != `User.giteaUsername` for the configured user). ### AWS EC2 Management **Required IAM permissions** (document in the setup wizard UI with a copy-pasteable JSON policy): ```json { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": [ "ec2:RunInstances", "ec2:TerminateInstances", "ec2:DescribeInstances", "ec2:CreateSecurityGroup", "ec2:DeleteSecurityGroup", "ec2:AuthorizeSecurityGroupIngress", "ec2:DescribeSecurityGroups", "ec2:ImportKeyPair", "ec2:DeleteKeyPair", "ec2:CreateTags", "sts:GetCallerIdentity" ], "Resource": "*" }] } ``` **EC2 instance tagging** — every instance PP launches must be tagged: ``` pp:managed = true pp:userId = pp:repo = / pp:prNumber = pp:previewId = ``` **EC2 setup per preview:** 1. Generate an ephemeral RSA key pair. Store the private key encrypted in `Preview.sshPrivateKey`. Import the public key to AWS as `pp-preview-` and store the name in `Preview.sshKeyName`. 2. Create a security group named `pp-preview-` in the default VPC. Allow inbound: TCP 22 (SSH) and TCP `` from `0.0.0.0/0`. 3. Launch instance: - AMI: Ubuntu 22.04 LTS (hardcode a per-region AMI map, or resolve via SSM `resolve:ssm:/aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id`) - Instance type from `RepoConfig.instanceType` - Key pair: `pp-preview-` - Security group: `pp-preview-` - User data bootstrap script (runs once on first boot): ```bash #!/bin/bash apt-get update -y apt-get install -y curl git unzip build-essential # Docker curl -fsSL https://get.docker.com | sh systemctl enable docker # Docker Compose v2 (plugin) apt-get install -y docker-compose-plugin # NVM + Node LTS curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash export NVM_DIR="/root/.nvm" && source "$NVM_DIR/nvm.sh" nvm install --lts nvm alias default lts/* ``` - All instance tags as above. 4. Poll `DescribeInstances` until status = `running` and the instance has a public IP. 5. Poll TCP port 22 until it accepts connections (max 5 minutes, then fail). **Orphan cleanup on startup:** On PP startup, call `DescribeInstances` with filter `tag:pp:managed=true` and compare against all non-`STOPPED` Preview records. Any instance with no matching Preview, or whose matching Preview is `STOPPED`, is terminated and its security group deleted. **On instance termination:** 1. Delete the AWS key pair (`DeleteKeyPair` using `Preview.sshKeyName`). 2. Delete the security group `pp-preview-`. 3. Terminate the instance. 4. Null out `Preview.sshPrivateKey` and `Preview.sshKeyName` in the DB. ### Deployment Flow Each PR has exactly one Preview record. The EC2 instance is provisioned once and reused on subsequent pushes. #### First push (PR opened / no existing Preview, or previous Preview is STOPPED) Set Preview status to `PROVISIONING`. 1. Check `AdminSettings.maxConcurrentInstancesPerUser` — count PROVISIONING + BUILDING + RUNNING previews for this user. If at limit: post a Gitea comment on the PR explaining the limit is reached, mark job FAILED, stop. 2. **Provision EC2** — as described above. 3. SSH in. All subsequent commands run over SSH. 4. **Install apt packages** (if any): `sudo apt-get install -y `. Only runs on first provision. 5. **Clone repo:** - Normal PR: `git clone https://:@//.git /opt/app` - Fork PR: clone from the fork's URL (`payload.pull_request.head.repo.clone_url`, injecting PAT auth). - `cd /opt/app && git fetch origin pull//head:pp-pr && git checkout pp-pr` 6. **Detect Node version:** Check for `/opt/app/.nvmrc`. If present: `nvm install && nvm use`. Otherwise: `nvm use default`. 7. **Write `.env`:** Write `/opt/app/.env` from `RepoConfig.envVars`. 8. **Setup commands** (if any): run each in order. Only runs on first provision. 9. Set Preview status to `BUILDING`. 10. **Build commands:** run each in order. 11. **Post-build commands** (if any): run each in order. 12. **Start:** - Docker Compose: `cd /opt/app && docker compose -f up -d --build --force-recreate` - Non-compose: run `runCommand` in background via `nohup ... > /opt/app/pp.log 2>&1 &`, capture PID → save to `Preview.pid`. 13. Set Preview status to `RUNNING`. Store `instanceIp`, `port`, `commitSha`, `lastActivityAt = now`. 14. **Comment on PR** — post initial status comment (see PR Commenting section). Store the comment ID in `Preview.giteaCommentId`. #### Subsequent push (synchronize / existing RUNNING or FAILED Preview) 1. SSH into existing instance using `Preview.instanceIp` and decrypted `Preview.sshPrivateKey`. 2. **Stop current process:** - Non-compose: `kill ` (SIGTERM, wait 5s, SIGKILL if still running). - Docker Compose: `cd /opt/app && docker compose -f down`. 3. **Pull latest:** ```bash cd /opt/app && git fetch origin pull//head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD ``` 4. **Re-detect Node version** (`.nvmrc` may have changed). 5. **Overwrite `.env`** — re-write from current `RepoConfig.envVars`. 6. Set Preview status to `BUILDING`. Append `\n--- Redeploy: ---\n` to `Preview.logs`. 7. **Build commands** — re-run each. 8. **Post-build commands** — re-run if defined. 9. **Start** — same as first provision start step. Update `Preview.pid`, `commitSha`, `lastActivityAt = now`. Set status to `RUNNING`. 10. **Update PR comment** — edit the existing comment in-place using `Preview.giteaCommentId` (see PR Commenting section). **If any step fails:** Set status to `FAILED`. Append the error output to `Preview.logs`. Edit the PR comment to show failure with the last few log lines. Stop processing. All log output is streamed to `Preview.logs` via WebSocket to the frontend (rj-web WebSocket support). Enforce log size cap: if `Preview.logs` exceeds `AdminSettings.logSizeLimitBytes`, drop oldest lines and prepend `--- logs truncated ---`. ### Inactivity Detection - `Preview.lastActivityAt` is updated on each `synchronize` webhook AND each `issue_comment` webhook for the PR. - A background cron job runs every 30 minutes. Any Preview where `lastActivityAt + inactivityHours < now` and `status = RUNNING` gets a `STOP` job enqueued. - On stop: terminate the EC2 instance, post a final PR comment, set status to `STOPPED`. ### Job Queue Webhook handler returns `200 OK` immediately and enqueues a Job. A background worker processes jobs: - **Per-PR serialisation:** at most one active Job per `previewId` at a time. - **New deploy cancels running deploy:** if a `DEPLOY` job is already `RUNNING` for a PR and a new `DEPLOY` arrives, the running job's SSH session is aborted and the new job takes over immediately. - **On PP startup:** any Jobs left in `RUNNING` state (from a crash) are reset to `PENDING` and requeued. - Job status (PENDING / RUNNING / DONE / FAILED) is visible on the preview detail page in the UI. ### Repo Discovery & Webhook Auto-Registration - When the user saves their Gitea credentials, PP fetches all repos via `GET /api/v1/repos/search?limit=50&token=` (paginate as needed). - These appear in the Repo Configuration page for the user to enable. - When the user **enables** a repo: PP calls `POST /api/v1/repos/{owner}/{repo}/hooks` to register a webhook with: - `type: gitea` - `config.url`: `https:///webhook/` - `config.secret`: `WebhookToken.token` - `config.content_type: json` - `events: ["pull_request", "issue_comment"]` - Store the returned hook ID in `RepoConfig.giteaWebhookId`. - When the user **disables or deletes** a repo config: PP warns the user that the Gitea webhook will be deleted, and on confirmation calls `DELETE /api/v1/repos/{owner}/{repo}/hooks/`. - **Repos without a RepoConfig** that trigger a webhook: PP checks `NoConfigComment` — if no entry exists for `userId + repoOwner + repoName + prNumber`, PP posts one comment ("No previews configured for this repo...") and creates a `NoConfigComment` record. - **Repo ownership conflict:** If a user tries to enable a repo that already has a `RepoConfig` owned by another user, return an error: "This repo is already configured by another user." ### Webhook Secret Rotation When the user regenerates their webhook secret: 1. Generate a new `WebhookToken.token`. 2. For every `RepoConfig` belonging to this user that has a `giteaWebhookId`, call `PATCH /api/v1/repos/{owner}/{repo}/hooks/` to update the secret. 3. Old secret is invalid immediately. ### AWS Credential Validation When the user saves AWS credentials, PP calls `sts:GetCallerIdentity`. Show inline success ("Connected as arn:aws:iam::...") or error ("Invalid credentials"). ### Gitea URL Validation When the user saves their Gitea instance URL, PP calls `GET /api/v1/version`. Show inline success (Gitea version) or error. ### DB Cleanup (background cron, daily) Delete Preview records where `status IN (STOPPED, FAILED)` and `stoppedAt < now - AdminSettings.previewRetentionDays days`. Also delete associated Job records. --- ## Admin Panel (`/admin`) Accessible only to `isAdmin` users. Has its own UI section, separate from the regular user dashboard. ### User Management - Create users (username + password). - Edit username or password of any user. - Delete users (cannot delete `isFounder` user). - Promote users to admin (cannot demote `isFounder` user). - Registration endpoint is always disabled — admin creates all accounts. ### Global Settings - **Default EC2 instance type** — shown as the default in all repo configs. - **Max concurrent EC2 instances per user** — when a user hits this, new deploys are rejected with a PR comment. - **Webhook rate limit** — max events per user per minute (default 10). - **Log size cap** — max bytes per Preview.logs before truncation (default 1MB). - **Preview retention** — days before STOPPED/FAILED records are purged (default 30). - **Contact email** — shown in the privacy policy footer. ### Admin Preview Dashboard - Shows all previews across all users (separate from the user's own dashboard). - Per preview: user, repo, PR number, status, instance IP, created at. - Admin can stop any preview. --- ## User Panel ### Account Settings (`/settings`) - **Username** — editable. - **Password** — change password. - **Gitea Instance URL** — e.g. `https://gitea.example.com`. Validated on save (`GET /api/v1/version`). - **Gitea Username** — the account that PP will comment as. - **Gitea PAT** — write-only display (show `••••••••` after save). Must have scopes: `repository` (read), `issue` (write), `admin:repo_hook` (for webhook management). Show a tooltip explaining required scopes with a link to Gitea's token settings page. - **AWS Access Key ID** — write-only display after save. Validated on save via `sts:GetCallerIdentity`. - **AWS Secret Access Key** — write-only display after save. - **AWS Region** — dropdown of all AWS regions. - **Webhook URL** — read-only. `https:///webhook/`. This is the "Target URL" in Gitea's webhook settings. Copy button included. - **Webhook Secret** — read-only (separate from the URL). This is the "Secret" field in Gitea's webhook settings. Regenerate button (rotates secret and auto-updates all registered hooks in Gitea with a confirmation prompt). Note displayed in settings: _"PP will post PR comments as your Gitea account (@username). Make sure your PAT has 'issue' write permission."_ ### Repo Configuration (`/repos`) - List of repos fetched from Gitea (refresh button). Each repo shows a toggle to enable/disable previews. - Enabling a repo auto-registers the webhook in Gitea. Disabling warns and then deletes it. - A repo already configured by another PP user shows a lock icon and "Claimed by another user" — the toggle is disabled. - Expanded per-repo config: - **Deny list** — tag input of Gitea usernames to skip. - **EC2 instance type** — searchable select (t2.micro, t2.medium, t3.medium, t3.large, m5.large, c5.large) + custom text input. Show estimated hourly cost next to each option. - **Inactivity kill timer** — slider 0.5h to 72h with labelled stops. - **App port** — number input (default 3000). - **Environment variables** — key/value editor, values masked. Add/remove rows. - **Docker Compose toggle** — if on: show compose file path input (default `docker-compose.yml`), hide manual command fields. - **Commands** (shown for both modes unless noted): - Apt packages — tag input (compose + non-compose) - Setup commands — ordered list, add/remove/reorder (compose + non-compose, run once on first provision) - Build commands — ordered list (non-compose only; compose uses `docker compose up --build`) - Post-build commands — ordered list, optional (non-compose only) - Run command — single text input (non-compose only) ### Preview Dashboard (`/` or `/previews`) - Cards for all of this user's previews across repos. - Card shows: repo name, PR number + title, status badge, preview URL (clickable when RUNNING), commit SHA, last updated. - Click into a preview to see: - Full log viewer (WebSocket — live stream while active, monospace, auto-scroll, ANSI codes stripped). - Current job status and queue position if PENDING. - All previous redeploy separators visible inline in the log. - Status history (PROVISIONING → BUILDING → RUNNING etc. with timestamps). --- ## PR Commenting PP comments using the configured user's Gitea PAT (comments appear as their account). There is no bot account. **One comment per deploy cycle, edited in-place.** PP stores the comment ID in `Preview.giteaCommentId` and calls `PATCH /api/v1/repos/{owner}/{repo}/issues/comments/{id}` to update it. A new comment is posted fresh at the start of each new push/redeploy cycle (replacing `giteaCommentId` with the new comment's ID). **Comment format — Markdown:** ```markdown ## 🚀 PR Preview — `/` # **Status:** 🟡 Provisioning EC2 instance... **Commit:** `` **Updated:** --- _Powered by [PR Previews]()_ ``` Status line updates as the deploy progresses: - `🟡 Provisioning EC2 instance...` - `🟡 Building... (EC2 ready at )` - `🟢 Live at http://:` - `🔴 Failed — last log lines:` _(followed by a fenced code block with the last 10 lines of logs)_ - `⚫ Stopped (inactivity timeout / PR closed / manual stop)` On a new redeploy, PP posts a **new** comment (fresh cycle) and updates `giteaCommentId`. --- ## Setup Wizard (first login) Shown to a user on first login if any of the required fields (Gitea URL, PAT, AWS credentials) are unset. Skippable at any point. Steps: 1. **Welcome** — brief explanation of what PP does. 2. **Gitea Connection** — Gitea instance URL + PAT. Validate on "Next". Show required PAT scopes with a link to Gitea settings. 3. **AWS Setup** — Access Key ID, Secret, Region. Show the required IAM policy JSON (copy button). Validate credentials on "Next". 4. **Your Webhook** — show the webhook URL and secret. Explain where to find these in Gitea's UI (per-repo or org-level webhook settings). Note: PP auto-registers per-repo webhooks when you enable a repo — this step is just for reference. 5. **Enable your first repo** — show the repo list, let the user enable one and fill in its config. On save, PP registers the webhook automatically. 6. **Done** — link to the preview dashboard. --- ## UI Design - Dark/light mode toggle, persisted to localStorage. - Animated route transitions and status badge updates. - Status badges: ⚫ grey (stopped/ignored), 🟡 yellow (provisioning/building), 🟢 green (running), 🔴 red (failed). - Log viewer: monospace font, auto-scroll to bottom, ANSI codes stripped, redeploy separator lines visually distinct (dimmed/italic). - Responsive layout — usable on mobile for monitoring previews on the go. - Toast notifications for save success/failure. - Confirmation dialogs for: deleting a user, stopping a preview, regenerating webhook secret, disabling a repo (which deletes the Gitea webhook). - Inline validation feedback on settings fields (Gitea URL, AWS credentials, PAT) that tests the connection on save. - Contextual tooltips on instance type selector (cost estimates), PAT field (required scopes), inactivity timer (what happens when it fires). --- ## Docker / Deployment `./docker-compose.yml` runs PP itself (not preview instances — those are EC2): - `pp-backend` — Node backend. - `pp-frontend` — Vite build served statically (or via backend). - `pp-db` — PostgreSQL. - Volume for Prisma migrations. **Required environment variables:** | Variable | Purpose | |---|---| | `DATABASE_URL` | PostgreSQL connection string | | `SESSION_SECRET` | Session signing secret | | `PP_BASE_URL` | Public URL of this PP instance (used in webhook URLs and PR comment links) | | `ENCRYPTION_KEY` | AES-256 key for encrypting PAT, AWS secrets, SSH private keys at rest | --- ## Security Notes - Gitea PAT, AWS credentials, and SSH private keys are encrypted at rest with AES-256 using `ENCRYPTION_KEY`. - Webhook HMAC-SHA256 signature is verified before any payload is processed. Invalid signatures return `401`. - Webhook rate limiting (default 10 req/min/user) prevents floods. - EC2 security groups are scoped per preview — created on provision, deleted on termination. - SSH keys are ephemeral per launch. Private key is stored encrypted in the DB and nulled out on instance termination. - Users can only see and manage their own previews, repos, and credentials. - Admins can see all previews in a dedicated admin UI section. - The repo config ownership model (first-to-save) prevents two users from deploying conflicting previews for the same repo. --- ## Privacy Policy A generic self-hosted app privacy policy is included in the footer. It covers: - What data PP stores (user credentials, Gitea PAT encrypted at rest, AWS credentials encrypted at rest, preview logs, PR metadata). - That credentials are used only to operate the preview service and are never shared with third parties. - That EC2 instances are launched in the user's own AWS account — PP does not have access to user data on those instances beyond what it deploys. - Log retention policy (`previewRetentionDays`). - How to contact the instance administrator (configurable contact email in admin settings). - No analytics, no tracking, no external data sharing. --- ## Out of Scope for V1 - GitHub/GitLab support (Gitea only). - Custom domains for previews (EC2 public IP + port only). - Preview instance autoscaling. - Billing or usage tracking. - Automated tests (manual QA first, tests added in V2). - Multi-port previews (one port per repo config only).