Every open pull request across the repos you work on, on one terminal board that keeps itself current: the ones people opened above the ones apps opened, those merged or closed lately below both, where each stands in words, its description, checks, files and comments beside the list, and a key per action.
It is for reading a backlog of pull requests, yours and the ones an app opens for you,
and acting on each one without leaving the terminal. It reads GitHub with the login
gh already holds, and Bitbucket through bbkt, so it keeps
no credentials of its own.
-
Install it. A release binary for Linux or macOS is on the releases page. With Go:
go install github.com/datapointchris/prs/cmd/prs@latest
-
Log
ghin, if it is not already. prs reads GitHub with the token gh would use for the same host, so a workinggh auth loginor aGH_TOKENis all it needs.GH_HOSTpoints both at a GitHub Enterprise host.gh auth login
-
Open the board. With nothing else set up, it lists the open pull requests on every repo your GitHub login owns.
prs tui
?lists every key.qquits.prs config showsays which repos prs is reading, and why. -
Name the repos you work on, once your own repos are not the set you want. A registry names each repo, on GitHub or Bitbucket, and where it is checked out.
ownersin the config reads every repo of some GitHub users or organizations instead.
prs asks these in order, and the first that answers wins:
PRS_REPOS_REGISTRY, naming a registry for one shell.repos_registryin prs's config, the YAML fileprs config pathprints.ownersin that config. A config naming bothrepos_registryandownersis refused.- A registry at
$XDG_DATA_HOME/prs/repos.json, which is~/.local/share/prs/repos.jsonby default. - Every repo your GitHub login owns.
repos_registry: ~/repos.json
# or, in its place:
owners: [acme, octo]owners reads each owner whole, so a large organization's search can fill its one page
with repos you never touch, or fail outright. A registry names each repo instead, so each
search asks only for those repos.
The registry is a JSON object holding repos, an array with one entry per repo. prs reads
these fields of an entry and ignores the rest:
| Field | Required | What prs does with it |
|---|---|---|
name |
yes | The repo's name on its provider. |
owner |
yes | The GitHub owner, or the Bitbucket project key. |
status |
yes | Only active repos are read. Every other value skips the entry. |
path |
no | Where it is checked out, ~-rooted or absolute. The diff keys and your own keys run there, and bbkt reads a Bitbucket repo there. |
provider |
no | github or bitbucket. An entry without one takes the file's top-level provider, and github where the file names none. |
status and owner are required rather than defaulted, because a reader that defaults a
field it filters on drops a repo silently, and every list it prints still looks plausible.
{
"repos": [
{ "name": "widget", "owner": "acme", "path": "~/code/widget", "status": "active" },
{ "name": "ledger", "owner": "FIN", "path": "~/work/ledger", "status": "active", "provider": "bitbucket" }
]
}- The rows are every open pull request on a repo prs reads, in three groups.
peopleholds those a person opened andbotsthose an app opened, each oldest first, because a backlog is a gauge and age is what it measures.recently closedholds those merged or closed within--since, newest first. Each header counts its rows, and enter on it folds them. One GitHub request asks for the registry's repos by name, andbbktlists each Bitbucket repo in its checkout, several repos at once. Bitbucket's listing does not say who is an app, so its pull requests sit underpeople. --sinceis how far backrecently closedreaches, andprs tui --helpprints its default. It takes a span such as12h,2dor1w, counted back from each read, or a day or an RFC 3339 instant. A second GitHub request reads the closed ones. It costs as much as the open one, so it runs on a timer of its own,closed_refresh_every_secondsin the config, which also paces the Summary tab's read of what changed. Its rows read no checks, files or comments, because each would cost a request of its own. prs reads no close time from Bitbucket, so a Bitbucket pull request reachesrecently closedonly whenmhere merged it.- Each GitHub row's checks, files and conversation are read after the list lands, a few pull requests at a time, so the list never waits on them. A pull request's detail is read again when its head commit, its update time or its checks state moves, or when its last read failed.
- The board opens on the rows the last run on the same repos read, kept under
$XDG_CACHE_HOME/prs/listings/in a file for each set of repos, and reads each provider behind them, so the first frame never waits on GitHub. Onlyprs tuikeeps them.prs list,--onceand--jsonwait for their reads. - Each provider's open pull requests are read again on a timer while the board is
open, whenever its terminal gains focus, and whenever a program a key handed the
terminal to exits.
refresh_every_secondsin the config sets the timer, andprs config pathprints its default. A return within a few seconds of the last read does not read again. Returning from a tmux window reaches prs as focus where tmux'sfocus-eventsoption is on. The line at the top says how old the newest read is, turns a spinner while a read runs, and names a read that failed. A failed read keeps the last good rows, and so does one Bitbucket repo that failed while the rest read. - A refresh leaves the cursor on its pull request, in the group it was in. One a read
moved to another group stays under the cursor where it was, in its new state, and one no
read lists any more stays there marked
gone, each until the cursor moves off it.mandxrefuse agoneone. - Each pull request takes two lines, with a dotted rule between it and the next. The first line holds where it stands, its title, its diff, its age and its branch, its columns spread across the width. The second holds its checks, its checkout, its number and its review. A glyph marks each field but the title, and a field's glyph is the same in every state.
- State is a word, never a color alone. The first column says where a pull request
stands:
ready,behind,conflicts,blocked,draftorunknownwhile open, ormergeablewhere it can merge with its checks not passing, andmergedorclosedafter. A Bitbucket row readsopen, because prs reads no merge state from Bitbucket.checks passingsits under the state, andreview requiredorreview 2 postedfollows the number. Each reads under every theme and underNO_COLOR, and the colors are the terminal's own 16. - A Claude review round reads on its row. Where one reviewed the pull request, its
second line gives the newest round after the review. Once its round comment posts, that
is its findings by section, as
claude 2 correctness · 1 design. It adds1 not runor1 unreadwhere a reviewer did not run or its review could not be read, and readsclaude did not runwhere none ran. Before the round comment, it readsclaude runningwhile the reviews post, andclaude unfinishedonce they have stopped for an hour. It readsclaude reviewedwhere the newest review is of a version prs does not read. - A pull request pushed to since its newest review says how much, as
2 commits since review, counted from the commit that review was submitted against. It readsrebased since reviewwhere the branch does not hold that commit. A blank means prs could not tell, or there is nothing to count. - A pull request whose base is another open one's branch names that one, as
↑ #12after its number. A pull request an app opened names the app at the end of its second line. - A checkout reads from
~, and a repo not cloned here reads as its owner/name,not cloned herebeside it. - The board keeps a margin at each side, which a narrow terminal gives up first. The margin also gives way to the side panel, so a terminal wide enough for the panel shows it.
- The glyphs are Nerd Font glyphs, so the board wants a Nerd Font in the terminal.
A pull request's document is markdown, under a line naming its repo, author and branches:
- Description is its body, drawn as soon as the list lands.
- Review is the newest Claude review round, where one reviewed it or a window is running one: where it stands, its synthesis, then every reviewer's findings under a heading for their section, read from each review's JSON block rather than its markdown. A line follows for each earlier round. A post whose marker names a version prs does not read shows whole. So does a marked post by anyone other than an app on the repo or someone who can write to it, and it counts in no round, because anyone can comment.
- Checks is each check on its head commit and its state, those not passing first.
- Files is each changed path with its lines added and removed, and says how many more the read left out.
- Diff is the diff itself, each file under a heading of its own, so
[and]move between files. A pull request changing more lines thandiff_section_max_linesin the config gets its size and the key that opens it instead.prs config pathprints its default. - Comments names who is still asked to review, then the conversation oldest first with each submitted review in it, then each review thread, unresolved first. A review round's post the Review section shows is left out of the conversation.
Checks, Files and Comments fill in once the pull request's own read lands, and say they are reading until then. Review fills in then too, except that a window running a review round shows it at once. Bitbucket's client reads no more than its listing, so a Bitbucket pull request's document has no Review, Checks, Files or Comments.
Each listed pull request's diff, recently closed ones included, is read once its list
lands, a few at a time. It is read
again when its head commit or base moves. A read that failed is tried again after a
minute, then after a wait that doubles up to half an hour. GitHub sends the diff, so prs
fetches nothing into a GitHub checkout. Bitbucket's client has no diff, so prs fetches a
Bitbucket pull request's head into refs/remotes/origin/pr/<number> in its checkout and
diffs it against the origin/<base> already there. It moves no other ref, and writes no
FETCH_HEAD. A Bitbucket pull request whose repo is not checked out says so in its Diff.
On a wide terminal a side panel beside the list shows the document of the row under the
cursor, under its title, and p hides or shows it. enter opens the document full screen,
where [ and ] jump between its headings and / searches it. On a narrower terminal
enter opens it in place under the row where it fits on the screen, and full screen where
it runs past.
d fetches the pull request's head and hands the terminal to nvim's diff (with the
Diffview plugin) against the base it targets. :qa comes back to the list with the same
row under the cursor, and reads again as any return to the list does. It never checks the
branch out.
nvim listens on $TMPDIR/prs-diff-<repo>-<number>.sock, so another program can ask it
what is under the cursor. Inside tmux, prs's window is named diff <repo> #<number> while
nvim has it.
Beside the socket, prs writes the pull request as prs-diff-<repo>-<number>.md, its title
over the document the side panel shows less its Diff, which nvim shows already, and its
review threads as
prs-diff-<repo>-<number>.threads.json, in the shape cmd/prs/diff-threads.schema.json
gives. nvim starts with PRS_DIFF_DOCUMENT and PRS_DIFF_THREADS naming them, so an nvim
config can show the description beside the diff and each thread on its line. prs removes
both once nvim exits.
i opens what was pushed since the newest review: the same fetch and nvim as d, with
the diff running from the commit that review was submitted against to the head. It asks
git whether the fetched head holds that commit, and whether the base was merged into the
branch since. Where the head does not hold it, as after a rebase, where a merge brought the
base's changes in, or where no review was submitted, it opens the whole diff instead, and
the footer says why once nvim exits. Where git cannot answer, it opens nothing and the
footer names git's failure. Where nothing was pushed since the review, it says so and
opens nothing. Its threads file reads "diff": "since_review", names the reviewed commit
as base, and holds only the RIGHT threads, because a LEFT thread's line counts in the
base branch's copy, which this diff does not show. threads_left_out counts the rest.
prs reads no reviews from Bitbucket, or of a pull request in recently closed, so i
refuses there.
m merges and x closes without merging. Each names the pull request and asks first:
enter alone merges, and enter alone leaves it open rather than closing it. A second press
while one runs is refused. On GitHub every merge pins a head: the one a diff key last
fetched, or else the one the Diff section showed, or else the one the list showed, so it
fails where the branch moved since. c opens your editor on an empty file and posts what
was written when it exits. Merging, closing, commenting and o, which opens the page in
the browser, each run while the list stays usable, named on the footer until they end. A
row merged or closed here moves to recently closed at once, reading merged or closed.
A cursor on it moves on to the next open row in its group, or the one above where it was
the last, so a queue takes one key a pull request. A read that starts after the key then
says where it stands, so a merge a queue only accepted, or a close reopened on its page,
returns it to its group. A failure says the client's own reason on the footer. Bitbucket's
client has no verb to close a pull request, so x refuses there.
space marks a row for m to merge with the others marked, and a second press takes the
mark off. It marks only a row whose checks pass and whose state reads ready, which is
GitHub's CLEAN or HAS_HOOKS, and names why on the footer where it refuses one. prs
reads no merge state from Bitbucket, so a Bitbucket row is never marked. On a group's header space folds the group,
and on a row open in place it closes the row.
With rows marked, m asks once. The question lists every pull request it will merge, then
each marked or refused one it leaves out, with why. A stacked one names the branch it
merges into. Enter alone merges them, one at a time, in the order the board draws them. Each
is read again just before its merge, because merging one can leave the next on the same
repo behind or conflicting. One that no longer reads ready with its checks passing is
left, and the rest go on. So is one whose base is a branch the batch already merged, which
would otherwise land on that branch rather than where it went. When the batch ends, the
footer names what merged and what was left and why, and the open listings are read again.
y copies the branch.
m on a pull request GitHub will not merge reads it again, and where GitHub still refuses
it, opens a menu of fixes rather than asking to merge. A merge GitHub refuses anyway opens
the same menu, with gh's words at its top, and f opens it on any row. Each choice is one
key, esc leaves the menu, and no choice exits prs. prs reads no merge state from
Bitbucket, so a Bitbucket row has no menu, and m there asks to merge.
- Conflicts name the files that conflict, which
git merge-treereads before anything is checked out.nresolves them in nvim by rebasing the head onto its base. prs fetches the head branch intoorigin/<branch>and adds a worktree of the checkout there, underworktree_root, as<checkout's directory>/pr-<number>-rebase, detached, so no branch checked out elsewhere moves. It runsgit rebase origin/<base>and hands each stop to nvim's:DiffviewOpen. Each time nvim exits with nothing left unmerged, prs runsgit rebase --continue, keeping each commit's message, and opens nvim again at the next stop. Once the rebase finishes, it lists the rebased commits and asks to force-push them to the head branch, enter meaning yes, then removes the worktree. The push's lease is on the head the rebase started from, so it fails rather than overwrites where anyone pushed to the branch since, and the footer names git's refusal. With paths left unmerged, or at a stop git cannot go on from, it offers nvim again,git rebase --abort, or a fix of your own. A rebase stopped or not pushed waits in the worktree, softhennopens it again, andfthenppushes a finished one. A head branch in a fork gets non, because prs pushes only to origin.xcloses the pull request, after asking. - A worktree a rebase left is named in the menu of any row it belongs to, and
aaborts its rebase and removes it. Sofon a row that reads ready offers that too, as when the conflict was fixed somewhere else. - A branch behind its base updates with
u, which isgh pr update-branch --rebase. GitHub rebases it onto the base, and its checks run again. - Failing checks are named.
lreads the failed jobs' log full screen, fromgh run view --log-failed, and keeps it in$TMPDIR.rreruns the failed jobs withgh run rerun --failed. Only a check GitHub Actions runs has a log or a rerun here. Where GitHub would merge it with the checks failing, because none of them is required,mmerges it anyway. - A draft is marked ready for review with
r, which isgh pr ready. - A merge state GitHub is still working out offers nothing, and
freads it again. A pull request GitHub blocks for anything else, such as a required review, says what its review and checks read.
A window action in the config is a key that runs a command in a new tmux window, started in the pull request's checkout:
window_actions:
- key: r
label: review
needs: [claude]
providers: [github]
window: "review {repo} #{number}"
command: "claude '/review-pr {number}'"
runs_review_round: trueneeds are the programs the command runs. A machine missing one is not offered the key,
because tmux would open the window and the command would die in it. {number}, {repo}
and {slug} are filled in. Quote a value holding #, which YAML reads as a comment.
A key prs or the board already binds is refused, and prs tui does not start until the
config moves it, because the board's key would take it. ? lists every key the board
binds, and prs config show names a refused one with the key that holds it. A release can
bind a letter that was free, so a window action on that letter is refused after the
upgrade by the same rule.
runs_review_round marks a command that runs a Claude review round. Its row then reads
claude running while the window is open, which prs asks tmux every few seconds, until a
round finishes after the key opened it. A session that ran a round keeps its window
afterwards, so a window prs did not open reads running only where no round has finished.
A merge fix in the config adds a choice to the fix menu, for the kinds of blocker it names:
worktree_root: ~/.worktrees
merge_fixes:
- key: c
label: hand to claude
needs: [claude]
blockers: [conflicts, behind, failing-checks]
window: "fix {repo} #{number}"
command: "claude '/make-mergeable {number} {blocker}'"
row_reads: claude fixingblockers names the kinds of blocker it is offered for, which prs config path --help
lists. The command runs in a tmux window opened behind the board, in the checkout, so the board stays in
front. The row reads row_reads, or fixing, while the window is open. window takes the
window action's placeholders, so the board finds the window from the row. command also
takes {path}, the checkout, {worktree}, where a rebase waits in one, {head} and
{base}, the branches, {blocker}, the kind, and {log}, a file holding the failing
checks' log. Each is empty where it does not apply. A branch name can hold ; or $, so
prs quotes the paths and branches for the shell itself. A command that puts one of them
inside quotes of its own is refused, because prs's quotes would end its quotes rather than
nest in them. A fix may not take a key the menu offers itself, and prs config show names
a fix that does with the keys the menu keeps.
worktree_root is $XDG_DATA_HOME/prs/worktrees where the config does not say.
prs tui --pr opens the board on one pull request, named as owner/name#number or by its
URL. The cursor starts on it with its document showing, in the side panel on a wide
terminal and open under its row otherwise, and q exits 0, so another terminal program
can run prs to show a pull request and get its screen back.
prs tui --pr 'acme/widget#12'
prs tui --pr https://github.com/acme/widget/pull/12
prs tui --pr https://bitbucket.example.com/projects/FIN/repos/ledger/pull-requests/4- An open pull request sits where the list puts it. One on a repo prs does not read is drawn among the list's rows too.
- A merged or closed one sits in a group above the list, however long ago it closed,
and not under
recently closed. Its row readsmergedorclosed, andmandxrefuse it. Bitbucket'sdeclinedreadsclosed. - While no listing holds it, prs reads it again on the board's timer, so one merged
elsewhere moves above the list, and one on a repo prs does not read stays current. One
merged with
mmoves there at once. owner/name#numberis the registry's repo of that name, on whichever provider hosts it, and GitHub where the registry names none. A URL says its provider itself. bbkt reads a Bitbucket one by its project and name, so its repo need not be checked out.- A pull request prs cannot read is refused before the board draws, with one line on stderr and exit 1. So is a URL on a host other than the one prs read the pull request from, because a repo of the same name there holds a different pull request.
tab moves from the board to the Summary tab, and prs tui --view summary opens on it. It
counts the pull requests over a window, and w and W step it shorter and longer. The
line at the top lists the windows and lights the one counted.
- The counts split each number into all, those a person opened and those an app opened: the pull requests open now, those opened, merged and closed unmerged within the window, the median and 90th percentile time from opening to merging, and the share of merges whose first review was a Claude review round's. Under them, each repo's merges, the most first, and the side panel says what the row under the cursor counts.
- Three charts sit above the counts: the merges per day as columns, per hour for a window of two days or less, the open pull requests across the window as a sparkline, and the merges by how long each took, in buckets from minutes to weeks. An app's merges draw in their own color, under a person's.
- The merged and closed pull requests are kept in SQLite under
$XDG_CACHE_HOME/prs/closed-pull-requests/, a file for each set of repos, so the tab opens on what the last run kept. The first read of a window reads every pull request updated within it, which holds every one merged or closed within it, because GitHub'sclosed:search leaves some out. Each read after asks only for those updated since the last, everyclosed_refresh_every_seconds, and a reopened one reads open again. A window reaching past what the file holds reads back to its start, and the line at the top says so until it lands. A search GitHub caps at 1,000 results is split in halves of its stretch until each fits. - The open pull requests are the board's own listings, read on the board's timer whichever tab shows. Bitbucket's count among the open only, because bbkt says no time a pull request closed.
A recap is a markdown account of what landed over a window of time, such as a day or a week, which a program of your own writes and keeps. The recaps tab lists the windows that program has written a recap for and shows each one, from three commands the config names:
recaps:
needs: [recap]
list: [recap, list]
show: [recap, show, "--{kind}", "{label}"]
write: [recap, write, "--{kind}", "{label}"]listprints the windows in the order the tab draws them, one a line as its kind and its label, such asday 2026-11-06orweek 2026-W45. prs reads nothing else any command prints, and leaves out a line that is not two words, saying so at the top.showprints one window's recap as markdown, which the side panel shows beside the list and enter opens full screen, where[and]jump between headings and/searches it. prs reads every listed window's recap in the background once the list lands, the cursor's first, so/on the list matches a recap's text.writewrites one window's recap, which is a model call, and may be left out.wasks first, naming that, andywrites the cursor's window. Typing another window's kind and label writes that one, such as a day the list does not hold yet. Enter alone writes nothing, so a reflex enter spends no model call. The write runs while the list stays usable, named on the footer, and the list runs again once it ends, so a window written for the first time joins it.- A write runs detached, so quitting prs leaves it to finish. It runs in a session of
its own with its output in
$XDG_CACHE_HOME/prs/recap-writes/<kind>-<label>.log, which the next write of that window replaces, and a failure names that file. prs refuses a second write of a window while its own first runs. Writing atomically, and refusing a write of a window another process is already writing, are the write program's to do. - Each is an argv run with no shell, so a command that pipes runs through
sh -c, withset -o pipefailso a failure ahead of the pipe is not read as an empty list.{kind}and{label}inshowandwritetake the window's. needsnames the programs the commands run beyond each one's first, whichsh -chides. A machine missing one draws no recaps tab, as a config naming nolistdoes, so one config serves machines with and without the program.- The list runs when the tab first shows, on a timer while it shows, on
R, and once a write ends, and each run reads every recap again.--jsongives the timer as each source'severy.
prs list reads each provider once and prints the board's open rows as a table, one line
a pull request, the people's then the bots', under a line naming any provider it could not
read. A CLAUDE column shows the newest review round while a row fills it. It reads no
closed pull requests. prs list --json prints them as a JSON array, one
object per pull request, in the shape cmd/prs/list.schema.json
gives. Beside the row's columns it carries each pull request's state, its merge state, its
head commit and the open pull request it is stacked on.
prs tui --json prints the board's snapshot instead: the open rows under pull_requests,
as prs list --json prints them, those merged or closed within --since under
recently_closed, newest first, and how each read stands. Both arrays hold rows of one
shape. With --pr, opened_on is that pull request in the shape of one of those rows,
merged or closed where it is, and null without --pr. Either array holds it only where a
listing does. prs tui --once prints one frame at COLUMNS by LINES.
prs summary prints the Summary tab's counts for --since up to --until, 30 days to
now by default, and prs summary --json prints them as one object in the shape
cmd/prs/summary.schema.json gives, with the series each
chart draws. prs tui --view summary takes --once and --json the same way.
prs tui --view recaps --json prints each window the list names, with the recap of the
first, which --once draws beside the list, in the shape
cmd/prs/recaps.schema.json gives. Either exits 1 where the
list or that recap could not be read, or the list printed a line that is not a window.
Each of these exits 1 after printing when a read failed or left open pull requests out, because a failed read and a repo with no open pull requests otherwise print the same rows. A refused search, a Bitbucket repo that failed or is not checked out, and a search past its page each leave pull requests out. The rest of the rows still print. A recently closed read past its page is not counted, because it keeps the newest, and the line at the top says how many it left out.
prs list --json | jq -r '.[] | select(.merge_state_status == "BEHIND") | .url'
prs tui --json | jq '.pull_requests[] | {slug, number, checks, review, stacked_on}'
prs summary --since 7d --json | jq '{merged, median_seconds_to_merge}'