phada
v0.16.0
Published
Review GitHub pull requests with the AI you already use: Claude Code, Codex or Ollama.
Maintainers
Readme
Phada
Phada publishes by default.
phada reviewposts the review on the pull request as the owner of the GitHub token. Use--dry-runto only print it.
Phada reviews a GitHub pull request with the AI you already use — Claude Code, Codex or a model served by Ollama — and publishes the review on the pull request.
It runs on your machine. Phada fetches the pull request, its diff, its conversation and its CI checks, reads the repository's own rules and docs, sends them to the AI provider you choose, lets the AI read the rest of the repository with read-only tools, checks that the answer is a valid review and scores it. It prints the review as Markdown or JSON and posts it on the pull request: one comment on the line of each finding and the rest in the review body (see Publishing).
Requirements
- Node.js 22.13 or newer
git, for the repository investigation andphada index(without it, the AI only sees the diff and the context)- A GitHub token that can read the pull request and write reviews, or the login of the GitHub CLI (
gh) (see GitHub token) - One AI provider:
- Claude Code (
claude), logged in with your subscription, - Codex CLI (
codex, install withnpm i -g @openai/codex, thencodex login), or - Ollama, running, with a model pulled (e.g.
ollama pull qwen2.5-coder:7b)
- Claude Code (
Install
npm install -g phada
phadaIn a terminal, phada opens the setup while you have no config of your own: one step at
a time, it gets the GitHub token, the AI, the language and the repositories to watch ready, and
writes nothing before you confirm it. phada setup opens it again at any time.
Or run it without installing:
npx phada review owner/repo#123Usage
phada review owner/repo#123
phada review https://github.com/owner/repo/pull/123 --provider codexProgress goes to stderr and the review to stdout, so phada review owner/repo#123 > review.md
saves only the review, and --format json prints it as JSON for scripts. A review of a large pull
request can take a few minutes. phada review owner/repo#123 --dry-run prints exactly what would
be posted and posts nothing; phada publish <id> then posts exactly that, without running the
AI again (see Look first, publish later).
phada --help lists the commands, and phada review --help, phada publish --help, phada setup --help,
phada init --help, phada index --help, phada rules --help, phada prompt --help, phada watch --help,
phada history --help, phada misses --help and phada lessons --help list their options. To have new pull requests and pushes reviewed without running anything, see
Watch mode; to see what happened to the comments Phada published, see
History, and for what people pointed out that Phada did not, Misses and
Lessons. To see all of it in one place, run phada alone (see
Terminal app).
GitHub token
Phada reads the token from GITHUB_TOKEN, or from GH_TOKEN when GITHUB_TOKEN is not set.
Without either, it uses the login of the GitHub CLI: after gh auth login, every command works
with no variable to export. Phada asks gh auth token (or, on older gh versions without it,
gh auth status --show-token) once per run, only when a command needs GitHub, and never saves
or prints the token. The token reads the pull request and its reviews and publishes a review, so the token needs:
- a fine-grained token with Contents: Read-only and Pull requests: Read and write on the repository, plus Checks: Read-only, Commit statuses: Read-only and Actions: Read-only for the CI checks, or
- a classic token with the
reposcope (public_repois enough for public repositories).
A read-only token is enough with --dry-run. Without write access, Phada still prints the review
and then stops with an error that names the missing permission. Contents: Read-only lets
Phada read the repository's rules and docs (see Rules and repository context);
without it, the review runs on the diff alone and says so. Without one of the CI permissions, the
review runs without that part of the checks and warns with the permission the token lacks, e.g.
CI check runs were not read: GitHub denied reading the check runs (HTTP 403): the token needs the checks:read permission.
Terminal app
Run phada with no command in a terminal to open the app: what Phada has set up, running and
reviewed, in one place, without remembering commands or folders, and the setup and the
changes to your config you would otherwise write by hand.
It opens on the terminal's alternate screen, like less or vim, and the terminal is back as it
was when you quit.
phada| Screen | What it shows |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Home | One line each for GitHub (where the token comes from and whose it is), the AI (installed and logged in), the watch, the queue and the repository maps; the last reviews; and what to do next, with the command |
| Repositories | The watched repositories, the ones with a config of their own and the ones in the history → the settings of each with where every value comes from, its watch filters, its map, its related repositories and its last reviews (and, for a watched one, whether it publishes); w, p, a, e and R change them, i indexes it, v reviews one of its open pull requests |
| Rules | The reviewer lens and your rules at once; the repository's .phada/ and the rules and docs a review of the default branch reads; p and a pull request number show what a review of it reads and its prompt |
| Reviews | The history, with f to filter → one review with its score, findings, what was published, the CI line and what happened to each comment; n reviews a pull request |
| Queue | The watch running in this app, in another terminal (with its PID) or not running, its last and next cycle, the job running (with the step of its review when the watch runs here), the jobs queued, waiting for CI, skipped for failing CI or failed, a pause, the reviews this hour and the log of the watch started here → a job with its requests; w starts or stops the watch, l opens its whole log |
| Automation | The watch, the CI wait, the request sources that are on (pull request comments, phada enqueue, phada mcp, Slack), the hooks and the when commands |
| Provider | The provider and model you chose and where they come from, the language, each AI checked (installed, logged in, Ollama running and its models) with the command that fixes it, and your MCP servers; c, L and l change them |
| Graph | The map of each repository of the Repositories screen: whether it is indexed, its commit, files, symbols and size → its commit and branch, when it was indexed and how long it took, its files per language, its symbols by kind, its imports, exports, calls and uses, and the files left out and why, and its connections: each related repository it declares (context.repos) linked by the packages one publishes and the other uses, without a link, or not indexed and why; i indexes it and I the related ones not indexed; s explores a symbol |
| Learnings | Per repository, the relations learned from reviews, what happened to Phada's comments, per repository and per rule, the open misses with how many the latest replay caught, and the pending lessons to accept or discard |
Keys: ←/→ or Tab move between screens, 1–9 jump to one, / finds a screen by name (and
/setup the setup, /language the language, /index the Graph, /review a review), ? lists every key, ↑/↓ and Enter open an
item, Esc goes back, r reads again what comes from GitHub or the AI CLIs, and q quits. The footer lists the keys of the screen you are on.
What is on your machine (the config, the history, the queue) is read every 2 seconds; what comes
from GitHub or the AI CLIs is read when a screen opens and on r. The app writes only what
Changing your config from the app lists, and the history
only for a review or the watch you run from it, as phada review and phada watch do (just
reading it never creates its file). It reads each map without changing it: a map that is
missing, made by another version of Phada or that cannot be read is shown as such on the Graph
screen, until you index it. The connections of the Graph come only from your machine: the
manifests and the .phada/config.yml of the indexed commit, read from the local copy the index
was built from, never fetched. It needs a terminal of at least 80×24 and says so
when it is smaller. It follows the language of your config (English,
Portuguese and Spanish), uses the full colors your terminal supports (down to 16), and none with
NO_COLOR or TERM=dumb. q exits with 0, Ctrl-C with 130 and SIGTERM with 143. Outside a
terminal (a pipe, a script), phada with no command prints the same error as before, and
phada setup stops with a usage error.
Setup
phada setup opens the app on the setup; phada alone opens it while your own config.yml does
not exist, and /setup opens it from any screen, from its first step. It has seven steps, shown as Step n of 7: s
skips one, and Esc asks whether to leave the setup for the Home (what you confirmed is already
written, and phada setup starts again).
- GitHub: where the token comes from (
GITHUB_TOKEN,GH_TOKENor theghlogin) and whose it is. Without a token, or when GitHub refuses the token of theghlogin,Enterhands the terminal togh auth loginand checks again when it ends. Withoutgh, it says how to install it or to exportGITHUB_TOKEN; a token from a variable that GitHub refuses has to be replaced or unset. - AI: Claude Code, Codex and Ollama, each checked (installed, logged in, running), and
rchecks again. Choosing a CLI that is not logged in hands the terminal to its login (claude auth login,codex login) and checks again when it ends; the credentials stay with the CLI. A CLI reviews with its own default model or one you type. When Ollama is not running, the step says to start it withollama serveand pressr; when it runs, you pick an installed model, type one, or take the suggestedqwen2.5-coder:7b(about 4.7 GB). A model Ollama does not have is pulled through its API (atOLLAMA_HOSTtoo), with its progress, andEsccancels the pull. - Language: English, Portuguese or Spanish, for the app and the reviews; the app speaks it at once.
- Repositories: the repositories of the token owner on GitHub (owned, as a collaborator and
of your organizations, without the archived ones, most recently pushed first), with the ones
already in
watch.reposmarked and first.ffilters the list,Spacemarks or unmarks one,aadds anowner/repothat is not listed, andEntersaves the marked ones towatch.repos. - Graph: builds the map of each watched repository, one at a time, with
N of M files.Escstops: what was extracted is kept, andphada watchfinishes it when it is idle, orphada index owner/reponow. - This repository: when the app runs inside a git repository without a
.phada/folder,Enterrunsphada initthere and lists the files it created. - Summary: what was written and to which file, what was indexed or left for later, what
phada initcreated, the steps skipped, and what to run now (phada watchwhen a repository is watched,phada review owner/repo#N).
A step that changes your config shows the change first, one line per value
(provider claude → codex, watch.repos + owner/repo), and writes it only on Enter; Esc
cancels it.
Reviewing a pull request from the app
/review in the palette, n on the Reviews screen or v on a repository of the Repositories
screen open the review in place of the tabs:
- The pull request: type
owner/repo#Nor its URL andEnter(anything else is refused on the spot), or pick a repository (Escleaves the field,ttypes again) → its open pull requests from GitHub, with their number, title, author and whether they are drafts (rreads them again).vopens straight on the open pull requests of that repository. - The mode: Only look reviews here and posts nothing, as
phada review --dry-run; Review and publish posts the review on the pull request. While aphada watchruns (in the app or in another terminal), it becomes Put in the queue: the watch reviews and publishes it, asphada enqueue owner/repo#N --publish pr, and the result is the new job, the job it joined or why the queue refused it, withEnterto the Queue. Only look still runs here. - For publishing, the pull request and the GitHub login that will publish it (the owner of
the token) to confirm:
Enterpublishes,Escgoes back. Without a token it does not offer to publish. - The steps, live: the pull request fetched (with a note under it when it is closed or a
draft, which it still reviews, as
phada reviewdoes), the context, the conversation, the CI, the indexing (N of M files), the related repositories, the impact, each run of the AI (review, verify, recheck, skill) with its time, publishing, and the warnings.Escasks before cancelling (Entercancels,Esckeeps it running): Phada stops the AI CLI (or the request to Ollama), posts nothing and saves nothing in the history. Once it publishes, it no longer cancels. - The result: the same detail as the Reviews screen (score, verdict, findings with their
text, what was published or, for only look, that nothing was posted, what it would resolve or
why it would skip, the CI), how long it took and whether the history kept it. A failed review
shows its error; with
phada --debug, also its causes and stack.Escgoes back to the screen you came from. - Publishing what you looked at: on the result of only look,
pasks to publish that exact review (the pull request, the GitHub login and how many line comments and repository debt items it posts) andEnterpublishes it without running the AI again, asphada publish; it shows the link, or why it did not publish (the pull request moved since the review, or Phada already reviewed that commit), andEscgoes back to the result, which no longer offersponce it is published. It needs the run in the history: withhistory: falseit is not offered. It also publishes while a watch runs, since no new review starts.
It is the same review as phada review owner/repo#N (with --dry-run for only look): the
provider, model and MCP servers of your config for that repository, your GitHub token, and the
run saved in the history unless history: false. The AI CLI runs in its own process group, so it
never writes on the app. Esc goes one step back; the palette stays closed while the review is
open, so it is left only with Esc. While a review runs, q and Ctrl-C ask
before quitting (Enter cancels it and quits, waiting for a publication under way; Esc stays;
a second Ctrl-C cancels and quits at once).
Running the watch from the app
w on the Queue screen, or /watch in the palette from any screen, starts the watch in the app:
the same watch as phada watch (your watch: settings, the lock, the history, the hooks and
Slack), until you stop it. The Queue says whether the watch runs in this app, in another terminal
(with its PID; w then only says so) or not at all, and what kept it from starting, in one line.
Under the jobs, its log: what it queued, started, published, skipped or retried, its warnings, the
time of each line, and the step of the review under way on the running job; l opens the last 200
lines. With the watch running here, w (or /watch) asks before stopping it after the current
review; Enter stops, Esc keeps it running. Quitting with q, Esc or Ctrl-C asks too:
Enter lets the current review finish and quits, a second Ctrl-C stops the AI at once and puts
the pull request back in the queue, and a SIGTERM does that without asking. A review you start
from the app while the watch runs here (only look) has its own AI CLI: cancelling one never stops
the other.
Changing your config from the app
- Provider:
cchanges the AI and its model (the choice of the setup's AI step, with the login or the model pull there),Lchanges the language andllogs in to the chosen AI CLI again (or to one that is not logged in)./languagechanges the language from any screen. - Graph and Repositories:
iindexes the selected (or open) repository, or indexes it again, withN of M filesas it goes;Escstops it, and the screen reads the map again when it ends. Leaving the screen stops it too. On an open repository of the Graph,Iindexes the related repositories it declares that are not indexed yet. - Graph: on an open repository,
sasks for a symbol name and searches the graph the review uses, linked to the related repositories it declares that are indexed and linked by package. Several definitions are listed to pick one; one opens as a tree with its callers and its dependencies:Enteror→opens a node one level (a caller into its own callers, a dependency into its own dependencies, across the linked repositories),←closes it, andTabmoves between screens meanwhile. Each level shows 20 items and how many more, and says when the graph cut the list short. A name not found or a repository that is not indexed says so, withito index it. Everything is read from your machine; the app keeps the graph of one repository open at a time. - Repositories: on the selected repository,
wwatches or unwatches it,pturns itspublishon or off,aadds repositories from your GitHub list or typed, andeandRopen its ownrepos/<owner>/<repo>/config.ymlandrules.md(in your config folder) in$VISUAL,$EDITORorvi, created with a commented header when they do not exist. The app is back when the editor exits, with the file read again.
Like the setup, each change shows what it does before → after and is written only on Enter.
The app writes:
- your own
config.yml:provider,model,languageandwatch.repos(a new file gets the values on top and the commented template ofphada init --globalbelow them); - the
repos/<owner>/<repo>/config.ymlandrules.mdyou open in the editor, created when they do not exist and never overwritten; - the map of a repository when you index it in the setup or with
ion the Graph and Repositories screens, in the cache, asphada indexdoes (on its default branch, replacing a map made by another version of Phada or that cannot be read); - a repository's
.phada/only throughphada init, in the setup's step 6; - the history, with a review or the watch you run from the app, as
phada reviewandphada watchdo (the watch also takes its lock and keeps the index of each default branch up to date).
Comments, the order of the keys and the lines a change does not touch stay as you wrote them. When
a line cannot be kept as written (an indentation of 4 spaces, for example), the change says that
the file is rewritten in a standard layout before you confirm it. The new text is checked like any
config before it is written, and it is written atomically (a temporary file renamed over it); a
symbolic link stays a link, and the file it points to changes. A config file that is already
invalid is never edited: the app shows its error and offers no change. If the file changed after
the change was shown, Enter shows it again.
While a login or the editor runs, the terminal is theirs and Ctrl-C goes to them, not to the app.
The login of an AI CLI runs without GITHUB_TOKEN and GH_TOKEN in its environment.
What a review looks like
Phada sends the diff with the line number of every added and context line, so the AI copies the line instead of counting it. The AI answers in a fixed JSON shape that Phada validates, and every finding must point to a file of the pull request and a line inside the diff. Phada then prints:
Confidence score (0–5), computed by Phada from the findings, never by the AI: 5 means no problems found, 4 only P2 findings, 3 one P1, 2 two or more P1, 1 one P0, 0 two or more P0. Neither the AI nor text inside the pull request can set the score directly; it only follows the findings.
Coverage next to the score when the review did not look at every file, e.g.
5/5 (ready to merge) · low coverage: 11 of 63 files (52 files not accounted for). The AI accounts for every changed file (judged from the diff, read whole, compared with existing code, or skipped with a reason), and Phada checks that account against what the tools actually read: a file said to be read but never read counts only as seen in the diff, and a new file of 50 or more added lines counts only when the AI compared it with existing code it read outside the diff, or with the file of the diff its code moved out of. The coverage is full when every file is covered, low below 70% of the files, partial in between; full shows nothing. It never changes the score or the verdict: it says how far the findings can be trusted. Without the tools the account is taken as given and the label says(declared). When the coverage is low, Phada runs one continuation pass: the same review with the list of what was left out and the findings already made, whose findings join the first ones (review.continuation: falseor--no-continuationturns it off). The coverage is in the terminal review, the body posted on the pull request,--dry-run(with each file left out),--format json(review.coverage), the history and the review's detail in the app.Summary of what the pull request changes and a table of the changed files.
Findings grouped by severity, each with file, line, confidence (0–100), why it matters and a suggested fix (with the exact code change when the fix is a local edit, see Suggested changes). A finding sits at the changed line where the fix goes, such as the function that copies existing code or the request without a
catch, not at a line that only calls it or shows its effect. Confidence is how sure the AI is that the problem is real and happens as described; how often it happens and how much it costs go into the severity, so a real but rare bug is a sure finding with a lower severity (a P0 stays P0 however rarely it happens). Only findings at the confidence cut or above are shown here (60 by default, see--min-confidence):- P0 · Must fix: security holes, data loss, crashes, wrong money handling;
- P1 · Should fix: bugs, incorrect behavior, edge cases, race conditions, leaks;
- P2 · Consider: maintainability or design risks with a concrete consequence.
A finding at most 10 points above the cut is marked close to the cut: the AI does not rate a finding the same way every time, so another run of the same commit may leave it out. The mark and their count show in the terminal review, in a "Close to the cut" section of
--dry-run, incloseToCutof each finding in--format jsonand the history, and in the review's detail in the app; the comments posted on the pull request do not carry it.Worth checking: up to five findings with confidence from 50 up to the cut, one line each. They do not change the score.
Rejected by verification: each candidate the verification turned down, with its place, confidence and the reason; only in the terminal, never on the pull request.
Repository debt: problems that are real but that this change neither introduces nor worsens (an exported function no caller uses that the change only touches, say), one line each with its place and why. See Repository debt.
A footer with how many findings there are, how many are worth checking, how many were dropped (rejected by verification, outside the diff, duplicates, below confidence 50, not in the expected shape, repository debt with
review.debt: off, or reimplementations without the existing code read) and the provider, model, time and tokens.
At the end, phada review prints on stderr the tokens of each call to the AI (each review pass,
one per skill, the continuation pass, the verification and the recheck of earlier comments; a native skill counts its run
and the conversion of its output together) and their total: the input tokens, how many of them
came from the provider's cache, and the output tokens, e.g.
Tokens: review 812.0k in (640.0k cached) · 9.0k out; verify 120.0k in · 2.0k out; total 932.0k in (640.0k cached) · 11.0k out.
A provider that does not report them shows not reported (Ollama reports no cache). The same
counts are in a "Tokens" section of --dry-run, in usage of --format json and the history
(passes with pass, skill and usage, and the total), in the review's detail in the app
and next to each AI step while the app runs a review.
Large pull requests
With review.groups: true or --groups (off by default), when the diff has 30 KB or more of code,
Phada splits it into diff groups of about 20 KB each, by directory (a directory too large is
split by its subfolders, a single file larger than that is a group of its own, and there are at most
6 groups), and each review pass runs once per group: the AI sees the diff of its group, the list of
every changed file with the ones of the group marked, the same context and the same tools to read
the rest. Findings outside the lines of a group are left out. The answers are joined into one
review: the summary is made from the part of each group, the claims are merged, the coverage
counts every group, and the continuation pass and the verification run once over the whole diff. A
pull request below 30 KB of code, or whose code fits in one group, is reviewed as before. Grouping
spreads the attention over every part of a large pull request, at about twice the tokens.
Data files (.json, .lock, .csv, .tsv, .svg, .snap, .txt, .md, .map) and generated
files (marked linguist-generated in the .gitattributes at the root of the base branch, or
starting with @generated or Code generated … DO NOT EDIT) do not count toward those sizes and
never make a group of their own: they are reviewed with the group of the nearest directory.
Groups run as many at a time as the provider takes: one with Ollama, two with Claude Code and
Codex; review.groups: { concurrency: 1 } to 4 sets it. A group whose answer is not a valid
review becomes a warning and its files are left as coverage gaps; a provider failure, such as a
usage limit, fails the review. phada review prints Group 2/4: api/src/services (6 files, 21 KB)
as each one starts; the groups, with their files, size, findings, time and tokens, are in a
"Groups" section of --dry-run and of phada history <id>, in groups of --format json and
the history, in phada prompt and in the review's detail in the app. The body posted on the pull
request does not change. Native skills are never grouped. --no-groups turns grouping off for one
review when the config turns it on.
By default (turn it off with --no-verify or verify: false), the first pass asks the AI for every
candidate from confidence 25 instead of 50, and Phada makes a second call to the same AI before
scoring. It sends the pull request, the diff and the candidates, without their severity and
confidence, and asks a skeptical reviewer to confirm each one, with its own severity and confidence,
or reject it. A finding is confirmed only when it can happen in the repository under review: when
the effect it claims depends on a condition (a setting, the callers, the log level, a flag, the
inputs that reach the code), the verifier looks for that condition with the
investigation tools (without them, in the diff) and rejects the
finding, naming what is missing, when the repository does not meet it; how a library behaves on its
own is not enough. Rejected findings leave the review and are counted in the footer; --format json
lists them with the reason, and gives the verifier's reason for every confirmed finding too (null
for a finding it did not check). Confirmed findings still below 50 are dropped like any other. A
finding without an answer keeps its first rating and the footer says how many were left unchecked
(not verified when none got an answer). Verification adds about half the time and doubles the
tokens; the time and tokens shown are the total of both calls.
If the AI's answer is not a valid review, Phada stops with an error instead of printing it;
--debug shows the start of the answer.
Claims of the description
The title and description of a pull request are the author's account of the change, so the
review checks them instead of repeating them. The AI lists each concrete, checkable thing they
assert the change does or guarantees ("X prevents Y", "covers Z", "is backward compatible",
"only runs on main") and checks it against the diff and, with the tools, the repository:
confirmed or contradicted with the file and line that show it, or unverified with what is
missing. A contradicted claim that matters is also reported as a finding at the line that breaks
it, and the summary never states an unverified or contradicted claim as fact. The description
stays data to check: an instruction in it is never followed. The claims show in a "Claims of the
description" section of --dry-run, in claims of the review in --format json and the
history, and in the review's detail in the app; they are not posted on the pull request. When
several skills review the pull request, the same claim is kept once, with the strongest verdict
(contradicted, then confirmed): case, spacing and punctuation do not tell claims apart, and a claim
of three words or more that another one only extends (… to the topics and … to the topics of
A and B) is the same claim. Once a skill has listed the claims, the unverified ones of the
skills after it are left out. An AI answer without the list still gives a review, with a warning
that the claims were not checked. A skill that runs natively has its claims taken from what it
wrote, and only the ones it confirms or contradicts: one that reviewed nothing adds no claims.
Repository debt
The AI marks each finding with its scope: change when this pull request introduces or worsens
the problem, repository when the problem is already there and the change only moves or touches
the code. A finding without a scope is a finding of the change. review.debt decides what happens
to the ones of the repository:
summary(default): they are not posted as line comments. The review body lists them under "Repository debt", one line each with severity, file and line, title and a short why, and they count neither in the score nor in the verdict. The ones below the confidence cut stay in "Worth checking" like any other.comments: they are posted and scored like every other finding.off: they are dropped and counted in the footer (debtindroppedof the JSON).
--format json and the history give the scope of every finding and the debt list of the
review; the app marks a finding of the repository and shows the debt in the review's detail.
Reimplementations
The built-in reviewer also looks for code the pull request writes again although the repository
already has code that does the same (a helper, a function, a module): a P2 finding, or P1 when the
copy diverges from the existing code and causes a bug. Such a finding gives the place of the
existing code in existing (path:line, or owner/repo:path:line in a related
repository), and Phada keeps it only when the AI read that file during the
review, with read_file or in the results of a grep, and the line is not one the diff adds; the
place then comes first in the finding's sources. Otherwise a P2 finding is dropped and counted in
the footer, e.g. 1 dropped (1 reimplementation finding without the existing code read), in the
terminal and in the body posted on the pull request (in the review language; unbacked in dropped
of the JSON, and the dropped findings themselves in unbacked); a P0 or P1 finding is a problem on
its own, so it stays without existing. Without the investigation
tools nothing can be read, so these P2 findings are always dropped. The
verifier reads the cited code and rejects the finding when that code does not do the same thing.
Findings of a skill that runs natively have no existing and stay
ordinary findings. --format json and the history give existing on every finding (null when it
has none).
Suggested changes
When the fix of a finding is a local edit, the AI also gives the lines it replaces and the code
that takes their place. Phada keeps that suggestion only when the lines are added or context
lines of one @@ section of the diff, at most 20 of them, include the line of the finding, are
quoted exactly as they are in the diff, and the new code differs from them and holds no hidden
control or bidirectional characters. Otherwise the finding keeps only its fix in words. The
verifier sees the suggestion too and drops it when the new code would not fix the problem or
would break something else. A kept suggestion shows as a diff block in the terminal and is
published as a GitHub suggestion that the author applies with one click (suggestion in the
JSON, and a suggestions count of valid, invalid and dropped ones).
Publishing
Each run posts one GitHub review (a comment review, never an approval or a request for changes) on the commit it reviewed, as the owner of the token:
- every finding becomes a comment on its line, with severity, title, confidence, why it matters and the suggested fix, and a "Based on" line with the evidence (the files and CI checks it relies on) and the rule it breaks; the skill that found it stays in a hidden marker of the comment and in the history;
- the review body starts with the butterfly and the score (
## 🦋 3/5 (implementation issues)) and has the summary, the changed files, how many findings were posted, the findings worth checking, the repository debt and what was dropped; it names neither Phada nor the provider and model; - a pull request without changes gets nothing.
Phada reads its earlier reviews and threads on the pull request first, and only counts the ones written by the owner of the token. It decides before calling the AI:
| Earlier Phada review on the same commit | What happens | | ------------------------------------------------------ | -------------------------------------------------------- | | None | Reviews and publishes | | Exists, and a Phada thread on the pull request is open | Skips: "already reviewed by Phada; N threads still open" | | Exists, it had findings and every thread is resolved | Reviews and publishes again (see below) | | Exists and found nothing | Skips: "already reviewed by Phada; nothing was found" |
--force always reviews; --dry-run never skips and says what a real run would do. A skipped run
exits with 0 and does not call the AI.
After a new push, a finding that matches an open Phada thread (same file, and the same title or a
line within three of it) is not posted again as a comment: the review body lists it under "Still
open from previous reviews", so it never disappears from the pull request. When every finding is
still open, Phada posts only a short note saying there is nothing new and how many findings remain
open. A finding with the same title as a Phada thread someone resolved in that file is not posted
again either (a different finding near it still is): the run says how many findings were
dismissed earlier and left out (dismissed in the JSON).
Look first, publish later
Another run of the AI can find something else, so publishing after a --dry-run should post what
the dry run found, not review again. phada publish <id> posts the dry run kept in the
history as run <id> (the id is at the end of phada review --dry-run and in
phada history), without calling the AI:
phada review owner/repo#123 --dry-run # … Saved as run 42: phada history show 42
phada publish 42 # posts exactly that reviewIt reads the pull request first and refuses when it moved to another commit since the dry run
(The pull request moved from a1b2c3d to 9f8e7d6 since run 42): review it again, or use --force
to publish on the commit the dry run reviewed, where the line comments still point. It plans the
comments against the threads on the pull request now, as a review does: a finding whose thread is
still open is not posted again, one dismissed earlier is left out, and the comments the dry run
found fixed are resolved if they are still open. A commit Phada already reviewed is skipped
unless --force, so publishing the same dry run twice posts it once. The review is written in
the language of the dry run. Only a dry run that reviewed something can be published: a run
already published, skipped or failed is refused, and so is a dry run saved by a version before
phada publish, which did not keep everything the review needs. The publication is saved as a
new run of the history (phada history show names the dry run it came from), and
--format json prints its JSON, with publishedFrom. In the app, p on the result of Only
look does the same, after asking.
Comments fixed in a new commit
On a new commit, Phada first rechecks its own open comments from earlier commits, one AI call
for all of them. For each comment it sends what changed in that file since the comment was
posted (GitHub's comparison of the two commits) and lets the AI read the file at the new head;
a comment whose file did not change stays open without asking the AI, and so does one Phada
cannot check (no comparison after a rebase and no access to the repository with
--no-investigate). The AI answers fixed, still-valid or unsure for each one, and Phada
only acts on fixed: right before posting the new review, it resolves the thread and replies
"Fixed in abc1234: …" with the reason. A comment that is still valid or unsure stays open,
Phada never reopens a thread, never touches threads of other people, leaves alone a thread a
person reopened after Phada resolved it, and a failure on one thread does not stop the others
(the run says "Resolved N of M comments found fixed" and warns about each failure). The new review does not post again a finding of a
comment found fixed in the same run; if the problem comes back in a later commit, it is posted
again, while a finding whose thread a person resolved is never posted again. --dry-run says
what it would resolve and resolves nothing, and nothing is resolved when publishing is off.
Turn it off with resolveThreads: false in a config file or --no-resolve. The JSON has a
recheck block with every verdict and resolved counts in the publication block.
Pushes that change nothing to review
Every review keeps a fingerprint of the reviewed diff, without the files left out of the review
and without the line numbers of its hunks. A new commit whose diff has the same fingerprint as the last
Phada review of the pull request is skipped without calling the AI: a push that only touches
ignored files, or a merge of the base branch that only moves the changed lines, does not trigger
a new review. --force reviews anyway and --dry-run says it would skip.
Replaying an earlier commit
--head <sha> reviews the pull request as it was at an earlier commit, to see whether this
version of Phada (or your config) finds a problem it missed there:
phada review owner/repo#123 --head 1e6a005 --dry-runThe SHA is a commit of the pull request (at least 7 characters), including one a force-push
replaced. The review gets the diff from the base up to that commit and nothing created after the
moment it became the head: reviews, comments, threads and replies written later are left out,
Phada's own included; the threads from before are shown open and current, on the line where they
were written, since GitHub keeps no record of when a thread was resolved; a check that finished later is shown as still running; and
a native skill reads nothing of the repository on GitHub (it works on the checkout at that commit,
and gh pr view, gh pr diff and gh pr checks answer from the replay). The moment is the
first force-push that brought the commit; GitHub does not record when a normal push happened, so
otherwise the cut is the commit date, which comes before the push, and the run warns
The cut is approximate. The title and the description are the ones of today.
A replay always runs with --dry-run (without it Phada refuses: a replay never publishes),
never skips, rechecks and resolves no comment, and does not wait for the checks (--wait-ci is
refused). It is kept in the history as a replay, which phada publish refuses and
the feedback of the comments does not count. phada prompt owner/repo#123 --head <sha> shows the
prompt of the replay without calling the AI.
Text written by the AI cannot mention anyone, link other issues or pull requests (#12, owner/repo#12), add HTML or forge Phada's own markers, and a body
longer than GitHub allows is shortened with a visible "(truncated)". Two runs on the same pull
request at the same time can both publish. GitHub refuses a new review while you have a pending
review of your own open in its interface; submit or discard it first.
Watch mode
phada watch follows the repositories you list in your own config and reviews new pull
requests and new pushes on its own, one at a time, and publishes them like phada review. The
setup and the Repositories screen of the app list them for you; the other keys are
written by hand:
# ~/.config/phada/config.yml
watch:
interval: 5m # how often to look for changes (60s at least)
settle: 3m # wait after the last push before reviewing
maxReviewsPerHour: 6
repos:
- owner/repo
- repo: owner/other
publish: false # keep the review in the history only
allowExternal: true
authors: [alice] # only these authors
labels: [phada] # only pull requests with one of these labels
baseBranches: [main]phada watch # in the foreground; Ctrl-C once lets the running review finish
phada watch --once # one cycle and exit, e.g. from cron (always looks for changes)
phada watch --status # the queue (and jobs waiting for CI), reviews in progress, failures, last reviewswatchis only read from your top-level config, never from a repository's.phada/or fromrepos/<owner>/<repo>/config.yml: a repository cannot make your machine review anything. The provider, model, language and the other options of each repository come from the usual config files, as withphada review.- The first time the watch sees a repository, the pull requests already open are recorded as
they are and not reviewed; a new pull request, or a new push on any of them, is.
--backfillreviews the open ones too; on a repository the watch already recorded it has no effect, and the watch says so. - A push is reviewed once the
settletime has passed since the watch first saw it, so a burst of pushes leads to one review of the newest commit. There is one job per pull request and one review at a time, at mostmaxReviewsPerHouran hour. When a pull request moves to a newer commit while it is being reviewed, the review is kept in the history and not published, and the newer commit is reviewed instead. A commit Phada already reviewed is never reviewed again. - Before a job starts, the watch reads the checks of its commit. While some are
pending, the job waits for CI (
waitinginphada watch --status, with until when and the pending checks), read again every minute, for at mostci.wait(15 minutes by default,0sturns it off) after the watch saw the commit; then it is reviewed with the checks as they are. A failing check does not end the wait while others are still pending, so the review gets the result of the whole pipeline. A commit without any check 2 minutes after the watch saw it, or whose checks all ended, is reviewed at once, and so is one whose checks cannot be read (with a warning). A push replaces a waiting job like a queued one, a request joins it and reads the checks again at once, and waiting does not count inmaxReviewsPerHour. - With
ci.onFailure: skip, a commit whose checks fail is not reviewed: the job is skipped asci-failing(in the history, thereview.skippedevent and Slack), its checks are read again everyintervalfor 24 hours, and the same job is reviewed once they no longer fail. A request for that commit reads them again at once. A new push ends it: the new commit is reviewed on its own, and the requests of the skipped one move to it. - Drafts and pull requests opened by bots are never picked up by the polling (someone can still
ask for them). On a public repository, only
pull requests of owners, members and collaborators are, unless the repository sets
allowExternal: true: the AI runs on that code with nobody watching. - A failure that may pass (network, GitHub 5xx, a provider error) is tried again after 1 minute and after 5 minutes; after the third attempt the job is marked failed until a new commit. A provider usage limit pauses the queue for 15 minutes (doubling up to 2 hours while it lasts), and the GitHub rate limit pauses it until it resets. When the AI CLI is not installed or not logged in, or GitHub rejects the token, the watch stops with the error. Nothing is published from a review that did not finish.
- With nothing to review, the watch updates the mirror and the map of each default branch when it moved, a minute at a time, so reviews start from a warm index.
- One watch runs at a time on a machine (a lock in the data folder). The first Ctrl-C lets the running review finish and stops; the second stops the AI too and puts the pull request back in the queue. To keep it running, start it in tmux or as a service of your own.
phada watch --status --format json prints the same as JSON (schemaVersion: 1).
Asking for reviews from anywhere
Besides its own polling, the watch reviews what people and tools ask for. Phada knows nothing
about chat apps or mail: it takes review requests through three doors (phada enqueue,
phada mcp and a /phada review comment) plus one built-in client, Slack, and it sends
review events to your hooks. Every request goes through the same checks, whatever asked for
it, and is refused with a reason when it fails one:
- the repository is listed under
watch:in your own config (not-watched); - the pull request is open (
closed); - on a public repository, the author is an owner, member or collaborator unless the repository
sets
allowExternal: true(external); - the
whencommand of the repository lets it through (when-command); - the commit was not already reviewed (
already-reviewed, with that review); - fewer than
maxReviewsPerHournew reviews were queued in the last hour (hourly-limit).
A request for a commit that already has a job waiting or running joins that job: one review, and
everyone who asked hears back. The one exception is a request that publishes while the running
job does not: it gets a review of its own once that one ends. A request is reviewed at once, without the settle time, and the
filters of watch: (authors, labels, baseBranches, drafts) only decide what the polling
picks up: a draft someone asks for is reviewed. When the pull request gets a new commit before
its review starts, the request moves to the new commit. Each request says where the review goes:
pr (the default) publishes it on the pull request, none keeps it in the history and the
events only. The review is the same either way.
Requests wait in the queue of phada watch, which runs them; with no watch running they wait
until one starts.
From a script: phada enqueue
phada enqueue owner/repo#123 # exit 0 when queued or joined, 1 when refused
phada enqueue owner/repo#123 --publish none # review it without posting on the pull request
phada enqueue owner/repo#123 --format jsonFor example, from cron every morning:
0 9 * * 1-5 phada enqueue acme/shop#42 --publish none.
From your AI: phada mcp
phada mcp is an MCP server over stdio. Add it to your AI CLI, for instance
claude mcp add phada -- phada mcp, and the AI can ask for a review and wait for the answer:
| Tool | What it does |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| review_pull_request | pr (owner/repo#N or a URL) and publish (pr or none): queues the review, joins the one already queued, or says why it was refused |
| job_status | The state of a job, its place in the queue and, once it ended, its event |
| wait_for_job | Waits until the job ends (timeoutSeconds, 300 by default, 1800 at most) and returns its event with the full review |
| list_queue | The jobs waiting and the one running |
The AI asks; Phada decides. A message that talks the AI into asking for a review of some other
repository is refused like any other request. phada mcp needs a GitHub token (GITHUB_TOKEN,
GH_TOKEN or the gh login) to read the pull requests asked for.
From the pull request: /phada review
An owner, member or collaborator of a watched repository can comment /phada review (as the
first line) on a pull request. The watch reads the new comments of each repository once per
interval, reacts with 👀 to the ones it takes and reviews the pull request; the review is
published on it. Comments from anyone else, from bots, or older than when the watch first saw
the repository are ignored.
Slack
With slack: in your own config, phada watch also listens to the Slack channels you list
(Socket Mode, so no public URL is needed) and reviews the pull requests linked in their messages:
# ~/.config/phada/config.yml
slack:
channels:
- C0123ABCD # the channel ID, at the bottom of the channel details
- id: C0456EFGH
publish: none # pr (default) or none: answer in Slack only
reply: full # link (default), summary or full
emojis: # optional; these are the defaults
queued: hourglass_flowing_sand
clean: white_check_mark
comments: speech_balloon
blocking: x
failed: warning
skipped: eyesexport PHADA_SLACK_BOT_TOKEN=xoxb-… # the bot token of your Slack app
export PHADA_SLACK_APP_TOKEN=xapp-… # an app-level token with connections:write
phada watchThe Slack app needs Socket Mode on, the bot scopes channels:history (and groups:history for
private channels), chat:write and reactions:write, the message.channels (and
message.groups) events, and to be invited to the channels. The tokens are only read from the
environment. A message with up to five pull request links of watched repositories gets ⏳ and,
when each review ends, the emoji of its verdict and an answer in its thread: link (the
verdict and the links), summary (plus the summary, the score and the counts) or full (plus
every finding with a link to its line in the reviewed commit, split into several messages when
long). A commit Phada already reviewed is answered with the link of that review. A commit skipped
for failing CI gets the skipped emoji and CI failing: test, lint. It is
reviewed once these checks pass.; once it is reviewed, that emoji gives way to the verdict's.
A refused request is answered with the reason; messages in other channels, from bots, or with links to
repositories outside watch: are left alone.
Review events and hooks
# ~/.config/phada/config.yml
hooks:
review.started: ./notify.sh started
review.finished: ./notify.sh
review.failed: ./notify.sh
review.skipped: ./notify.sh
timeout: 30s # how long a hook may run (default 30s, 10m at most)Each hook is a shell command run from your config folder, with your environment (tokens
included, as in your own shell: do not let a hook print it), for the jobs of the queue. It gets
the event as JSON on stdin (schemaVersion: 1):
{
"schemaVersion": 1,
"event": "review.finished",
"job": {
"id": 7,
"repo": "acme/shop",
"number": 12,
"headSha": "a1b2…",
"url": "https://github.com/acme/shop/pull/12"
},
"requests": [
{
"source": "slack",
"publish": "none",
"data": { "channel": "C0456EFGH", "ts": "1729688400.1", "thread": "1729688400.1" }
}
],
"verdict": "blocking",
"ci": {
"state": "failing",
"checks": [
{ "name": "test", "state": "failing" },
{ "name": "lint", "state": "passing" }
]
},
"publication": { "status": "not-published" },
"review": { "...": "the same JSON as phada review --format json" }
}source is watch, enqueue, mcp, pr-command or slack, and data is what that source
brought. review comes with review.finished, reason with review.skipped (superseded,
closed, already-reviewed, ci-failing, …) and error with review.failed, which is only
sent once the job gave up. ci is the state of the checks (none, pending,
passing or failing) and each check that counts, once the review or the watch read them (in
review.started, as the watch read them before starting the review); null when the checks were
not read. A hook that fails or runs out of time is reported in the watch and never stops it.
The verdict is computed by Phada from the findings, never by the AI: blocking when a
finding is P0 or P1, comments when there are only P2 findings or findings worth checking,
clean when there is nothing, failed and skipped. It is also in the JSON of every review and
in the history.
A condition of your own
# ~/.config/phada/config.yml
watch:
when: { command: ./only-payments-team.sh } # for every repository
repos:
- acme/shop
- repo: acme/web
when: { command: ./web-rules.sh } # instead, for this oneThe command runs like a hook and gets the pull request and the request as JSON on stdin; it has 10 seconds to exit: 0 lets the request through; anything else, an error or no answer refuses it, with the first line the command wrote to stderr as the reason. It runs before a new review is queued, for a request or for a push the polling finds; a request that joins a job or finds the commit already reviewed does not ask it, and a refused push is not asked again until a new commit.
Recipes
- Slack through your AI: with a Slack MCP server and
phada mcpin your AI CLI, ask it to watch a channel, callreview_pull_requestwithpublish: nonefor each pull request link,wait_for_job, and post the findings it gets back in the thread. - Teams, mail or anything with a webhook: a
review.finishedhook that posts the event, e.g.jq '{text: "\(.job.url): \(.verdict)"}' | curl -s -H 'Content-Type: application/json' -d @- "$TEAMS_WEBHOOK_URL". - A schedule:
phada enqueuefrom cron, as above.
History
Every review is saved on your machine in ~/.local/share/phada/history.sqlite
($XDG_DATA_HOME/phada when set; PHADA_DATA_HOME to choose another folder): the reviews of the
watch, its queue and failures, and every phada review, including --dry-run and skipped runs.
Each run keeps the same JSON as --format json, without the diff; findings can still quote code,
so the file stays readable only by you. Unlike the cache, the history is never rebuilt or deleted
by Phada; a newer version updates it in place, and an older one refuses a file written by a newer
one instead of touching it. history: false in your own config turns it off (phada watch
needs it).
phada history # the last 50 reviews
phada history owner/repo --since 30d # one repository, the last 30 days
phada history owner/repo#123 # one pull request, with what happened to each comment
phada history show 42 # one run, with everything it sent and found
phada history --stats # what happened to the comments, per repository
phada history --refresh --stats # read the comments on GitHub first
phada history prune --older-than 180dEach review of the watch shows who asked for it: watch (the polling), enqueue, mcp,
pr-command or slack.
Each run of the list starts with its id, and phada review ends with it when it saves the run
(Saved as run 42: phada history show 42). phada history show <id> prints that run again with
the sections of --dry-run, read from the history: what was posted on the pull request (or would
have been), the findings close to the cut, the claims of the description, the context,
conversation and CI sent to the AI, the impact, the related repositories, the skills, what the
investigation read and the tokens. It starts with a line naming the run, the pull request, its
commit, when, who asked, the provider and model, and how it ended (published with the link, dry
run, skipped and why, failed and the error). A run saved by an older version shows the sections
it kept. --format json prints the run's history fields under run and its saved JSON under
result, with schemaVersion: 1.
What happened to each comment
The history also keeps every comment Phada published and what happened to it, read from the comment's thread on GitHub, without the AI. Each comment has one outcome:
- fixed: Phada resolved it after a recheck (see Comments fixed in a new commit), or a person resolved it after the lines it points at changed;
- dismissed: a person resolved it without the lines changing;
- ignored: it was still open when the pull request was merged or closed;
- open: none of the above yet.
And two marks: negative reaction (a 👎 or 😕 on the comment) and replied (someone answered in the thread; Phada's own replies and bots do not count). Outcomes and marks follow the thread: a resolved comment that is reopened is open again, and a removed reaction stops counting. The history keeps when each one started and, when it stopped, when.
phada review saves the state of the threads it reads on every run. phada watch also reads
the pull requests Phada reviewed (with comments or not, dry runs included) every 30 minutes when
the queue is idle, and once more when they are merged or closed. Without the watch, phada history --refresh reads them on demand
(it needs a GitHub token); phada history owner/repo#123 --refresh reads that pull request even
when the history has nothing about it yet.
--stats counts the comments published in the period per repository, with the share of each
outcome. They are signals, not verdicts: a dismissed comment may still have been right.
--format json prints the runs, the comments with their outcome and the history of each signal,
or the stats, with schemaVersion: 1. phada history prune --older-than <age> removes reviews,
comments and finished jobs older than that age (1d at least); it never touches what the watch
needs to know what it already saw.
Misses
A miss is a problem someone pointed out that Phada did not. It comes from one of three places (its origin):
- person: a thread opened on a pull request by someone who is not a bot, not Phada and not the author of the pull request, on lines that a later commit changed (GitHub marks the thread outdated), with no Phada finding near it;
- bot: the same, from a bot you list in
misses.bots(see below); other bots never count; - phada: a finding of Phada (from any review of the pull request, a replay included) that the latest review at a later commit no longer reports, although the lines it points to did not change between the two com
