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
claudeCLI 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.
| Module | Lines | Job |
|---|---|---|
cli.py | ~270 | Entry point (praline = praline.cli:main). Argument parsing, the main menu, ANSI colors. All input() prompts live here or in reviewer.py. |
github.py | ~320 | Every GitHub call, REST and GraphQL, via requests (no gh CLI dependency), plus local git helpers. This is the safety boundary; see Invariants. |
reviewer.py | ~275 | Assembles the review request, parses Claude's JSON reply, runs the approval loop, posts accepted comments. |
memory.py | ~320 | Builds and updates the knowledge base under .praline/, renders it to HTML. |
prompts.py | ~165 | The three system prompts: PR review, repo knowledge, PR history. The review output schema is defined here, in prose. |
claude_client.py | ~80 | One function, ask(). Runs a single headless claude turn and returns its text. |
auto.py | ~170 | Non-interactive mode (praline auto): picks PRs, reviews them, posts every comment, no prompts. See Auto mode. |
term.py | ~20 | ANSI color codes and the _c / _rule helpers, shared by cli.py and auto.py. |
config.py | ~80 | Paths 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:
github.list_open_prslists open PRs; the user picks one and sees a small status line (diff size, who has commented).reviewer.review_prfetches the raw diff and the full existing conversation (top-level comments plus line-level threads, tagged with comment ids), builds the system prompt fromprompts.DEFAULT_REVIEW_PROMPT(or a custom prompt file) plus the knowledge base, and makes oneclaude_client.askcall.- Claude must answer with a single JSON object: a
summary,repliesto existing threads (withreply_to_idand an optionalresolvedflag), newcomments(severitybug/warning/nit) andbugs.review_prstrips markdown fences defensively andjson.loadsthe rest. reviewer.run_approval_loopprints 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.- After a final confirmation,
reviewer.post_accepted_commentsposts 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.
auto.select_prslists 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 ownupdated_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.- 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. - Before reviewing anything, it refetches full stats per candidate (the list endpoint
omits
changed_files, same gotcha asget_pr_status) and sums them. Past--max-changed-filesit asks a single yes/no confirmation; this is the only prompt auto mode ever shows. - Each review's
summary/replies/bugs/commentsare flattened byauto._flatten— the same shaperun_approval_loopbuilds — and posted unconditionally. - 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 fromgit 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 inmemory.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
RuntimeErrorcarrying 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.pymaps 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 throughrun_approval_loopand the final confirmation; nothing incli._do_reviewmay skip them.auto.run_autois the one deliberate exception, gated behind its own subcommand. - No API key. The model backend stays the
claudeCLI. Do not introduceanthropicSDK calls or key handling. - The review reply is JSON with a fixed schema. The schema lives in
prompts.DEFAULT_REVIEW_PROMPTand its consumers arerun_approval_loopandpost_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 action | cli._main_menu plus a _do_* helper next to the existing ones. |
| Change how reviews behave | prompts.py first. Most behavior (tone, priorities, reply-before-comment) is prompt-defined, not code-defined. |
| Add a GitHub operation | github.py, respecting the ALLOWED list. Return plain data (dataclasses or dicts); rendering belongs to callers. |
| Change what a review sees | reviewer.review_pr and _format_existing_conversation for the user message, _build_review_prompt for the system side. |
| Change the approval UX | reviewer.run_approval_loop, _display_comment, _prompt_action. |
| Change knowledge-base content | prompts.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 posting | auto.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.pycarries two small GraphQL calls (find_review_thread_id,resolve_review_thread). Everything else is REST. reply_to_idmust 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_prspaginates byupdated, notmerged_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_prstrips 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_commitstake an explicit ref. They readgit ls-tree/git log <ref>, not the working tree orHEAD. Callers outsidebuild_repo_knowledgemust still pass a ref (e.g."HEAD") if they want the local checkout.PRInfocounts are only populated on single-PR fetches. The list endpoint omitsadditions/deletions/changed_files, so those default to 0 untilget_pris called; that is whyget_pr_statusandauto.run_autoboth refetch.draftandupdated_at, by contrast, are present on the list endpoint already.