Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ sources, the frame and the `--json` snapshot, `sections.go` the people, bots and
closed groups, one record per pull request and the row held under the cursor,
`listing_cache.go` the last listing kept on
disk for the next start, `table.go` what `prs list` prints, `document.go` each pull
request's markdown document, a section per heading, `review_rounds.go` the CLAUDE cell,
request's markdown document, a section per heading, `review_rounds.go` the round's cell,
the Review section and the tmux windows that run a round, `diff_files.go` the document and
review threads `d` writes for nvim, and `opened_on.go` the pull request `prs tui --pr`
names. `internal/reviewrounds` reads a pull request's Claude review rounds out of its
Expand Down Expand Up @@ -129,9 +129,17 @@ is the shape of the threads file `d` hands nvim, held the same way.
- **A round record's statuses stay apart.** A skipped reviewer was skipped on purpose and
reads as skipped. One not run or not posted counts as a gap beside the findings, and a
round none ran reads `claude did not run`, never as a clean one.
- **The CLAUDE column draws only while a row fills it**, so a board on repos no round
reviews never has it. `rowColumns` holds every column with the cell it draws, so a
- **`prs list`'s CLAUDE column draws only while a row fills it**, so a list of repos no
round reviews never has it. On the board the round follows the review on a row's second
line. `listColumns` and `boardColumns` hold every column with the cell it draws, so a
column's header and its cells cannot drift apart.
- **A pull request is two lines on the board, and a field keeps its line.** The first line
is `boardColumns` and the second is `secondLine`. A new field goes at the end of
`secondLine`'s flow before the bot, or into `boardColumns`. The second line is cut at the
table's edge, so the checkout's length decides what a narrow screen loses.
- **A field the board and `prs list` both show has one cell builder**, such as `numberCell`
or `botCell`, so the two layouts choose only where it sits. `prs list` places them through
`listColumns` and `titleLine`.
- **A window marked `runs_review_round` reads running only until a round finishes after
prs opened it.** A session that ran a round keeps its window afterwards, so one prs did
not open reads running only where no round has finished. tmux is asked every
Expand Down Expand Up @@ -183,7 +191,14 @@ is the shape of the threads file `d` hands nvim, held the same way.
request's markdown until its row, its detail state or the minute moves.
- **A row's `ReaderTitle` is its pull request's title**, because the table's enter would
otherwise title the Reader with the row's first cells, which here are its state words.
- **A row's `Under` line shows only once `f` turns summaries on.**
- **`Board.Look` is gotermboard's `LookBoard`**: a margin, the columns spread across the
width, rules under the titles and over the keys, the cursor's row bold on the selection,
and no row chevrons. `PanelFrom` is measured inside the margin, so the margin gives way
from `PanelFrom` columns and the panel shows wherever it would with no margin.
- **A column with no title continues the one before it under `Look.Spread`**, so the
diff's three counts take two untitled columns after DIFF and stay together.
- **`f` applies only where a row or group sets `Under`**, and no prs row does, so `f`
stays out of the footer and `?`.
- **A group header that is not `Still` takes the cursor**, so `g` lands on the people
header and enter there folds the group. The cursor starts on the first row holding cells,
of whichever group draws first, so recently closed waits for the first open listing.
Expand Down
40 changes: 25 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,23 +122,32 @@ field it filters on drops a repo silently, and every list it prints still looks
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. `m`
and `x` refuse a `gone` one.
- **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`, `draft` or `unknown` while open, or
`mergeable` where it can merge with its checks not passing, and `merged` or `closed`
after. A Bitbucket row reads `open`, because prs reads no merge state from Bitbucket. `checks
passing`, `review required` and `review 2 posted` follow it. Each reads under every
theme and under `NO_COLOR`, and the colors are the terminal's own 16.
- **A Claude review round reads on its row.** Where one reviewed the pull request, a
`CLAUDE` cell after the review gives the newest round. Once its round comment posts, the
cell is its findings by section, as `claude 2 correctness · 1 design`. It adds `1 not
run` or `1 unread` where a reviewer did not run or its review could not be read, and
reads `claude did not run` where none ran. Before the round comment, it reads `claude
running` while the reviews post, and `claude unfinished` once they have stopped for an
hour. It reads `claude reviewed` where the newest review is of a version prs does not
read. The column shows only while a row fills it.
after. A Bitbucket row reads `open`, because prs reads no merge state from Bitbucket.
`checks passing` sits under the state, and `review required` or `review 2 posted`
follows the number. Each reads under every theme and under `NO_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 adds `1 not run` or
`1 unread` where a reviewer did not run or its review could not be read, and reads
`claude did not run` where none ran. Before the round comment, it reads `claude running`
while the reviews post, and `claude unfinished` once they have stopped for an hour. It
reads `claude reviewed` where the newest review is of a version prs does not read.
- **A pull request whose base is another open one's branch names that one**, as `↑ #12`
after its title. A pull request an app opened names the app.
- **The branch and the checkout are under the row**, dim, where `f` shows them.
after 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 here` beside 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.

## Each pull request's document
Expand Down Expand Up @@ -287,8 +296,9 @@ prs tui --pr https://bitbucket.example.com/projects/FIN/repos/ledger/pull-reques

## Reading it from a program

`prs list` reads each provider once and prints the board's open rows as a table, the
people's then the bots', under a line naming any provider it could not read. It reads no
`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`](cmd/prs/list.schema.json)
gives. Beside the row's columns it carries each pull request's state, its merge state, its
Expand Down
30 changes: 15 additions & 15 deletions cmd/prs/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,21 @@ show names, in three groups. people holds those a person opened, and bots those
opened, each oldest first. recently closed holds those merged or closed within --since,
newest first. Each header counts its rows, and enter on it folds them.

Each row gives where it stands in one word: ready, behind, conflicts, blocked, mergeable
with checks not passing, draft or unknown while open, and merged or closed after. Then its
checks and review in words, its age, or how long ago it closed, its diff, its repo and
number, and its title. f shows its branch and checkout under it. A Bitbucket row reads
open, and only one merged here reaches recently closed, because prs reads no merge state
or close time from Bitbucket.

A pull request a Claude review round reviewed gains a CLAUDE cell after its review, from
the newest round: its findings by section once the round comment posts, as in claude 2
correctness · 1 design, with any reviewer that did not run counted beside them. Before the
round comment, it reads claude running while the reviews post and claude unfinished once
they have stopped for an hour. It reads claude reviewed where the newest review is of a
version prs does not read. The column shows only while a row fills it. A window action
marked runs_review_round reads running while its window is open and the round has not
finished.
Each pull request takes two lines. The first gives where it stands in one word: ready,
behind, conflicts, blocked, mergeable with checks not passing, draft or unknown while open,
and merged or closed after. Then its title, its diff, its age, or how long ago it closed,
and its branch. The second gives its checks in words, its checkout, its number and its
review, and names the app that opened it. A Bitbucket row reads open, and only one merged
here reaches recently closed, because prs reads no merge state or close time from
Bitbucket.

A pull request a Claude review round reviewed reads the newest round after its review:
its findings by section once the round comment posts, as in claude 2 correctness · 1
design, with any reviewer that did not run counted beside them. Before the round comment,
it reads claude running while the reviews post and claude unfinished once they have
stopped for an hour. It reads claude reviewed where the newest review is of a version prs
does not read. A window action marked runs_review_round reads running while its window is
open and the round has not finished.

It opens on the rows the last run on the same repos read, kept under $XDG_CACHE_HOME/prs,
with their age on the first line, and reads each provider behind them. It reads the open
Expand Down
39 changes: 28 additions & 11 deletions cmd/prs/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,21 @@ func gitHubStandIn(t *testing.T, listing *gitHub) string {
return config
}

// listSide is the list's half of each line of a frame, left of the side panel's border.
func listSide(frame string) string {
var list strings.Builder
for line := range strings.SplitSeq(frame, "\n") {
left, _, _ := strings.Cut(line, "│")
list.WriteString(left + "\n")
}
return list.String()
}

// underRow matches a row whose first line holds title and whose second holds below.
func underRow(title, below string) string {
return regexp.QuoteMeta(title) + `[^\n]*\n[^\n]*` + regexp.QuoteMeta(below)
}

func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
Expand Down Expand Up @@ -393,6 +408,7 @@ window_actions:
}
}
s.env = map[string]string{
"HOME": dir,
"PATH": bin + string(os.PathListSeparator) + os.Getenv("PATH"),
"XDG_CONFIG_HOME": filepath.Join(dir, "config"),
"TMUX": filepath.Join(dir, "tmux") + ",1,0",
Expand Down Expand Up @@ -421,7 +437,7 @@ func (s standIn) ran(t *testing.T, lines ...string) bool {
func (s standIn) run(t *testing.T, args ...string) (string, string, int) {
t.Helper()
cmd := exec.Command(tuidrive.Build(t, "."), args...)
cmd.Env = []string{"HOME=" + t.TempDir()}
cmd.Env = []string{}
for name, value := range s.env {
cmd.Env = append(cmd.Env, name+"="+value)
}
Expand Down Expand Up @@ -587,8 +603,8 @@ func TestTheBoardSortsPeopleAboveBotsAndClosedBelowInWordsShowsTheDocumentBeside
} {
term.WaitForPattern(pattern)
}
text := term.Frame().Text()
order := []string{"#12 Cache the widget lookups", "#13 Count cache hits", "#9 Log the widget misses", "#8 Bump the gadget's linter"}
text := listSide(term.Frame().Text())
order := []string{"Cache the widget lookups", "Count cache hits", "Log the widget misses", "Bump the gadget's linter"}
for i := 1; i < len(order); i++ {
if strings.Index(text, order[i-1]) > strings.Index(text, order[i]) {
t.Errorf("%q should sit above %q:\n%s", order[i-1], order[i], text)
Expand All @@ -602,7 +618,8 @@ func TestTheBoardSortsPeopleAboveBotsAndClosedBelowInWordsShowsTheDocumentBeside
term.WaitFor("mona approved at 9e8d7c6")
beside := false
for line := range strings.SplitSeq(term.Frame().Text(), "\n") {
beside = beside || strings.Contains(line, "review ") && strings.Contains(line, "acme/widget #12 · opened by octo")
list, document, found := strings.Cut(line, "│")
beside = beside || found && strings.TrimSpace(list) != "" && strings.Contains(document, "acme/widget #12 · opened by octo")
}
if !beside {
t.Errorf("#12's document should share a line of the screen with a row of the table:\n%s", term.Frame().Text())
Expand Down Expand Up @@ -648,9 +665,9 @@ func TestEachRowReadsItsNewestReviewRoundAndItsDocumentHoldsTheRound(t *testing.
t.Fatal(err)
}
term := tuidrive.Start(t, tuidrive.Program{Path: tuidrive.Build(t, "."), Args: []string{"tui"}, Env: s.env, Cols: 300, Rows: 40})
term.WaitForPattern(`claude 5 correctness · 3 design · 4 prose .*Count cache hits`)
term.WaitForPattern(`claude unfinished .*Tidy the gadget`)
term.WaitForPattern(`claude reviewed .*Cache the widget lookups`)
term.WaitForPattern(underRow("Count cache hits", "claude 5 correctness · 3 design · 4 prose"))
term.WaitForPattern(underRow("Tidy the gadget", "claude unfinished"))
term.WaitForPattern(underRow("Cache the widget lookups", "claude reviewed"))

// d hands nvim the whole document, which the stand-in nvim keeps. The people's rows run
// ledger #4, widget #12 and gadget #5, then widget #13 under the bots' header.
Expand Down Expand Up @@ -705,11 +722,11 @@ func TestEachRowReadsItsNewestReviewRoundAndItsDocumentHoldsTheRound(t *testing.
// The cursor is on #13, whose round finished before r opened a window for another.
term.Press("r")
term.WaitFor("opened review widget #13 in a tmux window")
term.WaitForPattern(`claude running .*Count cache hits`)
term.WaitForPattern(underRow("Count cache hits", "claude running"))
if err := os.WriteFile(s.windows, nil, 0o644); err != nil {
t.Fatal(err)
}
term.WaitForPattern(`claude 5 correctness · 3 design · 4 prose .*Count cache hits`)
term.WaitForPattern(underRow("Count cache hits", "claude 5 correctness · 3 design · 4 prose"))
term.Press("q")
if code := term.WaitExit(); code != 0 {
t.Errorf("q exited %d", code)
Expand All @@ -722,7 +739,7 @@ func TestEachRowReadsItsNewestReviewRoundAndItsDocumentHoldsTheRound(t *testing.
func TestTheDiffSectionShowsASmallDiffWholeAndNamesALargeOnesSize(t *testing.T) {
t.Parallel()
s := newStandIn(t)
term := tuidrive.Start(t, tuidrive.Program{Path: tuidrive.Build(t, "."), Args: []string{"tui"}, Env: s.env, Cols: 340, Rows: 40})
term := tuidrive.Start(t, tuidrive.Program{Path: tuidrive.Build(t, "."), Args: []string{"tui"}, Env: s.env, Cols: 360, Rows: 40})
term.WaitFor("people · 3")
term.WaitFor(" #5")

Expand Down Expand Up @@ -923,7 +940,7 @@ func TestOneQuestionMergesEveryMarkedGreenPullRequestInTurnEachAfterItsOwnRead(t
t.Fatal(err)
}
term.Press("enter")
term.WaitFor("merged 3 of 4: acme/widget #21, acme/widget #23, acme/widget #25. Left acme/widget #22: GitHub reads its merge state as behind, not ready")
term.WaitForPattern(`merged 3 of 4: acme/widget #21, acme/widget #23, acme/widget #25\. Left acme/widget #22: GitHub reads its merge state as behind,\s+not\s+ready`)

recorded, _ := os.ReadFile(s.recorded)
var events []string
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ require (
github.com/cli/go-gh/v2 v2.17.0
github.com/datapointchris/goclikit v0.5.3
github.com/datapointchris/goselfupdate v0.11.2
github.com/datapointchris/gotermboard v0.30.0
github.com/datapointchris/gotermstyle v0.4.0
github.com/datapointchris/gotermboard v0.35.0
github.com/datapointchris/gotermstyle v0.5.0
github.com/datapointchris/tuidrive v0.1.3
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
github.com/spf13/cobra v1.10.2
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ github.com/datapointchris/goclikit v0.5.3 h1:6ec9nuswLXeIqZLa0YVEQeqsCnrjkqF/3gD
github.com/datapointchris/goclikit v0.5.3/go.mod h1:hEbplQyvrWq5m5UepXVNKfv8jvvlmQshNMrWdoHBb6Y=
github.com/datapointchris/goselfupdate v0.11.2 h1:7aiYmeatQnfLqHiFawNlFHUohxAyOsiKyio8Vosje4I=
github.com/datapointchris/goselfupdate v0.11.2/go.mod h1:k3gVv/y3AwoAyGGn5sN/UcAxhANB2W+GdN9rYw/oob4=
github.com/datapointchris/gotermboard v0.30.0 h1:UOeJm18Ma3a9s6crk0eBxePNhAwjybgBLhCP6g1jMkQ=
github.com/datapointchris/gotermboard v0.30.0/go.mod h1:gzSG1LtOC2McInuKV5mSFJOLZ3EnFIY/1chPyivuxe0=
github.com/datapointchris/gotermstyle v0.4.0 h1:kOovTrAVF4oI/P6ptNg5VTuU9woNpPyjrhcKd6ndJsI=
github.com/datapointchris/gotermstyle v0.4.0/go.mod h1:pcTXWC/xls5Riw0rFtTueCQGlnszB3sqlepRItgdgeg=
github.com/datapointchris/gotermboard v0.35.0 h1:FSy9u9Mi30sfjBGmmc7lFDd71SdIBOPt8IGlNtSt9d4=
github.com/datapointchris/gotermboard v0.35.0/go.mod h1:A/9phVumidQPEVvHBLHTIWGgMfwbyDT+1/0iOHtjmao=
github.com/datapointchris/gotermstyle v0.5.0 h1:xdpuNASa4vS0jSMpBDQOO0rRFGY7jaft5H3hTHOrREE=
github.com/datapointchris/gotermstyle v0.5.0/go.mod h1:pcTXWC/xls5Riw0rFtTueCQGlnszB3sqlepRItgdgeg=
github.com/datapointchris/tuidrive v0.1.3 h1:Gmu8Lye5+UMMJB30AbMk0EHosWk6AOW+PxgiEWi7dMA=
github.com/datapointchris/tuidrive v0.1.3/go.mod h1:gkHhDQs6zOLaRUP9MFKNTh+IkegUq0RedcBeavctBYY=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
Expand Down
5 changes: 1 addition & 4 deletions internal/board/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,6 @@ type Board struct {
// their windows are open, nil until ReadDetails is called inside tmux.
reviewActions []config.WindowAction
roundWindows *roundWindows
// columns are the columns the frame being built draws.
columns []rowColumn
// working is every command a key started that has not ended, oldest first.
working []underWay
workCount int
Expand Down Expand Up @@ -377,7 +375,6 @@ func (b *Board) Frame(now time.Time, _ string) gotermboard.Frame {
}
prs := b.records(now, extra...)
parents := stackParents(prs)
b.columns = b.drawnColumns(prs, now)
rows := ordered(b.hold(placedBy(prs)))
clear(b.shown)
clear(b.drawnIn)
Expand All @@ -395,7 +392,7 @@ func (b *Board) Frame(now time.Time, _ string) gotermboard.Frame {
maps.DeleteFunc(b.rounds, func(key string, _ roundsMemo) bool { return !b.listed(key) })
return gotermboard.Frame{
Strip: b.StripAt(now),
Columns: headers(b.columns),
Columns: headers(boardColumns),
Groups: groups,
StartAt: b.startAt(),
}
Expand Down
Loading
Loading