diff --git a/.claude/skills/new-feature-setup/SKILL.md b/.claude/skills/new-feature-setup/SKILL.md new file mode 100644 index 0000000000..79f281de21 --- /dev/null +++ b/.claude/skills/new-feature-setup/SKILL.md @@ -0,0 +1,114 @@ +--- +name: new-feature-setup +description: Use when starting a new feature, Linear ticket, or bugfix in this repo — establishes the branch + worktree + env + DB + dev-server conventions so the work is immediately ready to code without fighting infra. Triggers on "start a new feature", "spin up a worktree", "begin ticket", "new branch". +--- + +# New Feature Setup (comp monorepo) + +## Overview + +This repo has a lot of infrastructure pre-wired into `git worktree add`. Use it. Don't reinvent env copying, database setup, or dependency install flows in every new session. + +## When to Use + +- User says "start a new feature", "spin up a worktree for X", "begin this Linear ticket", "new branch for …" +- Before running `bun install` / `bun run db:generate` manually in a new directory +- Before copying `.env` files around by hand + +## When NOT to Use + +- User is editing an existing worktree (already set up) +- Infra repair / debugging of the hook itself (read `.githooks/README.md` instead) + +## Workflow + +### 1. Create the worktree from `origin/main` + +```sh +cd /Users/mariano/code/comp # must be the MAIN clone, not another worktree +git fetch origin main +git worktree add .worktrees/ -b origin/main +``` + +- For Linear tickets, use Linear's suggested branch name (`mariano/`). +- For infra / chore work, use `mariano/` or `chore/`. +- The `` on the worktree path should match the branch's suffix (it becomes the Postgres DB slug after `tr '-' '_'`). + +### 2. Let the hook do everything else + +`git worktree add` fires the `post-checkout` hook at `.githooks/post-checkout`, which runs synchronously: + +1. Creates `compdev_` Postgres database (isolated per worktree) +2. Links `.env*` files from the main clone (copies the ones with `DATABASE_URL`, rewriting it to the isolated URL; symlinks the rest so API keys auto-propagate) +3. Runs `bun install`, applies Prisma migrations, regenerates clients + +**Do not** run any of these by hand. If the hook logs a failure, diagnose and fix at the source — don't paper over with a manual install. + +Skip toggles (rare): +- `SKIP_WORKTREE_DB=1` — share the main `comp` DB (drift risk; only for read-only worktrees) +- `SKIP_WORKTREE_SETUP=1` — skip install + migrate + generate (for a "just files" worktree) +- `SETUP_WORKTREE_WITH_BUILD=1` — also run `bun run build` (adds minutes; only when you need the built artifacts) + +### 3. Start the dev server — coordinate with the "active worktree" rule + +Trigger.dev's `trigger dev` CLI **cannot** be isolated per worktree. Running `bun run dev` in multiple worktrees stomps on task registration. + +- **One active worktree** runs `bun run dev` (full stack with `trigger dev`). +- **All other worktrees** run: + ```sh + bun run --filter '@trycompai/app' dev:no-trigger # Next.js only + bun run --filter '@trycompai/api' dev:no-trigger # NestJS only + ``` +- Non-active worktrees need a different `PORT` to avoid collision — add `PORT=3001` (or `3334`, etc.) to the worktree's `.env.local`. `.env.local` is not symlinked and stays per-worktree. +- When swapping which worktree is active, kill the old full `bun run dev` first so task registration is clean. + +### 4. Code the feature + +Standard repo conventions apply (see `CLAUDE.md`). Highlights: +- TDD for any non-trivial change (`superpowers:test-driven-development`) +- Brainstorm before building new UX (`superpowers:brainstorming`) +- Plans + subagent-driven execution for multi-step work +- Run `audit-design-system` after any frontend component edit +- Always run typecheck before declaring a change done (`npx turbo run typecheck --filter=`) + +### 5. When done, clean up + +Use the `stale-worktree-cleanup` skill when worktrees accumulate. It handles both `git worktree remove` and dropping the `compdev_` database in one pass. Never leave orphan databases — they pile up silently because git has no `pre-worktree-remove` hook. + +## Quick Reference + +```sh +# Spin up a new worktree (does env + DB + install + migrate + generate automatically) +git worktree add .worktrees/ -b mariano/ origin/main + +# Start dev — ONLY in the worktree you're actively iterating on +cd .worktrees/ +bun run dev + +# Start dev in a background worktree (no trigger dev, custom port via .env.local) +echo "PORT=3001" >> apps/app/.env.local +bun run --filter '@trycompai/app' dev:no-trigger + +# Clean up when branch is done +# (use the stale-worktree-cleanup skill) +``` + +## Red Flags + +If you catch yourself doing any of these, stop — the hook should have handled it: + +- Running `bun install` manually in a new worktree +- `cp` or `ln -s` to copy `.env` files into a worktree +- Writing a script that "creates a database for my branch" +- Running `bun run db:migrate` in a worktree right after creating it +- Ignoring a failing `bun run dev` in two worktrees instead of swapping to `dev:no-trigger` + +## Common Mistakes + +| Mistake | Fix | +|---|---| +| Creating the worktree from another worktree instead of the main clone | Always `cd` to `/Users/mariano/code/comp` first | +| Editing `.env` in a worktree and expecting it to propagate | If it's a symlink, yes; if it's a real copy (has `DATABASE_URL`), no. Check with `ls -la`. | +| Forgetting to bump `PORT` → two dev servers collide | Put `PORT=` in the worktree's `.env.local` | +| Running `trigger dev` in multiple worktrees | Switch to `dev:no-trigger` in all but one | +| Not cleaning up → orphan `compdev_*` databases piling up | Use the `stale-worktree-cleanup` skill regularly | diff --git a/.claude/skills/stale-worktree-cleanup/SKILL.md b/.claude/skills/stale-worktree-cleanup/SKILL.md new file mode 100644 index 0000000000..dcd19860f1 --- /dev/null +++ b/.claude/skills/stale-worktree-cleanup/SKILL.md @@ -0,0 +1,174 @@ +--- +name: stale-worktree-cleanup +description: Use when cleaning up old git worktrees, removing worktrees whose branches have merged or been abandoned, or dropping orphaned compdev_* Postgres databases. Triggers on "clean up worktrees", "delete stale worktrees", "worktrees piling up", "orphaned databases", "remove unused worktree". +--- + +# Stale Worktree Cleanup + +## Overview + +This repo's `.githooks/post-checkout` creates a per-worktree Postgres database (`compdev_`) on every `git worktree add`. Git has no `pre-worktree-remove` hook, so dead databases pile up over time. This skill defines a safe, reversible process to reap both the worktree directories and their dangling databases together. + +**Core principle**: never delete work the user might still want. Classify first, ask second, remove last. + +## When to Use + +- User says "clean up worktrees", "delete stale worktrees", "remove old worktrees", "worktrees piling up" +- User mentions orphaned `compdev_*` DBs or running out of disk space from dead `node_modules` +- Starting a new feature and noticing many old worktree dirs + +## When NOT to Use + +- User wants to remove ONE specific worktree (just run `git worktree remove ` + drop its DB directly — don't load the whole process) +- User is actively working in a worktree (never touch active work) + +## Process + +### Step 1 — Inventory + +Run these four commands (parallel-safe) and capture the output: + +```sh +git worktree list --porcelain +gh pr list --author @me --state all --limit 50 --json headRefName,state,url,number +git branch --no-color | cat +``` + +Then query the DB for `compdev_*` databases. The management URL is the main worktree's `DATABASE_URL` with the database path swapped to `postgres`: + +```sh +bun -e ' + import { Client } from "pg"; + const raw = require("fs").readFileSync("packages/db/.env", "utf8"); + const url = raw.match(/^DATABASE_URL=(.*)$/m)[1].replace(/^["\x27]|["\x27]$/g, ""); + const mgmt = url.replace(/\/[^/?]+(\?|$)/, "/postgres$1"); + const c = new Client({ connectionString: mgmt }); + await c.connect(); + const r = await c.query("SELECT datname FROM pg_database WHERE datname LIKE \x27compdev\\\\_%\x27 ORDER BY 1"); + for (const row of r.rows) console.log(row.datname); + await c.end(); +' +``` + +### Step 2 — Classify each worktree + +For each worktree (skip the main one): + +| Signal | Classification | +|---|---| +| Branch merged to main AND clean working tree AND no unpushed commits | **safe** | +| PR is `CLOSED` (not merged) | **needs-confirm** | +| Uncommitted changes OR unpushed commits | **needs-confirm** | +| No matching PR, no merge, has local commits | **keep** (user may still be working on it) | +| Is the main worktree | **skip** | + +Gather per worktree: +- `cd && git status --porcelain | wc -l` — uncommitted changes count +- `cd && git log @{upstream}..HEAD --oneline 2>/dev/null | wc -l` — unpushed commits (0 if no upstream) +- Branch → PR lookup from step 1 + +### Step 3 — Present to user + +Show a table and explicit recommendations. Example: + +``` +Path Branch PR state Changes Recommendation +.worktrees/sale-45-… mariano/sale-45-… MERGED 0 / 0 ✅ safe to remove +.worktrees/old-experiment mariano/scratch — 3 / 0 ⚠ uncommitted — confirm first +.worktrees/worktree-env-auto-link mariano/worktree-env-auto-link OPEN 0 / 0 ⏳ keep (PR open) + +Orphan databases (no worktree dir): + compdev_abandoned_feature + compdev_old_migration_test +``` + +Then ask: **"Remove the items marked ✅? Confirm by listing anything you want me to also nuke from the ⚠ / ⏳ set."** + +### Step 4 — Remove confirmed items + +For each worktree the user approved: + +```sh +# 1. Remove the worktree dir (use --force only if user confirmed dirty-removal) +git worktree remove "" # clean case +git worktree remove --force "" # only after explicit user OK + +# 2. Derive the slug and drop the database +slug=$(basename "" | tr '[:upper:]' '[:lower:]' | tr '-' '_' | tr -cd 'a-z0-9_') +bun -e ' + import { Client } from "pg"; + const raw = require("fs").readFileSync("packages/db/.env", "utf8"); + const url = raw.match(/^DATABASE_URL=(.*)$/m)[1].replace(/^["\x27]|["\x27]$/g, ""); + const mgmt = url.replace(/\/[^/?]+(\?|$)/, "/postgres$1"); + const c = new Client({ connectionString: mgmt }); + await c.connect(); + await c.query(`DROP DATABASE IF EXISTS "compdev_'"$slug"'"`); + await c.end(); + console.log("dropped compdev_'"$slug"'"); +' +``` + +For orphan databases (no matching worktree dir), just drop them — no worktree to remove. + +**Do NOT** delete the local branch unless the user explicitly asked. Branches can be recreated from `origin` cheaply; worktrees cannot. + +### Step 5 — Verify and report + +```sh +git worktree list +# then re-run the compdev_* query from step 1 +``` + +Report back: +- Worktrees removed (paths) +- Databases dropped (names) +- Anything skipped and why +- What's left (still-active worktrees) + +## Safety Rules + +- **Never remove the main worktree.** Its path is always the first line of `git worktree list --porcelain`. +- **Never `--force` without confirmation.** Dirty worktrees can contain un-stashed work. +- **Never `DROP DATABASE` on anything not matching `^compdev_[a-z0-9_]+$`.** Never drop `comp`, `postgres`, or any production-looking name. +- **Never delete a local branch** as part of cleanup unless the user explicitly asks. Orphaned branches are cheap; lost work isn't. +- **Never run this inside a worktree you're about to delete.** `cd` to the main worktree first. + +## Common Mistakes + +| Mistake | Fix | +|---|---| +| Dropping the DB but leaving the worktree dir | Run `git worktree prune` then `git worktree remove` | +| Removing the worktree but leaving the DB (accumulates orphans) | Always do both in the same pass | +| Using hyphens in the DB name | The hook slug rule is `tr '-' '_'` — always underscores | +| Running from inside a doomed worktree | `cd` to the main worktree before starting the process | +| Using `gh pr list` on a branch with no PR | Missing data is not "abandoned" — needs `needs-confirm` classification | + +## Red Flags + +If any of these show up mid-process, **stop and ask the user**: + +- A worktree has unpushed commits AND no PR → might be unreleased work +- The classification returned >10 "safe to remove" items → unusual volume, double-check +- `git worktree remove` errors with "working tree is not clean" → never retry with `--force` without explicit consent +- A `compdev_*` name has weird characters or unexpected format → don't drop + +## Quick Reference + +```sh +# List everything +git worktree list --porcelain +gh pr list --author @me --state all --limit 50 --json headRefName,state + +# Per-worktree inspection +git -C status --porcelain +git -C log @{upstream}..HEAD --oneline 2>/dev/null + +# Clean removal +git worktree remove + +# Dirty removal (requires user confirmation first) +git worktree remove --force + +# Database drop (run from main worktree) +# See the bun -e snippets above for the exact invocation +``` diff --git a/.githooks/README.md b/.githooks/README.md new file mode 100644 index 0000000000..89f8007d8a --- /dev/null +++ b/.githooks/README.md @@ -0,0 +1,130 @@ +# Git hooks + +Shared, repo-tracked git hooks for this project. + +## Setup (once per clone) + +```sh +git config core.hooksPath .githooks +``` + +All worktrees share a single `.git/` directory with the main clone, so this +one `git config` call enables the hooks for every current and future +worktree — no need to re-run after `git worktree add`. + +## What's here + +### `post-checkout` + +Runs three steps automatically on `git worktree add`: + +1. **Create an isolated Postgres database** for this worktree + (`scripts/setup-worktree-db.sh`). Connects to the Postgres instance + from the main worktree's `packages/db/.env` and creates + `compdev_` if it doesn't already exist. The slug is derived + from the worktree's directory name (hyphens → underscores, + lowercase). Returns the isolated `DATABASE_URL`. +2. **Set up `.env*` files** in the new worktree + (`scripts/link-worktree-envs.sh`). Files without a `DATABASE_URL` + line are symlinked to the main worktree so shared secrets + auto-propagate. Files with `DATABASE_URL` get a real copy with + `DATABASE_URL` (and `DIRECT_URL`) rewritten to the isolated URL — + this way each worktree uses its own DB while still sharing API + keys, etc. +3. **Install deps + apply migrations + generate clients** — + `bun install`, `cd packages/db && bun run db:migrate`, then + `bun run db:generate` (`scripts/setup-worktree.sh`). The full + `bun run build` is opt-in because it adds several minutes and most + dev workflows (`dev`, tests, typechecks) don't need it. + +The hook fires **only** inside `git worktree add` — regular `git checkout`, +`git switch`, and file checkouts are filtered by checking that the +previous HEAD is the null SHA (true only for fresh checkouts) and that +the current worktree isn't the main one. + +Step 2 is synchronous on purpose: callers — including Claude Code — +typically start running commands in the new worktree immediately, and we +don't want them racing ahead of the install. + +Toggles: +- `SKIP_WORKTREE_DB=1 git worktree add …` — skip isolated DB creation + and `DATABASE_URL` rewriting. The worktree uses the shared `comp` DB + (same behavior as before DB isolation was added). +- `SKIP_WORKTREE_SETUP=1 git worktree add …` — skip install + generate + (just link envs + create the DB). +- `SETUP_WORKTREE_WITH_BUILD=1 git worktree add …` — also run + `bun run build` when you actually need the built artifacts. + +## Backfilling existing worktrees + +For worktrees created before the hook was installed: + +```sh +# From inside the worktree that needs envs linked: +scripts/link-worktree-envs.sh + +# Or pass an explicit path from anywhere: +scripts/link-worktree-envs.sh /path/to/.worktrees/some-feature + +# And/or run the full install + build: +scripts/setup-worktree.sh /path/to/.worktrees/some-feature +``` + +## Only one worktree runs `trigger dev` at a time + +Trigger.dev's dev CLI has no per-branch or per-session isolation — +it's hardcoded to connect to the project's single shared "dev" +environment (verified by reading the CLI source in +`node_modules/trigger.dev/dist/esm/commands/dev.js`). Running +`bun run dev` in five worktrees → five processes register tasks +against the same environment, last-writer-wins, zombie workers, +tasks cross-executed on the wrong branch's code. + +The rule: **pick one "active" worktree** for `bun run dev` (full +stack with `trigger dev`). In the other worktrees, run the UI +framework only: + +```sh +# apps/app — Next.js only, no trigger dev +bun run --filter '@trycompai/app' dev:no-trigger + +# apps/api — NestJS only, no trigger dev +bun run --filter '@trycompai/api' dev:no-trigger +``` + +Port collisions across worktrees are still your problem: bump the +`PORT` in the worktree's `.env.local` (e.g. `PORT=3001`, `PORT=3334`) +for any non-default worktree. + +When you swap which worktree is active, kill the full `bun run dev` +in the old one first so task registration is clean before the new +one claims the dev environment. + +## Per-worktree overrides + +Env files without `DATABASE_URL` are still symlinks — editing them +mutates the shared file in the main worktree. Files with `DATABASE_URL` +are real copies taken at worktree creation time; if you add a new env +var to a main-worktree `.env` that you want to see in existing +worktrees, re-run +`ISOLATED_DATABASE_URL= scripts/link-worktree-envs.sh ` to +regenerate (or delete the file in the worktree and re-run the linker). + +For ad-hoc per-worktree values (alternate ports, etc.), use +`.env.local` — loaded by Next.js and NestJS on top of `.env`, and +never touched by the hook. + +## Cleaning up dead worktree databases + +The hook creates `compdev_` databases but doesn't drop them when +you remove a worktree (git has no pre-worktree-remove hook). To clean +up old ones occasionally: + +```sh +# List all compdev_* DBs +psql "$(grep ^DATABASE_URL= packages/db/.env | cut -d= -f2-)" \ + -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'compdev\\_%'" + +# Drop a specific one +psql "" -c 'DROP DATABASE "compdev_old_feature"' +``` diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100755 index 0000000000..a7ce66336f --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Auto-link .env* files from the main worktree into freshly created worktrees. +# +# post-checkout fires for every `git checkout`, `git switch`, and +# `git worktree add`. This script uses two layered checks to run ONLY for +# fresh worktree creation: +# +# 1. `` equals the null SHA — git fills this in only when there +# was no previous HEAD in the current directory (fresh checkout). +# Normal branch or file checkouts have a real SHA here. +# +# 2. The current worktree is not the main worktree — filters the fresh-clone +# case (which also gets the null SHA on its first checkout). +# +# Combined, the only invocation that satisfies both is `git worktree add`. +set -euo pipefail + +prev="$1" + +# Check 1: only proceed if this is a fresh checkout (no previous HEAD). +[[ "$prev" == "0000000000000000000000000000000000000000" ]] || exit 0 + +main=$(git worktree list --porcelain | awk '$1=="worktree"{print $2; exit}') +cur=$(git rev-parse --show-toplevel) + +# Check 2: don't run on the main worktree (fresh clone case). +[[ "$main" == "$cur" ]] && exit 0 + +# Spin up an isolated Postgres database for this worktree so branches with +# divergent migrations don't step on each other. The returned URL is fed +# into the linker so .env files with DATABASE_URL point at the new DB. +# Skippable via SKIP_WORKTREE_DB=1. +iso_url="" +if [[ "${SKIP_WORKTREE_DB:-}" != "1" ]]; then + # Script prints the isolated URL on stdout; logs go to stderr (inherited). + iso_url=$("$main/scripts/setup-worktree-db.sh" "$cur") || { + echo "post-checkout: setup-worktree-db failed — continuing without DB isolation" >&2 + iso_url="" + } +fi + +# Delegate env setup. When $iso_url is non-empty, env files with +# DATABASE_URL are rewritten (copy, not symlink) to point at the isolated DB. +ISOLATED_DATABASE_URL="$iso_url" "$main/scripts/link-worktree-envs.sh" "$cur" || { + echo "post-checkout: failed to link envs (continuing)" >&2 +} + +# Envs must be in place first because `db:migrate` and `db:generate` read +# DATABASE_URL from .env. Skippable via SKIP_WORKTREE_SETUP=1 for callers +# that want the worktree without the install cycle. +"$main/scripts/setup-worktree.sh" "$cur" || { + echo "post-checkout: setup-worktree failed (worktree is still usable, fix manually)" >&2 +} diff --git a/apps/api/package.json b/apps/api/package.json index 4bf6aa80c4..f3f51de438 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -184,6 +184,7 @@ "deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy", "dev": "bunx concurrently --kill-others --names \"nest,trigger\" --prefix-colors \"green,blue\" \"nest start --watch\" \"trigger dev\"", "dev:nest": "nest start --watch", + "dev:no-trigger": "nest start --watch", "dev:trigger": "trigger dev", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", diff --git a/apps/app/package.json b/apps/app/package.json index a2a5f22020..cfe571db22 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -192,6 +192,7 @@ "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/app", "deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy", "dev": "bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"NODE_OPTIONS='--no-deprecation' next dev --turbo -p 3000\" \"NODE_OPTIONS='--no-deprecation' trigger dev\"", + "dev:no-trigger": "NODE_OPTIONS='--no-deprecation' next dev --turbo -p 3000", "lint": "eslint . && prettier --check .", "prebuild": "bun run db:generate", "postinstall": "prisma generate --schema=./prisma/schema || exit 0", diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.test.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.test.tsx index a38be263d1..05baa58be4 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.test.tsx @@ -239,4 +239,43 @@ describe('VendorsTable', () => { expect(screen.getByText('Acme Corp')).toBeInTheDocument(); expect(screen.getByText('Cloud')).toBeInTheDocument(); }); + + it('renders the INHERENT RISK column with a numeric score for assessed vendors', () => { + setMockPermissions({}); + + render( + , + ); + + // Column header + expect(screen.getByText('INHERENT RISK')).toBeInTheDocument(); + // Acme Corp (possible × moderate) → raw 9 → score 4/10 + expect(screen.getByText('4/10')).toBeInTheDocument(); + }); + + it('shows an em-dash for vendors that have not been assessed', () => { + setMockPermissions({}); + + const notAssessedVendor = { + ...mockVendors[0], + id: 'vendor-2', + name: 'Pending Inc', + status: 'not_assessed', + }; + + render( + , + ); + + expect(screen.getByText('—')).toBeInTheDocument(); + expect(screen.queryByText('1/10')).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx index e0bf11e674..376b6b731a 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx @@ -1,9 +1,11 @@ 'use client'; import { OnboardingLoadingAnimation } from '@/components/onboarding-loading-animation'; +import { RiskScoreBadge } from '@/components/risks/RiskScoreBadge'; +import { VendorStatus } from '@/components/vendor-status'; import { usePermissions } from '@/hooks/use-permissions'; import { useVendors, useVendorActions, type Vendor } from '@/hooks/use-vendors'; -import { VendorStatus } from '@/components/vendor-status'; +import { getRiskScore } from '@/lib/risk-score'; import { AlertDialog, AlertDialogAction, @@ -168,7 +170,10 @@ export function VendorsTable({ // Local state for search, sorting, and pagination const [searchQuery, setSearchQuery] = useState(''); - const [sort, setSort] = useState<{ id: 'name' | 'updatedAt'; desc: boolean }>({ + const [sort, setSort] = useState<{ + id: 'name' | 'updatedAt' | 'inherentRisk'; + desc: boolean; + }>({ id: 'name', desc: false, }); @@ -319,15 +324,17 @@ export function VendorsTable({ // Sort result.sort((a, b) => { - const aValue = sort.id === 'name' ? a.name : a.updatedAt; - const bValue = sort.id === 'name' ? b.name : b.updatedAt; - if (sort.id === 'name') { - const comparison = (aValue as string).localeCompare(bValue as string); + const comparison = a.name.localeCompare(b.name); + return sort.desc ? -comparison : comparison; + } + if (sort.id === 'inherentRisk') { + const aScore = getRiskScore(a.inherentProbability, a.inherentImpact).raw; + const bScore = getRiskScore(b.inherentProbability, b.inherentImpact).raw; + const comparison = aScore - bScore; return sort.desc ? -comparison : comparison; } - const comparison = - new Date(aValue as string).getTime() - new Date(bValue as string).getTime(); + const comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); return sort.desc ? -comparison : comparison; }); @@ -385,7 +392,7 @@ export function VendorsTable({ router.push(`/${orgId}/vendors/${vendorId}`); }; - const handleSort = (columnId: 'name' | 'updatedAt') => { + const handleSort = (columnId: 'name' | 'updatedAt' | 'inherentRisk') => { if (sort.id === columnId) { setSort({ id: columnId, desc: !sort.desc }); } else { @@ -393,7 +400,7 @@ export function VendorsTable({ } }; - const getSortIcon = (columnId: 'name' | 'updatedAt') => { + const getSortIcon = (columnId: 'name' | 'updatedAt' | 'inherentRisk') => { if (sort.id !== columnId) { return ; } @@ -528,6 +535,16 @@ export function VendorsTable({ STATUS + + + CATEGORY OWNER {hasPermission('vendor', 'delete') && ACTIONS} @@ -549,6 +566,16 @@ export function VendorsTable({ + + {vendor.status === 'not_assessed' ? ( + + ) : ( + + )} + {CATEGORY_MAP[vendor.category] || vendor.category} diff --git a/apps/app/src/components/risks/RiskScoreBadge.tsx b/apps/app/src/components/risks/RiskScoreBadge.tsx new file mode 100644 index 0000000000..5cb0f52811 --- /dev/null +++ b/apps/app/src/components/risks/RiskScoreBadge.tsx @@ -0,0 +1,42 @@ +import { cn } from '@/lib/utils'; +import type { Impact, Likelihood } from '@db'; +import { getRiskScore, type RiskLevel } from '@/lib/risk-score'; + +const LEVEL_CLASSES: Record = { + 'very-low': + 'bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300', + low: 'bg-green-500/15 border-green-500/40 text-green-700 dark:text-green-300', + medium: 'bg-yellow-500/15 border-yellow-600/40 text-yellow-700 dark:text-yellow-300', + high: 'bg-orange-500/15 border-orange-500/40 text-orange-700 dark:text-orange-300', + 'very-high': 'bg-red-500/15 border-red-500/40 text-red-700 dark:text-red-300', +}; + +const LEVEL_LABEL: Record = { + 'very-low': 'Very low', + low: 'Low', + medium: 'Medium', + high: 'High', + 'very-high': 'Very high', +}; + +export interface RiskScoreBadgeProps { + likelihood: Likelihood; + impact: Impact; + className?: string; +} + +export function RiskScoreBadge({ likelihood, impact, className }: RiskScoreBadgeProps) { + const { score, level } = getRiskScore(likelihood, impact); + return ( + + {score}/10 + + ); +} diff --git a/apps/app/src/components/risks/charts/RiskMatrixChart.tsx b/apps/app/src/components/risks/charts/RiskMatrixChart.tsx index 923b74d5f9..7853115fd3 100644 --- a/apps/app/src/components/risks/charts/RiskMatrixChart.tsx +++ b/apps/app/src/components/risks/charts/RiskMatrixChart.tsx @@ -1,25 +1,10 @@ 'use client'; +import { IMPACT_SCORES, LIKELIHOOD_SCORES, getRiskLevel } from '@/lib/risk-score'; import { Impact, Likelihood } from '@db'; import { Button, HStack, Section } from '@trycompai/design-system'; import { useEffect, useState } from 'react'; -const LIKELIHOOD_SCORES: Record = { - very_unlikely: 1, - unlikely: 2, - possible: 3, - likely: 4, - very_likely: 5, -}; - -const IMPACT_SCORES: Record = { - insignificant: 1, - minor: 2, - moderate: 3, - major: 4, - severe: 5, -}; - const VISUAL_LIKELIHOOD_ORDER: Likelihood[] = [ Likelihood.very_likely, Likelihood.likely, @@ -114,13 +99,7 @@ export function RiskMatrixChart({ const likelihoodScore = LIKELIHOOD_SCORES[VISUAL_LIKELIHOOD_ORDER[probabilityLevels.indexOf(probability)]]; const impactScore = IMPACT_SCORES[VISUAL_IMPACT_ORDER[impactLevels.indexOf(impact)]]; - const score = likelihoodScore * impactScore; - - let level: RiskCell['level'] = 'very-low'; - if (score > 16) level = 'very-high'; - else if (score > 9) level = 'high'; - else if (score > 4) level = 'medium'; - else if (score > 1) level = 'low'; + const level = getRiskLevel(likelihoodScore * impactScore); return { probability, @@ -207,7 +186,7 @@ export function RiskMatrixChart({ onClick={() => handleCellClick(probability, impact)} > {cell?.value && ( -
+
)}
); diff --git a/apps/app/src/lib/risk-score.test.ts b/apps/app/src/lib/risk-score.test.ts new file mode 100644 index 0000000000..70b1a951c7 --- /dev/null +++ b/apps/app/src/lib/risk-score.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { getRiskLevel, getRiskScore } from './risk-score'; + +describe('getRiskScore', () => { + it('returns 1/10 very-low at the minimum corner (1×1)', () => { + expect(getRiskScore('very_unlikely', 'insignificant')).toEqual({ + raw: 1, + score: 1, + level: 'very-low', + }); + }); + + it('returns 10/10 very-high at the maximum corner (5×5)', () => { + expect(getRiskScore('very_likely', 'severe')).toEqual({ + raw: 25, + score: 10, + level: 'very-high', + }); + }); + + it('computes raw as likelihood × impact', () => { + expect(getRiskScore('possible', 'moderate').raw).toBe(9); + expect(getRiskScore('likely', 'major').raw).toBe(16); + expect(getRiskScore('very_likely', 'major').raw).toBe(20); + }); + + it('normalizes raw 1–25 into a 1–10 integer score', () => { + for (const l of ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'] as const) { + for (const i of ['insignificant', 'minor', 'moderate', 'major', 'severe'] as const) { + const { score } = getRiskScore(l, i); + expect(score).toBeGreaterThanOrEqual(1); + expect(score).toBeLessThanOrEqual(10); + expect(Number.isInteger(score)).toBe(true); + } + } + }); +}); + +describe('getRiskLevel', () => { + it('matches the risk-matrix thresholds used elsewhere', () => { + expect(getRiskLevel(1)).toBe('very-low'); + expect(getRiskLevel(2)).toBe('low'); + expect(getRiskLevel(4)).toBe('low'); + expect(getRiskLevel(5)).toBe('medium'); + expect(getRiskLevel(9)).toBe('medium'); + expect(getRiskLevel(10)).toBe('high'); + expect(getRiskLevel(16)).toBe('high'); + expect(getRiskLevel(17)).toBe('very-high'); + expect(getRiskLevel(25)).toBe('very-high'); + }); +}); diff --git a/apps/app/src/lib/risk-score.ts b/apps/app/src/lib/risk-score.ts new file mode 100644 index 0000000000..7aa10a4f06 --- /dev/null +++ b/apps/app/src/lib/risk-score.ts @@ -0,0 +1,39 @@ +import { Impact, Likelihood } from '@db'; + +export const LIKELIHOOD_SCORES: Record = { + very_unlikely: 1, + unlikely: 2, + possible: 3, + likely: 4, + very_likely: 5, +}; + +export const IMPACT_SCORES: Record = { + insignificant: 1, + minor: 2, + moderate: 3, + major: 4, + severe: 5, +}; + +export type RiskLevel = 'very-low' | 'low' | 'medium' | 'high' | 'very-high'; + +export interface RiskScore { + raw: number; + score: number; + level: RiskLevel; +} + +export function getRiskLevel(raw: number): RiskLevel { + if (raw > 16) return 'very-high'; + if (raw > 9) return 'high'; + if (raw > 4) return 'medium'; + if (raw > 1) return 'low'; + return 'very-low'; +} + +export function getRiskScore(likelihood: Likelihood, impact: Impact): RiskScore { + const raw = LIKELIHOOD_SCORES[likelihood] * IMPACT_SCORES[impact]; + const score = Math.max(1, Math.ceil(raw / 2.5)); + return { raw, score, level: getRiskLevel(raw) }; +} diff --git a/scripts/create-database.mjs b/scripts/create-database.mjs new file mode 100644 index 0000000000..db13451276 --- /dev/null +++ b/scripts/create-database.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Create a Postgres database if it doesn't already exist. +// Usage: +// node scripts/create-database.mjs +// +// The management URL should point at the `postgres` maintenance database +// on the same host/credentials as the target. Exits 0 on success (whether +// the DB was created or already existed). +import { Client } from 'pg'; + +const [mgmtUrl, dbName] = process.argv.slice(2); +if (!mgmtUrl || !dbName) { + console.error('usage: node create-database.mjs '); + process.exit(2); +} + +const client = new Client({ connectionString: mgmtUrl }); +try { + await client.connect(); + const { rowCount } = await client.query( + 'SELECT 1 FROM pg_database WHERE datname = $1', + [dbName], + ); + if (rowCount === 0) { + // dbName is validated upstream (shell slug: [a-z0-9_]+), safe to interpolate. + await client.query(`CREATE DATABASE "${dbName}"`); + console.error(`create-database: created ${dbName}`); + } else { + console.error(`create-database: ${dbName} already exists`); + } +} finally { + await client.end(); +} diff --git a/scripts/link-worktree-envs.sh b/scripts/link-worktree-envs.sh new file mode 100755 index 0000000000..95442ef970 --- /dev/null +++ b/scripts/link-worktree-envs.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Symlink every .env* file from the main worktree into a target worktree. +# +# Usage: +# scripts/link-worktree-envs.sh [target-worktree-path] +# +# If no target is given, uses the current working directory. The script is +# safe to re-run — it overwrites existing symlinks with `ln -sfn`. +# +# Also invoked automatically by .githooks/post-checkout after +# `git worktree add`; run it manually to backfill worktrees created before +# the hook was installed. +set -euo pipefail + +target="${1:-$(pwd)}" + +# The first line of `git worktree list --porcelain` is the main worktree. +main=$(git worktree list --porcelain | awk '$1=="worktree"{print $2; exit}') + +if [[ -z "$main" ]]; then + echo "error: could not resolve main worktree" >&2 + exit 1 +fi + +# Resolve target to absolute path +target=$(cd "$target" && pwd) + +if [[ "$target" == "$main" ]]; then + echo "skip: target is the main worktree ($main)" >&2 + exit 0 +fi + +cd "$main" + +# Optional: per-worktree DATABASE_URL override. When set, any .env file +# whose source contains DATABASE_URL= will be copied (not symlinked) and +# rewritten to point at the isolated URL. All other .env files stay as +# symlinks so API keys and such still auto-propagate. +iso_url="${ISOLATED_DATABASE_URL:-}" + +linked=0 +rewritten=0 +while IFS= read -r -d '' env; do + rel="${env#./}" + src="$main/$rel" + dest="$target/$rel" + mkdir -p "$(dirname "$dest")" + + if [[ -n "$iso_url" ]] && grep -qE '^DATABASE_URL=' "$src"; then + # Copy + rewrite DATABASE_URL (and DIRECT_URL, if present). + rm -f "$dest" + awk -v u="$iso_url" ' + /^DATABASE_URL=/ { print "DATABASE_URL=" u; next } + /^DIRECT_URL=/ { print "DIRECT_URL=" u; next } + { print } + ' "$src" > "$dest" + rewritten=$((rewritten + 1)) + else + ln -sfn "$src" "$dest" + linked=$((linked + 1)) + fi +done < <( + find . -maxdepth 3 -name ".env*" \ + ! -name "*.example" \ + -not -path "./node_modules/*" \ + -not -path "./.worktrees/*" \ + -not -path "./.git/*" \ + -print0 +) + +if [[ -n "$iso_url" ]]; then + echo "linked $linked env file(s), rewrote DATABASE_URL in $rewritten file(s) -> isolated URL" >&2 +else + echo "linked $linked env file(s) from $main into $target" >&2 +fi diff --git a/scripts/setup-worktree-db.sh b/scripts/setup-worktree-db.sh new file mode 100755 index 0000000000..df7665f8db --- /dev/null +++ b/scripts/setup-worktree-db.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Create a per-worktree Postgres database and print its connection URL on +# stdout. Uses the connection info from the main worktree's packages/db/.env. +# +# Usage: +# scripts/setup-worktree-db.sh [target-worktree-path] +# +# Output (stdout): the isolated DATABASE_URL (postgresql://...) +# Logs go to stderr. +# +# Requires `psql` on PATH. +set -euo pipefail + +# Resolve paths from the script's own location so this works regardless of +# which worktree holds the script file (main vs. any worktree vs. the +# hook's own copy at core.hooksPath). +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +target="${1:-$(pwd)}" +target=$(cd "$target" && pwd) + +main=$(git worktree list --porcelain | awk '$1=="worktree"{print $2; exit}') +if [[ "$target" == "$main" ]]; then + echo "setup-worktree-db: target is the main worktree — skipping" >&2 + exit 0 +fi + +# Slug: basename of the worktree dir, lowercased, hyphens → underscores, +# anything non-[a-z0-9_] dropped. Postgres identifiers don't tolerate hyphens. +raw=$(basename "$target") +slug=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr '-' '_' | tr -cd 'a-z0-9_') +db_name="compdev_${slug}" + +# Source connection info from main's packages/db/.env. +src_env="$main/packages/db/.env" +if [[ ! -f "$src_env" ]]; then + echo "setup-worktree-db: $src_env not found — can't derive connection info" >&2 + exit 1 +fi + +src_url=$(grep -E '^DATABASE_URL=' "$src_env" | head -n1 | cut -d= -f2- | tr -d '"' | tr -d "'") +if [[ -z "$src_url" ]]; then + echo "setup-worktree-db: DATABASE_URL missing in $src_env" >&2 + exit 1 +fi + +# Replace the database-name segment in the URL (path after host:port, before ?). +# postgresql://user:pass@host:port/dbname?params → .../?params +iso_url=$(printf '%s' "$src_url" \ + | perl -pe 's{^(postgres(?:ql)?://[^/]+/)[^?]*(\?.*)?$}{$1'"$db_name"'$2};') + +if [[ -z "$iso_url" || "$iso_url" == "$src_url" ]]; then + echo "setup-worktree-db: failed to rewrite DATABASE_URL (got: $iso_url)" >&2 + exit 1 +fi + +# Connect to the `postgres` maintenance database (same host/creds) to run +# CREATE DATABASE. Build the maintenance URL the same way. +mgmt_url=$(printf '%s' "$src_url" \ + | perl -pe 's{^(postgres(?:ql)?://[^/]+/)[^?]*(\?.*)?$}{${1}postgres$2};') + +# Use the main worktree's node_modules (which always has `pg` via +# @prisma/adapter-pg) so we don't require psql on PATH. +if [[ ! -d "$main/node_modules/pg" ]]; then + echo "setup-worktree-db: $main/node_modules/pg missing — run bun install in the main worktree first" >&2 + exit 1 +fi + +(cd "$main" && bun run "$script_dir/create-database.mjs" "$mgmt_url" "$db_name" >&2) + +# stdout: the isolated URL for callers to consume +printf '%s\n' "$iso_url" diff --git a/scripts/setup-worktree.sh b/scripts/setup-worktree.sh new file mode 100755 index 0000000000..273955b2af --- /dev/null +++ b/scripts/setup-worktree.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Prepare a freshly created worktree: install deps, generate Prisma client, +# and optionally run the full build. Assumes `.env*` files are already +# linked in (the post-checkout hook does that before calling this script). +# +# Usage: +# scripts/setup-worktree.sh [target-worktree-path] +# +# Environment toggles: +# SKIP_WORKTREE_SETUP=1 — skip everything (just link envs) +# SETUP_WORKTREE_WITH_BUILD=1 — also run `bun run build` (slow, minutes; +# unnecessary for `dev`, tests, typechecks) +# +# Defaults to the current working directory. +set -euo pipefail + +if [[ "${SKIP_WORKTREE_SETUP:-}" == "1" ]]; then + echo "setup-worktree: SKIP_WORKTREE_SETUP=1 — skipping install + build" >&2 + exit 0 +fi + +target="${1:-$(pwd)}" +target=$(cd "$target" && pwd) + +# Refuse to run on the main worktree — it already has its own lifecycle. +main=$(git worktree list --porcelain | awk '$1=="worktree"{print $2; exit}') +if [[ "$target" == "$main" ]]; then + echo "setup-worktree: target is the main worktree — skipping" >&2 + exit 0 +fi + +cd "$target" + +echo "▸ Installing dependencies (bun install) in $target" +bun install + +echo "▸ Applying Prisma migrations (cd packages/db && bun run db:migrate)" +(cd packages/db && bun run db:migrate) + +echo "▸ Generating Prisma client (bun run db:generate)" +bun run db:generate + +if [[ "${SETUP_WORKTREE_WITH_BUILD:-}" == "1" ]]; then + echo "▸ Building all packages (bun run build)" + bun run build +else + echo "▸ Skipping build — set SETUP_WORKTREE_WITH_BUILD=1 to include it" +fi + +echo "✓ Worktree setup complete: $target"