# Build your own Linear

> Source: https://buildyourown.software/like/linear
> Category: Issue tracking. Original vendor: Linear Orbit, Inc.. Last updated 2026-09-10.
> This file is written for AI coding agents. It contains the context, the build prompt, and the test plan for a self-built replacement of Linear.

## How to use this file

1. Read the whole file first.
2. Build the app described in "Build prompt". Treat its acceptance criteria as the definition of done.
3. When the build works, follow "Test prompt" to write and run automated tests.
4. Ask the user before changing the data model, the stack, or the non-goals.
5. The app must run the "one box" way described in "Default deployment" below: one process, SQLite, local disk, no hosted services unless the build prompt names one.

## What Linear does

Linear is an issue tracker for software teams. Every issue gets an identifier like ENG-123, a status, a priority, an assignee, and labels. Issues roll up into projects and get scheduled into cycles, which are two-week sprints that create themselves and carry unfinished work forward.

The thing people love is the speed. Every screen opens instantly, every action has a keyboard shortcut, and cmd+k does anything. Linear also links pull requests to issues by branch name and moves them to Done when the PR merges, and it posts to Slack when things change.

Around that core Linear sells roadmaps, initiatives, customer requests, SLAs, AI triage, and analytics. A company with three engineering teams uses the issue list, the board, cycles, and the GitHub hook. That part is a well-scoped CRUD app with one webhook and a lot of attention to keyboard handling.

## What it costs

Typical spend: $1,920 per year. Linear Business for 10 people: $16 × 10 seats × 12 months, billed yearly. Basic for the same team is $1,200 but caps you at 5 teams and has no private teams.

- Free: $0 unlimited members (The 250-issue cap applies to the whole workspace across every team. Once you hit it you can't create new issues until you pay.)
- Basic: $10 per user / month, billed yearly (Monthly billing exists but the page only shows the yearly price. Verify the monthly rate before you budget.)
- Business: $16 per user / month, billed yearly (Coding sessions and Loops also need AI credits, which are a separate add-on.)
- Enterprise: Custom per year, annual billing only (Contact sales. No list price published.)

Prices checked 2026-09-10 at https://linear.app/pricing.

## Features and whether to build them

- [build] Issues with team identifiers: Every issue is ENG-123 or DES-45. Title, markdown description, creator, timestamps. The identifier is the whole product. People say 'ENG-123' in standup and in branch names.
- [build] Status workflow: Backlog, Todo, In Progress, Done, Canceled, editable per team. Five statuses cover almost every team. A per-team table with a type column lets you rename them later.
- [build] Priority, assignee, labels: Urgent to low priority, one assignee, many labels with colors. This is how you filter and sort. Each one is a column or a join table.
- [build] Projects: A named container for issues with a lead, a target date, and a progress bar. You need some way to group 'the checkout rewrite' across teams. A project is a row plus a count.
- [build] Cycles: Two-week sprints that start on a set weekday, auto-create the next one, and roll unfinished issues forward. The auto-rollover is what keeps a sprint tracker honest. It's one daily job in the same process.
- [build] Command palette and keyboard navigation: cmd+k for any action. j/k to move, c to create, s for status, a to assign, and so on. This is why people pick Linear over Jira. It's front-end work, and you control all of it.
- [build] Optimistic updates and realtime sync: Changes apply instantly and show up on teammates' screens without a refresh. Optimistic mutations are the second half of the speed feeling. Realtime is an in-process event bus pushed over SSE.
- [build] Markdown descriptions and comments: Markdown everywhere, @mentions, ENG-123 references that auto-link, image uploads. A markdown renderer with a sanitizer and an upload bucket. A day of work.
- [build] GitHub integration: Link a PR to an issue from the branch name or 'Fixes ENG-123' in the PR body. Move to In Progress on open, Done on merge. This is the one integration that removes real manual work. One webhook endpoint and a regex.
- [build] Slack notifications: Post to a channel when an issue is created, changes status, gets assigned, or gets a comment. An incoming webhook URL per team. Half a day including the message formatting.
- [maybe] Triage inbox: A holding area for issues from outside the team before they enter the backlog. Useful if support or other teams file issues into yours. Otherwise it's just Backlog with a filter.
- [skip] Roadmaps, initiatives, SLAs: Timeline views across projects, company-level goals, and time-to-resolve targets. Companies with three teams plan in a doc. Add a timeline view later if anyone asks.
- [skip] Customer requests, Asks, AI triage, Insights: Zendesk and Intercom sync, Slack-to-issue forms, AI-suggested labels and assignees, analytics dashboards. These are the Business tier upsells. A Slack slash command that creates an issue covers Asks in an afternoon.

## Under the hood

### Data model

- Team: id, name, key (ENG, DES), issue_counter, timezone, cycle_length_weeks, cycle_start_weekday, cycles_enabled, slack_webhook_url. issue_counter is incremented in the same transaction that inserts the issue. Never read then write.
- Issue: id, team_id, number, identifier (ENG-123), title, description (markdown), state_id, priority (0 to 4), assignee_id, creator_id, project_id, cycle_id, parent_id, due_date, sort_order, completed_at, canceled_at. Labels live in an issue_labels join table. Unique on (team_id, number). Search is an FTS5 table kept in sync by triggers.
- Workflow state: id, team_id, name, type (backlog | unstarted | started | completed | canceled), color, position. Seed five per team. The type column is what the GitHub hook and cycle rollover key off, so renaming is safe.
- Project: id, name, description, lead_id, status (planned | in_progress | completed | canceled), target_date, team_ids
- Cycle: id, team_id, number, starts_at, ends_at, completed_scope, total_scope. starts_at is midnight in the team's timezone stored as UTC. Only one cycle per team can be active at a time.
- Pull request: id, issue_id, repo, number, title, url, branch, author, state (open | merged | closed), merged_at. Keyed on (repo, number) so a webhook replay updates instead of duplicating.

### Key flows

**Create an issue**
1. Press c anywhere. A modal opens with title, markdown description, and pickers for team, status, priority, assignee, labels, project, cycle.
2. Submit. The client inserts a placeholder row with a temporary id and closes the modal in under 50ms.
3. The server increments team.issue_counter and inserts the issue in one transaction, returning ENG-124.
4. The client swaps the placeholder for the real row. On error it removes the row and shows a toast with a retry.
5. Subscribers (creator, assignee, mentioned users) get an inbox notification. Every open browser gets the row over SSE. The team's Slack channel gets a message.

**Change anything from the keyboard**
1. On a list, j/k moves the cursor, x selects, enter opens.
2. s opens a status picker, a assignee, p priority, l labels, shift+p project, shift+c cycle. Each picker is a filtered list you type into.
3. Selecting applies the change to every selected issue optimistically and writes one history row per issue.
4. cmd+k opens the palette with the same actions plus navigation and issue search by identifier or title.

**GitHub PR links an issue**
1. GitHub sends a pull_request webhook (or, on a laptop, the Simulate webhook button posts a fixture). The server verifies the HMAC signature.
2. It scans the branch name, PR title, and body for identifiers like ENG-123 and for magic words like 'Fixes ENG-123'.
3. For each match it upserts a pull_requests row on (repo, number) and links it to the issue.
4. On opened: if the issue's state type is backlog or unstarted, move it to the team's started state.
5. On closed with merged = true: move the issue to the completed state, stamp completed_at, notify Slack.

**Cycle rollover**
1. An in-process croner job runs every hour, claims the cycles row in job_locks, and checks each team with cycles enabled.
2. If the active cycle's ends_at is in the past, mark it complete and record completed_scope and total_scope.
3. Create the next cycle starting at 00:00 in the team's timezone on the next start weekday, if one doesn't exist.
4. Move every issue from the old cycle whose state type is not completed or canceled into the new cycle. Each team commits in its own transaction, so a restart mid-run picks up where it stopped.

**Notify Slack**
1. Every mutation on an issue emits an event: created, state_changed, assigned, commented.
2. A job that runs every 30 seconds in the same process collapses events for the same issue into one message.
3. It posts a Block Kit message to the team's webhook URL: identifier, title, who did what, a link back.
4. Failures are logged and retried three times. They never block the mutation.

### Integrations

- SQLite on local disk (required): The database, FTS5 search, and the job_locks table. One file at ./data/app.db, backed up with sqlite3 .backup.
- Local disk for attachments (required): Images and files under ./data/files, served through the app's own signed URLs.
- Self-issued sessions (required): First-run admin, invite links for teammates, signed httpOnly cookies. Google sign-in is an optional add-on.
- Server-sent events (required): An in-memory bus pushes changes to open clients so lists stay current.
- croner in-process scheduler (required): Cycle rollover, the Slack outbox, the email digest, and cleanup, guarded by job_locks.
- GitHub webhook (required): PR events in, issue status changes out. Needs a public URL; the Simulate webhook button covers a laptop.
- Slack incoming webhook (optional): Channel notifications per team. Just a URL, and optional.
- SMTP relay (optional; outbox in dev) (optional): Invite emails and a daily digest. Without SMTP_URL, mail goes to ./data/outbox.
- Caddy (required): TLS on the VPS. It's the second container in docker compose and gets its own certificate.

## Where to build it

- Claude Code (https://claude.com/claude-code): Best fit. The identifier transaction, webhook signature check, and keyboard layer all need an agent that runs the app and tests as it goes.
- Replit (https://replit.com): Fine for building and trying it. The Replit URL even works as the GitHub webhook target while you test. Then move the folder to a VPS and run docker compose.
- Lovable (https://lovable.dev): Skip it, or use it for the issue list and board UI only. Lovable is built around Supabase, and this app runs on SQLite in one process.
- ChatGPT / Codex (https://chatgpt.com): Use ChatGPT to set your team keys, statuses, and cycle schedule first, then hand the spec to Codex.

---

## Default deployment

Also at https://buildyourown.software/deploy.md. The build prompt below assumes this.

Every web app on buildyourown.software is written to run the same way. Learn it once and every tool's build prompt makes sense.

## The shape

- **One process.** Node 22 running the app. A second process only when the tool genuinely needs one (a job worker, a probe runner). Never a serverless function.
- **SQLite.** `better-sqlite3` with Drizzle, WAL mode, file at `./data/app.db`. Migrations checked into the repo and run on startup. Keep the schema portable so `DATABASE_URL=postgres://...` works later, but don't build for it now.
- **Local disk for files.** Uploads live under `./data/files/<table>/<id>/`. The app serves them through an authenticated route with short-lived signed URLs it mints itself. No bucket.
- **In-process scheduler.** `croner` inside the app process for reminders, retention, digests, and polling. A row in a `job_locks` table stops two instances from running the same job.
- **Sessions the app issues itself.** On first run the app creates an admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD` (or prompts in the browser if unset). Teammates join through invite links. Sessions are signed, httpOnly cookies. Google sign-in is an optional add-on, never a requirement.
- **Realtime from the process.** Server-sent events fed by an in-memory event bus. No database change feeds, no websocket service.
- **Email through any SMTP server.** `nodemailer` with `SMTP_URL`. Without it, mail is written to `./data/outbox/*.eml` and printed to the log, so every flow works on a laptop with no account. When you go public, point `SMTP_URL` at the one relay you choose. Sending straight from a VPS lands in spam, so this is the one hosted service most tools keep.
- **UTC in the database,** local time in the UI, `date-fns-tz` for every conversion.

## Running it

- **Laptop:** `pnpm install && pnpm dev`. That's the whole install. No env vars are required; every setting has a default that works locally.
- **Public:** a VPS with 1 CPU and 1 GB RAM, one DNS record, and `docker compose up -d`. The compose file has two services: `app` and `caddy`. Caddy terminates TLS with a certificate it gets on its own. `./data` is a named volume.
- **Backups:** `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db` and prunes to the last 14. A `backup` service in compose runs it nightly. Restoring is copying one file.
- **Updates:** `git pull && docker compose up -d --build`. Migrations run on boot.

## What this rules out

No Vercel, Supabase, Neon, Clerk, Auth0, R2, S3, Fly, Cloudflare Workers, Upstash, or any service that needs an account to run the app. If a tool can't avoid an outside service (a calendar OAuth app, a GitHub webhook, an SMTP relay, a second probe location), the build prompt says so in one line and makes it optional or last.

## Acceptance criteria every tool inherits

- A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
- `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
- Killing the process mid-job and restarting it doesn't double-run or lose the job.
- `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
- Every outbound email in dev shows up in `./data/outbox`.

---

## Build prompt

Also available on its own at https://buildyourown.software/like/linear/build-prompt.md

# Build a fast issue tracker (replacing Linear for one company)

You are building an issue tracker for one company with two to six teams and up to 50 people. It replaces Linear for teams that use issues, statuses, priorities, labels, projects, cycles, the GitHub PR link, and Slack notifications. Build it end to end. Speed and keyboard handling matter more than features. Every list should open in under 100ms from cache and every mutation should apply on screen before the server responds.

## Stack

- One Node 22 process running Next.js (App Router) with TypeScript and Tailwind. No serverless functions and no second process.
- SQLite through `better-sqlite3` with Drizzle, WAL mode, file at `./data/app.db`. Migrations are checked into the repo and run on startup. Keep the schema portable so Postgres works later, but don't build for it now.
- Files on local disk under `./data/files/<table>/<id>/`, served by an authenticated route with short-lived signed URLs the app mints itself.
- `croner` inside the app process for cycle rollover, the Slack outbox, the email digest, and cleanup. A `job_locks` table stops two instances from running the same job.
- Sessions the app issues itself as signed httpOnly cookies. First run creates an admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD`, or prompts in the browser if they're unset. Teammates join through invite links. Google sign-in is an optional add-on, never a requirement.
- Realtime: server-sent events at `/api/events`, fed by an in-memory event bus.
- Email: `nodemailer` with `SMTP_URL`. Without it, every message is written to `./data/outbox/*.eml` and printed to the log.
- UTC in the database as ISO 8601 strings, local time in the UI, `date-fns-tz` for every conversion. Never do timezone math by hand.
- Client state: TanStack Query with optimistic mutations. Markdown: `react-markdown` with `remark-gfm` and `rehype-sanitize`. Palette and pickers: `cmdk`. Keyboard: one `useHotkeys` layer with scopes (global, list, issue, modal).
- Outside services, each with a laptop fallback: a GitHub webhook (a PR can't link itself without one; the Simulate webhook button in Settings posts a signed fixture instead), a Slack incoming webhook URL per team (optional), and an SMTP relay once you go public (the outbox covers dev).

Full profile: https://buildyourown.software/deploy.md

## Running it

- Laptop: `pnpm install && pnpm dev`. No env vars. The app creates `./data` on first start and the first browser visit creates the admin.
- Public: a VPS with 1 CPU and 1 GB RAM, one A record, and `docker compose up -d`. The compose file has `app` and `caddy` (plus `backup`). Caddy terminates TLS with a certificate it gets on its own. `./data` is a named volume.
- Backups: `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db` and prunes to the last 14. The `backup` service runs it nightly. Restoring is copying one file back and restarting.
- Updates: `git pull && docker compose up -d --build`. Migrations run on boot.
- Secrets: `APP_SECRET` signs sessions and file URLs and encrypts the Slack webhook URL. If unset, generate one on first run and store it in `./data/app-secret`. Same for `GITHUB_WEBHOOK_SECRET`.

## Data model

Every table has `id` (text, uuid), `created_at`, `updated_at` (text, ISO 8601 UTC like `2026-09-10T14:03:00.000Z`). SQLite types only: text, integer, real. Booleans are integer 0 or 1. Dates without a time are `YYYY-MM-DD` text. JSON columns are text holding JSON. What changes from a hosted-database draft: no `timestamptz` (ISO strings sort correctly as text, so range queries still work), no `jsonb` (parse `slack_outbox.events` in code), no `tsvector` (an FTS5 virtual table instead), and no generated column across tables (set `identifier` at insert). There is one workspace per deployment, so there is no `workspace_id`.

- `users`: `email` (unique), `name`, `avatar_url`, `role` (`admin` | `member`), `password_hash` (nullable when Google-only), `timezone` (IANA), `slack_user_id` (nullable), `digest_enabled` (integer, default 0), `active` (integer).
- `sessions`: `token_hash`, `user_id`, `expires_at`. `invites`: `email`, `token_hash`, `role`, `invited_by`, `expires_at`, `accepted_at` (nullable).
- `teams`: `name`, `key` (unique, 2 to 5 uppercase letters, e.g. `ENG`), `issue_counter` (integer, default 0), `timezone` (IANA, default `America/New_York`), `cycles_enabled`, `cycle_length_weeks` (default 2), `cycle_start_weekday` (0 = Sunday to 6 = Saturday, default 1), `cycle_cooldown_weeks` (default 0), `slack_webhook_url` (nullable, encrypted with `APP_SECRET`), `slack_channel_name` (nullable), `private` (integer).
- `team_members`: `team_id`, `user_id`. Unique on the pair.
- `workflow_states`: `team_id`, `name`, `type` (`backlog` | `unstarted` | `started` | `completed` | `canceled`), `color`, `position` (integer). Seed each team with Backlog (backlog), Todo (unstarted), In Progress (started), Done (completed), Canceled (canceled). Every team must have at least one state of each type.
- `issues`: `team_id`, `number` (integer), `identifier` (text, `ENG-123`, unique, set at insert), `title`, `description` (markdown, nullable), `state_id`, `priority` (integer: 0 none, 1 urgent, 2 high, 3 medium, 4 low), `assignee_id` (nullable), `creator_id`, `project_id` (nullable), `cycle_id` (nullable), `parent_id` (nullable, self FK), `due_date` (date text, nullable), `estimate` (integer, nullable), `sort_order` (real, for manual ordering on boards), `started_at`, `completed_at`, `canceled_at` (all nullable ISO text). Unique on (`team_id`, `number`).
- `issues_fts`: an FTS5 virtual table over `identifier`, `title`, `description`, kept in sync by triggers on insert, update, and delete of `issues`.
- `labels`: `name`, `color`, `team_id` (nullable; null means workspace-wide). Unique on (`team_id`, `name`).
- `issue_labels`: `issue_id`, `label_id`. Unique on the pair.
- `projects`: `name`, `slug` (unique), `description` (markdown), `lead_id` (nullable), `status` (`planned` | `in_progress` | `paused` | `completed` | `canceled`), `target_date` (date text, nullable), `color`.
- `project_teams`: `project_id`, `team_id`.
- `cycles`: `team_id`, `number` (integer), `name` (nullable), `starts_at` (ISO UTC text), `ends_at` (ISO UTC text), `completed_at` (nullable), `total_scope` (integer, nullable), `completed_scope` (integer, nullable). Unique on (`team_id`, `number`). At most one cycle per team where `starts_at <= now < ends_at`, compared as ISO strings.
- `comments`: `issue_id`, `author_id`, `body` (markdown), `parent_id` (nullable, one level of threading), `edited_at` (nullable).
- `issue_history`: `issue_id`, `actor_id` (nullable for system), `field` (`state` | `assignee` | `priority` | `labels` | `project` | `cycle` | `title` | `due_date` | `parent`), `from_value` (text), `to_value` (text), `source` (`user` | `github` | `cycle_rollover` | `import`).
- `pull_requests`: `issue_id`, `repo` (`owner/name`), `number` (integer), `title`, `url`, `branch`, `author_login`, `state` (`open` | `merged` | `closed`), `draft` (integer), `merged_at` (nullable). Unique on (`repo`, `number`, `issue_id`).
- `github_deliveries`: `delivery_id` (unique), `received_at`. Rows older than 24 hours are deleted by the cleanup job.
- `subscriptions`: `issue_id`, `user_id`. Unique on the pair.
- `notifications`: `user_id`, `issue_id`, `kind` (`assigned` | `mentioned` | `commented` | `state_changed` | `pr_merged`), `actor_id`, `read_at` (nullable), `snoozed_until` (nullable).
- `attachments`: `issue_id` (nullable), `comment_id` (nullable), `uploader_id`, `path` (relative to `./data/files`), `filename`, `size_bytes`, `content_type`.
- `slack_outbox`: `issue_id`, `events` (text, JSON array), `send_after`, `sent_at` (nullable), `attempts` (integer), `error` (nullable).
- `job_locks`: `name` (primary key), `locked_by`, `locked_at`, `expires_at`.

### Identifier assignment

Issue creation runs in one `better-sqlite3` transaction, which is synchronous and holds SQLite's single write lock:

```ts
const create = db.transaction((teamId, fields) => {
  const { issue_counter } = db.prepare(
    "UPDATE teams SET issue_counter = issue_counter + 1 WHERE id = ? RETURNING issue_counter"
  ).get(teamId);
  return db.prepare("INSERT INTO issues (team_id, number, identifier, ...) VALUES (?, ?, ?, ...) RETURNING *")
    .get(teamId, issue_counter, `${key}-${issue_counter}`, ...);
});
```

Never read the counter, compute in application code, then write. Twenty concurrent creates must produce ENG-124 through ENG-143, never a duplicate and never a gap. Moving an issue to another team assigns a new number from that team and records the old identifier in `issue_history` so old links can redirect.

## Screens

Left sidebar: Inbox, My Issues, then each team the user belongs to with Issues, Active cycle, Backlog, Projects, Cycles underneath. Bottom: Settings. Top bar: search input that opens the command palette, and a "New issue" button that does what `c` does.

1. **First run and sign in (`/setup`, `/login`, `/invite/[token]`).** With no users in the database every route redirects to `/setup`, which creates the admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD` when set and otherwise shows a form for name, email, and password. The admin is prompted to create the first team. `/login` is email and password. An invite link lets a teammate set a name and password; links expire after 7 days. If `ALLOWED_EMAIL_DOMAINS` is set, invites to other domains are rejected. Google sign-in appears only when `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set, and only for an email that already has an account or a pending invite.
2. **Inbox (`/inbox`).** Unread notifications grouped by issue, newest first. Each row shows the actor, the kind, and the issue title. Enter opens the issue in a split pane with the inbox still on the left. `e` archives (marks read), `h` snoozes until tomorrow 9am in the user's timezone. Unread count in the sidebar.
3. **My Issues (`/my-issues`).** Issues assigned to me, grouped by state, excluding completed and canceled by default with a toggle to show them. Also tabs for Created by me and Subscribed.
4. **Team issues (`/[teamKey]/issues`).** All non-backlog issues in the team as a list grouped by state. Each row: priority icon, identifier, title, labels, project, cycle, assignee avatar, updated time. Toggle to a board with one column per state, drag and drop between columns and within a column to set `sort_order`. Display options: group by state, assignee, priority, project, or label; sort by priority, updated, created, due date; filter by any field. Persist display options per view per user in `localStorage`.
5. **Backlog (`/[teamKey]/backlog`).** Same list, only backlog-type states.
6. **Active cycle (`/[teamKey]/active`).** Issues in the current cycle with a header showing cycle number, date range in the team's timezone, days left, and a progress bar of completed vs total issues.
7. **Cycles (`/[teamKey]/cycles`).** Past, current, and upcoming cycles with scope and completed counts. Clicking one shows its issues.
8. **Projects (`/[teamKey]/projects` and `/projects/[slug]`).** List of projects with lead, status, target date, progress. Project page shows description, issues grouped by team and state, and an activity feed.
9. **Issue (`/issue/[identifier]`, e.g. `/issue/ENG-123`).** Also reachable as `/[teamKey]/issues/[identifier]`; redirect to the canonical URL. Left: title (editable inline), description (markdown, click to edit, cmd+enter to save), sub-issues list, linked pull requests with state badges, attachments, then the activity feed of history rows and comments interleaved by time. Right sidebar: state, priority, assignee, labels, project, cycle, due date, estimate, parent, subscribers, created and updated times. Every field is a picker that opens with its shortcut.
10. **Settings (`/settings`).** Members (invite by email: the app creates the link, emails it, and shows it to copy; deactivate; set admin). Teams (create, rename, change key with a warning that identifiers change, edit workflow states, cycle settings, timezone, Slack webhook URL). Labels. GitHub (the webhook URL and secret to paste into GitHub, the last 50 deliveries with status, and a Simulate webhook button). Email (whether mail is going to SMTP or the outbox). Import from Linear CSV. Export CSV per team.

## Keyboard and command palette

One global hotkey layer with scopes. When a modal or picker is open, list shortcuts are inactive. Two-key sequences like `g i` wait up to 800ms for the second key.

Global:

- `cmd+k` opens the command palette
- `c` opens the new issue modal
- `/` focuses search
- `?` shows the shortcut sheet
- `g i` inbox, `g m` my issues, `g b` backlog of the current team, `g a` active cycle, `g p` projects
- `esc` closes whatever is open, in order: picker, then modal, then side pane

List and board:

- `j` / `k` and arrow keys move the cursor. The cursor is a visible row highlight and survives refetches.
- `x` toggles selection on the cursor row. `shift+j` / `shift+k` extend the selection. `cmd+a` selects all visible rows.
- `enter` or `o` opens the issue. `space` peeks it in a side pane without leaving the list.
- `s` state picker, `a` assignee picker, `i` assigns to me, `p` priority picker, `l` labels picker, `shift+p` project picker, `shift+c` cycle picker, `d` due date picker
- `cmd+shift+,` copies the identifier. `cmd+shift+.` copies the URL. `cmd+c` copies the identifier and title as a markdown link.
- `backspace` deletes selected issues after a confirm dialog. `cmd+z` undoes the last mutation.
- On the board, `h` / `l` move the cursor between columns and `cmd+shift+arrow` moves the selected issue to the next or previous state.

Issue page:

- Same field shortcuts as the list, applied to the open issue
- `e` edits the description, `r` focuses the comment box, `cmd+enter` submits either
- `shift+s` toggles subscription
- `[` / `]` go to the previous and next issue in the list you came from
- `cmd+shift+.` copies the git branch name (see the GitHub section)

Command palette (`cmd+k`):

- A `cmdk` list with sections: Issues, Actions, Navigation, People, Projects, Cycles.
- Typing an identifier like `eng-12` or `ENG-12` jumps to it on enter.
- Typing anything else searches issues by title through `issues_fts` with prefix matching on the last word and shows the top 10.
- Actions include every list shortcut, navigation, "create issue", "toggle theme", and "copy branch name". They apply to the current selection or the open issue. Show the shortcut key next to each action.
- If the palette is opened with a selection, put "Change state", "Assign", and "Set priority" at the top.

Pickers (state, assignee, priority, labels, project, cycle):

- Open instantly with the filter input focused. Arrow keys or typed prefix to move, enter to apply, esc to cancel.
- Show the current value checked. The labels picker is multi-select with space to toggle.
- Numbers 0 to 4 in the priority picker set priority directly. The assignee picker lists team members first, then everyone else.
- Applying to a multi-selection shows "3 issues" in the picker header.

## Optimistic updates and realtime

- Every mutation applies to the client cache first, then sends the request with a client-generated `mutation_id`. On failure, roll back and show a toast with the error and a Retry button.
- Issue creation inserts a placeholder with a temporary id and identifier `ENG-?`; replace it when the server returns. The create modal closes immediately.
- Bulk mutations send one request with an array of issue ids and return the updated rows.
- Realtime: `GET /api/events` is an SSE stream. After every commit that touches `issues`, `comments`, `issue_history`, or `notifications`, the server publishes `{ id, table, op, rows, actor_id, mutation_id }` on an in-memory bus, and the SSE route forwards it to every open connection that can see the team. The server keeps the last 500 events in memory; a client reconnects with `Last-Event-ID` and gets the gap replayed, or refetches if the gap is too old. On an event whose `mutation_id` matches a pending optimistic update, ignore it. Otherwise patch the cache in place.
- `cmd+z` undoes the last mutation in the current session by applying the inverse (state back, assignee back, and so on). Keep a stack of the last 20.
- Lists render from cache instantly on navigation; refetch in the background. Paginate at 200 issues per list with virtualization (`@tanstack/react-virtual`).

## Markdown, mentions, and references

- Descriptions and comments are markdown. Render with GFM (tables, task lists, strikethrough) and syntax-highlighted code blocks. Sanitize output.
- `@name` in the editor opens a people picker and inserts `@[Dan Wolchonok](user:uuid)`. Rendering shows the name; the mentioned user gets subscribed and a `mentioned` notification.
- Bare identifiers like `ENG-123` in any markdown auto-link to the issue and show its title on hover. Do not link inside code blocks or code spans.
- Pasting an image posts it to `/api/upload`, which writes it to `./data/files/attachments/<id>/<filename>` and inserts `![filename](attachment:<id>)`. The renderer swaps `attachment:<id>` for a fresh signed URL from `/api/files/:id`. Drag and drop files onto the description or comment box does the same. Max 25MB per file.
- Comments support one level of replies. Editing a comment stamps `edited_at` and shows "(edited)".
- Task list checkboxes in the description are toggleable in the rendered view and save on click.

## Subscriptions, notifications, and email

- Auto-subscribe: the creator, anyone assigned, anyone who comments, anyone mentioned.
- Create a notification for each subscriber other than the actor on: assignment (to the assignee), comment, mention, state change to completed or canceled, PR merged.
- Coalesce: if a user has an unread notification of the same kind on the same issue, update it instead of adding a second one.
- Email through `nodemailer`: invite links, and a daily digest of unread notifications for users with `digest_enabled` (the `email-digest` job, 08:00 in each user's timezone). With `SMTP_URL` unset, write each message to `./data/outbox/<timestamp>-<to>.eml` and log the invite link, so a laptop needs no mail account.

## Jobs and the lock table

`croner` schedules these inside the app process: `cycles` hourly, `slack-outbox` every 30 seconds, `email-digest` hourly (sends to users whose local time is 08:00), `cleanup` daily (delivery ids older than 24 hours, expired sessions and invites, orphaned files). Before a job runs it claims its `job_locks` row in one statement: insert, or update where `expires_at < now`, setting `locked_by` to the process id plus a random suffix and `expires_at` to now plus the job's timeout. If the statement changes no row, skip this tick. Release the lock in `finally`. Every job commits per team (or per outbox row) in its own transaction and checks state before acting, so a process killed mid-job leaves at most one partial unit and the next tick finishes it without repeating work. Run every job once at startup too, so a laptop closed over the weekend catches up.

## Cycles (job `cycles`, hourly)

For each team with `cycles_enabled`:

1. Compute "now" in the team's timezone with `date-fns-tz`.
2. If there is no active or upcoming cycle, create cycle 1 starting at 00:00 on the next `cycle_start_weekday` in the team's timezone, ending `cycle_length_weeks` later at 00:00. Store both as UTC ISO strings. Always keep one upcoming cycle after the active one.
3. If the active cycle's `ends_at <= now`: set `completed_at`, compute `total_scope` (issues in the cycle) and `completed_scope` (issues in a completed state), then move every issue whose state type is not `completed` or `canceled` into the next cycle. Write an `issue_history` row per moved issue with `source = cycle_rollover`. If `cycle_cooldown_weeks > 0`, the next cycle starts after the cooldown. Do all of this for one team in one transaction.
4. Handle DST: a two-week cycle starting Monday 00:00 in `America/Los_Angeles` that crosses a DST change still ends on a Monday at 00:00 local, so its UTC length is 13 days 23 hours or 14 days 1 hour. Compute `ends_at` by adding weeks in local time, then converting to UTC.

Moving an issue to a completed state while it's in a cycle keeps it in that cycle. Moving an issue to a cycle that has already completed is not allowed.

## GitHub integration (`POST /api/webhooks/github`)

This is the one outside service the tracker can't do without: a PR can't link itself. Use a plain repository or organization webhook (GitHub Settings > Webhooks), not a GitHub App. Point it at `https://<your-domain>/api/webhooks/github`, content type JSON, events `pull_request` and `push`, secret `GITHUB_WEBHOOK_SECRET`. The app only receives; it never calls GitHub. On a laptop with no public URL, the Simulate webhook button in Settings picks a payload from `fixtures/github/`, signs it, and posts it to the same route, so the whole flow is testable offline.

1. Verify `X-Hub-Signature-256` with HMAC SHA-256 over the raw body using `GITHUB_WEBHOOK_SECRET`. Reject with 401 on mismatch. Respond 200 to events you don't handle.
2. Deduplicate on `X-GitHub-Delivery` through the `github_deliveries` table. Skip repeats.
3. Extract identifiers from, in order: the branch name (`head.ref`), the PR title, the PR body. The regex is `/\b([A-Z]{2,5})-(\d+)\b/gi`, applied to the branch after replacing `/` and `_` with spaces. `dan/eng-123-fix-login` matches `ENG-123`. Only keep matches where a team with that key exists. Also detect magic words: `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved` followed by an identifier in the title or body; those identifiers get the auto-close behavior. Identifiers found only in the branch name or without a magic word are linked but only auto-move on merge if `AUTO_CLOSE_ON_MERGE_WITHOUT_MAGIC_WORD` is true (default true).
4. On `pull_request` `opened`, `reopened`, `edited`, `synchronize`, `ready_for_review`, `converted_to_draft`: upsert a `pull_requests` row per matched issue on (`repo`, `number`, `issue_id`) with title, url, branch, author, draft, and `state = open`. If the issue's state type is `backlog` or `unstarted` and the PR is not a draft, move it to the team's first `started` state with `source = github`.
5. On `pull_request` `closed` with `merged = true`: set `state = merged`, `merged_at`, and move each linked issue to the team's first `completed` state with `source = github`. Skip issues already completed or canceled. Notify subscribers with `pr_merged`.
6. On `pull_request` `closed` with `merged = false`: set `state = closed`. Do not change the issue.
7. On `push` to a branch (not a PR): if the branch name matches an identifier and the issue is in a backlog or unstarted state, move it to started. This is what makes the issue move when someone starts work before opening the PR.
8. Show linked PRs on the issue page with a GitHub icon, repo and number, title, and a badge for open, draft, merged, or closed.

Also expose a "Copy git branch name" action (`cmd+shift+.` on the issue, and in the palette) that produces `<username>/<identifier-lowercase>-<slugified-title-max-40-chars>`, e.g. `dan/eng-123-fix-login-redirect`.

## Slack notifications

Optional. Each team can store a Slack incoming webhook URL (just a URL, no Slack app to install). Post a Block Kit message when an issue in that team is created, changes state, is assigned, or receives a comment. Teams without a URL enqueue nothing.

- Format: a header line with the identifier and title as a link, then a context line like "Dan moved to In Progress" or "Priya commented: first 200 characters". Include assignee and priority in a fields block.
- Coalesce: buffer events per issue for 30 seconds in `slack_outbox` and send one message describing all changes. The `slack-outbox` job drains rows where `send_after <= now` and `sent_at` is null.
- Retry up to 3 times on non-2xx with backoff. Never block the user's mutation on Slack.
- Users can turn off Slack posting for comment events per team in settings.

## Search

- `GET /api/search?q=` returns up to 20 issues from `issues_fts` ordered by `bm25()`, with `*` appended to the last term for prefix matching, plus exact identifier match at the top. Under 50ms on 20,000 issues.
- The palette and the `/` search box both use it.

## Import and export

- Import a Linear CSV export per team. Map columns: `ID` → identifier (keep the number, set `issue_counter` to the max), `Title`, `Description`, `Status` → workflow state by name (create missing ones with a guessed type), `Priority` (Urgent, High, Medium, Low, No priority), `Assignee` → user by name or email, `Labels` (comma-separated), `Project`, `Cycle Number`, `Created`, `Completed`. Write one `issue_history` row per import with `source = import`. Show a preview of the first 20 rows with the mapping before committing.
- Export a team's issues as CSV with the same columns, plus a second CSV of comments with `identifier`, `author`, `created_at`, `body`.

## API

All routes are under `/api`, JSON in and out, authenticated by session cookie except the webhook, login, setup, and invite routes. Validate every body with zod. Return 400 with field errors, 403 for private teams the user isn't in, 404 for missing rows.

- `POST /setup` (only while there are no users), `POST /auth/login`, `POST /auth/logout`, `GET /invite/:token`, `POST /invite/:token` (sets name and password, creates the session).
- `POST /invites` (admin) creates an invite, emails the link, and returns it. `DELETE /invites/:id`.
- `POST /issues` creates one issue. Body: `team_id`, `title`, optional `description`, `state_id`, `priority`, `assignee_id`, `label_ids`, `project_id`, `cycle_id`, `parent_id`, `due_date`, `mutation_id`. Returns the row with `identifier`.
- `PATCH /issues` updates many. Body: `ids: string[]`, `mutation_id`, plus any subset of the fields above. Returns the updated rows. Writes one `issue_history` row per changed field per issue.
- `DELETE /issues` with `ids`. Soft delete is not needed; hard delete and cascade comments, labels, subscriptions, history, and files on disk.
- `GET /issues?team=ENG&view=active|backlog|all&cursor=` returns up to 200 issues with labels, assignee, project, and cycle embedded. Sort by `sort_order` then `created_at`.
- `POST /issues/:identifier/comments`, `PATCH /comments/:id`, `DELETE /comments/:id`.
- `POST /issues/:identifier/move` with `team_id` reassigns the issue to another team, allocates a new number, and records the old identifier. `GET /issue/OLD-1` redirects to the new one.
- `GET /search?q=` as described above.
- `POST /upload` accepts multipart, writes the file under `./data/files/attachments/<id>/`, and returns `{ id, filename, size_bytes, content_type, url }` where `url` is signed. `GET /files/:id?exp=&sig=` checks the session, the expiry (15 minutes), and the HMAC over `id` and `exp`, then streams the file.
- `GET /notifications`, `POST /notifications/read` with `ids`, `POST /notifications/snooze` with `ids` and `until`.
- `GET /teams`, `POST /teams`, `PATCH /teams/:id`, plus `/teams/:id/states` for workflow state CRUD. Deleting a state requires a replacement state id and moves every issue there.
- `GET /projects`, `POST /projects`, `PATCH /projects/:id`.
- `GET /teams/:id/cycles`, `POST /teams/:id/cycles` (create the next upcoming cycle by hand).
- `POST /import/linear` with a CSV file and a team id. `GET /export/:teamKey.csv`.
- `POST /webhooks/github`, `POST /webhooks/github/simulate` (admin, body `{ fixture }`), `GET /events` (SSE), `POST /jobs/:name/run` (admin, runs a job now through the same lock).

Every mutation route returns the full updated rows so the client cache can be patched without a refetch.

## Edge cases to handle

- Changing a team's `key` rewrites `identifier` for every issue in the team. Keep an `identifier_aliases` table (`old_identifier`, `issue_id`) so old links redirect, and rewrite bare references in descriptions and comments in a background job.
- Deleting a user is not allowed. Deactivate instead. Deactivated users stay as assignee and author on old rows but don't show in pickers. Deactivating deletes their sessions.
- A sub-issue can't be its own ancestor. Reject cycles in `parent_id` with 400.
- Completing a parent does not complete its children. The parent shows "3 of 5 sub-issues done".
- Priority sort order is urgent, high, medium, low, then none. Store none as 0 and sort with `NULLS LAST` semantics by mapping 0 to 5 in the query.
- Due dates are stored as plain dates with no time component. "Overdue" means `due_date < today` in the viewing user's timezone, taken from the browser and stored on the user row on sign-in.
- The GitHub webhook can arrive before the issue exists (someone names a branch for an issue they haven't created). Store the PR in a `pending_pr_links` table and link it when an issue with that identifier is created.
- Two users editing the same description: last write wins, but show a banner "Dan updated this description while you were editing" if `updated_at` changed under you, with a diff.
- Deleting a cycle moves its issues to no cycle. Deleting the active cycle is not allowed.
- The Slack webhook URL is a secret. Encrypt it at rest with `APP_SECRET` and show only the channel name in the UI.
- Two app containers pointed at the same `./data` volume must not double-run jobs; the `job_locks` row is what prevents it. Don't rely on "there's only one process".

## Performance budget

- Issue list first paint from cache under 100ms. Navigation between lists never shows a spinner if the list was loaded before.
- `GET /issues` for a 200-row page under 150ms on a database with 20,000 issues. Add indexes on (`team_id`, `state_id`), (`assignee_id`), (`cycle_id`), (`project_id`), and (`team_id`, `sort_order`).
- Every keyboard action gives visual feedback within one frame. Pickers open synchronously from already-loaded data.
- Realtime patches must not cause the list to re-sort under the cursor. Apply sort changes on the next navigation or when the user presses `r` to refresh.
- Bundle: the issue list route ships under 250KB of JS gzipped. Lazy-load the markdown editor, the syntax highlighter, and the import screen.

## Non-goals

Do not build: roadmaps, initiatives, SLAs, customer requests, Asks, Zendesk or Intercom sync, AI triage, thread summaries, analytics dashboards, mobile apps, SSO or SAML, multi-workspace, billing, public issue pages. No hosted services beyond a GitHub webhook, a Slack incoming webhook URL, and an SMTP relay. If you're tempted, leave a TODO comment.

## Acceptance criteria

1. The admin invites `priya@example.com`, the invite email lands in the outbox with a link, and following it creates a member who can sign in. With `ALLOWED_EMAIL_DOMAINS=example.com`, an invite to `x@other.com` is rejected with 400. The admin creates team ENG and the first issue is ENG-1.
2. Twenty concurrent `POST /api/issues` requests for the same team produce identifiers ENG-2 through ENG-21 with no duplicates and no gaps.
3. Creating an issue with `c` shows it in the list before the server responds. If the server returns 500, the row disappears and a toast with Retry appears.
4. Changing an issue's state in one browser session appears in a second session within two seconds without a refresh, delivered over `/api/events`.
5. Selecting three issues with `x` and pressing `a` then choosing a user assigns all three, writes three `issue_history` rows, and creates three `assigned` notifications.
6. `cmd+k`, typing `eng-7`, enter opens `/issue/ENG-7`. Typing `login redirect` lists issues whose title matches, ranked by relevance.
7. A description containing a GFM table, a fenced code block with `ENG-3` inside it, and a bare `ENG-3` outside it renders the table, leaves the code block text unlinked, and links the bare reference.
8. A `pull_request` `opened` webhook with `head.ref = "dan/eng-15-fix-login"` and a valid signature links the PR to ENG-15 and moves it from Todo to In Progress. The same delivery id sent twice is processed once. An invalid signature returns 401. The Simulate webhook button produces the same result from the `pr-opened` fixture.
9. A `pull_request` `closed` webhook with `merged = true` for that PR moves ENG-15 to Done, sets `completed_at`, and enqueues a Slack message. A `closed` with `merged = false` leaves the state alone.
10. A PR whose body says `Fixes ENG-15 and DES-4` links both issues, and an identifier for a nonexistent team key is ignored.
11. With `cycle_start_weekday = 1`, `cycle_length_weeks = 2`, and timezone `America/Los_Angeles`, the `cycles` job creates cycle 1 starting Monday 00:00 Pacific. When the clock passes `ends_at`, the cycle completes, `total_scope` and `completed_scope` are correct, and every unfinished issue moves to cycle 2. Across the March DST change, cycle 2 still ends at Monday 00:00 Pacific.
12. Three state changes on one issue within 30 seconds produce exactly one Slack POST describing all three. A Slack 500 is retried and does not fail the mutation.
13. Importing a 500-row Linear CSV creates 500 issues with matching identifiers, states, priorities, labels, and assignees, and sets `issue_counter` so the next new issue is one higher than the max imported number.
14. `/api/search?q=login` returns in under 50ms with 20,000 seeded issues, and `?q=ENG-12` returns ENG-12 first.
15. A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
16. `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
17. Killing the process mid-job and restarting it doesn't double-run or lose the job.
18. `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
19. Every outbound email in dev shows up in `./data/outbox`.

## Deliverables

- The app, migrations, and a seed script that creates three teams (ENG, DES, OPS), 12 users, 400 issues spread across states, 4 projects, 3 cycles per team (one completed, one active, one upcoming), 300 comments, and 20 linked pull requests.
- `lib/identifiers.ts` (regex extraction and magic words), `lib/cycles.ts` (date math), `lib/slack.ts` (message formatting), and `lib/jobs.ts` (the lock claim and release), each pure where possible and unit-tested.
- Sample GitHub webhook payloads in `fixtures/github/` for opened, synchronize, closed-merged, closed-unmerged, and push, with a script that signs them. The Simulate webhook button uses the same files.
- `docker-compose.yml` with `app`, `caddy`, and `backup`; a `Caddyfile` that reads the domain from `DOMAIN`; `scripts/backup.sh` behind `pnpm backup`.
- README covering the laptop run, the VPS run, backup and restore, the three outside services (GitHub webhook setup and the simulate button, Slack incoming webhooks, `SMTP_URL`), the optional Google sign-in, and how to import from Linear.

Build the data model and the identifier transaction first, with the concurrency test. Then first-run setup and sessions. Then the issue list and issue page with the keyboard layer. Then optimistic updates and SSE. Then the GitHub webhook with the simulate button, then the job lock and cycles, then Slack, then email and import, then compose, Caddy, and backup. Run the app in the browser after each step and check the shortcuts by hand.

---

## Manual test checklist

- [ ] Delete ./data, run pnpm dev with no env vars, create the admin on the first visit, then create teams ENG and DES and confirm the first issue in each is ENG-1 and DES-1.
- [ ] Create ten issues quickly from two browser tabs and confirm no identifier repeats or skips.
- [ ] Press c, type a title, hit enter, and confirm the issue appears in the list before the network request finishes.
- [ ] Turn off wifi, change a status, and confirm the change rolls back with an error toast.
- [ ] Change an issue's status in one browser and confirm the other browser updates within two seconds without a refresh.
- [ ] Select three issues with x, press a, pick a person, and confirm all three are assigned and each has a history entry.
- [ ] Open cmd+k, type 'eng-12', and confirm the issue opens on enter.
- [ ] Write a description with a code block, a table, and a reference to another issue and confirm all three render.
- [ ] Push a branch named dan/eng-15-fix-login and open a PR. Confirm ENG-15 moves to In Progress and shows the PR. Merge it and confirm ENG-15 moves to Done and Slack gets a message. On a laptop, use the Simulate webhook button in Settings instead.
- [ ] Set a team's cycle start day to Monday and timezone to America/Los_Angeles, then fake the clock past Sunday midnight and confirm the new cycle starts at 00:00 Pacific with the unfinished issues.
- [ ] Import a Linear CSV export and confirm identifiers, statuses, priorities, and labels come through.
- [ ] Press ? on any screen and confirm every shortcut listed actually works.
- [ ] Run `docker compose up -d` on a fresh VPS and open the domain over HTTPS.

## Test prompt

Also available on its own at https://buildyourown.software/like/linear/test-prompt.md

Write automated tests for the issue tracker in this repo. Treat the acceptance criteria below as the spec. Use Vitest for pure functions and API routes against an on-disk temp SQLite database, and Playwright for keyboard and realtime flows. Mock only the Slack webhook and SMTP at the module boundary with a fetch mock and a nodemailer stub that record calls. GitHub webhooks are tested by posting signed fixture payloads to the real route. Nothing else is mocked: the database, files, jobs, sessions, and the outbox are all real and local.

## Acceptance criteria to cover

1. The admin creates an invite for `priya@example.com`; one `.eml` appears in the outbox with the link; following the link with a name and password creates a member and a session. With `ALLOWED_EMAIL_DOMAINS=example.com`, inviting `x@other.com` returns 400. Creating team ENG and one issue yields ENG-1.
2. Twenty concurrent `POST /api/issues` for one team return ENG-2 through ENG-21 with no duplicates and no gaps. Use `Promise.all`.
3. Creating an issue with `c` shows the row before the response. With the API forced to 500, the row disappears and a toast with a Retry button appears (Playwright, route interception).
4. A state change in one browser context appears in a second context within two seconds without reload, and the second context's `/api/events` stream carried the event (Playwright, two contexts).
5. Selecting three issues with `x`, pressing `a`, and choosing a user assigns all three, writes three `issue_history` rows with `field = assignee`, and creates three `assigned` notifications.
6. `cmd+k` then `eng-7` then enter navigates to `/issue/ENG-7`. `cmd+k` then `login redirect` lists matching issues, best match first.
7. Markdown rendering: a GFM table renders as `<table>`, `ENG-3` inside a fenced code block is plain text, a bare `ENG-3` outside it is a link to `/issue/ENG-3`.
8. A signed `pull_request` `opened` fixture with branch `dan/eng-15-fix-login` links the PR to ENG-15 and moves it from Todo to In Progress with `source = github`. Re-posting the same `X-GitHub-Delivery` changes nothing. A bad signature returns 401 and changes nothing. `POST /api/webhooks/github/simulate` with `{ fixture: "pr-opened" }` as admin produces the same result.
9. A signed `closed` fixture with `merged = true` moves ENG-15 to Done, sets `completed_at`, and inserts one `slack_outbox` row. A `closed` with `merged = false` sets the PR to `closed` and leaves the issue state unchanged.
10. A PR body of `Fixes ENG-15 and DES-4` links both; `ZZZ-9` for a team key that doesn't exist is ignored.
11. Cycles job: with `cycle_start_weekday = 1`, `cycle_length_weeks = 2`, timezone `America/Los_Angeles`, and an injected clock, cycle 1 starts Monday 00:00 Pacific (07:00 or 08:00 UTC depending on DST). Advancing the clock past `ends_at` completes it, records `total_scope` and `completed_scope`, and moves unfinished issues to cycle 2 with `source = cycle_rollover`. A cycle spanning the March DST change ends at Monday 00:00 Pacific, and its UTC length is 13 days 23 hours.
12. Three state changes on one issue within 30 seconds produce one Slack POST whose body mentions all three states. A Slack 500 is retried up to 3 times and the original mutation still returns 200.
13. Importing a 500-row Linear CSV fixture creates 500 issues with matching identifiers, states, priorities, labels, and assignees, and the next created issue gets `max number + 1`.
14. `/api/search?q=login` returns in under 50ms with 20,000 seeded issues; `?q=ENG-12` returns ENG-12 first.
15. A fresh data directory with no env vars boots, `GET /` redirects to `/setup`, and `POST /api/setup` creates an admin and a session. A second `POST /api/setup` returns 403.
16. `docker compose config` validates and lists `app`, `caddy`, and `backup`; the `Caddyfile` references `DOMAIN`. (Serving HTTPS on a real VPS is a manual check; don't fake it.)
17. Job lock: two concurrent `runJob("cycles")` calls execute the body once. A job that throws mid-run releases its lock, and the next run completes the work without repeating the part that already committed. A lock whose `expires_at` is in the past is taken over.
18. `pnpm backup` writes `./data/backups/<date>.db`; deleting `app.db`, copying the backup back, and rebooting returns the same row counts and the same issue identifiers.
19. With `SMTP_URL` unset, creating an invite writes one `.eml` to the outbox containing the invite link and no `sendMail` call happens. With `SMTP_URL` set, the nodemailer stub receives the message and the outbox stays empty.
20. `cmd+z` after a state change reverts the state and writes a second `issue_history` row.
21. A draft PR opened against a Todo issue links it but does not move it; marking it ready for review moves it to In Progress.

## Test layout

- Each test file opens its own database at `<os tmpdir>/linear-tests/<file>.db`, runs migrations, and deletes the file in `afterAll`. Set `DATA_DIR` to a matching temp folder so files, the outbox, and backups stay isolated. Never touch `./data`.
- `tests/unit/identifiers.test.ts`: branch, title, and body extraction; magic words; unknown team keys; case insensitivity; no matches inside fenced code blocks or code spans. Covers 7 and 10.
- `tests/unit/cycles.test.ts`: table-driven cases for 11. Each case is `{ name, timezone, startWeekday, lengthWeeks, cooldownWeeks, now, expectStartsAtUtc, expectEndsAtUtc }`. Include the March and November US DST changes, a Sunday start, and a one-week cooldown.
- `tests/unit/slack.test.ts`: message formatting for each event kind and coalescing of three events into one message. Covers the formatting half of 12.
- `tests/unit/markdown.test.ts`: rendering rules for 7, including that mentions render as names and task-list checkboxes carry a data attribute for toggling.
- `tests/unit/jobs.test.ts`: the lock claim statement against a temp database. Covers 17, with a job body that commits one team then throws.
- `tests/integration/`: API routes against the temp SQLite file. Covers 1, 2, 5, 8, 9, 10, 12, 13, 14, 15, 18, 19, 21. Sign webhook fixtures with `GITHUB_WEBHOOK_SECRET` in a helper; never hand-write a signature. Delete from every table between tests. For 18, shell out to the backup script and assert on the file.
- `tests/e2e/`: Playwright. Covers 3, 4, 6, 20. Start the app with a temp `DATA_DIR`, seed it with the seed script before each spec, and sign in through `/api/auth/login` with a seeded user. Use two browser contexts for 4 and route interception for the forced 500 in 3.
- `tests/deploy.test.ts`: covers 16 by running `docker compose config` and reading the `Caddyfile`. Skip if `docker` isn't on the path.

## Fixtures

- `fixtures/github/pr-opened.json`, `pr-synchronize.json`, `pr-ready-for-review.json`, `pr-closed-merged.json`, `pr-closed-unmerged.json`, `push.json`, each shaped like GitHub's documented payloads with `head.ref`, `title`, `body`, `merged`, `draft`, and `X-GitHub-Delivery` set in the test. The simulate button and the tests share these files.
- `fixtures/linear-export.csv`: 500 rows with Linear's column names (`ID`, `Title`, `Description`, `Status`, `Priority`, `Assignee`, `Labels`, `Project`, `Cycle Number`, `Created`, `Completed`), including rows with empty assignee, multiple labels, and a status name that doesn't exist yet.
- A `seed:perf` script that inserts 20,000 issues for criterion 14.

## Rules

- Name every test after its criterion: `test("AC8: opened PR links by branch name and moves issue to In Progress")`.
- Freeze time with `vi.useFakeTimers()` or an injected clock for cycles, the job lock expiry, and Slack coalescing. Never depend on the real date.
- Use the fixtures in `fixtures/github/` and `fixtures/linear-export.csv`. If they don't exist, create realistic ones from GitHub's documented webhook payload shape and Linear's CSV column names.
- Run the search benchmark on the seeded 20,000 issues and assert on wall time; skip it in CI if `CI_SKIP_PERF` is set.
- Add `pnpm test` and a GitHub Actions workflow that runs it. No services needed: `pnpm install`, install Playwright browsers, run the suite. SQLite and the outbox are files in the runner's temp dir.
- Run the whole suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass/fail and what you changed.
