$ cat developer_guide.md

🍫 PRaline Developer Guide

Everything you need to start hacking on the code review chocolate.

What PRaline is

PRaline is an interactive terminal tool that reviews GitHub pull requests. It fetches a PR's diff and comment thread, asks Claude for a review, then walks you through each proposed comment so you can accept, reject or edit it before anything is posted under your account.

Three decisions shape the whole codebase. Keep them in mind before touching anything:

  • Claude Code is the LLM backend. Reviews run through the claude CLI on the user's existing subscription. There is no Anthropic API key and no billed API call anywhere.
  • PRaline never writes code. The GitHub layer can read content and post comments or issues. It has no code path that pushes, merges, approves or creates branches.
  • A human approves every comment. Nothing reaches GitHub without going through the interactive approval loop first.

The whole tool is about 1,500 lines of Python across seven modules, with two runtime dependencies: requests and markdown.

Dev setup

You need Python 3.12+, uv, the claude CLI logged in, and a GitHub token in GITHUB_TOKEN or GH_TOKEN (Contents: read, Pull requests: read/write, Issues: read/write; a classic token with repo scope also works).

git clone git@github.com:ameroyer/PRaline.git
cd PRaline
uv sync

# run your working copy against any repo you have PRs in
uv run praline --dir ~/src/some-test-repo --model sonnet

Lint with uvx ruff check . (config in pyproject.toml: line length 100, rules E, F, I). There is no test suite yet, so testing is manual: point --dir at a scratch repo with an open PR and exercise the menu. A first test suite would be a very welcome contribution; github.py and reviewer.py are the natural starting points since both are easy to mock.

Reviews post real comments under your GitHub account. Use a scratch repo while developing, and answer "n" at the posting confirmation if you only want to inspect the output.

Code layout

Each module has one job. The import graph is a shallow tree with cli.py at the root; nothing imports cli.

ModuleLinesJob
cli.py~270Entry point (praline = praline.cli:main). Argument parsing, the main menu, ANSI colors. All input() prompts live here or in reviewer.py.
github.py~320Every GitHub call, REST and GraphQL, via requests (no gh CLI dependency), plus local git helpers. This is the safety boundary; see Invariants.
reviewer.py~275Assembles the review request, parses Claude's JSON reply, runs the approval loop, posts accepted comments.
memory.py~320Builds and updates the knowledge base under .praline/, renders it to HTML.
prompts.py~165The three system prompts: PR review, repo knowledge, PR history. The review output schema is defined here, in prose.
claude_client.py~80One function, ask(). Runs a single headless claude turn and returns its text.
auto.py~170Non-interactive mode (praline auto): picks PRs, reviews them, posts every comment, no prompts. See Auto mode.
term.py~20ANSI color codes and the _c / _rule helpers, shared by cli.py and auto.py.
config.py~80Paths and load/save helpers for the .praline/ files. No logic beyond file IO.

How a review runs

Menu option 1 ends up in cli._do_review, which drives this sequence:

  1. github.list_open_prs lists open PRs; the user picks one and sees a small status line (diff size, who has commented).
  2. reviewer.review_pr fetches the raw diff and the full existing conversation (top-level comments plus line-level threads, tagged with comment ids), builds the system prompt from prompts.DEFAULT_REVIEW_PROMPT (or a custom prompt file) plus the knowledge base, and makes one claude_client.ask call.
  3. Claude must answer with a single JSON object: a summary, replies to existing threads (with reply_to_id and an optional resolved flag), new comments (severity bug / warning / nit) and bugs. review_pr strips markdown fences defensively and json.loads the rest.
  4. reviewer.run_approval_loop prints an overview of everything, then goes item by item: accept, reject or edit. The summary is the first item and, if accepted, becomes the top-level PR comment.
  5. After a final confirmation, reviewer.post_accepted_comments posts each item: replies go into their original thread (and can mark it resolved), items with a file and line become line comments, the rest become general comments.

On a PR that already has a conversation, the prompt makes replying the primary job: the model must consider every existing thread before raising anything new, and an empty list of new comments is treated as a good outcome, not a failure.

Auto mode

praline auto [PR ...] runs the same review as the interactive menu, minus every prompt: auto.run_auto picks PRs, reviews each with reviewer.review_pr, and posts every proposed item straight through reviewer.post_accepted_comments. There is no approval loop.

  1. auto.select_prs lists open PRs and keeps a PR if it was passed explicitly as an argument, or if it is not a draft and has activity — its own updated_at, or a comment created or edited — more recent than the last time this user auto-reviewed it. Explicit PR numbers skip both the draft and activity checks.
  2. Per-user "last reviewed" timestamps live in .praline/auto_state.json (config.load_auto_state / save_auto_state), keyed by PR number, and are only updated after a PR is actually processed.
  3. Before reviewing anything, it refetches full stats per candidate (the list endpoint omits changed_files, same gotcha as get_pr_status) and sums them. Past --max-changed-files it asks a single yes/no confirmation; this is the only prompt auto mode ever shows.
  4. Each review's summary / replies / bugs / comments are flattened by auto._flatten — the same shape run_approval_loop builds — and posted unconditionally.
  5. A per-PR result (files changed, comments added, replies left, threads resolved, or the error if the review call failed) is collected and printed as a summary table at the end.

This is the one intentional exception to the "no auto-posting" invariant below: a human still approves every comment in the interactive menu, but auto mode exists precisely to skip that step for PRs the user has pointed it at.

The knowledge base

build_repo_knowledge never reads your local checkout as-is: it looks up the repo's default branch via the GitHub API, runs github.fetch_remote_branch (git fetch origin <branch>, quiet), and reads git ls-tree / git log off the resulting origin/<branch> ref instead of HEAD. git fetch only updates refs/remotes/*; it never touches the working tree, the index, or local branches, so a stale local checkout can't skew what the knowledge base sees, and running it never surprises you with local changes. If your checkout was behind, a one-line note reports how many commits.

Menu option 2 calls memory.update_knowledge, which writes four files into .praline/ inside the reviewed repo (not inside PRaline):

  • repo.md: architecture, conventions and pain points, distilled by Claude from git ls-files (capped at 300 files) and the last 50 commits.
  • pr_history.md: lessons from merged PRs in the chosen time window, every claim cited as (#123). Citations get linkified to the PR pages.
  • knowledge.html: both documents rendered through the template in memory.py.
  • artifact_url.txt: optional pointer to a published copy of that HTML.

Both markdown files are fed into the review prompt on every review, which is the whole point: reviews get repo-specific over time.

Updates are edit passes, not rewrites. The previous document is included in the prompt with instructions to merge, and _guard_against_erasure refuses any result shorter than half the previous version, keeping the old file instead. A .bak copy is written before each save. If you touch this flow, preserve both protections; silently losing accumulated knowledge is the worst failure mode this module has.

The Claude backend

claude_client.ask() is the only place PRaline talks to a model. It runs:

claude -p --output-format json --model <model> \
       --allowedTools "" --system-prompt <prompt>

Details worth knowing before you modify it:

  • The user message (typically a large diff) is piped via stdin, not argv. Large diffs exceed the OS argument size limit (E2BIG) otherwise.
  • --allowedTools "" disables all tools. Claude only reasons over the text it is handed; it cannot read files or run commands during a review.
  • Every failure mode (timeout, non-zero exit, empty stdout, bad JSON, error payload, empty result) raises a RuntimeError carrying truncated stderr/stdout. Keep that style: errors here surface directly in the CLI and are the user's only debugging signal.
  • The default timeout is 600 seconds; big diffs on big models are slow.

Invariants

These are promises the README makes to users. A PR that breaks one will be rejected regardless of how useful the feature is.

  • No write-to-code endpoints. Every function in github.py maps to a read endpoint or a comment/issue endpoint. When adding a GitHub call, keep it inside the ALLOWED list documented at the top of that file.
  • No auto-posting outside praline auto. The interactive menu's comments flow through run_approval_loop and the final confirmation; nothing in cli._do_review may skip them. auto.run_auto is the one deliberate exception, gated behind its own subcommand.
  • No API key. The model backend stays the claude CLI. Do not introduce anthropic SDK calls or key handling.
  • The review reply is JSON with a fixed schema. The schema lives in prompts.DEFAULT_REVIEW_PROMPT and its consumers are run_approval_loop and post_accepted_comments. Change all of them together, and remember users can supply custom prompt files that must still produce the same schema.

Where changes go

You want to...Touch
Add a menu actioncli._main_menu plus a _do_* helper next to the existing ones.
Change how reviews behaveprompts.py first. Most behavior (tone, priorities, reply-before-comment) is prompt-defined, not code-defined.
Add a GitHub operationgithub.py, respecting the ALLOWED list. Return plain data (dataclasses or dicts); rendering belongs to callers.
Change what a review seesreviewer.review_pr and _format_existing_conversation for the user message, _build_review_prompt for the system side.
Change the approval UXreviewer.run_approval_loop, _display_comment, _prompt_action.
Change knowledge-base contentprompts.INIT_REPO_PROMPT / INIT_PR_HISTORY_PROMPT; the surrounding pipeline is memory.py.
Add a stored file under .praline/config.py for the path and load/save helpers, then use them from wherever needs it.
Change auto-mode selection or postingauto.select_prs for which PRs qualify, auto.run_auto for the review/post/summary loop.

Gotchas

  • Resolving review threads is GraphQL-only. GitHub's REST API cannot mark a conversation resolved, so github.py carries two small GraphQL calls (find_review_thread_id, resolve_review_thread). Everything else is REST.
  • reply_to_id must be the thread's root comment. Replying to a reply detaches the comment from its thread. The prompt instructs the model accordingly; keep that instruction if you rewrite it.
  • get_merged_prs paginates by updated, not merged_at. The early-exit condition is deliberately conservative (a whole page must be older than the cutoff) and there is a hard cap of 10 pages. Easy to break if you "simplify" it.
  • Claude sometimes wraps JSON in code fences. review_pr strips a leading ``` block before parsing. Removing this "redundant" code will produce intermittent parse failures.
  • The knowledge files live in the reviewed repo. Consider suggesting .praline/ for that repo's .gitignore; PRaline itself never commits anything, so it cannot do this for the user.
  • get_repo_structure / get_recent_commits take an explicit ref. They read git ls-tree / git log <ref>, not the working tree or HEAD. Callers outside build_repo_knowledge must still pass a ref (e.g. "HEAD") if they want the local checkout.
  • PRInfo counts are only populated on single-PR fetches. The list endpoint omits additions/deletions/changed_files, so those default to 0 until get_pr is called; that is why get_pr_status and auto.run_auto both refetch. draft and updated_at, by contrast, are present on the list endpoint already.