Add more QoL and high prio features

This commit is contained in:
2026-07-28 10:17:18 +02:00
parent 43fa047765
commit fdaee6a69e
16 changed files with 2850 additions and 302 deletions

View File

@@ -3,8 +3,10 @@
A read-only terminal UI for people receiving GitHub pull-request reviews. It
shows open PRs and a scrollable PR dashboard with the description, branches,
review state, checks, people, labels, milestone, activity, change statistics,
and thread totals. The thread viewer includes highlighted diff hunks and comment
authors. Resolved threads start folded. GitHub suggestion blocks are shown as
thread totals, submitted reviews, and the PR conversation. Review threads and
comments are paginated rather than silently stopping at the first page. The
thread viewer includes highlighted diff hunks and comment authors. Resolved
threads start folded. GitHub suggestion blocks are shown as
syntax-highlighted remove/add previews. Comments and PR descriptions render
GitHub Flavored Markdown, including quoted replies, inline and fenced code,
lists and tasks, links, tables, emphasis, strikethrough, emoji, and GitHub
@@ -67,7 +69,7 @@ On Linux this is normally `~/.config/gh-threads/config.toml`. On macOS,
exists.
```toml
theme = "dark" # "dark" or "light"
theme = "dark" # dark, light, high-contrast, or no-color
refresh_interval = "10s"
repository = "" # optional owner/repository default
show_all = false # requires repository
@@ -78,6 +80,7 @@ endpoint = "https://api.github.com/graphql"
fold_resolved = true
thread_list_width_percent = 33 # 20-60
dashboard_mode = "hotkey" # "hotkey" or "intermediate"
compact_reviews = true # aggregate submitted review history
[paths]
scroll = false
@@ -88,20 +91,44 @@ scroll_interval = "350ms" # minimum 50ms
# resolved threads remain in "resolved" even when they are also outdated.
status_order = ["unresolved", "outdated", "resolved"]
within_status = "file" # "file" or "timestamp" (oldest first)
[cache]
enabled = true # instant stale view plus offline fallback
max_age = "168h" # 7 days; 0 means no age limit
directory = "" # defaults to the OS user cache directory
```
When cached data exists, the picker and PR details are rendered immediately
from that snapshot while a live GitHub refresh runs in the background. Cached
screens are labelled with their save time and are replaced automatically when
fresh data arrives. Check annotations are fetched separately only for failed
checks so they do not inflate the initial PR query.
The cache uses separate JSON files for the picker and each visited PR. Cache
content is hashed before writing: unchanged responses do not rewrite their
files. Their modification time is touched at most once per day (or half the
configured maximum age, when shorter) so recently validated snapshots remain
usable without writing on every poll. Changed files are replaced atomically.
Command-line flags override the configuration. `GH_REPO` overrides the
configured repository when `--repo` is not provided. The corresponding flags
include `--config`, `--theme`, `--poll`, `--fold-resolved`,
`--thread-list-width`, `--dashboard-mode`, `--path-scroll`, and
`--path-scroll-interval`. Boolean settings can be disabled explicitly, for
example `--path-scroll=false`.
`--thread-list-width`, `--dashboard-mode`, `--compact-reviews`,
`--path-scroll`, and `--path-scroll-interval`, plus `--cache`,
`--cache-max-age`, and `--cache-dir`.
Boolean settings can be disabled explicitly, for
example `--compact-reviews=false`.
With the default `dashboard_mode = "hotkey"`, opening a PR goes directly to its
review threads and `d` opens the dashboard only when requested. Set
`dashboard_mode = "intermediate"` to follow picker → dashboard → review
threads instead.
Compact reviews aggregate submission counts by state and author. Reviews with
a written summary retain a compact one-line body, while timestamps and commit
SHAs are omitted. Set `compact_reviews = false` to restore the complete review
history and metadata.
## Keys
| Key | Action |
@@ -110,7 +137,9 @@ threads instead.
| `j` / `k` | Move between items or scroll the dashboard/focused detail |
| `?` | Show contextual keybinding help |
| `d` | Open the current pull request dashboard |
| `/` | Fuzzy-search thread file paths |
| `/` | Fuzzy-search paths and filter with `status:`, `author:`, `updated:true` |
| `F` | Clear active thread filters |
| `n` / `N` | Next / previous thread with a new update |
| `↑` / `↓` | Choose a fuzzy-search match |
| `g` / `G` | First / last item |
| `enter` / `l` | Open the selected PR dashboard or its review threads |
@@ -124,8 +153,11 @@ threads instead.
## Current scope
The application is intentionally read-only. GitHub's GraphQL API currently
limits this client to the first 100 review threads and first 100 comments per
thread; the UI warns when the thread list is truncated. GitHub features which
depend on server-side context, such as unfurling issue references or displaying
uploaded images, are represented textually in the terminal.
The application is intentionally read-only. The dashboard and contextual help
show the write capability gate, including why each future write action is
unavailable. Read state persists beside the configuration, and recent PR data
is cached for offline fallback. Check contexts and annotations are paginated.
GitHub features which depend
on server-side context, such as unfurling issue references or displaying
uploaded images, are represented textually in the terminal. See
[`TODO.md`](TODO.md) for remaining read-only work and write-support preparation.

38
TODO.md Normal file
View File

@@ -0,0 +1,38 @@
# TODO
The read-only model now includes paginated review threads and comments, PR
conversation comments, submitted reviews, individual checks, branch-protection
requirements, rulesets, merge queues, deployment requirements, viewer
capabilities, head SHA, persistent unread updates, offline cache fallback,
commit/force-push history, and paginated checks and annotations.
The following read-only improvements remain before or alongside write support.
## Workflow and navigation
- Open the current PR, comment, review, check, or source location in a browser.
- Copy URLs, commit SHAs, file paths, and rendered comment text to the
clipboard.
- Add navigation for the next thread by status or author, not only the next
unread update.
- Remember the selected PR, thread, scroll position, pane width, and folded
state between runs.
- Audit screen-reader behavior beyond the new no-color and high-contrast modes.
## Data completeness and resilience
- Model pending reviews, minimized comments, deleted comments, and explicit
reply relationships.
- Show rate-limit remaining/reset information and distinguish it from network
or permission failures.
- Support retrying individual failed pages while retaining clearly marked
partial data, rather than failing the entire refresh.
- Add fixture coverage for GitHub Enterprise Server schema differences, team
reviewers, deleted users, very large PRs, and mixed legacy status contexts.
## Preparation for write support
- Fetch per-comment update/delete permissions and the exact reply target IDs.
- Define head-SHA conflict handling for comments and suggestions composed
against an older revision.
- Design confirmation and optimistic-update behavior for resolving threads,
submitting reviews, and applying suggestions.

249
cache.go Normal file
View File

@@ -0,0 +1,249 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
type cacheEnvelope[T any] struct {
SavedAt time.Time `json:"saved_at"`
ContentHash string `json:"content_hash,omitempty"`
Value T `json:"value"`
}
type CachedGitHubService struct {
remote GitHubService
dir string
maxAge time.Duration
}
type cachedSnapshotService interface {
CachedPullRequests(string, string, int, bool) ([]PullRequest, error)
CachedPullRequest(string, string, int) (PRDetails, error)
}
type liveGitHubService interface {
LivePullRequests(context.Context, string, string, int, bool) ([]PullRequest, error)
LivePullRequest(context.Context, string, string, int) (PRDetails, error)
}
func NewCachedGitHubService(remote GitHubService, dir string, maxAge time.Duration) *CachedGitHubService {
return &CachedGitHubService{remote: remote, dir: dir, maxAge: maxAge}
}
func (c *CachedGitHubService) CachedPullRequests(
owner, repo string, limit int, showAll bool,
) ([]PullRequest, error) {
var cached cacheEnvelope[[]PullRequest]
savedAt, err := c.read(c.pullRequestsKey(owner, repo, limit, showAll), &cached)
if err != nil {
return nil, err
}
cached.SavedAt = savedAt
for i := range cached.Value {
cached.Value[i].FromCache, cached.Value[i].CachedAt = true, cached.SavedAt
}
return cached.Value, nil
}
func (c *CachedGitHubService) CachedPullRequest(owner, repo string, number int) (PRDetails, error) {
var cached cacheEnvelope[PRDetails]
savedAt, err := c.read(c.pullRequestKey(owner, repo, number), &cached)
if err != nil {
return PRDetails{}, err
}
cached.SavedAt = savedAt
cached.Value.FromCache, cached.Value.CachedAt = true, cached.SavedAt
return cached.Value, nil
}
func (c *CachedGitHubService) ListPullRequests(
ctx context.Context, owner, repo string, limit int, showAll bool,
) ([]PullRequest, error) {
prs, err := c.LivePullRequests(ctx, owner, repo, limit, showAll)
if err == nil {
return prs, nil
}
cached, cacheErr := c.CachedPullRequests(owner, repo, limit, showAll)
if cacheErr != nil {
return nil, fmt.Errorf("%w (cache unavailable: %v)", err, cacheErr)
}
return cached, nil
}
func (c *CachedGitHubService) GetPullRequest(
ctx context.Context, owner, repo string, number int,
) (PRDetails, error) {
details, err := c.LivePullRequest(ctx, owner, repo, number)
if err == nil {
return details, nil
}
cached, cacheErr := c.CachedPullRequest(owner, repo, number)
if cacheErr != nil {
return PRDetails{}, fmt.Errorf("%w (cache unavailable: %v)", err, cacheErr)
}
return cached, nil
}
func (c *CachedGitHubService) LivePullRequests(
ctx context.Context, owner, repo string, limit int, showAll bool,
) ([]PullRequest, error) {
prs, err := c.remote.ListPullRequests(ctx, owner, repo, limit, showAll)
if err != nil {
return nil, err
}
for i := range prs {
prs[i].FromCache, prs[i].CachedAt = false, time.Time{}
}
_ = c.write(c.pullRequestsKey(owner, repo, limit, showAll), prs)
return prs, nil
}
func (c *CachedGitHubService) LivePullRequest(
ctx context.Context, owner, repo string, number int,
) (PRDetails, error) {
details, err := c.remote.GetPullRequest(ctx, owner, repo, number)
if err != nil {
return PRDetails{}, err
}
details.FromCache, details.CachedAt = false, time.Time{}
_ = c.write(c.pullRequestKey(owner, repo, number), details)
return details, nil
}
func (c *CachedGitHubService) pullRequestsKey(owner, repo string, limit int, showAll bool) string {
return fmt.Sprintf("prs:%s/%s:%d:%t", owner, repo, limit, showAll)
}
func (c *CachedGitHubService) pullRequestKey(owner, repo string, number int) string {
return fmt.Sprintf("pr:%s/%s:%d", owner, repo, number)
}
func (c *CachedGitHubService) file(key string) string {
sum := sha256.Sum256([]byte(key))
return filepath.Join(c.dir, hex.EncodeToString(sum[:])+".json")
}
func (c *CachedGitHubService) write(key string, value any) error {
if err := os.MkdirAll(c.dir, 0o700); err != nil {
return err
}
valueData, err := json.Marshal(value)
if err != nil {
return err
}
sum := sha256.Sum256(valueData)
contentHash := hex.EncodeToString(sum[:])
target := c.file(key)
if existing, err := os.ReadFile(target); err == nil {
var metadata struct {
ContentHash string `json:"content_hash"`
}
if json.Unmarshal(existing, &metadata) == nil && metadata.ContentHash == contentHash {
if info, statErr := os.Stat(target); statErr == nil &&
time.Since(info.ModTime()) >= c.cacheTouchInterval() {
now := time.Now()
_ = os.Chtimes(target, now, now)
}
return nil
}
}
data, err := json.Marshal(cacheEnvelope[any]{
SavedAt: time.Now(), ContentHash: contentHash, Value: value,
})
if err != nil {
return err
}
temp, err := os.CreateTemp(c.dir, ".cache-*")
if err != nil {
return err
}
name := temp.Name()
defer os.Remove(name)
if err := temp.Chmod(0o600); err != nil {
temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
return os.Rename(name, target)
}
func (c *CachedGitHubService) cacheTouchInterval() time.Duration {
interval := 24 * time.Hour
if c.maxAge > 0 && c.maxAge/2 < interval {
interval = c.maxAge / 2
}
return interval
}
func (c *CachedGitHubService) read(key string, target any) (time.Time, error) {
path := c.file(key)
data, err := os.ReadFile(path)
if err != nil {
return time.Time{}, err
}
if err := json.Unmarshal(data, target); err != nil {
return time.Time{}, err
}
var metadata struct {
SavedAt time.Time `json:"saved_at"`
}
if err := json.Unmarshal(data, &metadata); err != nil {
return time.Time{}, err
}
savedAt := metadata.SavedAt
if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(savedAt) {
savedAt = info.ModTime()
}
if c.maxAge > 0 && time.Since(savedAt) > c.maxAge {
return time.Time{}, errors.New("cached data expired")
}
return savedAt, nil
}
type readStateStore struct {
path string
Data map[string]readPRState `json:"pull_requests"`
}
type readPRState struct {
Initialized bool `json:"initialized"`
Threads map[string]bool `json:"threads"`
Comments map[string]bool `json:"comments"`
}
func loadReadState(path string) *readStateStore {
store := &readStateStore{path: path, Data: make(map[string]readPRState)}
data, err := os.ReadFile(path)
if err == nil {
_ = json.Unmarshal(data, &store.Data)
}
return store
}
func (s *readStateStore) save() error {
if s == nil || s.path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(s.Data, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0o600)
}

191
cache_test.go Normal file
View File

@@ -0,0 +1,191 @@
package main
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
)
type switchService struct {
prs []PullRequest
details PRDetails
err error
}
type countingCachedService struct {
liveDetailsCalls int
cachedDetailsCalls int
}
func (s *countingCachedService) ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) {
return nil, nil
}
func (s *countingCachedService) GetPullRequest(context.Context, string, string, int) (PRDetails, error) {
return PRDetails{}, nil
}
func (s *countingCachedService) LivePullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) {
return nil, nil
}
func (s *countingCachedService) LivePullRequest(context.Context, string, string, int) (PRDetails, error) {
s.liveDetailsCalls++
return PRDetails{}, nil
}
func (s *countingCachedService) CachedPullRequests(string, string, int, bool) ([]PullRequest, error) {
return nil, nil
}
func (s *countingCachedService) CachedPullRequest(string, string, int) (PRDetails, error) {
s.cachedDetailsCalls++
return PRDetails{}, nil
}
func (s *switchService) ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) {
return s.prs, s.err
}
func (s *switchService) GetPullRequest(context.Context, string, string, int) (PRDetails, error) {
return s.details, s.err
}
func TestCachedServiceFallsBackToRecentReadData(t *testing.T) {
remote := &switchService{
prs: []PullRequest{{ID: "pr", Owner: "o", Repository: "r", Number: 1}},
details: PRDetails{PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1}},
}
service := NewCachedGitHubService(remote, t.TempDir(), time.Hour)
if _, err := service.ListPullRequests(context.Background(), "o", "r", 50, false); err != nil {
t.Fatal(err)
}
if _, err := service.GetPullRequest(context.Background(), "o", "r", 1); err != nil {
t.Fatal(err)
}
remote.err = errors.New("offline")
prs, err := service.ListPullRequests(context.Background(), "o", "r", 50, false)
if err != nil || len(prs) != 1 || !prs[0].FromCache || prs[0].CachedAt.IsZero() {
t.Fatalf("cached PR list = %#v, error = %v", prs, err)
}
details, err := service.GetPullRequest(context.Background(), "o", "r", 1)
if err != nil || !details.FromCache || details.CachedAt.IsZero() {
t.Fatalf("cached details = %#v, error = %v", details, err)
}
}
func TestReadStateSurvivesRestartWithUnreadComment(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
settings := defaultAppSettings()
settings.ReadState = loadReadState(path)
m := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings)
initial := PRDetails{
PullRequest: PullRequest{ID: "pr"},
Threads: []ReviewThread{{ID: "thread", Comments: []ReviewComment{{ID: "old"}}}},
}
m.trackThreadUpdates(initial)
updated := initial
updated.Threads[0].Comments = append(updated.Threads[0].Comments, ReviewComment{ID: "new"})
m.trackThreadUpdates(updated)
if !m.unreadThreads["thread"] {
t.Fatal("new comment was not unread before restart")
}
settings.ReadState = loadReadState(path)
restarted := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings)
restarted.trackThreadUpdates(updated)
if !restarted.unreadThreads["thread"] {
t.Fatal("unread comment was lost across restart")
}
}
func TestCachedPickerSnapshotStaysVisibleWhileLiveRefreshContinues(t *testing.T) {
m := NewApp(nil, "", "", false, 50, time.Second)
m.loading = true
cachedAt := time.Now().Add(-time.Hour)
updated, _ := m.Update(prsLoadedMsg{
cached: true,
prs: []PullRequest{{
ID: "cached", Number: 1, FromCache: true, CachedAt: cachedAt,
}},
})
m = updated.(App)
if len(m.prs) != 1 || !m.prs[0].FromCache || !m.loading {
t.Fatalf("cached snapshot was not shown during refresh: %#v", m)
}
updated, _ = m.Update(prsLoadedMsg{prs: []PullRequest{{ID: "live", Number: 2}}})
m = updated.(App)
if len(m.prs) != 1 || m.prs[0].ID != "live" || m.loading {
t.Fatalf("live response did not replace cached snapshot: %#v", m)
}
}
func TestRoutineRefreshDoesNotReplayCachedDetails(t *testing.T) {
service := &countingCachedService{}
m := NewApp(service, "o", "r", false, 50, time.Second)
pr := PullRequest{Owner: "o", Repository: "r", Number: 1}
if msg := m.loadDetails(pr, false)(); msg == nil {
t.Fatal("live refresh returned no message")
}
if service.liveDetailsCalls != 1 || service.cachedDetailsCalls != 0 {
t.Fatalf("routine refresh calls: live=%d cached=%d", service.liveDetailsCalls, service.cachedDetailsCalls)
}
msg := m.loadDetails(pr, true)()
batch, ok := msg.(tea.BatchMsg)
if !ok {
t.Fatalf("initial load command returned %T, want tea.BatchMsg", msg)
}
for _, command := range batch {
_ = command()
}
if service.liveDetailsCalls != 2 || service.cachedDetailsCalls != 1 {
t.Fatalf("initial load calls: live=%d cached=%d", service.liveDetailsCalls, service.cachedDetailsCalls)
}
}
func TestCacheDoesNotRewriteUnchangedContent(t *testing.T) {
remote := &switchService{details: PRDetails{
PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1, Title: "same"},
}}
service := NewCachedGitHubService(remote, t.TempDir(), time.Hour)
if _, err := service.LivePullRequest(context.Background(), "o", "r", 1); err != nil {
t.Fatal(err)
}
path := service.file(service.pullRequestKey("o", "r", 1))
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
time.Sleep(2 * time.Millisecond)
if _, err := service.LivePullRequest(context.Background(), "o", "r", 1); err != nil {
t.Fatal(err)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("unchanged cache content was rewritten")
}
remote.details.Title = "changed"
if _, err := service.LivePullRequest(context.Background(), "o", "r", 1); err != nil {
t.Fatal(err)
}
changed, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(after, changed) {
t.Fatal("changed cache content was not persisted")
}
}

View File

@@ -34,12 +34,14 @@ type Config struct {
Display DisplayConfig `toml:"display"`
Paths PathConfig `toml:"paths"`
Threads ThreadConfig `toml:"threads"`
Cache CacheConfig `toml:"cache"`
}
type DisplayConfig struct {
FoldResolved bool `toml:"fold_resolved"`
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
DashboardMode string `toml:"dashboard_mode"`
CompactReviews bool `toml:"compact_reviews"`
}
type PathConfig struct {
@@ -52,6 +54,12 @@ type ThreadConfig struct {
WithinStatus string `toml:"within_status"`
}
type CacheConfig struct {
Enabled bool `toml:"enabled"`
MaxAge configDuration `toml:"max_age"`
Directory string `toml:"directory"`
}
func defaultConfig() Config {
return Config{
Theme: "dark",
@@ -62,6 +70,7 @@ func defaultConfig() Config {
FoldResolved: true,
ThreadListWidthPercent: 33,
DashboardMode: "hotkey",
CompactReviews: true,
},
Paths: PathConfig{
Scroll: false,
@@ -71,6 +80,7 @@ func defaultConfig() Config {
StatusOrder: []string{"unresolved", "outdated", "resolved"},
WithinStatus: "file",
},
Cache: CacheConfig{Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}},
}
}
@@ -125,15 +135,15 @@ func loadConfig(path string, required bool) (Config, error) {
func validateConfig(config Config) error {
switch config.Theme {
case "dark", "light":
case "dark", "light", "no-color", "high-contrast":
default:
return fmt.Errorf("theme must be dark or light")
}
if config.RefreshInterval.Duration < 2*time.Second {
return fmt.Errorf("refresh_interval must be at least 2s")
}
if config.Limit < 1 || config.Limit > 100 {
return fmt.Errorf("limit must be between 1 and 100")
if config.Limit < 1 || config.Limit > 1000 {
return fmt.Errorf("limit must be between 1 and 1000")
}
if config.Paths.ScrollInterval.Duration < 50*time.Millisecond {
return fmt.Errorf("paths.scroll_interval must be at least 50ms")
@@ -157,9 +167,20 @@ func validateConfig(config Config) error {
if config.ShowAll && config.Repository == "" {
return fmt.Errorf("show_all requires repository")
}
if config.Cache.MaxAge.Duration < 0 {
return fmt.Errorf("cache.max_age must not be negative")
}
return nil
}
func defaultCacheDir() (string, error) {
base, err := os.UserCacheDir()
if err != nil {
return "", fmt.Errorf("find user cache directory: %w", err)
}
return filepath.Join(base, "gh-threads"), nil
}
func validateThreadStatusOrder(order []string) error {
if len(order) != 3 {
return fmt.Errorf("threads.status_order must contain unresolved, outdated, and resolved exactly once")

View File

@@ -18,7 +18,8 @@ func TestLoadConfigUsesDefaultsWhenOptionalFileIsMissing(t *testing.T) {
if got.Theme != want.Theme ||
got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
got.Paths.Scroll != want.Paths.Scroll ||
got.Display.FoldResolved != want.Display.FoldResolved {
got.Display.FoldResolved != want.Display.FoldResolved ||
got.Display.CompactReviews != want.Display.CompactReviews {
t.Fatalf("defaults = %#v, want %#v", got, want)
}
}
@@ -37,6 +38,7 @@ endpoint = "https://github.example.com/api/graphql"
fold_resolved = false
thread_list_width_percent = 45
dashboard_mode = "hotkey"
compact_reviews = false
[paths]
scroll = true
@@ -45,6 +47,11 @@ scroll_interval = "125ms"
[threads]
status_order = ["resolved", "unresolved", "outdated"]
within_status = "timestamp"
[cache]
enabled = false
max_age = "48h"
directory = "/tmp/gh-threads-cache"
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
@@ -60,9 +67,11 @@ within_status = "timestamp"
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
got.Display.DashboardMode != "hotkey" ||
got.Display.CompactReviews ||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
got.Threads.WithinStatus != "timestamp" {
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/gh-threads-cache" {
t.Fatalf("config = %#v", got)
}
}

1011
github.go

File diff suppressed because it is too large Load Diff

View File

@@ -73,6 +73,40 @@ func TestListPullRequestsRejectsGlobalShowAll(t *testing.T) {
}
}
func TestListPullRequestsPaginatesToConfiguredLimit(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
cursor := request.Variables["after"]
if requests == 1 {
if cursor != nil || request.Variables["first"] != float64(100) {
t.Fatalf("first page variables = %#v", request.Variables)
}
_, _ = w.Write([]byte(`{"data":{"viewer":{"login":"me"},"search":{
"pageInfo":{"hasNextPage":true,"endCursor":"next"},"nodes":[]}}}`))
return
}
if cursor != "next" || request.Variables["first"] != float64(50) {
t.Fatalf("second page variables = %#v", request.Variables)
}
_, _ = w.Write([]byte(`{"data":{"viewer":{"login":"me"},"search":{
"pageInfo":{"hasNextPage":false},"nodes":[]}}}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
if _, err := client.ListPullRequests(context.Background(), "", "", 150, false); err != nil {
t.Fatal(err)
}
if requests != 2 {
t.Fatalf("requests = %d, want 2", requests)
}
}
func TestGraphQLErrorsAreReturned(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"errors":[{"message":"no access"}]}`))
@@ -85,26 +119,165 @@ func TestGraphQLErrorsAreReturned(t *testing.T) {
}
}
func TestCheckContextsAndAnnotationsArePaginated(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
switch {
case strings.Contains(request.Query, "query CheckContextsPage"):
_, _ = w.Write([]byte(`{"data":{"node":{"contexts":{
"pageInfo":{"hasNextPage":false},"nodes":[{
"id":"check-2","name":"lint","conclusion":"FAILURE"
}]}}}}`))
case strings.Contains(request.Query, "query CheckAnnotationsPage"):
if request.Variables["after"] == nil {
_, _ = w.Write([]byte(`{"data":{"node":{"annotations":{
"pageInfo":{"hasNextPage":true,"endCursor":"annotation-next"},
"nodes":[{"path":"a.go","location":{"start":{"line":4},"end":{"line":4}},
"annotationLevel":"FAILURE","message":"first"}]
}}}}`))
} else {
_, _ = w.Write([]byte(`{"data":{"node":{"annotations":{
"pageInfo":{"hasNextPage":false},
"nodes":[{"path":"b.go","location":{"start":{"line":8},"end":{"line":8}},
"annotationLevel":"WARNING","message":"second"}]
}}}}`))
}
default:
t.Fatalf("unexpected query: %s", request.Query)
}
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
nodes, err := client.allCheckContexts(context.Background(), githubCheckContextConnection{
PageInfo: githubPageInfo{HasNextPage: true, EndCursor: "context-next"},
Nodes: []githubCheckContext{{ID: "check-1", Name: "tests", Conclusion: "SUCCESS"}},
}, "rollup")
if err != nil {
t.Fatal(err)
}
if len(nodes) != 2 || len(nodes[1].Annotations.Nodes) != 2 ||
nodes[1].Annotations.Nodes[0].Location.Start.Line != 4 ||
nodes[1].Annotations.Nodes[1].Location.End.Line != 8 {
t.Fatalf("paginated checks = %#v", nodes)
}
}
func TestCheckQueriesUseCurrentGitHubSchemaShape(t *testing.T) {
for name, query := range map[string]string{"annotations": checkAnnotationsPageQuery} {
if strings.Contains(query, "output {") ||
strings.Contains(query, "nodes { path startLine endLine annotationLevel") ||
!strings.Contains(query, "location { start { line column } end { line column } }") {
t.Fatalf("%s query uses obsolete CheckRun/CheckAnnotation fields:\n%s", name, query)
}
}
for name, query := range map[string]string{
"details": detailsQuery, "context page": checkContextsPageQuery,
} {
if strings.Contains(query, "annotations(first:") {
t.Fatalf("%s query eagerly loads annotations:\n%s", name, query)
}
}
}
func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
switch {
case strings.Contains(request.Query, "query ReviewThreadsPage"):
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"reviewThreads":{
"pageInfo":{"hasNextPage":false,"endCursor":null},
"nodes":[{"id":"thread-2","path":"b.go","line":20,"diffSide":"RIGHT",
"isResolved":false,"isOutdated":false,"viewerCanResolve":true,
"comments":{"pageInfo":{"hasNextPage":true,"endCursor":"comment-cursor"},
"nodes":[{"id":"review-comment-2a","body":"first","createdAt":"2026-01-02T00:00:00Z"}]}}]
}}}}}`))
case strings.Contains(request.Query, "query ReviewCommentsPage"):
_, _ = w.Write([]byte(`{"data":{"node":{"comments":{
"pageInfo":{"hasNextPage":false,"endCursor":null},
"nodes":[{"id":"review-comment-2b","body":"second","createdAt":"2026-01-03T00:00:00Z"}]
}}}}`))
case strings.Contains(request.Query, "query ConversationPage"):
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"comments":{
"totalCount":2,"pageInfo":{"hasNextPage":false,"endCursor":null},
"nodes":[{"id":"conversation-2","body":"reply","createdAt":"2026-01-03T00:00:00Z"}]
}}}}}`))
case strings.Contains(request.Query, "query ReviewsPage"):
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"reviews":{
"pageInfo":{"hasNextPage":false,"endCursor":null},
"nodes":[{"id":"review-2","body":"approved","state":"APPROVED","submittedAt":"2026-01-03T00:00:00Z"}]
}}}}}`))
default:
_, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE","pullRequest":{
"id":"pr","number":1,"title":"PR","url":"u","createdAt":"2026-01-01T00:00:00Z",
"updatedAt":"2026-01-01T00:00:00Z","author":{"login":"alice"},
"assignees":{"nodes":[]},"labels":{"nodes":[]},"reviewRequests":{"nodes":[]},
"latestReviews":{"nodes":[]},"commits":{"totalCount":1,"nodes":[]},
"comments":{"totalCount":2,"pageInfo":{"hasNextPage":true,"endCursor":"conversation-cursor"},
"nodes":[{"id":"conversation-1","body":"start","createdAt":"2026-01-01T00:00:00Z"}]},
"reviews":{"pageInfo":{"hasNextPage":true,"endCursor":"review-cursor"},
"nodes":[{"id":"review-1","body":"changes","state":"CHANGES_REQUESTED","submittedAt":"2026-01-02T00:00:00Z"}]},
"reviewThreads":{"pageInfo":{"hasNextPage":true,"endCursor":"thread-cursor"},
"nodes":[{"id":"thread-1","path":"a.go","line":10,"diffSide":"RIGHT",
"isResolved":false,"isOutdated":false,
"comments":{"pageInfo":{"hasNextPage":false},
"nodes":[{"id":"review-comment-1","body":"fix","createdAt":"2026-01-01T00:00:00Z"}]}}]}
}}}}`))
}
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
got, err := client.GetPullRequest(context.Background(), "o", "r", 1)
if err != nil {
t.Fatal(err)
}
if len(got.Threads) != 2 || len(got.Threads[1].Comments) != 2 ||
len(got.Conversation) != 2 || len(got.Reviews) != 2 {
t.Fatalf("paginated details were incomplete: %#v", got)
}
if !got.Permissions.CanResolveAny || got.Permissions.Repository != "WRITE" {
t.Fatalf("permissions = %#v", got.Permissions)
}
}
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{
_, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE","pullRequest":{
"id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false,
"createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z",
"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED",
"additions":12,"deletions":4,"changedFiles":3,
"baseRefName":"main","headRefName":"fix","author":{"login":"zam"},
"baseRefName":"main","headRefName":"fix","headRefOid":"abcdef0123456789",
"viewerCanUpdate":true,"viewerCanReact":true,"viewerCanSubscribe":true,
"viewerCanEnableAutoMerge":true,
"baseRef":{"branchProtectionRule":{"requiresApprovingReviews":true,
"requiredApprovingReviewCount":2,"requiresStatusChecks":true,
"requiresConversationResolution":true,"requiresCodeOwnerReviews":true}},
"author":{"login":"zam"},
"assignees":{"nodes":[{"login":"sam"}]},
"labels":{"nodes":[{"name":"bug"},{"name":"backend"}]},
"milestone":{"title":"v2"},
"comments":{"totalCount":5},
"reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]},
"latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]},
"commits":{"totalCount":7,"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE"}}}]},
"commits":{"totalCount":7,"nodes":[{"commit":{"oid":"abcdef0123456789",
"statusCheckRollup":{"state":"FAILURE","contexts":{"nodes":[
{"name":"tests","status":"COMPLETED","conclusion":"FAILURE","detailsUrl":"https://checks/tests"},
{"context":"legacy","state":"SUCCESS","targetUrl":"https://checks/legacy"}
]}}}}]},
"reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{
"id":"t","isResolved":false,"isOutdated":true,"path":"main.go",
"viewerCanResolve":true,
"line":null,"originalLine":42,"diffSide":"RIGHT",
"startLine":null,"originalStartLine":40,"startDiffSide":"RIGHT",
"comments":{"pageInfo":{"hasNextPage":true},"nodes":[{
"comments":{"pageInfo":{"hasNextPage":false},"nodes":[{
"id":"c","body":"change this","diffHunk":"@@ -1 +1 @@","createdAt":"2026-01-01T00:00:00Z",
"url":"cu","author":{"login":"reviewer"},"outdated":true,
"line":100,"startLine":99,"originalLine":42,"originalStartLine":40,
@@ -129,8 +302,15 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
got.CreatedAt.IsZero() {
t.Fatalf("unexpected dashboard metadata: %#v", got)
}
if got.HeadOID != "abcdef0123456789" || len(got.Checks) != 2 ||
got.Checks[0].Name != "tests" || got.Checks[1].Name != "legacy" ||
got.Permissions.Repository != "WRITE" || !got.Permissions.CanUpdatePR ||
!got.Permissions.CanResolveAny || got.Requirements.ApprovalsRequired != 2 ||
!got.Requirements.RequiresConversation || !got.Requirements.RequiresCodeOwnerReview {
t.Fatalf("unexpected read capabilities: %#v", got)
}
if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 ||
got.Threads[0].DiffSide != "RIGHT" || !got.Threads[0].IsTruncated {
got.Threads[0].DiffSide != "RIGHT" || got.Threads[0].IsTruncated {
t.Fatalf("unexpected thread: %#v", got.Threads)
}
comment := got.Threads[0].Comments[0]

View File

@@ -14,6 +14,7 @@ import (
const reviewContextLines = 3
var codeHighlightTheme = "github-dark"
var colorEnabled = true
var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
@@ -53,15 +54,27 @@ func highlightDiff(path, hunk string, startLine, endLine int, side string) []hig
out := make([]highlightedDiffLine, 0, len(visible))
for _, line := range visible {
if line.raw == "⋯" {
out = append(out, highlightedDiffLine{code: "\x1b[38;5;245m⋯\x1b[0m"})
code := "⋯"
if colorEnabled {
code = "\x1b[38;5;245m⋯\x1b[0m"
}
out = append(out, highlightedDiffLine{code: code})
continue
}
if line.notice {
out = append(out, highlightedDiffLine{code: "\x1b[38;5;245m" + line.raw + "\x1b[0m"})
code := line.raw
if colorEnabled {
code = "\x1b[38;5;245m" + line.raw + "\x1b[0m"
}
out = append(out, highlightedDiffLine{code: code})
continue
}
if line.header {
out = append(out, highlightedDiffLine{code: "\x1b[38;5;141m" + line.raw + "\x1b[0m"})
code := line.raw
if colorEnabled {
code = "\x1b[38;5;141m" + line.raw + "\x1b[0m"
}
out = append(out, highlightedDiffLine{code: code})
continue
}
out = append(out, renderDiffLine(lexer, line, padding))
@@ -176,11 +189,15 @@ func renderDiffLine(lexer string, line parsedDiffLine, padding int) highlightedD
source = strings.ReplaceAll(source, "\t", " ")
source = trimIndent(source, padding)
return highlightedDiffLine{
gutter: fmt.Sprintf(
gutter := fmt.Sprintf("%5s %s ", lineNumber, marker)
if colorEnabled {
gutter = fmt.Sprintf(
"\x1b[38;5;245m%5s\x1b[0m %s%s\x1b[0m ",
lineNumber, markerStyle, marker,
),
)
}
return highlightedDiffLine{
gutter: gutter,
code: highlightedSource(lexer, source),
selected: line.selected,
}
@@ -224,6 +241,9 @@ func coordinateText(line int) string {
}
func highlightedSource(lexer, source string) string {
if !colorEnabled {
return source
}
var highlighted bytes.Buffer
if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", codeHighlightTheme); err != nil {
return source

37
main.go
View File

@@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -20,14 +21,18 @@ func main() {
repo = flag.String("repo", "", "optional GitHub repository filter as owner/name (or GH_REPO)")
poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "refresh interval")
showAll = flag.Bool("all", defaults.ShowAll, "show all open PRs in --repo, not only PRs assigned to you")
limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-100)")
limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-1000)")
endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL endpoint")
theme = flag.String("theme", defaults.Theme, "color theme: dark or light")
theme = flag.String("theme", defaults.Theme, "color theme: dark, light, high-contrast, or no-color")
foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded")
listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)")
dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "dashboard navigation: intermediate or hotkey")
compactReviews = flag.Bool("compact-reviews", defaults.Display.CompactReviews, "aggregate submitted review history")
pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths")
pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval")
cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable local read cache fallback")
cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "maximum offline cache age (0 disables expiry)")
cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read cache directory")
)
flag.Parse()
@@ -66,12 +71,24 @@ func main() {
if visited["dashboard-mode"] {
config.Display.DashboardMode = *dashboardMode
}
if visited["compact-reviews"] {
config.Display.CompactReviews = *compactReviews
}
if visited["path-scroll"] {
config.Paths.Scroll = *pathScroll
}
if visited["path-scroll-interval"] {
config.Paths.ScrollInterval.Duration = *pathScrollRate
}
if visited["cache"] {
config.Cache.Enabled = *cacheEnabled
}
if visited["cache-max-age"] {
config.Cache.MaxAge.Duration = *cacheMaxAge
}
if visited["cache-dir"] {
config.Cache.Directory = *cacheDir
}
if err := validateConfig(config); err != nil {
exitf("configuration: %v", err)
}
@@ -93,12 +110,26 @@ func main() {
}
client := NewGitHubClient(config.Endpoint, token)
var service GitHubService = client
if config.Cache.Enabled {
cacheDir := config.Cache.Directory
if cacheDir == "" {
cacheDir, err = defaultCacheDir()
if err != nil {
exitf("configuration: %v", err)
}
}
service = NewCachedGitHubService(client, cacheDir, config.Cache.MaxAge.Duration)
}
statePath := filepath.Join(filepath.Dir(*configFile), "state.json")
app := NewAppWithSettings(
client, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration,
service, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration,
AppSettings{
FoldResolved: config.Display.FoldResolved,
ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
DashboardMode: config.Display.DashboardMode,
CompactReviews: config.Display.CompactReviews,
ReadState: loadReadState(statePath),
PathScroll: config.Paths.Scroll,
PathScrollInterval: config.Paths.ScrollInterval.Duration,
ThreadStatusOrder: config.Threads.StatusOrder,

View File

@@ -82,6 +82,8 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
style := styles.DarkStyleConfig
if markdownStyleName == "light" {
style = styles.LightStyleConfig
} else if markdownStyleName == "notty" {
style = styles.NoTTYStyleConfig
}
zero := uint(0)
style.Document.Margin = &zero

View File

@@ -7,6 +7,7 @@ import (
)
func applyTheme(name string) error {
colorEnabled = true
switch name {
case "dark":
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
@@ -50,6 +51,32 @@ func applyTheme(name string) error {
suggestionAddBackground = "\x1b[48;5;194m"
changedRemoveBackground = "\x1b[48;2;255;170;170m"
changedAddBackground = "\x1b[48;2;170;230;170m"
case "high-contrast":
titleStyle = lipgloss.NewStyle().Bold(true).Underline(true).Foreground(lipgloss.Color("#FFFF00"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
activeStyle = lipgloss.NewStyle().Bold(true).Reverse(true)
okStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF00"))
warnStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFF00"))
badStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF5555"))
paneInactiveColor, paneActiveColor = lipgloss.Color("#FFFFFF"), lipgloss.Color("#FFFF00")
authorPalette = []lipgloss.Color{"#00FFFF", "#FF55FF", "#FFFF00", "#00FF00", "#FFFFFF"}
codeHighlightTheme, markdownStyleName = "github-dark", "dark"
quoteRailStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF"))
selectedLineBackground = "\x1b[7m"
suggestionRemoveBackground, suggestionAddBackground = "\x1b[48;5;88m", "\x1b[48;5;28m"
changedRemoveBackground, changedAddBackground = "\x1b[48;5;88m", "\x1b[48;5;28m"
case "no-color":
colorEnabled = false
titleStyle = lipgloss.NewStyle()
dimStyle = lipgloss.NewStyle()
activeStyle = lipgloss.NewStyle().Reverse(true)
okStyle, warnStyle, badStyle = lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()
paneInactiveColor, paneActiveColor = "", ""
authorPalette = []lipgloss.Color{""}
codeHighlightTheme, markdownStyleName = "github", "notty"
quoteRailStyle = lipgloss.NewStyle()
selectedLineBackground, suggestionRemoveBackground, suggestionAddBackground = "", "", ""
changedRemoveBackground, changedAddBackground = "", ""
default:
return fmt.Errorf("unknown theme %q", name)
}

View File

@@ -1,6 +1,9 @@
package main
import "testing"
import (
"strings"
"testing"
)
func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
defer func() {
@@ -18,3 +21,14 @@ func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
t.Fatal("unknown theme was accepted")
}
}
func TestNoColorThemeDisablesSyntaxColors(t *testing.T) {
defer applyTheme("dark")
if err := applyTheme("no-color"); err != nil {
t.Fatal(err)
}
rendered := highlightedSource("go", "func main() {}")
if strings.Contains(rendered, "\x1b[") || colorEnabled {
t.Fatalf("no-color source contains terminal colors: %q", rendered)
}
}

752
tui.go

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"slices"
"strings"
"testing"
"time"
@@ -30,6 +31,7 @@ func (s *recordingService) GetPullRequest(_ context.Context, owner, repo string,
func TestResolvedThreadsStartFolded(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10)
m.screen = threadScreen
m.details = PRDetails{PullRequest: PullRequest{Number: 7}}
updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{
PullRequest: PullRequest{Number: 7},
@@ -48,6 +50,7 @@ func TestResolvedThreadFoldingCanBeDisabled(t *testing.T) {
settings := defaultAppSettings()
settings.FoldResolved = false
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.screen = threadScreen
m.details = PRDetails{PullRequest: PullRequest{Number: 7}}
updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{
PullRequest: PullRequest{Number: 7},
@@ -75,6 +78,7 @@ func TestResolvedThreadIconTakesPrecedenceOverOutdated(t *testing.T) {
func TestSelectionSurvivesRefresh(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10)
m.screen = threadScreen
m.details = PRDetails{
PullRequest: PullRequest{Number: 7},
Threads: []ReviewThread{{ID: "a"}, {ID: "b"}},
@@ -266,18 +270,36 @@ func TestDashboardRendersDescriptionAndMetadata(t *testing.T) {
ChangedFiles: 3,
CommitCount: 2,
CommentCount: 5,
HeadOID: "0123456789abcdef",
Checks: []Check{{Name: "unit tests", State: "SUCCESS", URL: "https://checks/1"}},
Reviews: []ReviewSummary{{
ID: "review", Author: "dave", State: "APPROVED", Body: "Looks **good**.",
SubmittedAt: time.Date(2026, 7, 1, 11, 0, 0, 0, time.UTC),
}},
Conversation: []PRComment{{
ID: "comment", Author: "erin", Body: "Please retain `compatibility`.",
CreatedAt: time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC),
}},
Permissions: ViewerPermissions{Repository: "WRITE", CanUpdatePR: true, CanResolveAny: true},
Requirements: MergeRequirements{
ApprovalsRequired: 1, RequiresApprovals: true,
RequiresStatusChecks: true, RequiresConversation: true,
},
Threads: []ReviewThread{
{ID: "open"},
{ID: "old", IsOutdated: true},
{ID: "done", IsResolved: true, IsOutdated: true},
},
}
plain := ansi.Strip(m.View())
plain := ansi.Strip(strings.Join(m.dashboardLines(), "\n"))
for _, wanted := range []string{
"Improve dashboard", "@alice", "feature → main", "@bob", "@carol",
"ui, review", "v2", "+20", "-4", "3 files", "2 commits",
"1 open", "1 outdated", "1 resolved", "Description",
"Existing behavior", "new_behavior",
"Existing behavior", "new_behavior", "unit tests", "Submitted reviews",
"@dave", "Looks good", "Conversation", "@erin", "compatibility",
"1 approval", "status checks", "resolved conversations", "write, update, resolve",
"0123456",
} {
if !strings.Contains(plain, wanted) {
t.Fatalf("dashboard is missing %q:\n%s", wanted, plain)
@@ -285,6 +307,183 @@ func TestDashboardRendersDescriptionAndMetadata(t *testing.T) {
}
}
func TestDashboardCompactsSubmittedReviewsByDefault(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.width = 100
m.details = PRDetails{
PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "PR"},
BaseRef: "main",
Reviews: []ReviewSummary{
{
Author: "alice", State: "COMMENTED",
Body: "First line\n\nSecond **line**",
SubmittedAt: time.Date(2099, 1, 2, 3, 4, 0, 0, time.UTC),
CommitOID: "abcdef0123456789",
},
{Author: "bob", State: "APPROVED", Body: "Ship it."},
},
}
lines := m.dashboardLines()
plain := make([]string, len(lines))
for index, line := range lines {
plain[index] = ansi.Strip(line)
}
alice := slices.IndexFunc(plain, func(line string) bool {
return strings.Contains(line, "@alice") && strings.Contains(line, "First line Second line")
})
bob := slices.IndexFunc(plain, func(line string) bool {
return strings.Contains(line, "@bob") && strings.Contains(line, "Ship it.")
})
if alice < 0 || bob != alice+1 {
t.Fatalf("compact reviews are not adjacent one-line entries:\n%s", strings.Join(plain, "\n"))
}
joined := strings.Join(plain, "\n")
if strings.Contains(joined, "2099-01-02") || strings.Contains(joined, "abcdef0") {
t.Fatalf("compact reviews include timestamp or commit SHA:\n%s", joined)
}
}
func TestDashboardAggregatesBodylessSubmittedReviews(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.width = 100
reviews := make([]ReviewSummary, 0, 48)
for range 40 {
reviews = append(reviews, ReviewSummary{Author: "mhoff", State: "COMMENTED"})
}
for range 8 {
reviews = append(reviews, ReviewSummary{Author: "Pablu23", State: "COMMENTED"})
}
m.details = PRDetails{
PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "PR"},
BaseRef: "main",
Reviews: reviews,
}
plain := ansi.Strip(strings.Join(m.dashboardLines(), "\n"))
for _, wanted := range []string{"Submitted reviews (48)", "COMMENTED ×48", "@mhoff ×40", "@Pablu23 ×8"} {
if !strings.Contains(plain, wanted) {
t.Fatalf("compact review aggregate is missing %q:\n%s", wanted, plain)
}
}
if strings.Count(plain, "@mhoff") != 1 || strings.Count(plain, "@Pablu23") != 1 ||
strings.Count(plain, "COMMENTED") != 1 {
t.Fatalf("body-less reviews were rendered individually:\n%s", plain)
}
}
func TestDashboardCanExpandSubmittedReviews(t *testing.T) {
settings := defaultAppSettings()
settings.CompactReviews = false
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.width = 100
m.details = PRDetails{
PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "PR"},
BaseRef: "main",
Reviews: []ReviewSummary{{
Author: "alice", State: "COMMENTED", Body: "First line\n\nSecond line",
SubmittedAt: time.Date(2099, 1, 2, 3, 4, 0, 0, time.UTC),
CommitOID: "abcdef0123456789",
}},
}
plain := ansi.Strip(strings.Join(m.dashboardLines(), "\n"))
if !strings.Contains(plain, "2099-01-02") || !strings.Contains(plain, "abcdef0") ||
!strings.Contains(plain, "First line") || !strings.Contains(plain, "Second line") {
t.Fatalf("expanded review metadata or body missing:\n%s", plain)
}
}
func TestThreadFilterCombinesPathStatusAuthorAndUpdates(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, time.Second)
m.details.Threads = []ReviewThread{
{ID: "match", Path: "src/generic_adder/rule.py", Comments: []ReviewComment{{Author: "Alice"}}},
{ID: "wrong-status", Path: "src/generic_adder/rule.py", IsResolved: true, Comments: []ReviewComment{{Author: "Alice"}}},
{ID: "wrong-author", Path: "src/generic_adder/rule.py", Comments: []ReviewComment{{Author: "Bob"}}},
}
m.updatedThreads["match"] = true
m.searchQuery = "generic rule status:open author:ali updated:true"
if got := m.matchingThreadIndices(); !slices.Equal(got, []int{0}) {
t.Fatalf("combined filter matches = %v", got)
}
}
func TestDetailRefreshKeepsLogicalCommentAnchored(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, time.Second)
m.screen, m.width, m.height = threadScreen, 80, 6
m.listHidden = true
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1},
Threads: []ReviewThread{{ID: "thread", Comments: []ReviewComment{
{ID: "first", Author: "a", Body: "one"},
{ID: "anchor", Author: "b", Body: "two"},
}}},
}
for index, line := range m.detailLines(m.width) {
if line.anchor == "comment:anchor:header" {
m.scroll = index
break
}
}
refreshed := m.details
refreshed.Threads = append([]ReviewThread(nil), m.details.Threads...)
refreshed.Threads[0].Comments = append([]ReviewComment{{ID: "inserted", Author: "c", Body: "new"}}, refreshed.Threads[0].Comments...)
updated, _ := m.Update(detailsLoadedMsg{owner: "o", repo: "r", number: 1, details: refreshed})
m = updated.(App)
if got := m.detailScrollAnchor(); got != "comment:anchor:header" {
t.Fatalf("detail anchor after refresh = %q", got)
}
}
func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) {
cached := writeCapabilities(PRDetails{FromCache: true}, nil)
if cached[0].reason != "offline cached snapshot" || cached[0].authorized {
t.Fatalf("cached capability = %#v", cached[0])
}
thread := &ReviewThread{ViewerCanReply: true}
live := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanReact: true}}, thread)
if !live[0].authorized || live[1].authorized || !live[2].authorized {
t.Fatalf("live capabilities = %#v", live)
}
}
func TestPollingMarksNewThreadCommentsUnread(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
initial := PRDetails{
PullRequest: PullRequest{ID: "pr", Number: 1},
Threads: []ReviewThread{{
ID: "thread", Path: "a.go", Comments: []ReviewComment{{ID: "comment-1"}},
}},
}
updated, _ := m.Update(detailsLoadedMsg{number: 1, details: initial})
m = updated.(App)
if m.unreadThreads["thread"] {
t.Fatal("initial data was marked unread")
}
refreshed := initial
refreshed.Threads = []ReviewThread{{
ID: "thread", Path: "a.go",
Comments: []ReviewComment{{ID: "comment-1"}, {ID: "comment-2"}},
}}
updated, _ = m.Update(detailsLoadedMsg{number: 1, details: refreshed})
m = updated.(App)
if !m.unreadThreads["thread"] {
t.Fatal("new review comment was not marked unread")
}
plain := ansi.Strip(m.threadList(48, 10))
if !strings.Contains(plain, "NEW") {
t.Fatalf("thread list does not indicate unread update:\n%s", plain)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
m = updated.(App)
if m.unreadThreads["thread"] {
t.Fatal("visiting unread thread did not mark it read")
}
}
func TestDashboardDescriptionScrolls(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = dashboardScreen
@@ -347,6 +546,35 @@ func TestHelpCanScrollInShortTerminal(t *testing.T) {
}
}
func TestHelpWrapsLongActionsAndExplainsFilterClear(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.width, m.height = threadScreen, 46, 20
rows := m.helpRows(m.helpContentWidth())
plain := strings.Join(strings.Fields(ansi.Strip(strings.Join(rows, "\n"))), " ")
if !strings.Contains(plain, "Clear the active thread filter and show every thread") {
t.Fatalf("F binding is not explained clearly:\n%s", plain)
}
if !strings.Contains(plain, "GitHub did not grant update permission") {
t.Fatalf("long capability explanation was cut off:\n%s", plain)
}
for index, row := range rows {
if width := ansi.StringWidth(row); width > m.helpContentWidth() {
t.Fatalf("wrapped help row %d width = %d, content width = %d", index, width, m.helpContentWidth())
}
}
}
func TestUppercaseFClearsAppliedThreadFilter(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
m.searchQuery = "status:resolved author:alice"
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("F")})
m = updated.(App)
if m.searchQuery != "" {
t.Fatalf("F left filter active: %q", m.searchQuery)
}
}
func TestViewNeverExceedsTerminalWidth(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
@@ -484,6 +712,21 @@ func TestMergeReadyRequiresApproval(t *testing.T) {
if !strings.Contains(approved, "merge: ready") {
t.Fatalf("approved PR not shown as ready: %q", approved)
}
unresolved := reviewAndMergeState(PRDetails{
Mergeable: "MERGEABLE", ReviewDecision: "APPROVED",
Requirements: MergeRequirements{RequiresConversation: true},
Threads: []ReviewThread{{ID: "open"}},
})
if !strings.Contains(unresolved, "unresolved conversations") {
t.Fatalf("required unresolved conversation did not block merge: %q", unresolved)
}
failing := reviewAndMergeState(PRDetails{
Mergeable: "MERGEABLE", ReviewDecision: "APPROVED", CheckState: "FAILURE",
Requirements: MergeRequirements{RequiresStatusChecks: true},
})
if !strings.Contains(failing, "checks failing") {
t.Fatalf("required failing checks did not block merge: %q", failing)
}
}
func TestThreadCommentCountsUseSameColumn(t *testing.T) {
@@ -670,7 +913,7 @@ func TestDetailsLoadUsesSelectedPRRepository(t *testing.T) {
service := &recordingService{}
m := NewApp(service, "", "", false, 50, 10*time.Second)
pr := PullRequest{Owner: "other-owner", Repository: "other-repo", Number: 17}
msg := m.loadDetails(pr)().(detailsLoadedMsg)
msg := m.loadDetails(pr, false)().(detailsLoadedMsg)
if service.owner != pr.Owner || service.repo != pr.Repository || service.number != pr.Number {
t.Fatalf("detail request used %s/%s#%d", service.owner, service.repo, service.number)

102
types.go
View File

@@ -15,6 +15,8 @@ type PullRequest struct {
UpdatedAt time.Time
ReviewCount int
ViewerAuthored bool
FromCache bool
CachedAt time.Time
}
type PRDetails struct {
@@ -34,10 +36,107 @@ type PRDetails struct {
ChangedFiles int
CommitCount int
CommentCount int
HeadOID string
CheckState string
Checks []Check
ReviewDecision string
Conversation []PRComment
Reviews []ReviewSummary
Permissions ViewerPermissions
Requirements MergeRequirements
Threads []ReviewThread
ThreadsTruncated bool
Timeline []TimelineEvent
Rulesets []Ruleset
MergeQueue *MergeQueue
FromCache bool
CachedAt time.Time
}
type Check struct {
ID string
Name string
State string
Conclusion string
URL string
Summary string
Annotations []CheckAnnotation
}
type CheckAnnotation struct {
Path string
StartLine int
EndLine int
Level string
Title string
Message string
}
type TimelineEvent struct {
Kind string
OID string
BeforeOID string
AfterOID string
Author string
Title string
CreatedAt time.Time
}
type Ruleset struct {
Name string
Enforcement string
RuleTypes []string
Applies bool
}
type MergeQueue struct {
State string
Position int
EnqueuedAt time.Time
EstimatedSeconds int
}
type PRComment struct {
ID string
Author string
Body string
URL string
CreatedAt time.Time
}
type ReviewSummary struct {
ID string
Author string
Body string
State string
URL string
CommitOID string
SubmittedAt time.Time
}
type ViewerPermissions struct {
Repository string
CanUpdatePR bool
CanResolveAny bool
CanUnresolveAny bool
CanReplyAny bool
CanReact bool
CanSubscribe bool
CanEnableMerge bool
}
type MergeRequirements struct {
ApprovalsRequired int
RequiresApprovals bool
RequiresStatusChecks bool
RequiresConversation bool
RequiresCodeOwnerReview bool
RequiresDeployments bool
RequiredDeployments []string
RequiresStrictChecks bool
RequiresLinearHistory bool
RequiresSignatures bool
RequiresMergeQueue bool
}
type Reviewer struct {
@@ -54,6 +153,9 @@ type ReviewThread struct {
IsResolved bool
IsOutdated bool
IsTruncated bool
ViewerCanResolve bool
ViewerCanUnresolve bool
ViewerCanReply bool
Comments []ReviewComment
}