Skip to content
VirtusLabPublic

About

Explains GitHub PRs in a human-friendly way, using LLMs.

Topics

Resources

Stars

118 stars

Watchers

1 watching

Forks

Repository files navigation

Tulip

npm

Tulip explains GitHub pull requests and GitLab merge requests - categorizing and prioritizing the changes - and presenting them on a web page as prose, diagrams, and code.

Every code change appears somewhere in the explanation, so nothing slips past you. Yet you don't have to wade through it all: instead of digging through piles of code to find the logic that matters, you can focus on the algorithms, the design choices, the core functionality. The explanations are top-down: a high-level overview first, then down into the code.

And if you don't know the codebase at all, the prose and the generated diagrams may be enough on their own to understand the PR well enough to accept or reject it.

Tulip uses a headless agent CLI to do the analysis: Claude Code's claude by default, or Cursor's agent or OpenAI's codex. No API key needed, just a logged-in CLI.

A generated explanation with a Mermaid diagram of the changed flow A prose explanation interleaved with a syntax-highlighted side-by-side diff

Click a screenshot to view it full size.

Note

Tulip is part of Visdom, VirtusLab's AI-native SDLC platform.

How it works

Tulip fetches the PR and runs it through a few LLM passes, each using a model sized to the job (Haiku, Sonnet, or Opus; see Choosing the agent for the models used with Cursor and Codex):

  1. Fetch & check out. Get the PR (title, description, diff) via gh (or the GitHub API) — or, for a GitLab merge request, via the GitLab API — check out its head revision, and write the full diff plus the before-versions of changed files into the checkout, so later passes can read the real code.
  2. Categorize (Sonnet). Split the changes into a few self-contained groups by concern — tests and docs ride along with the code they belong to. Each group gets an attention rating (Read closely / Read through / Skim); groups are ordered most-important first. A second Sonnet pass reviews the groups (when there is more than one) and amends them once if the split is off.
  3. Split large changes (Sonnet). For each change over a size threshold, decide whether it spans more than one group's concern and, if so, where to cut it — turned into an exact partition in code, so no line is lost. This lets the next step route the pieces of a big multi-concern file to different groups.
  4. Classify (Haiku, a fresh session per batch). Assign every change to one or more groups and mark it as production or test code. Unfitting changes can propose a new group; generated files and lockfiles are dropped; every change is checked to be covered. A change that lands in several groups is owned by one — its highest-attention group — and only linked from the others.
  5. Explain (Opus, one session per group; Sonnet review). Write a top-down prose explanation grounded in the real code: open with the overall shape and an orienting diagram — a dependency graph, data flow, class, state, ER, or sequence diagram, as the change calls for — then drill into each change, leading with its interface, folding routine code by default, and showing in full only what the group's attention rating and the must-see cases demand. A change owned by another group is linked, not re-explained. A Sonnet pass reviews each explanation and the writer amends it once; every referenced snippet is checked to appear and every diagram validated to render.
  6. Render. Assemble everything into one self-contained HTML page: light/dark theme, foldable sidebar table of contents, syntax-highlighted side-by-side diffs, and the attention badges.

The result is a temporary directory with the page; Tulip logs the file:// path to open.

Prerequisites

  • Node.js LTS (>= 24) — its bundled npm installs Tulip; pnpm is only needed to build from source
  • A logged-in agent CLI on PATH — Tulip drives it to analyze the PR: claude (the default), Cursor's agent (agent login), or OpenAI's codex (codex login); see Choosing the agent
  • Optionally, the gh CLI, logged in — used to fetch PR data when available; Tulip falls back to the GitHub REST API otherwise (set GITHUB_TOKEN to raise the unauthenticated rate limit)
  • For a private GitLab project, a GITLAB_TOKEN environment variable holding a personal access token with the read_api and read_repository scopes

Private repositories also need credentials for git fetch. If your git config rewrites the host to SSH (insteadOf), git uses your SSH key and nothing else applies. Otherwise git uses the first credential helper that answers: your own setup first (such as the OS keychain or gh auth setup-git), then a logged-in gh, then GITHUB_TOKEN; for GitLab, GITLAB_TOKEN instead of gh and GITHUB_TOKEN. After a successful fetch, git may store that token with your own credential helper (such as the OS keychain). If your own helper answers with a credential the host rejects, fix or clear that credential. Tulip fails with a hint instead of asking for a username and password on the terminal.

Self-hosted GitHub Enterprise Server URLs (e.g. https://git.example.com/owner/repo/pull/7) work too. Authenticate that host once with gh auth login --hostname git.example.com; the REST fallback uses the host's /api/v3 endpoint with GITHUB_TOKEN.

GitLab merge requests work on gitlab.com and self-hosted GitLab, nested groups included (e.g. https://git.example.com/group/sub/project/-/merge_requests/7). Use the merge request's own URL: one copied from a tab such as .../diffs, or one with a ?query or #fragment, is not accepted. Tulip reads merge requests through the GitLab REST API (no glab needed), sending GITLAB_TOKEN when it is set (only to the merge request's host); public projects work without it. If GitLab truncated a very large merge request's diff at its diff limits, Tulip stops with an error rather than explaining part of it. --serve is GitHub-only for now.

Install

npm install -g @virtuslab/tulip
# or: pnpm add -g @virtuslab/tulip

This puts a tulip command on your PATH. To run from source instead, see Development.

Usage

tulip <PR URL> [options]

Example:

tulip https://github.com/owner/repo/pull/123
tulip https://gitlab.com/group/project/-/merge_requests/45

Flags: --diff-threshold <n>, --verbose, --serve, --no-open, --agent <id>. Run tulip --help for details, or tulip --version for the build.

On success, Tulip logs a file://... path to the generated HTML page — open it in a browser.

Choosing the agent

Tulip uses claude unless you pass --agent with another id from the table below:

tulip <PR URL> --agent cursor-anthropic

The choice is saved in ~/.config/tulip/config.json ($XDG_CONFIG_HOME/tulip/ if set; %APPDATA%\tulip\ on Windows) and used on later runs until you pass another --agent.

Each pass asks for a fast, balanced, or strong model, mapped per agent in src/config.ts:

Tier claude cursor-anthropic cursor-grok codex
fast haiku claude-haiku-5-5-medium grok-4.7-low gpt-6-luna
balanced sonnet claude-sonnet-5-5-medium grok-4.7-medium gpt-6-sol
strong opus claude-opus-5-5-high grok-4.7-high gpt-6.1-sol

cursor-anthropic and cursor-grok both run agent in its read-only ask mode and trust the temporary checkout, after deleting the PR's .cursor/ and .claude/ dirs from it: Cursor would otherwise run hooks defined there. They differ only in the model ids above. Cursor's CLI has no structured-output flag, so the JSON schema goes into the prompt and the reply is parsed and validated by Tulip (with one retry), as for claude.

With codex, Tulip runs codex exec with --ignore-user-config and deletes the PR's .codex/ dir from the checkout first. The model's shell can read only the checkout and system dirs, has no network, and gets only core env vars (PATH, HOME, ...). Web search, apps, and plugins are off. The schema goes in a temporary file passed as --output-schema (made strict, as OpenAI requires); the prompt goes on stdin. Tulip validates the last agent message of the --json event stream (with one retry), as for the other agents.

The page's Content Security Policy lets it load only its own assets, so model-written text can't make it fetch a URL on open. Remote images in the PR description don't show either.

Reviewing on the page

tulip <PR URL> --serve

--serve runs a local server that hosts the page and opens it (unless --no-open), adding a box under each category. Type a note and click Post to GitHub to post it as a PR comment, prefixed with the category name. Each box posts its own comment. Ctrl+C stops the server.

Serve mode supports GitHub PRs only; with a GitLab merge request URL, Tulip stops with an error before fetching anything.

This needs an authenticated gh CLI for the PR's host (self-hosted included) — gh auth login --hostname <host>; Tulip checks this before starting. Comments are posted via gh, so they come from your GitHub account.

Development

pnpm build        # compile TypeScript + copy rendering assets
pnpm test         # run the test suite (vitest)
pnpm lint         # check formatting/lint rules (biome)
pnpm lint:fix     # auto-fix formatting/lint issues
pnpm dev <PR URL> # run the CLI from source, no build step (tsx)

To put your local build on PATH as tulip, run pnpm add -g . after pnpm build.

Releasing

npm version 0.9.0

This bumps package.json, commits, tags v0.9.0 and pushes. The tag triggers the Release workflow, which publishes to npm and creates a GitHub release with generated notes. Run it on master with a clean working tree.

Design decisions are recorded as ADRs in docs/adr/; the LLM prompts live as plain text in src/prompts/.

About

Explains GitHub PRs in a human-friendly way, using LLMs.

Topics

Resources

Stars

118 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages