Issue tracking · by Linear Orbit, Inc.
Build your own Linear.
A fast issue list with keyboard shortcuts, a kanban board, and a GitHub hook that closes issues on merge. Linear charges per seat for it.
What you're paying now
$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.
Build it in any tool
Paste into Claude Code, ChatGPT, Codex, Replit, Lovable, or Cursor. The agent fetches everything it needs.
Paste anywhere
Build my own Linear. Fetch https://buildyourown.software/like/linear/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals.
Claude Code
claude "Build my own Linear. Fetch https://buildyourown.software/like/linear/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
Codex CLI
codex "Build my own Linear. Fetch https://buildyourown.software/like/linear/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
01
What it 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.
02
What it costs
List prices from the vendor's own pricing page. The fine print is where the money goes.
Free
$0
unlimited members
- –250 issues total
- –2 teams
- –10MB file uploads
- –Linear Agent and agent platform
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
- –Unlimited issues
- –5 teams
- –Unlimited file uploads
- –Admin roles
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
- –Unlimited teams and private teams
- –Guest accounts
- –Triage Intelligence and Loops
- –Linear Insights and Asks
- –Zendesk and Intercom integrations
Coding sessions and Loops also need AI credits, which are a separate add-on.
Enterprise
Custom
per year, annual billing only
- –SAML and SCIM
- –Granular admin controls
- –Invoice and PO billing
- –Priority support and account management
Contact sales. No list price published.
The fine print
- Every unsuspended member is a billed seat. A designer or PM who opens the app once a month costs the same as an engineer.
- The Free plan stops at 250 issues across the whole workspace. A team of five fills that in a couple of months, and then you can't create issues until you upgrade.
- Basic caps you at 5 teams. Private teams, which you want the moment you have a founders or a security team, are Business only.
- The AI features on Business (coding sessions, Loops) need AI credits bought separately on top of the seat price.
- Adding seats mid-year is prorated and charged right away. Removing seats gives you a credit toward future bills rather than a refund.
- Your issue history, comments, and attachments live in Linear. Export is a CSV per team and does not include comments.
Prices checked September 10, 2026 at linear.app. They change. Check before you quote them.
03
What you actually use
The headline features, and an honest call on whether a typical team needs each one.
10 of 13 headline features are worth building for a typical user. The rest is what you're paying for.
- Build it
Issues with team identifiers
Every issue is ENG-123 or DES-45. Title, markdown description, creator, timestamps.
Why: The identifier is the whole product. People say 'ENG-123' in standup and in branch names.
- Build it
Status workflow
Backlog, Todo, In Progress, Done, Canceled, editable per team.
Why: Five statuses cover almost every team. A per-team table with a type column lets you rename them later.
- Build it
Priority, assignee, labels
Urgent to low priority, one assignee, many labels with colors.
Why: This is how you filter and sort. Each one is a column or a join table.
- Build it
Projects
A named container for issues with a lead, a target date, and a progress bar.
Why: You need some way to group 'the checkout rewrite' across teams. A project is a row plus a count.
- Build it
Cycles
Two-week sprints that start on a set weekday, auto-create the next one, and roll unfinished issues forward.
Why: The auto-rollover is what keeps a sprint tracker honest. It's one daily cron job.
- Build it
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.
Why: This is why people pick Linear over Jira. It's front-end work, and you control all of it.
- Build it
Optimistic updates and realtime sync
Changes apply instantly and show up on teammates' screens without a refresh.
Why: Optimistic mutations are the second half of the speed feeling. Realtime is a Postgres change feed.
- Build it
Markdown descriptions and comments
Markdown everywhere, @mentions, ENG-123 references that auto-link, image uploads.
Why: A markdown renderer with a sanitizer and an upload bucket. A day of work.
- Build it
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.
Why: This is the one integration that removes real manual work. One webhook endpoint and a regex.
- Build it
Slack notifications
Post to a channel when an issue is created, changes status, gets assigned, or gets a comment.
Why: 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.
Why: 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.
Why: 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.
Why: These are the Business tier upsells. A Slack slash command that creates an issue covers Asks in an afternoon.
04
How it works under the hood
The data model, the flows that matter, and the integrations you need. This is what the build prompt is based on.
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 · search (tsvector)
Labels live in an issue_labels join table. Unique on (team_id, number).
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. 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. 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.A cron job runs every hour 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.
Notify Slack
- 1.Every mutation on an issue emits an event: created, state_changed, assigned, commented.
- 2.A worker collapses events for the same issue within 30 seconds 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
- Auth (Clerk, Supabase Auth, or NextAuth)requiredGoogle sign-in restricted to your company domain.
- Postgres (Supabase, Neon, or Railway)requiredThe database, full-text search, and the change feed for realtime.
- GitHub App with webhooksrequiredPR events in, issue status changes out.
- Slack incoming webhooksrequiredChannel notifications per team.
- Realtime (Supabase Realtime or server-sent events)requiredPush changes to open clients so lists stay current.
- Object storage (S3 or Supabase Storage)requiredImage and file attachments on issues and comments.
- Cron (Vercel Cron or pg_cron)requiredCycle rollover and Slack digest.
- ResendoptionalInvite emails and a daily digest for people who don't use Slack.
- OpenRouteroptionalSummarize long comment threads with any model.
05
The build prompt
The full spec the one-line command points at. Paste it directly if your tool can't fetch URLs, or edit the data model and non-goals first to fit your team.
# 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
- Next.js (App Router) with TypeScript and Tailwind
- Postgres via Supabase or Neon, with Drizzle (or Prisma)
- Client state: TanStack Query with optimistic mutations, or a small normalized store. Pick one and use it everywhere.
- Realtime: Supabase Realtime on the `issues`, `comments`, and `issue_history` tables, or a server-sent events endpoint at `/api/events` backed by Postgres `LISTEN/NOTIFY`
- Auth: Google sign-in restricted to an allowed email domain (Supabase Auth, Clerk, or NextAuth)
- Markdown: `react-markdown` with `remark-gfm` and `rehype-sanitize`
- Command palette and pickers: `cmdk`
- Keyboard: a single `useHotkeys` layer with scopes (global, list, issue, modal). No per-component `keydown` listeners.
- Object storage for attachments: Supabase Storage or S3
- Cron: Vercel Cron hitting `/api/cron/cycles` hourly and `/api/cron/slack-digest` daily
- `date-fns` and `date-fns-tz` for cycle date math. Never do timezone math by hand.
- Deploy target: Vercel
If the repo already uses a different stack, use that and keep the behavior identical.
## Data model
Every table has `id` (uuid), `created_at`, `updated_at`. There is one workspace per deployment, so there is no `workspace_id`.
- `users`: `email` (unique), `name`, `avatar_url`, `role` (`admin` | `member`), `slack_user_id` (nullable), `active` (bool).
- `teams`: `name`, `key` (unique, 2 to 5 uppercase letters, e.g. `ENG`), `issue_counter` (int, default 0), `timezone` (IANA, default `America/New_York`), `cycles_enabled` (bool), `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), `slack_channel_name` (nullable), `private` (bool).
- `team_members`: `team_id`, `user_id`. Unique on the pair.
- `workflow_states`: `team_id`, `name`, `type` (`backlog` | `unstarted` | `started` | `completed` | `canceled`), `color`, `position` (int). 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` (int), `identifier` (generated: `key || '-' || number`, unique), `title`, `description` (markdown, nullable), `state_id`, `priority` (int: 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, nullable), `estimate` (int, nullable), `sort_order` (double, for manual ordering on boards), `started_at`, `completed_at`, `canceled_at` (all nullable timestamps), `search` (tsvector generated from title and description, GIN index). Unique on (`team_id`, `number`).
- `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, nullable), `color`.
- `project_teams`: `project_id`, `team_id`.
- `cycles`: `team_id`, `number` (int), `name` (nullable), `starts_at` (timestamptz), `ends_at` (timestamptz), `completed_at` (nullable), `total_scope` (int, nullable), `completed_scope` (int, nullable). Unique on (`team_id`, `number`). At most one cycle per team where `starts_at <= now() < ends_at`.
- `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` (int), `title`, `url`, `branch`, `author_login`, `state` (`open` | `merged` | `closed`), `draft` (bool), `merged_at` (nullable). Unique on (`repo`, `number`, `issue_id`).
- `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`, `url`, `filename`, `size_bytes`, `content_type`.
- `thread_summaries` (optional, for the OpenRouter feature): `issue_id` (unique), `body`, `model`, `comment_count_at_generation`, `generated_at`.
### Identifier assignment
Issue creation runs in one transaction:
```sql
UPDATE teams SET issue_counter = issue_counter + 1 WHERE id = $1 RETURNING issue_counter;
INSERT INTO issues (team_id, number, ...) VALUES ($1, <returned counter>, ...);
```
Never read the counter, compute in application code, then write. Two concurrent creates must produce ENG-124 and ENG-125, 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. **Sign in (`/login`).** Google only. Reject emails outside `ALLOWED_EMAIL_DOMAIN`. The first user becomes admin and is prompted to create the first team.
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, 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 App install link and connected repos. 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 using the `search` tsvector 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. 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: subscribe to changes on `issues`, `comments`, `issue_history`, and `notifications`. On a change from another user, patch the cache in place. On a change from the current user that matches a pending optimistic update, ignore it.
- `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 uploads it to storage and inserts ``. 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 and notifications
- 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.
## Cycles (`/api/cron/cycles`, hourly, protected by `CRON_SECRET` header)
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. 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.
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`)
Set up as a GitHub App with `pull_request` and `push` events and `Pull requests: read` plus `Contents: read` permissions. Store the installation id and the list of repos in settings.
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`. Store delivery ids for 24 hours and 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
Each team can store a Slack incoming webhook URL. Post a Block Kit message when an issue in that team is created, changes state, is assigned, or receives a comment.
- 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 and send one message describing all changes. Use a `slack_outbox` table with `issue_id`, `events` (jsonb array), `send_after`, `sent_at`, `attempts`, `error`. A job every 30 seconds (or the same cron endpoint called frequently) drains it.
- 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 ranked by `ts_rank` on the `search` tsvector with prefix matching on the last term, 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`.
## Optional: thread summaries via OpenRouter
If `OPENROUTER_API_KEY` is set, show a "Summarize thread" button on issues with more than 10 comments. Send the description and all comments to the configured model (default `anthropic/claude-sonnet-4`) with a prompt asking for a five-bullet summary of decisions, open questions, and who owns what. Store the result in `thread_summaries`. Show "Regenerate" when `comment_count_at_generation` is behind the current count. Never send attachments.
## API
All routes are under `/api`, JSON in and out, authenticated by session cookie except the webhook and cron 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 /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`. Returns the row with `identifier`.
- `PATCH /issues` updates many. Body: `ids: string[]` 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, and history.
- `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 below.
- `POST /upload` accepts multipart, stores the file, returns `{ url, filename, size_bytes, content_type }`.
- `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`, `GET /cron/cycles`, `GET /cron/slack-outbox`, `GET /events` (SSE).
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.
- 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.
- A PR can be linked to an issue in a team whose repo mapping doesn't include that repo. Link it anyway. Repo mapping only controls which repos are shown in settings.
- 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 and show only the channel name in the UI.
## 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, analytics dashboards, mobile apps, SSO or SAML, multi-workspace, billing, public issue pages. If you're tempted, leave a TODO comment.
## Acceptance criteria
1. The first user signs in with an allowed Google account, creates team ENG, and the first issue is ENG-1. A user with an email outside the allowed domain is rejected.
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.
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.
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 cron 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.
## 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), and `lib/slack.ts` (message formatting), each pure 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.
- README covering environment variables, creating the GitHub App (permissions, events, webhook URL, secret), setting up Slack incoming webhooks, the cron configuration, and how to import from Linear.
Build the data model and the identifier transaction first, with the concurrency test. Then the issue list and issue page with the keyboard layer. Then optimistic updates and realtime. Then the GitHub webhook, then cycles, then Slack, then import. Run the app in the browser after each step and check the shortcuts by hand.06
Where to build it
Any of these will take the prompt. Use the one you already pay for.
- Claude CodeAgentic coding in your terminal
Best fit. The identifier transaction, webhook signature check, and keyboard layer all need an agent that runs the app and tests as it goes.
- ReplitBuild and host in the browser
Replit gives you Postgres, hosting, and a public URL for the GitHub webhook in one step. Good for getting the integration working on day one.
- LovablePrompt-to-app with Supabase built in
Fast for the issue list and board UI on Supabase. Build the GitHub webhook and cycle cron as Supabase edge functions afterward.
- ChatGPT / CodexPlan, then build with Codex
Use ChatGPT to set your team keys, statuses, and cycle schedule first, then hand the spec to Codex.
- OpenRouterBring your own model
Optional. Add a 'summarize thread' button on issues with more than ten comments. Any cheap model does this well.
07
Test it
Walk the checklist by hand once, then let the agent write the automated tests.
Manual checklist
- Create two teams with keys 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 that PR and confirm ENG-15 moves to Done within a few seconds and Slack gets a message.
- 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.
Generate automated tests
Paste this into the same tool that built the app. It writes tests against the acceptance criteria from the build prompt.
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 a real test Postgres, and Playwright for keyboard and realtime flows. Mock the Slack webhook and OpenRouter at the module boundary with a fetch mock that records calls. GitHub webhooks are tested by posting signed fixture payloads to the real route.
## Acceptance criteria to cover
1. Sign-in with an allowed-domain Google account creates the user; the first user is admin; an email outside `ALLOWED_EMAIL_DOMAIN` is rejected. 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 (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.
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. Cycle cron: 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. `cmd+z` after a state change reverts the state and writes a second `issue_history` row.
16. 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
- `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/integration/`: API routes against a real Postgres (`DATABASE_URL_TEST` or Testcontainers). Covers 1, 2, 5, 8, 9, 10, 12, 13, 14, 16. Sign webhook fixtures with `GITHUB_WEBHOOK_SECRET` in a helper; never hand-write a signature. Truncate tables between tests.
- `tests/e2e/`: Playwright. Covers 3, 4, 6, 15. Seed a fresh database before each spec with the seed script. Use two browser contexts for 4 and route interception for the forced 500 in 3.
## 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.
- `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 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 with a Postgres service container and Playwright browsers installed.
- 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.Linear is a trademark of Linear Orbit, Inc.. This page is independent research and is not affiliated with or endorsed by Linear Orbit, Inc.. Last updated 2026-09-10.