Add more QoL and high prio features
This commit is contained in:
58
README.md
58
README.md
@@ -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
|
||||
@@ -77,7 +79,8 @@ endpoint = "https://api.github.com/graphql"
|
||||
[display]
|
||||
fold_resolved = true
|
||||
thread_list_width_percent = 33 # 20-60
|
||||
dashboard_mode = "hotkey" # "hotkey" or "intermediate"
|
||||
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
38
TODO.md
Normal 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
249
cache.go
Normal 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
191
cache_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
27
config.go
27
config.go
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
190
github_test.go
190
github_test.go
@@ -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]
|
||||
|
||||
32
highlight.go
32
highlight.go
@@ -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
37
main.go
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
27
theme.go
27
theme.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
249
tui_test.go
249
tui_test.go
@@ -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)
|
||||
|
||||
120
types.go
120
types.go
@@ -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 {
|
||||
@@ -46,15 +145,18 @@ type Reviewer struct {
|
||||
}
|
||||
|
||||
type ReviewThread struct {
|
||||
ID string
|
||||
Path string
|
||||
Line int
|
||||
StartLine int
|
||||
DiffSide string
|
||||
IsResolved bool
|
||||
IsOutdated bool
|
||||
IsTruncated bool
|
||||
Comments []ReviewComment
|
||||
ID string
|
||||
Path string
|
||||
Line int
|
||||
StartLine int
|
||||
DiffSide string
|
||||
IsResolved bool
|
||||
IsOutdated bool
|
||||
IsTruncated bool
|
||||
ViewerCanResolve bool
|
||||
ViewerCanUnresolve bool
|
||||
ViewerCanReply bool
|
||||
Comments []ReviewComment
|
||||
}
|
||||
|
||||
type ReviewComment struct {
|
||||
|
||||
Reference in New Issue
Block a user