Skip to content

feat(tb-pr): add GitHub PR radar tool [WIP]#17

Open
ilucin wants to merge 22 commits intomainfrom
feat/tb-pr
Open

feat(tb-pr): add GitHub PR radar tool [WIP]#17
ilucin wants to merge 22 commits intomainfrom
feat/tb-pr

Conversation

@ilucin
Copy link
Copy Markdown

@ilucin ilucin commented Apr 16, 2026

Summary

Adds tb-pr — a kanban-style TUI + non-interactive CLI for tracking GitHub PRs that need attention across the Productive organization.

Screenshot 2026-04-16 at 23 27 35

Full design and milestones live in docs/features/tb-pr/PLAN.md.

Columns

  1. Draft (mine) — unfinished experiments
  2. In review (mine) — my PRs waiting on reviewers
  3. Ready to merge (mine) — approved, waiting to merge
  4. Waiting on me — review requested, highlighted by staleness
  5. Waiting on author — I reviewed, waiting on author reply (with a 🆕 marker when author pushed new commits)

Two modes, one binary

  • Interactive TUI (default, tb-pr) — kanban with arrow/vim navigation, auto-refresh every 5 min.
  • Non-interactive CLI (tb-pr list --json, tb-pr show, tb-pr prime) — structured output for the Claude Code skill and shell scripts.

Per-PR display

  • Title, phase (draft/review/approved), repo, age in days
  • Productive task link (extracted from PR body)
  • Size badge (XS/S/M/L/XL based on additions + deletions)
  • Color-coded rotting per column (green → red)

Status

Draft PR — this commit adds only the plan. Implementation follows in milestones tracked as beads under a single epic.

Test plan

  • M1 skeleton compiles and tb-pr doctor reports gh auth status
  • M2 tb-pr list --json returns correct data for all columns
  • M3 "Waiting on author" reflects actual review state
  • M4 pretty CLI output readable
  • M5 cache reduces GitHub API calls
  • M6-M7 TUI navigation, colors, auto-refresh
  • M8 skill installs and prime output is useful
  • M9 doctor, help popup, README

🤖 Generated with Claude Code

ilucin and others added 13 commits April 16, 2026 20:25
Plan for a new tb-pr tool — kanban TUI + non-interactive CLI for
tracking GitHub PRs that need attention across the Productive org.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add new tb-pr workspace crate with clap CLI scaffolding for all planned
subcommands (tui, list, show, refresh, open, prime, skill, config,
doctor). Only doctor, config init/show, and prime are wired up — the
rest return a "not implemented" error noting the milestone where they
land. Goal: cargo build -p tb-pr succeeds and tb-pr doctor reports gh
auth status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add core module with github client (parallel search + per-PR detail
fetch via gh auth token + reqwest), model structs (Pr, Column,
BoardState), classifier (size bucket, rotting bucket per column), and
productive task URL extraction. Wire list --json to output the four
column JSON dump. Support --column=<slug> and --stale-days=<n> filters.

ready_to_merge_mine stays empty until M3 lands the reviews API split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add core::reviews with per-reviewer latest-state logic, extend the gh
client with pull_reviews and commit_date, and rework fetch_board_state:

- Split review_mine into review_mine (still needs review) and
  ready_to_merge_mine (≥1 approval + no pending CHANGES_REQUESTED).
- Filter waiting_on_author to keep only PRs where the viewer's last
  review was COMMENTED or CHANGES_REQUESTED. Flag
  has_new_commits_since_my_review when the head commit post-dates the
  review, driving the 🆕 marker planned for the TUI.

Column counts verified against gh api: 34/6/1/33/20 (was 34/7/0/33/32
before the split + filter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implement human-readable output for the three non-interactive commands:

- list (default, no --json): flattened table sorted by urgency bucket
  (critical → fresh), with column tag, size, color-coded age, task ID,
  and a 🆕 marker for PRs where the author pushed after the viewer's
  review. Footer shows per-column counts + fetch timestamp.
- show <ref>: detail view — metadata, per-reviewer latest-state summary,
  viewer's last review with 🆕 if author pushed since.
- open <ref>: resolve the PR URL and open it via `open`/`xdg-open`.

`<ref>` accepts a full GitHub URL, `owner/repo#N`, or a bare number
(resolved from `git remote get-url origin`).

Extended PullDetail with title/body/user/draft/created_at/comments so
show only needs one PR endpoint call (plus reviews + head commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap toolbox-core::cache for the tb-pr data model:
- list: reads BoardState from cache (5m TTL), falls back to fetch.
  Footer shows "refreshed Nm ago" based on fetched_at.
- show: caches the per-PR detail+reviews+head-date payload keyed by URL.
- refresh: clears cache, fetches fresh, saves.

Enables toolbox-core `cache` feature. Adds Serialize to PullDetail,
BaseRef, HeadRef, SearchItem/User, Review/User so the show payload can
round-trip through the cache.

Warm list hits in <10ms (vs ~5s cold). Refresh forces re-fetch.

Closes productive-work-7j6
Add ratatui + crossterm workspace deps and a tui/ module:
- app.rs: App state, event loop, arrow+hjkl nav, Enter opens URL, q/Esc/Ctrl-C quit.
- columns.rs: header (user + "refreshed Nm ago"), five equal-width columns, footer.
- card.rs: two-row PR card (title, repo#num + size + age).

tb-pr tui (and bare tb-pr) now launches the kanban board against the
same cached BoardState used by list/show. No auto-refresh yet — one-shot
fetch on startup (cache hit when fresh). Covered by two unit tests:
navigation wrap/clamp and a TestBackend render smoke test.

Closes productive-work-sfz
TUI presentation:
- Each PR renders as its own bordered Block, border color from the
  rotting bucket (grey/green/yellow/orange/red). Selected card uses
  reversed + bold on the same color.
- Card body: bold title (🆕 prefix for waiting-on-author with new
  commits), repo#N + [P-xxx] task chip, SIZE + AGE + 💬N comments.
- Column scrolls: selection keeps the card in view; "+N more ↓" hint
  when clipped. Dimmed borders on unfocused columns.
- Header shows spinner + "fetching…" during refresh and ⚠ err message
  on failure without dropping the previous state.

Event loop:
- Migrated to an mpsc channel fed by a blocking keyboard thread, a
  tokio `interval(5 min)` auto-refresh tick, and a 120ms animation
  tick. Fetches are spawned as tokio tasks — UI never blocks.
- Intent enum decouples key handling from side effects so handle_key
  stays unit-testable.

Keybinds added: t (open Productive task), r (manual refresh,
no-op while fetching), c (copy URL via pbcopy/xclip), ? (help popup),
d (alias for Enter). Ctrl-C always quits.

Closes productive-work-1ap
Two UX tweaks for the TUI only — CLI output (`list`, `show`, `--json`)
keeps the raw GitHub title.

- `display_title()` strips conventional-commit prefixes (fix/feat/
  refactor/chore/docs/update/test/ci/perf/build/style/revert), including
  scoped forms (`fix(tb-pr):`) and breaking markers (`feat!:`). Only
  strips when the head before the colon matches a known tag, so
  "this: whatever" sentences stay intact.
- New `w` keybind flips `full_titles`: titles wrap across as many lines
  as needed and card height grows per-PR. Scroll math now walks variable
  heights to keep the selected card fully visible, and resets when the
  mode toggles so re-anchoring is predictable.
Swap `review-requested:@me` for `user-review-requested:@me` in the
GitHub search query. The former matches PRs where the user OR any team
they're a member of was requested (via CODEOWNERS), which meant the
column lit up for every team PR. The latter is direct-only.

Dropped waiting_on_me from 33 to 8 on ilucin@productiveio.
- prime command now loads the cached BoardState (or fetches) and emits
  a markdown summary: counts per column, plus oldest-first lists for
  waiting-on-me, ready-to-merge, and waiting-on-author. PRs include
  age, size, Productive task chip, 💬 comments, and 🆕 new-commit marker.
- SKILL.md rewritten: clear capabilities, quick reference, and a live
  `!\`tb-pr prime\`` directive so Claude sessions get current state.
- install.sh --all now includes tb-pr.

Closes productive-work-1sn
- doctor now verifies cache readability (reports cached PR count when
  present) and probes /orgs/productiveio so a bad token / missing org
  access surfaces immediately. Migrated to async.
- Rate-limit detection in the GitHub client: 429 or
  `X-RateLimit-Remaining: 0` on a 403 now returns a friendly message
  with the reset time instead of the raw body.
- New crates/tb-pr/README.md documenting features, columns, keybinds,
  config, and cache. Root README.md gains a tb-pr row and a
  `Bash(tb-pr:*)` permission entry.
- scripts/bump.sh accepts tb-pr. Version stays at 0.1.0 (initial).

Closes productive-work-tbi
@ilucin ilucin marked this pull request as ready for review April 16, 2026 19:45
ilucin and others added 8 commits April 16, 2026 21:57
- Wire `refresh.interval_minutes` through `FetchCtx` so the TUI
  auto-refresh cadence honours config instead of a hardcoded 5m.
  Clamp to ≥30s to guard against accidental hammering.
- Add an `App.status` slot distinct from `last_error`; route clipboard
  success into it and render it green (`✓`) instead of red (`⚠`).
- Paginate `search_issues` via the `Link: rel="next"` header up to 10
  pages (GitHub's 1000-result cap). Previously columns silently
  truncated at 100 items, which mattered most for `reviewed-by:@me`.
- Replace silent `.ok()` on the head-commit-date fetch in `tb-pr show`
  with a stderr warning so users know the "🆕 new commits" indicator
  was skipped rather than seeing it vanish without explanation.

Adds unit tests for `parse_link_next` and status/error exclusivity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dedup `show.rs` "latest review per user" by exposing
  `ReviewSummary::iter_latest()` and reusing it in place of the inline
  hashmap. Keeps classification logic single-sourced with the fetcher.
- Key per-PR lookup maps by `(owner, repo, number)` instead of
  `(repo, number)`. Safe today since we scope to `org:productiveio`,
  but the owner-less key was a quiet footgun the moment the org is
  overridden or a mirror-forked repo shares a name. New `PrKey` alias
  names the contract.
- Cap concurrent per-PR API calls (`pull_detail`, `pull_reviews`,
  `commit_date`) with a `Semaphore`(16) so a 100-PR refresh doesn't
  trip GitHub's secondary rate limiter.
- Swap the bare `setup_terminal`/`restore_terminal` pair for a
  `RawModeGuard` with Drop. Panics inside `event_loop` now restore the
  terminal instead of leaving the shell in raw-mode + alt-screen.
- Extract `is_wait_on_author_state()` as a pure helper — makes the
  column-filter predicate testable without a live `GhClient`.
- Tighten `parse_pr_ref` / `parse_git_remote` to https-only; drop the
  http:// branch so a typoed or log-scrubbed URL fails loudly.
- Add `crates/tb-pr/tests/cli.rs` integration smoke tests (assert_cmd
  + predicates) for `--help`, `-V`, and `list --help`. Justifies the
  dev-deps that were previously declared but unused.
- Drop unused `tempfile` dev-dep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a 6th "Mentions" column that surfaces unread GitHub notifications
on PRs in the configured org, so email can stop being the inbox.

## Data layer
- `Notification` + `NotificationReason` in core::model, with a Vec on
  ColumnsData (serde default so cached BoardStates pre-M10 still load).
- `GhClient::list_notifications(org)` — paginated `/notifications`
  (unread only), filtered to `subject.type == PullRequest` and the
  configured owner. PR number parsed from `subject.url`.
- `resolve_comment_html_url`, `mark_thread_read`, and
  `mark_all_notifications_read` bindings.
- `fetch_board_state` gains a 5th parallel call via `tokio::join!`.
  Notifications are nice-to-have: if the token is missing the
  `notifications` scope we print a warning and carry on with the PR
  board instead of failing the whole fetch.

## TUI
- Mentions is column 6. Per-notification card: bold (stripped) PR
  title, reason badge (`@me`, `💬`, `👀`, …), repo#N, age colored by
  the same rotting bucket as Waiting-on-me.
- Enter on a notification: spawn a task that resolves
  `latest_comment_url → html_url`, opens it, then PATCHes the thread
  read. Falls back to the PR URL if the resolve fails.
- `m` key: PUT /notifications → clear the local inbox. No-op when
  already empty.
- Success/error feedback uses the existing `status` / `last_error`
  banners. Help popup updated.

## CLI
- `tb-pr list --column=mentions` now prints a dedicated table
  (reason, repo#N, age, title). Mentions stay out of the flattened
  PR table because their shape is different.
- `tb-pr prime` gains a "## Mentions (urgent first)" section and a
  summary count.
- `list --json` gains the `columns.notifications` array automatically.

Closes productive-work-6ia
Allow-list: mention, team_mention, author, comment. Drop
review_requested (already a dedicated column) and state_change /
subscribed (firehose of every PR close/merge/deploy event).

Took my inbox from 17 → 2 entries — the actually actionable ones.
Rolls up GitHub Actions check-runs into ✓ / ✗ / ● per PR head SHA.

## Data layer
- `CheckState` enum + `Pr.check_state: Option<CheckState>` (serde skip
  on None so cached BoardStates without it still load).
- `GhClient::check_rollup(owner, repo, sha)` → `Option<CheckState>`
  following the failure-wins-over-pending-wins-over-success rule.
  Skipped/neutral alone → None, so doc-only filtered PRs don't render
  a misleading ✓.
- Parallel fetch via `fetch_all_check_states`, scoped to my PRs only
  (draft_mine + review_mine + ready_to_merge_mine). Per-PR errors
  degrade to None instead of tanking the whole refresh.

## Display
- TUI card: ✓ / ✗ / ● colored prefix before the title; card_height
  accounts for the extra glyph width.
- CLI `list` pretty table: new 2-wide CI column; blank when no CI
  configured. JSON payload already covered via Pr serialization.
- `show`: "Checks: ✓ passing" line (or failing / pending). Cached
  alongside the detail/reviews payload.

Rollup rule is unit-tested (6 cases covering failure precedence, all-
skipped → None, timed_out as failure, etc.).

Closes productive-work-480
TUI launch used to block ~5s on the initial fetch when the cache was
empty or stale. Now it opens immediately:

- Cache fresh (<5 min): render instantly, no fetch. Unchanged.
- Cache stale (5 min – 1 h): render instantly, spawn background fetch;
  UI swaps to fresh data when FetchDone arrives. Spinner in the header
  during the gap.
- Cache empty (or evicted past Long TTL): open with an empty placeholder
  BoardState — empty columns, blank user — and spawn the fetch. Header
  renders just "tb-pr" + spinner while the user waits.

Implementation:
- `commands/tui.rs` loads with `CacheTtl::Long`, checks `fetched_at`
  against a 5-minute freshness window, and passes `needs_refresh` into
  `app::run`. An `empty_state()` helper builds the placeholder.
- `app::run` gains a `needs_refresh: bool` parameter; on true, the
  event loop calls `spawn_fetch` right after the initial draw so the
  UI is live (keys + spinner animate) throughout the fetch.
- `render_header` skips the `{user}@productiveio · refreshed Nm ago`
  line when user is empty to avoid a "refreshed 56 years ago" gag
  from the epoch-zero fetched_at.

Closes productive-work-hjs
The TUI's spawn_fetch updated app state but never wrote the fresh
BoardState to disk — only `tb-pr refresh` and `tb-pr list`'s fallback
did. Result: every relaunch showed the stale count and re-fetched,
undoing the whole point of the cache for repeat users.

Fix: write to the cache inside spawn_fetch before emitting FetchDone.
Errors on write are swallowed (stale cache is a UX nuisance, not a
correctness problem). Covers all three fetch paths — initial on cold
boot, the 5-min auto-refresh tick, and the `r` manual refresh.
@ilucin ilucin requested a review from trogulja April 16, 2026 21:28
@ilucin
Copy link
Copy Markdown
Author

ilucin commented Apr 16, 2026

@trogulja izvibeao sam si Github UI u terminalu :) želim ubiti github email notifikacije. Baci oko, sutra ću to malo testirati pa mergam ako nemaš zamjerki

GitHub's `subject.latest_comment_url` on /notifications is unreliable —
frequently null, and when set, often points at the PR itself instead
of a comment. Result: Enter on a Mentions card fell through to the
fallback PR URL and the browser scrolled to the PR top instead of to
the comment that triggered the notification.

Ignore `latest_comment_url` entirely. On Enter, fetch both feeds for
the PR in parallel — issue comments and inline review comments — pick
the one with the latest `updated_at`, and open its `html_url`. That
always includes the proper anchor (`#issuecomment-X` or
`#discussion_rX`) when anything comments-shaped exists. Fall back to
the PR URL only if both feeds are empty.

`Notification` now carries `owner` instead of the dropped
`latest_comment_api_url`. `Intent::OpenNotification` and
`spawn_open_notification` take `(owner, repo, pr_number)` so the
resolver can do its own two-call fetch without pre-baked URLs.

Closes productive-work-wti
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant