Add more QoL and high prio features
This commit is contained in:
56
README.md
56
README.md
@@ -3,8 +3,10 @@
|
|||||||
A read-only terminal UI for people receiving GitHub pull-request reviews. It
|
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,
|
shows open PRs and a scrollable PR dashboard with the description, branches,
|
||||||
review state, checks, people, labels, milestone, activity, change statistics,
|
review state, checks, people, labels, milestone, activity, change statistics,
|
||||||
and thread totals. The thread viewer includes highlighted diff hunks and comment
|
thread totals, submitted reviews, and the PR conversation. Review threads and
|
||||||
authors. Resolved threads start folded. GitHub suggestion blocks are shown as
|
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
|
syntax-highlighted remove/add previews. Comments and PR descriptions render
|
||||||
GitHub Flavored Markdown, including quoted replies, inline and fenced code,
|
GitHub Flavored Markdown, including quoted replies, inline and fenced code,
|
||||||
lists and tasks, links, tables, emphasis, strikethrough, emoji, and GitHub
|
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.
|
exists.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
theme = "dark" # "dark" or "light"
|
theme = "dark" # dark, light, high-contrast, or no-color
|
||||||
refresh_interval = "10s"
|
refresh_interval = "10s"
|
||||||
repository = "" # optional owner/repository default
|
repository = "" # optional owner/repository default
|
||||||
show_all = false # requires repository
|
show_all = false # requires repository
|
||||||
@@ -78,6 +80,7 @@ endpoint = "https://api.github.com/graphql"
|
|||||||
fold_resolved = true
|
fold_resolved = true
|
||||||
thread_list_width_percent = 33 # 20-60
|
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]
|
[paths]
|
||||||
scroll = false
|
scroll = false
|
||||||
@@ -88,20 +91,44 @@ scroll_interval = "350ms" # minimum 50ms
|
|||||||
# resolved threads remain in "resolved" even when they are also outdated.
|
# resolved threads remain in "resolved" even when they are also outdated.
|
||||||
status_order = ["unresolved", "outdated", "resolved"]
|
status_order = ["unresolved", "outdated", "resolved"]
|
||||||
within_status = "file" # "file" or "timestamp" (oldest first)
|
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
|
Command-line flags override the configuration. `GH_REPO` overrides the
|
||||||
configured repository when `--repo` is not provided. The corresponding flags
|
configured repository when `--repo` is not provided. The corresponding flags
|
||||||
include `--config`, `--theme`, `--poll`, `--fold-resolved`,
|
include `--config`, `--theme`, `--poll`, `--fold-resolved`,
|
||||||
`--thread-list-width`, `--dashboard-mode`, `--path-scroll`, and
|
`--thread-list-width`, `--dashboard-mode`, `--compact-reviews`,
|
||||||
`--path-scroll-interval`. Boolean settings can be disabled explicitly, for
|
`--path-scroll`, and `--path-scroll-interval`, plus `--cache`,
|
||||||
example `--path-scroll=false`.
|
`--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
|
With the default `dashboard_mode = "hotkey"`, opening a PR goes directly to its
|
||||||
review threads and `d` opens the dashboard only when requested. Set
|
review threads and `d` opens the dashboard only when requested. Set
|
||||||
`dashboard_mode = "intermediate"` to follow picker → dashboard → review
|
`dashboard_mode = "intermediate"` to follow picker → dashboard → review
|
||||||
threads instead.
|
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
|
## Keys
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
@@ -110,7 +137,9 @@ threads instead.
|
|||||||
| `j` / `k` | Move between items or scroll the dashboard/focused detail |
|
| `j` / `k` | Move between items or scroll the dashboard/focused detail |
|
||||||
| `?` | Show contextual keybinding help |
|
| `?` | Show contextual keybinding help |
|
||||||
| `d` | Open the current pull request dashboard |
|
| `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 |
|
| `↑` / `↓` | Choose a fuzzy-search match |
|
||||||
| `g` / `G` | First / last item |
|
| `g` / `G` | First / last item |
|
||||||
| `enter` / `l` | Open the selected PR dashboard or its review threads |
|
| `enter` / `l` | Open the selected PR dashboard or its review threads |
|
||||||
@@ -124,8 +153,11 @@ threads instead.
|
|||||||
|
|
||||||
## Current scope
|
## Current scope
|
||||||
|
|
||||||
The application is intentionally read-only. GitHub's GraphQL API currently
|
The application is intentionally read-only. The dashboard and contextual help
|
||||||
limits this client to the first 100 review threads and first 100 comments per
|
show the write capability gate, including why each future write action is
|
||||||
thread; the UI warns when the thread list is truncated. GitHub features which
|
unavailable. Read state persists beside the configuration, and recent PR data
|
||||||
depend on server-side context, such as unfurling issue references or displaying
|
is cached for offline fallback. Check contexts and annotations are paginated.
|
||||||
uploaded images, are represented textually in the terminal.
|
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"`
|
Display DisplayConfig `toml:"display"`
|
||||||
Paths PathConfig `toml:"paths"`
|
Paths PathConfig `toml:"paths"`
|
||||||
Threads ThreadConfig `toml:"threads"`
|
Threads ThreadConfig `toml:"threads"`
|
||||||
|
Cache CacheConfig `toml:"cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DisplayConfig struct {
|
type DisplayConfig struct {
|
||||||
FoldResolved bool `toml:"fold_resolved"`
|
FoldResolved bool `toml:"fold_resolved"`
|
||||||
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
|
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
|
||||||
DashboardMode string `toml:"dashboard_mode"`
|
DashboardMode string `toml:"dashboard_mode"`
|
||||||
|
CompactReviews bool `toml:"compact_reviews"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PathConfig struct {
|
type PathConfig struct {
|
||||||
@@ -52,6 +54,12 @@ type ThreadConfig struct {
|
|||||||
WithinStatus string `toml:"within_status"`
|
WithinStatus string `toml:"within_status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CacheConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
MaxAge configDuration `toml:"max_age"`
|
||||||
|
Directory string `toml:"directory"`
|
||||||
|
}
|
||||||
|
|
||||||
func defaultConfig() Config {
|
func defaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Theme: "dark",
|
Theme: "dark",
|
||||||
@@ -62,6 +70,7 @@ func defaultConfig() Config {
|
|||||||
FoldResolved: true,
|
FoldResolved: true,
|
||||||
ThreadListWidthPercent: 33,
|
ThreadListWidthPercent: 33,
|
||||||
DashboardMode: "hotkey",
|
DashboardMode: "hotkey",
|
||||||
|
CompactReviews: true,
|
||||||
},
|
},
|
||||||
Paths: PathConfig{
|
Paths: PathConfig{
|
||||||
Scroll: false,
|
Scroll: false,
|
||||||
@@ -71,6 +80,7 @@ func defaultConfig() Config {
|
|||||||
StatusOrder: []string{"unresolved", "outdated", "resolved"},
|
StatusOrder: []string{"unresolved", "outdated", "resolved"},
|
||||||
WithinStatus: "file",
|
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 {
|
func validateConfig(config Config) error {
|
||||||
switch config.Theme {
|
switch config.Theme {
|
||||||
case "dark", "light":
|
case "dark", "light", "no-color", "high-contrast":
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("theme must be dark or light")
|
return fmt.Errorf("theme must be dark or light")
|
||||||
}
|
}
|
||||||
if config.RefreshInterval.Duration < 2*time.Second {
|
if config.RefreshInterval.Duration < 2*time.Second {
|
||||||
return fmt.Errorf("refresh_interval must be at least 2s")
|
return fmt.Errorf("refresh_interval must be at least 2s")
|
||||||
}
|
}
|
||||||
if config.Limit < 1 || config.Limit > 100 {
|
if config.Limit < 1 || config.Limit > 1000 {
|
||||||
return fmt.Errorf("limit must be between 1 and 100")
|
return fmt.Errorf("limit must be between 1 and 1000")
|
||||||
}
|
}
|
||||||
if config.Paths.ScrollInterval.Duration < 50*time.Millisecond {
|
if config.Paths.ScrollInterval.Duration < 50*time.Millisecond {
|
||||||
return fmt.Errorf("paths.scroll_interval must be at least 50ms")
|
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 == "" {
|
if config.ShowAll && config.Repository == "" {
|
||||||
return fmt.Errorf("show_all requires 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
|
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 {
|
func validateThreadStatusOrder(order []string) error {
|
||||||
if len(order) != 3 {
|
if len(order) != 3 {
|
||||||
return fmt.Errorf("threads.status_order must contain unresolved, outdated, and resolved exactly once")
|
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 ||
|
if got.Theme != want.Theme ||
|
||||||
got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
|
got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
|
||||||
got.Paths.Scroll != want.Paths.Scroll ||
|
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)
|
t.Fatalf("defaults = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +38,7 @@ endpoint = "https://github.example.com/api/graphql"
|
|||||||
fold_resolved = false
|
fold_resolved = false
|
||||||
thread_list_width_percent = 45
|
thread_list_width_percent = 45
|
||||||
dashboard_mode = "hotkey"
|
dashboard_mode = "hotkey"
|
||||||
|
compact_reviews = false
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
scroll = true
|
scroll = true
|
||||||
@@ -45,6 +47,11 @@ scroll_interval = "125ms"
|
|||||||
[threads]
|
[threads]
|
||||||
status_order = ["resolved", "unresolved", "outdated"]
|
status_order = ["resolved", "unresolved", "outdated"]
|
||||||
within_status = "timestamp"
|
within_status = "timestamp"
|
||||||
|
|
||||||
|
[cache]
|
||||||
|
enabled = false
|
||||||
|
max_age = "48h"
|
||||||
|
directory = "/tmp/gh-threads-cache"
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -60,9 +67,11 @@ within_status = "timestamp"
|
|||||||
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
|
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
|
||||||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
|
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
|
||||||
got.Display.DashboardMode != "hotkey" ||
|
got.Display.DashboardMode != "hotkey" ||
|
||||||
|
got.Display.CompactReviews ||
|
||||||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
|
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
|
||||||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
|
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)
|
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) {
|
func TestGraphQLErrorsAreReturned(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(`{"errors":[{"message":"no access"}]}`))
|
_, _ = 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) {
|
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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,
|
"id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false,
|
||||||
"createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z",
|
"createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z",
|
||||||
"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED",
|
"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED",
|
||||||
"additions":12,"deletions":4,"changedFiles":3,
|
"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"}]},
|
"assignees":{"nodes":[{"login":"sam"}]},
|
||||||
"labels":{"nodes":[{"name":"bug"},{"name":"backend"}]},
|
"labels":{"nodes":[{"name":"bug"},{"name":"backend"}]},
|
||||||
"milestone":{"title":"v2"},
|
"milestone":{"title":"v2"},
|
||||||
"comments":{"totalCount":5},
|
"comments":{"totalCount":5},
|
||||||
"reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]},
|
"reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]},
|
||||||
"latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]},
|
"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":[{
|
"reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{
|
||||||
"id":"t","isResolved":false,"isOutdated":true,"path":"main.go",
|
"id":"t","isResolved":false,"isOutdated":true,"path":"main.go",
|
||||||
|
"viewerCanResolve":true,
|
||||||
"line":null,"originalLine":42,"diffSide":"RIGHT",
|
"line":null,"originalLine":42,"diffSide":"RIGHT",
|
||||||
"startLine":null,"originalStartLine":40,"startDiffSide":"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",
|
"id":"c","body":"change this","diffHunk":"@@ -1 +1 @@","createdAt":"2026-01-01T00:00:00Z",
|
||||||
"url":"cu","author":{"login":"reviewer"},"outdated":true,
|
"url":"cu","author":{"login":"reviewer"},"outdated":true,
|
||||||
"line":100,"startLine":99,"originalLine":42,"originalStartLine":40,
|
"line":100,"startLine":99,"originalLine":42,"originalStartLine":40,
|
||||||
@@ -129,8 +302,15 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
|||||||
got.CreatedAt.IsZero() {
|
got.CreatedAt.IsZero() {
|
||||||
t.Fatalf("unexpected dashboard metadata: %#v", got)
|
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 ||
|
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)
|
t.Fatalf("unexpected thread: %#v", got.Threads)
|
||||||
}
|
}
|
||||||
comment := got.Threads[0].Comments[0]
|
comment := got.Threads[0].Comments[0]
|
||||||
|
|||||||
32
highlight.go
32
highlight.go
@@ -14,6 +14,7 @@ import (
|
|||||||
const reviewContextLines = 3
|
const reviewContextLines = 3
|
||||||
|
|
||||||
var codeHighlightTheme = "github-dark"
|
var codeHighlightTheme = "github-dark"
|
||||||
|
var colorEnabled = true
|
||||||
|
|
||||||
var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
|
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))
|
out := make([]highlightedDiffLine, 0, len(visible))
|
||||||
for _, line := range visible {
|
for _, line := range visible {
|
||||||
if line.raw == "⋯" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if line.notice {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if line.header {
|
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
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, renderDiffLine(lexer, line, padding))
|
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 = strings.ReplaceAll(source, "\t", " ")
|
||||||
source = trimIndent(source, padding)
|
source = trimIndent(source, padding)
|
||||||
return highlightedDiffLine{
|
gutter := fmt.Sprintf("%5s %s ", lineNumber, marker)
|
||||||
gutter: fmt.Sprintf(
|
if colorEnabled {
|
||||||
|
gutter = fmt.Sprintf(
|
||||||
"\x1b[38;5;245m%5s\x1b[0m %s%s\x1b[0m ",
|
"\x1b[38;5;245m%5s\x1b[0m %s%s\x1b[0m ",
|
||||||
lineNumber, markerStyle, marker,
|
lineNumber, markerStyle, marker,
|
||||||
),
|
)
|
||||||
|
}
|
||||||
|
return highlightedDiffLine{
|
||||||
|
gutter: gutter,
|
||||||
code: highlightedSource(lexer, source),
|
code: highlightedSource(lexer, source),
|
||||||
selected: line.selected,
|
selected: line.selected,
|
||||||
}
|
}
|
||||||
@@ -224,6 +241,9 @@ func coordinateText(line int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func highlightedSource(lexer, source string) string {
|
func highlightedSource(lexer, source string) string {
|
||||||
|
if !colorEnabled {
|
||||||
|
return source
|
||||||
|
}
|
||||||
var highlighted bytes.Buffer
|
var highlighted bytes.Buffer
|
||||||
if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", codeHighlightTheme); err != nil {
|
if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", codeHighlightTheme); err != nil {
|
||||||
return source
|
return source
|
||||||
|
|||||||
37
main.go
37
main.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
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)")
|
repo = flag.String("repo", "", "optional GitHub repository filter as owner/name (or GH_REPO)")
|
||||||
poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "refresh interval")
|
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")
|
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")
|
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")
|
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)")
|
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")
|
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")
|
pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths")
|
||||||
pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -66,12 +71,24 @@ func main() {
|
|||||||
if visited["dashboard-mode"] {
|
if visited["dashboard-mode"] {
|
||||||
config.Display.DashboardMode = *dashboardMode
|
config.Display.DashboardMode = *dashboardMode
|
||||||
}
|
}
|
||||||
|
if visited["compact-reviews"] {
|
||||||
|
config.Display.CompactReviews = *compactReviews
|
||||||
|
}
|
||||||
if visited["path-scroll"] {
|
if visited["path-scroll"] {
|
||||||
config.Paths.Scroll = *pathScroll
|
config.Paths.Scroll = *pathScroll
|
||||||
}
|
}
|
||||||
if visited["path-scroll-interval"] {
|
if visited["path-scroll-interval"] {
|
||||||
config.Paths.ScrollInterval.Duration = *pathScrollRate
|
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 {
|
if err := validateConfig(config); err != nil {
|
||||||
exitf("configuration: %v", err)
|
exitf("configuration: %v", err)
|
||||||
}
|
}
|
||||||
@@ -93,12 +110,26 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := NewGitHubClient(config.Endpoint, token)
|
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(
|
app := NewAppWithSettings(
|
||||||
client, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration,
|
service, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration,
|
||||||
AppSettings{
|
AppSettings{
|
||||||
FoldResolved: config.Display.FoldResolved,
|
FoldResolved: config.Display.FoldResolved,
|
||||||
ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
|
ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
|
||||||
DashboardMode: config.Display.DashboardMode,
|
DashboardMode: config.Display.DashboardMode,
|
||||||
|
CompactReviews: config.Display.CompactReviews,
|
||||||
|
ReadState: loadReadState(statePath),
|
||||||
PathScroll: config.Paths.Scroll,
|
PathScroll: config.Paths.Scroll,
|
||||||
PathScrollInterval: config.Paths.ScrollInterval.Duration,
|
PathScrollInterval: config.Paths.ScrollInterval.Duration,
|
||||||
ThreadStatusOrder: config.Threads.StatusOrder,
|
ThreadStatusOrder: config.Threads.StatusOrder,
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
|
|||||||
style := styles.DarkStyleConfig
|
style := styles.DarkStyleConfig
|
||||||
if markdownStyleName == "light" {
|
if markdownStyleName == "light" {
|
||||||
style = styles.LightStyleConfig
|
style = styles.LightStyleConfig
|
||||||
|
} else if markdownStyleName == "notty" {
|
||||||
|
style = styles.NoTTYStyleConfig
|
||||||
}
|
}
|
||||||
zero := uint(0)
|
zero := uint(0)
|
||||||
style.Document.Margin = &zero
|
style.Document.Margin = &zero
|
||||||
|
|||||||
27
theme.go
27
theme.go
@@ -7,6 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func applyTheme(name string) error {
|
func applyTheme(name string) error {
|
||||||
|
colorEnabled = true
|
||||||
switch name {
|
switch name {
|
||||||
case "dark":
|
case "dark":
|
||||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
|
||||||
@@ -50,6 +51,32 @@ func applyTheme(name string) error {
|
|||||||
suggestionAddBackground = "\x1b[48;5;194m"
|
suggestionAddBackground = "\x1b[48;5;194m"
|
||||||
changedRemoveBackground = "\x1b[48;2;255;170;170m"
|
changedRemoveBackground = "\x1b[48;2;255;170;170m"
|
||||||
changedAddBackground = "\x1b[48;2;170;230;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:
|
default:
|
||||||
return fmt.Errorf("unknown theme %q", name)
|
return fmt.Errorf("unknown theme %q", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
|
func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -18,3 +21,14 @@ func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
|
|||||||
t.Fatal("unknown theme was accepted")
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,6 +31,7 @@ func (s *recordingService) GetPullRequest(_ context.Context, owner, repo string,
|
|||||||
|
|
||||||
func TestResolvedThreadsStartFolded(t *testing.T) {
|
func TestResolvedThreadsStartFolded(t *testing.T) {
|
||||||
m := NewApp(nil, "o", "r", false, 50, 10)
|
m := NewApp(nil, "o", "r", false, 50, 10)
|
||||||
|
m.screen = threadScreen
|
||||||
m.details = PRDetails{PullRequest: PullRequest{Number: 7}}
|
m.details = PRDetails{PullRequest: PullRequest{Number: 7}}
|
||||||
updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{
|
updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{
|
||||||
PullRequest: PullRequest{Number: 7},
|
PullRequest: PullRequest{Number: 7},
|
||||||
@@ -48,6 +50,7 @@ func TestResolvedThreadFoldingCanBeDisabled(t *testing.T) {
|
|||||||
settings := defaultAppSettings()
|
settings := defaultAppSettings()
|
||||||
settings.FoldResolved = false
|
settings.FoldResolved = false
|
||||||
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
|
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
|
||||||
|
m.screen = threadScreen
|
||||||
m.details = PRDetails{PullRequest: PullRequest{Number: 7}}
|
m.details = PRDetails{PullRequest: PullRequest{Number: 7}}
|
||||||
updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{
|
updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{
|
||||||
PullRequest: PullRequest{Number: 7},
|
PullRequest: PullRequest{Number: 7},
|
||||||
@@ -75,6 +78,7 @@ func TestResolvedThreadIconTakesPrecedenceOverOutdated(t *testing.T) {
|
|||||||
|
|
||||||
func TestSelectionSurvivesRefresh(t *testing.T) {
|
func TestSelectionSurvivesRefresh(t *testing.T) {
|
||||||
m := NewApp(nil, "o", "r", false, 50, 10)
|
m := NewApp(nil, "o", "r", false, 50, 10)
|
||||||
|
m.screen = threadScreen
|
||||||
m.details = PRDetails{
|
m.details = PRDetails{
|
||||||
PullRequest: PullRequest{Number: 7},
|
PullRequest: PullRequest{Number: 7},
|
||||||
Threads: []ReviewThread{{ID: "a"}, {ID: "b"}},
|
Threads: []ReviewThread{{ID: "a"}, {ID: "b"}},
|
||||||
@@ -266,18 +270,36 @@ func TestDashboardRendersDescriptionAndMetadata(t *testing.T) {
|
|||||||
ChangedFiles: 3,
|
ChangedFiles: 3,
|
||||||
CommitCount: 2,
|
CommitCount: 2,
|
||||||
CommentCount: 5,
|
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{
|
Threads: []ReviewThread{
|
||||||
{ID: "open"},
|
{ID: "open"},
|
||||||
{ID: "old", IsOutdated: true},
|
{ID: "old", IsOutdated: true},
|
||||||
{ID: "done", IsResolved: true, 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{
|
for _, wanted := range []string{
|
||||||
"Improve dashboard", "@alice", "feature → main", "@bob", "@carol",
|
"Improve dashboard", "@alice", "feature → main", "@bob", "@carol",
|
||||||
"ui, review", "v2", "+20", "-4", "3 files", "2 commits",
|
"ui, review", "v2", "+20", "-4", "3 files", "2 commits",
|
||||||
"1 open", "1 outdated", "1 resolved", "Description",
|
"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) {
|
if !strings.Contains(plain, wanted) {
|
||||||
t.Fatalf("dashboard is missing %q:\n%s", wanted, plain)
|
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) {
|
func TestDashboardDescriptionScrolls(t *testing.T) {
|
||||||
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
||||||
m.screen = dashboardScreen
|
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) {
|
func TestViewNeverExceedsTerminalWidth(t *testing.T) {
|
||||||
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
||||||
m.screen = threadScreen
|
m.screen = threadScreen
|
||||||
@@ -484,6 +712,21 @@ func TestMergeReadyRequiresApproval(t *testing.T) {
|
|||||||
if !strings.Contains(approved, "merge: ready") {
|
if !strings.Contains(approved, "merge: ready") {
|
||||||
t.Fatalf("approved PR not shown as ready: %q", approved)
|
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) {
|
func TestThreadCommentCountsUseSameColumn(t *testing.T) {
|
||||||
@@ -670,7 +913,7 @@ func TestDetailsLoadUsesSelectedPRRepository(t *testing.T) {
|
|||||||
service := &recordingService{}
|
service := &recordingService{}
|
||||||
m := NewApp(service, "", "", false, 50, 10*time.Second)
|
m := NewApp(service, "", "", false, 50, 10*time.Second)
|
||||||
pr := PullRequest{Owner: "other-owner", Repository: "other-repo", Number: 17}
|
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 {
|
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)
|
t.Fatalf("detail request used %s/%s#%d", service.owner, service.repo, service.number)
|
||||||
|
|||||||
102
types.go
102
types.go
@@ -15,6 +15,8 @@ type PullRequest struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
ReviewCount int
|
ReviewCount int
|
||||||
ViewerAuthored bool
|
ViewerAuthored bool
|
||||||
|
FromCache bool
|
||||||
|
CachedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type PRDetails struct {
|
type PRDetails struct {
|
||||||
@@ -34,10 +36,107 @@ type PRDetails struct {
|
|||||||
ChangedFiles int
|
ChangedFiles int
|
||||||
CommitCount int
|
CommitCount int
|
||||||
CommentCount int
|
CommentCount int
|
||||||
|
HeadOID string
|
||||||
CheckState string
|
CheckState string
|
||||||
|
Checks []Check
|
||||||
ReviewDecision string
|
ReviewDecision string
|
||||||
|
Conversation []PRComment
|
||||||
|
Reviews []ReviewSummary
|
||||||
|
Permissions ViewerPermissions
|
||||||
|
Requirements MergeRequirements
|
||||||
Threads []ReviewThread
|
Threads []ReviewThread
|
||||||
ThreadsTruncated bool
|
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 {
|
type Reviewer struct {
|
||||||
@@ -54,6 +153,9 @@ type ReviewThread struct {
|
|||||||
IsResolved bool
|
IsResolved bool
|
||||||
IsOutdated bool
|
IsOutdated bool
|
||||||
IsTruncated bool
|
IsTruncated bool
|
||||||
|
ViewerCanResolve bool
|
||||||
|
ViewerCanUnresolve bool
|
||||||
|
ViewerCanReply bool
|
||||||
Comments []ReviewComment
|
Comments []ReviewComment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user