diff --git a/README.md b/README.md index 0b593ff..fdf6b7b 100644 --- a/README.md +++ b/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. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..54f362c --- /dev/null +++ b/TODO.md @@ -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. diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..7dd7a14 --- /dev/null +++ b/cache.go @@ -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) +} diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..37f2431 --- /dev/null +++ b/cache_test.go @@ -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") + } +} diff --git a/config.go b/config.go index 3624ccb..6ba5dd3 100644 --- a/config.go +++ b/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") diff --git a/config_test.go b/config_test.go index 5256c89..6232aa5 100644 --- a/config_test.go +++ b/config_test.go @@ -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) } } diff --git a/github.go b/github.go index 3e3960f..08cddf5 100644 --- a/github.go +++ b/github.go @@ -8,8 +8,10 @@ import ( "fmt" "io" "net/http" + "path" "sort" "strings" + "sync" "time" ) @@ -90,9 +92,10 @@ func (c *GitHubClient) query(ctx context.Context, query string, variables map[st } const listPRsQuery = ` -query PullRequests($query: String!, $limit: Int!) { +query PullRequests($query: String!, $first: Int!, $after: String) { viewer { login } - search(query: $query, type: ISSUE, first: $limit) { + search(query: $query, type: ISSUE, first: $first, after: $after) { + pageInfo { hasNextPage endCursor } nodes { ... on PullRequest { id number title url isDraft updatedAt @@ -108,110 +111,33 @@ query PullRequests($query: String!, $limit: Int!) { } }` -func (c *GitHubClient) ListPullRequests(ctx context.Context, owner, name string, limit int, showAll bool) ([]PullRequest, error) { - if showAll && owner == "" { - return nil, errors.New("--all requires --repo to avoid an unbounded global search") +type githubPRSearchNode struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + IsDraft bool `json:"isDraft"` + UpdatedAt time.Time `json:"updatedAt"` + Author *githubActor `json:"author"` + Repository struct { + Name, NameWithOwner string + Owner githubActor } - var data struct { - Viewer struct { - Login string `json:"login"` - } `json:"viewer"` - Search struct { - Nodes []struct { - ID string `json:"id"` - Number int `json:"number"` - Title string `json:"title"` - URL string `json:"url"` - IsDraft bool `json:"isDraft"` - UpdatedAt time.Time `json:"updatedAt"` - Author *struct { - Login string `json:"login"` - } `json:"author"` - Repository struct { - Name string `json:"name"` - NameWithOwner string `json:"nameWithOwner"` - Owner struct { - Login string `json:"login"` - } `json:"owner"` - } `json:"repository"` - ReviewThreads struct { - TotalCount int `json:"totalCount"` - } `json:"reviewThreads"` - } `json:"nodes"` - } `json:"search"` - } - search := "is:pr is:open sort:updated-desc" - if owner != "" { - search += " repo:" + owner + "/" + name - } - if !showAll { - search += " assignee:@me" - } - if err := c.query(ctx, listPRsQuery, map[string]any{"query": search, "limit": limit}, &data); err != nil { - return nil, err - } - - prs := make([]PullRequest, 0, len(data.Search.Nodes)) - for _, node := range data.Search.Nodes { - if node.Repository.NameWithOwner == "" { - continue - } - author := "[ghost]" - if node.Author != nil { - author = node.Author.Login - } - prs = append(prs, PullRequest{ - ID: node.ID, Owner: node.Repository.Owner.Login, Repository: node.Repository.Name, - RepoWithOwner: node.Repository.NameWithOwner, - Number: node.Number, Title: node.Title, URL: node.URL, - Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, - ReviewCount: node.ReviewThreads.TotalCount, ViewerAuthored: author == data.Viewer.Login, - }) - } - sort.SliceStable(prs, func(i, j int) bool { - left, right := strings.ToLower(prs[i].RepoWithOwner), strings.ToLower(prs[j].RepoWithOwner) - if left != right { - return left < right - } - return prs[i].UpdatedAt.After(prs[j].UpdatedAt) - }) - return prs, nil + ReviewThreads struct{ TotalCount int } } -const detailsQuery = ` -query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { +const reviewThreadsPageQuery = ` +query ReviewThreadsPage($owner: String!, $name: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { - id number title url body isDraft createdAt updatedAt - mergeable mergeStateStatus reviewDecision - baseRefName headRefName - author { login } - assignees(first: 20) { nodes { login } } - labels(first: 20) { nodes { name } } - milestone { title } - additions deletions changedFiles - comments(first: 1) { totalCount } - reviewRequests(first: 50) { + reviewThreads(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } nodes { - requestedReviewer { - ... on User { login } - ... on Team { name } - } - } - } - latestReviews(first: 50) { nodes { state author { login } } } - commits(last: 1) { - totalCount - nodes { commit { statusCheckRollup { state } } } - } - reviewThreads(first: 100) { - pageInfo { hasNextPage } - nodes { - id isResolved isOutdated path + id isResolved isOutdated viewerCanResolve viewerCanUnresolve viewerCanReply path line originalLine diffSide startLine originalStartLine startDiffSide comments(first: 100) { - pageInfo { hasNextPage } + pageInfo { hasNextPage endCursor } nodes { id body diffHunk createdAt url outdated line startLine originalLine originalStartLine @@ -225,75 +151,707 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { } }` -func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) { - type actor struct { - Login string `json:"login"` - Name string `json:"name"` +const reviewCommentsPageQuery = ` +query ReviewCommentsPage($id: ID!, $after: String) { + node(id: $id) { + ... on PullRequestReviewThread { + comments(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + id body diffHunk createdAt url outdated + line startLine originalLine originalStartLine + originalCommit { oid } + author { login } + } + } + } + } +}` + +const conversationPageQuery = ` +query ConversationPage($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + comments(first: 100, after: $after) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { id body url createdAt author { login } } + } + } + } +}` + +const reviewsPageQuery = ` +query ReviewsPage($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviews(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { id body state url submittedAt author { login } commit { oid } } + } + } + } +}` + +func (c *GitHubClient) ListPullRequests(ctx context.Context, owner, name string, limit int, showAll bool) ([]PullRequest, error) { + if showAll && owner == "" { + return nil, errors.New("--all requires --repo to avoid an unbounded global search") } + search := "is:pr is:open sort:updated-desc" + if owner != "" { + search += " repo:" + owner + "/" + name + } + if !showAll { + search += " assignee:@me" + } + var nodes []githubPRSearchNode + viewer := "" + after := "" + loaded := 0 + for loaded < limit { + var data struct { + Viewer githubActor + Search struct { + PageInfo githubPageInfo + Nodes []githubPRSearchNode + } + } + first := min(100, limit-loaded) + if err := c.query(ctx, listPRsQuery, map[string]any{ + "query": search, "first": first, "after": nullableCursor(after), + }, &data); err != nil { + return nil, err + } + viewer = data.Viewer.Login + nodes = append(nodes, data.Search.Nodes...) + loaded += first + if !data.Search.PageInfo.HasNextPage || data.Search.PageInfo.EndCursor == "" { + break + } + after = data.Search.PageInfo.EndCursor + } + prs := make([]PullRequest, 0, len(nodes)) + for _, node := range nodes { + if node.Repository.NameWithOwner == "" { + continue + } + author := "[ghost]" + if node.Author != nil { + author = node.Author.Login + } + prs = append(prs, PullRequest{ + ID: node.ID, Owner: node.Repository.Owner.Login, Repository: node.Repository.Name, + RepoWithOwner: node.Repository.NameWithOwner, + Number: node.Number, Title: node.Title, URL: node.URL, + Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, + ReviewCount: node.ReviewThreads.TotalCount, ViewerAuthored: author == viewer, + }) + } + sort.SliceStable(prs, func(i, j int) bool { + left, right := strings.ToLower(prs[i].RepoWithOwner), strings.ToLower(prs[j].RepoWithOwner) + if left != right { + return left < right + } + return prs[i].UpdatedAt.After(prs[j].UpdatedAt) + }) + return prs, nil +} + +func nullableCursor(cursor string) any { + if cursor == "" { + return nil + } + return cursor +} + +const detailsQuery = ` +query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + viewerPermission + defaultBranchRef { name } + rulesets(first: 100, includeParents: true, targets: [BRANCH]) { + nodes { + name enforcement target + conditions { refName { include exclude } } + rules(first: 100) { nodes { type } } + } + } + pullRequest(number: $number) { + id number title url body isDraft createdAt updatedAt + mergeable mergeStateStatus reviewDecision + mergeQueueEntry { state position enqueuedAt estimatedTimeToMerge } + baseRefName headRefName headRefOid + viewerCanUpdate viewerCanReact viewerCanSubscribe viewerCanEnableAutoMerge + baseRef { + branchProtectionRule { + requiresApprovingReviews requiredApprovingReviewCount + requiresStatusChecks requiresConversationResolution + requiresCodeOwnerReviews + requiresDeployments requiredDeploymentEnvironments + requiresStrictStatusChecks requiresLinearHistory requiresCommitSignatures + } + } + author { login } + assignees(first: 20) { nodes { login } } + labels(first: 20) { nodes { name } } + milestone { title } + additions deletions changedFiles + comments(first: 100) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { id body url createdAt author { login } } + } + reviews(first: 100) { + pageInfo { hasNextPage endCursor } + nodes { id body state url submittedAt author { login } commit { oid } } + } + reviewRequests(first: 50) { + nodes { + requestedReviewer { + ... on User { login } + ... on Team { name } + } + } + } + latestReviews(first: 50) { nodes { state author { login } } } + commits(last: 1) { + totalCount + nodes { + commit { + oid + statusCheckRollup { + id + state + contexts(first: 100) { + pageInfo { hasNextPage endCursor } + nodes { + ... on CheckRun { + id name status conclusion detailsUrl + title summary text + } + ... on StatusContext { context state targetUrl } + } + } + } + } + } + } + timelineItems(first: 100, itemTypes: [PULL_REQUEST_COMMIT, HEAD_REF_FORCE_PUSHED_EVENT]) { + pageInfo { hasNextPage endCursor } + nodes { + ... on PullRequestCommit { + commit { oid committedDate messageHeadline author { user { login } name } } + } + ... on HeadRefForcePushedEvent { + id createdAt actor { login } + beforeCommit { oid } + afterCommit { oid } + } + } + } + reviewThreads(first: 100) { + pageInfo { hasNextPage endCursor } + nodes { + id isResolved isOutdated viewerCanResolve viewerCanUnresolve viewerCanReply path + line originalLine diffSide + startLine originalStartLine startDiffSide + comments(first: 100) { + pageInfo { hasNextPage endCursor } + nodes { + id body diffHunk createdAt url outdated + line startLine originalLine originalStartLine + originalCommit { oid } + author { login } + } + } + } + } + } + } +}` + +const timelinePageQuery = ` +query TimelinePage($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + timelineItems(first: 100, after: $after, itemTypes: [PULL_REQUEST_COMMIT, HEAD_REF_FORCE_PUSHED_EVENT]) { + pageInfo { hasNextPage endCursor } + nodes { + ... on PullRequestCommit { + commit { oid committedDate messageHeadline author { user { login } name } } + } + ... on HeadRefForcePushedEvent { + id createdAt actor { login } beforeCommit { oid } afterCommit { oid } + } + } + } + } + } +}` + +const checkContextsPageQuery = ` +query CheckContextsPage($id: ID!, $after: String) { + node(id: $id) { + ... on StatusCheckRollup { + contexts(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + ... on CheckRun { + id name status conclusion detailsUrl + title summary text + } + ... on StatusContext { context state targetUrl } + } + } + } + } +}` + +const checkAnnotationsPageQuery = ` +query CheckAnnotationsPage($id: ID!, $after: String) { + node(id: $id) { + ... on CheckRun { + annotations(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + path annotationLevel message title + location { start { line column } end { line column } } + } + } + } + } +}` + +type githubActor struct { + Login string `json:"login"` + Name string `json:"name"` +} + +type githubPageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` +} + +type githubReviewComment struct { + ID, Body, DiffHunk, URL string + Line, StartLine *int + OriginalLine, OriginalStartLine *int + Outdated bool + OriginalCommit *struct{ OID string } + CreatedAt time.Time + Author *githubActor +} + +type githubReviewCommentConnection struct { + PageInfo githubPageInfo `json:"pageInfo"` + Nodes []githubReviewComment `json:"nodes"` +} + +type githubReviewThread struct { + ID, Path string + DiffSide, StartDiffSide string + Line, OriginalLine, StartLine, OriginalStartLine *int + IsResolved, IsOutdated bool + ViewerCanResolve, ViewerCanUnresolve, ViewerCanReply bool + Comments githubReviewCommentConnection +} + +type githubReviewThreadConnection struct { + PageInfo githubPageInfo `json:"pageInfo"` + Nodes []githubReviewThread `json:"nodes"` +} + +type githubPRComment struct { + ID, Body, URL string + CreatedAt time.Time + Author *githubActor +} + +type githubPRCommentConnection struct { + TotalCount int `json:"totalCount"` + PageInfo githubPageInfo `json:"pageInfo"` + Nodes []githubPRComment `json:"nodes"` +} + +type githubReviewSummary struct { + ID, Body, State, URL string + SubmittedAt time.Time + Author *githubActor + Commit *struct{ OID string } +} + +type githubReviewSummaryConnection struct { + PageInfo githubPageInfo `json:"pageInfo"` + Nodes []githubReviewSummary `json:"nodes"` +} + +type githubCheckContext struct { + ID string + Name, Status, Conclusion, DetailsURL string + Context, State, TargetURL string + Title, Summary, Text string + Annotations githubCheckAnnotationConnection +} + +type githubCheckAnnotation struct { + Path, AnnotationLevel, Message, Title string + Location struct { + Start, End struct { + Line, Column int + } + } +} + +type githubCheckAnnotationConnection struct { + PageInfo githubPageInfo + Nodes []githubCheckAnnotation +} + +type githubCheckContextConnection struct { + PageInfo githubPageInfo + Nodes []githubCheckContext +} + +type githubTimelineNode struct { + ID, CreatedAtRaw string + CreatedAt time.Time + Actor *githubActor + BeforeCommit, AfterCommit *struct{ OID string } + Commit *struct { + OID, MessageHeadline string + CommittedDate time.Time + Author *struct { + Name string + User *githubActor + } + } +} + +type githubTimelineConnection struct { + PageInfo githubPageInfo + Nodes []githubTimelineNode +} + +type githubRuleset struct { + Name, Enforcement, Target string + Conditions struct { + RefName *struct{ Include, Exclude []string } + } + Rules struct{ Nodes []struct{ Type string } } +} + +type githubPullRequestDetails struct { + ID, Title, URL, Body, Mergeable, MergeStateStatus string + ReviewDecision, BaseRefName, HeadRefName, HeadRefOID string + Number, Additions, Deletions, ChangedFiles int + IsDraft bool + CreatedAt, UpdatedAt time.Time + Author *githubActor + ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe, ViewerCanEnableAutoMerge bool + BaseRef *struct { + BranchProtectionRule *struct { + RequiresApprovingReviews, RequiresStatusChecks bool + RequiresConversationResolution, RequiresCodeOwnerReviews bool + RequiredApprovingReviewCount int + RequiresDeployments, RequiresStrictStatusChecks bool + RequiresLinearHistory, RequiresCommitSignatures bool + RequiredDeploymentEnvironments []string + } + } + MergeQueueEntry *struct { + State string + Position, EstimatedTimeToMerge int + EnqueuedAt time.Time + } + Assignees struct { + Nodes []githubActor `json:"nodes"` + } + Labels struct { + Nodes []struct { + Name string `json:"name"` + } `json:"nodes"` + } + Milestone *struct { + Title string `json:"title"` + } + Comments githubPRCommentConnection + Reviews githubReviewSummaryConnection + ReviewRequests struct { + Nodes []struct { + RequestedReviewer githubActor `json:"requestedReviewer"` + } `json:"nodes"` + } + LatestReviews struct { + Nodes []struct { + State string + Author *githubActor + } `json:"nodes"` + } + Commits struct { + TotalCount int `json:"totalCount"` + Nodes []struct { + Commit struct { + OID string + StatusCheckRollup *struct { + ID string + State string + Contexts githubCheckContextConnection + } + } + } `json:"nodes"` + } + ReviewThreads githubReviewThreadConnection + TimelineItems githubTimelineConnection +} + +func (c *GitHubClient) allReviewThreads( + ctx context.Context, owner, name string, number int, connection githubReviewThreadConnection, +) ([]githubReviewThread, error) { + nodes := append([]githubReviewThread(nil), connection.Nodes...) + for pages := 0; connection.PageInfo.HasNextPage; pages++ { + if pages >= 100 { + return nil, errors.New("review thread pagination exceeded 100 pages") + } + var data struct { + Repository *struct { + PullRequest *struct { + ReviewThreads githubReviewThreadConnection `json:"reviewThreads"` + } `json:"pullRequest"` + } `json:"repository"` + } + variables := map[string]any{ + "owner": owner, "name": name, "number": number, "after": connection.PageInfo.EndCursor, + } + if err := c.query(ctx, reviewThreadsPageQuery, variables, &data); err != nil { + return nil, fmt.Errorf("load more review threads: %w", err) + } + if data.Repository == nil || data.Repository.PullRequest == nil { + return nil, errors.New("pull request disappeared while loading review threads") + } + connection = data.Repository.PullRequest.ReviewThreads + nodes = append(nodes, connection.Nodes...) + } + for i := range nodes { + comments, err := c.allReviewComments(ctx, nodes[i].ID, nodes[i].Comments) + if err != nil { + return nil, err + } + nodes[i].Comments.Nodes = comments + nodes[i].Comments.PageInfo = githubPageInfo{} + } + return nodes, nil +} + +func (c *GitHubClient) allReviewComments( + ctx context.Context, threadID string, connection githubReviewCommentConnection, +) ([]githubReviewComment, error) { + nodes := append([]githubReviewComment(nil), connection.Nodes...) + for pages := 0; connection.PageInfo.HasNextPage; pages++ { + if pages >= 100 { + return nil, fmt.Errorf("review comments for thread %s exceeded 100 pages", threadID) + } + var data struct { + Node *struct { + Comments githubReviewCommentConnection `json:"comments"` + } `json:"node"` + } + if err := c.query(ctx, reviewCommentsPageQuery, map[string]any{ + "id": threadID, "after": connection.PageInfo.EndCursor, + }, &data); err != nil { + return nil, fmt.Errorf("load more comments for review thread %s: %w", threadID, err) + } + if data.Node == nil { + return nil, fmt.Errorf("review thread %s disappeared while loading comments", threadID) + } + connection = data.Node.Comments + nodes = append(nodes, connection.Nodes...) + } + return nodes, nil +} + +func (c *GitHubClient) allConversationComments( + ctx context.Context, owner, name string, number int, connection githubPRCommentConnection, +) ([]githubPRComment, error) { + nodes := append([]githubPRComment(nil), connection.Nodes...) + for pages := 0; connection.PageInfo.HasNextPage; pages++ { + if pages >= 100 { + return nil, errors.New("PR conversation pagination exceeded 100 pages") + } + var data struct { + Repository *struct { + PullRequest *struct { + Comments githubPRCommentConnection `json:"comments"` + } `json:"pullRequest"` + } `json:"repository"` + } + if err := c.query(ctx, conversationPageQuery, map[string]any{ + "owner": owner, "name": name, "number": number, "after": connection.PageInfo.EndCursor, + }, &data); err != nil { + return nil, fmt.Errorf("load more PR conversation comments: %w", err) + } + if data.Repository == nil || data.Repository.PullRequest == nil { + return nil, errors.New("pull request disappeared while loading conversation") + } + connection = data.Repository.PullRequest.Comments + nodes = append(nodes, connection.Nodes...) + } + return nodes, nil +} + +func (c *GitHubClient) allReviewSummaries( + ctx context.Context, owner, name string, number int, connection githubReviewSummaryConnection, +) ([]githubReviewSummary, error) { + nodes := append([]githubReviewSummary(nil), connection.Nodes...) + for pages := 0; connection.PageInfo.HasNextPage; pages++ { + if pages >= 100 { + return nil, errors.New("review summary pagination exceeded 100 pages") + } + var data struct { + Repository *struct { + PullRequest *struct { + Reviews githubReviewSummaryConnection `json:"reviews"` + } `json:"pullRequest"` + } `json:"repository"` + } + if err := c.query(ctx, reviewsPageQuery, map[string]any{ + "owner": owner, "name": name, "number": number, "after": connection.PageInfo.EndCursor, + }, &data); err != nil { + return nil, fmt.Errorf("load more submitted reviews: %w", err) + } + if data.Repository == nil || data.Repository.PullRequest == nil { + return nil, errors.New("pull request disappeared while loading reviews") + } + connection = data.Repository.PullRequest.Reviews + nodes = append(nodes, connection.Nodes...) + } + return nodes, nil +} + +func (c *GitHubClient) allTimelineItems( + ctx context.Context, owner, name string, number int, connection githubTimelineConnection, +) ([]githubTimelineNode, error) { + nodes := append([]githubTimelineNode(nil), connection.Nodes...) + for pages := 0; connection.PageInfo.HasNextPage; pages++ { + if pages >= 100 { + return nil, errors.New("PR timeline pagination exceeded 100 pages") + } + var data struct { + Repository *struct { + PullRequest *struct{ TimelineItems githubTimelineConnection } + } + } + if err := c.query(ctx, timelinePageQuery, map[string]any{ + "owner": owner, "name": name, "number": number, "after": connection.PageInfo.EndCursor, + }, &data); err != nil { + return nil, fmt.Errorf("load more PR timeline events: %w", err) + } + if data.Repository == nil || data.Repository.PullRequest == nil { + return nil, errors.New("pull request disappeared while loading timeline") + } + connection = data.Repository.PullRequest.TimelineItems + nodes = append(nodes, connection.Nodes...) + } + return nodes, nil +} + +func (c *GitHubClient) allCheckContexts( + ctx context.Context, connection githubCheckContextConnection, rollupID string, +) ([]githubCheckContext, error) { + nodes := append([]githubCheckContext(nil), connection.Nodes...) + for pages := 0; connection.PageInfo.HasNextPage; pages++ { + if pages >= 100 { + return nil, errors.New("check context pagination exceeded 100 pages") + } + var data struct { + Node *struct{ Contexts githubCheckContextConnection } + } + if err := c.query(ctx, checkContextsPageQuery, map[string]any{ + "id": rollupID, "after": connection.PageInfo.EndCursor, + }, &data); err != nil { + return nil, fmt.Errorf("load more check contexts: %w", err) + } + if data.Node == nil { + return nil, errors.New("check rollup disappeared while loading contexts") + } + connection = data.Node.Contexts + nodes = append(nodes, connection.Nodes...) + } + for index := range nodes { + if nodes[index].ID == "" || !checkMayHaveUsefulAnnotations(nodes[index]) { + continue + } + annotations, err := c.checkAnnotations(ctx, nodes[index].ID) + if err != nil { + return nil, err + } + nodes[index].Annotations.Nodes = annotations + } + return nodes, nil +} + +func checkMayHaveUsefulAnnotations(check githubCheckContext) bool { + state := strings.ToUpper(firstNonEmpty(check.Conclusion, check.State, check.Status)) + switch state { + case "FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STALE": + return true + default: + return false + } +} + +func (c *GitHubClient) checkAnnotations( + ctx context.Context, checkID string, +) ([]githubCheckAnnotation, error) { + var data struct { + Node *struct { + Annotations githubCheckAnnotationConnection + } + } + if err := c.query(ctx, checkAnnotationsPageQuery, map[string]any{ + "id": checkID, "after": nil, + }, &data); err != nil { + return nil, fmt.Errorf("load annotations for check %s: %w", checkID, err) + } + if data.Node == nil { + return nil, fmt.Errorf("check %s disappeared while loading annotations", checkID) + } + return c.allCheckAnnotations(ctx, checkID, data.Node.Annotations) +} + +func (c *GitHubClient) allCheckAnnotations( + ctx context.Context, checkID string, connection githubCheckAnnotationConnection, +) ([]githubCheckAnnotation, error) { + nodes := append([]githubCheckAnnotation(nil), connection.Nodes...) + for pages := 0; connection.PageInfo.HasNextPage; pages++ { + if pages >= 100 { + return nil, fmt.Errorf("annotations for check %s exceeded 100 pages", checkID) + } + var data struct { + Node *struct { + Annotations githubCheckAnnotationConnection + } + } + if err := c.query(ctx, checkAnnotationsPageQuery, map[string]any{ + "id": checkID, "after": connection.PageInfo.EndCursor, + }, &data); err != nil { + return nil, fmt.Errorf("load more annotations for check %s: %w", checkID, err) + } + if data.Node == nil { + return nil, fmt.Errorf("check %s disappeared while loading annotations", checkID) + } + connection = data.Node.Annotations + nodes = append(nodes, connection.Nodes...) + } + return nodes, nil +} + +func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) { var data struct { Repository *struct { - PullRequest *struct { - ID, Title, URL, Body, Mergeable, MergeStateStatus string - ReviewDecision, BaseRefName, HeadRefName string - Number, Additions, Deletions, ChangedFiles int - IsDraft bool - CreatedAt, UpdatedAt time.Time - Author *actor - Assignees struct { - Nodes []actor `json:"nodes"` - } - Labels struct { - Nodes []struct { - Name string `json:"name"` - } `json:"nodes"` - } - Milestone *struct { - Title string `json:"title"` - } - Comments struct { - TotalCount int `json:"totalCount"` - } - ReviewRequests struct { - Nodes []struct { - RequestedReviewer actor `json:"requestedReviewer"` - } `json:"nodes"` - } - LatestReviews struct { - Nodes []struct { - State string - Author *actor - } `json:"nodes"` - } - Commits struct { - TotalCount int `json:"totalCount"` - Nodes []struct { - Commit struct { - StatusCheckRollup *struct{ State string } - } - } `json:"nodes"` - } - ReviewThreads struct { - PageInfo struct{ HasNextPage bool } - Nodes []struct { - ID, Path string - DiffSide, StartDiffSide string - Line, OriginalLine, StartLine, OriginalStartLine *int - IsResolved, IsOutdated bool - Comments struct { - PageInfo struct{ HasNextPage bool } - Nodes []struct { - ID, Body, DiffHunk, URL string - Line, StartLine *int - OriginalLine, OriginalStartLine *int - Outdated bool - OriginalCommit *struct{ OID string } - CreatedAt time.Time - Author *actor - } `json:"nodes"` - } - } `json:"nodes"` - } - } `json:"pullRequest"` + ViewerPermission string `json:"viewerPermission"` + DefaultBranchRef *struct{ Name string } + Rulesets struct{ Nodes []githubRuleset } + PullRequest *githubPullRequestDetails `json:"pullRequest"` } `json:"repository"` } if err := c.query(ctx, detailsQuery, map[string]any{"owner": owner, "name": name, "number": number}, &data); err != nil { @@ -303,23 +861,102 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n return PRDetails{}, fmt.Errorf("pull request #%d was not found", number) } node := data.Repository.PullRequest - author := "[ghost]" - if node.Author != nil { - author = node.Author.Login + var ( + threadNodes []githubReviewThread + conversationNodes []githubPRComment + reviewNodes []githubReviewSummary + timelineNodes []githubTimelineNode + checkNodes []githubCheckContext + threadErr error + conversationErr error + reviewErr error + timelineErr error + checkErr error + wait sync.WaitGroup + ) + wait.Add(4) + go func() { + defer wait.Done() + threadNodes, threadErr = c.allReviewThreads(ctx, owner, name, number, node.ReviewThreads) + }() + go func() { + defer wait.Done() + conversationNodes, conversationErr = c.allConversationComments(ctx, owner, name, number, node.Comments) + }() + go func() { + defer wait.Done() + reviewNodes, reviewErr = c.allReviewSummaries(ctx, owner, name, number, node.Reviews) + }() + go func() { + defer wait.Done() + timelineNodes, timelineErr = c.allTimelineItems(ctx, owner, name, number, node.TimelineItems) + }() + if len(node.Commits.Nodes) > 0 && node.Commits.Nodes[0].Commit.StatusCheckRollup != nil { + wait.Add(1) + go func() { + defer wait.Done() + rollup := node.Commits.Nodes[0].Commit.StatusCheckRollup + checkNodes, checkErr = c.allCheckContexts(ctx, rollup.Contexts, rollup.ID) + }() + } + wait.Wait() + for _, err := range []error{threadErr, conversationErr, reviewErr, timelineErr, checkErr} { + if err != nil { + return PRDetails{}, err + } } details := PRDetails{ PullRequest: PullRequest{ ID: node.ID, Owner: owner, Repository: name, RepoWithOwner: owner + "/" + name, Number: node.Number, Title: node.Title, URL: node.URL, - Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, - ReviewCount: len(node.ReviewThreads.Nodes), + Author: actorLogin(node.Author), IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, + ReviewCount: len(threadNodes), }, Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName, - Mergeable: node.Mergeable, MergeState: node.MergeStateStatus, + HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus, Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles, CommitCount: node.Commits.TotalCount, CommentCount: node.Comments.TotalCount, - ThreadsTruncated: node.ReviewThreads.PageInfo.HasNextPage, - CheckState: "NONE", ReviewDecision: node.ReviewDecision, + CheckState: "NONE", ReviewDecision: node.ReviewDecision, + Permissions: ViewerPermissions{ + Repository: data.Repository.ViewerPermission, + CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact, + CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge, + }, + } + if node.BaseRef != nil && node.BaseRef.BranchProtectionRule != nil { + rule := node.BaseRef.BranchProtectionRule + details.Requirements = MergeRequirements{ + ApprovalsRequired: rule.RequiredApprovingReviewCount, + RequiresApprovals: rule.RequiresApprovingReviews, + RequiresStatusChecks: rule.RequiresStatusChecks, + RequiresConversation: rule.RequiresConversationResolution, + RequiresCodeOwnerReview: rule.RequiresCodeOwnerReviews, + RequiresDeployments: rule.RequiresDeployments, + RequiredDeployments: append([]string(nil), rule.RequiredDeploymentEnvironments...), + RequiresStrictChecks: rule.RequiresStrictStatusChecks, + RequiresLinearHistory: rule.RequiresLinearHistory, + RequiresSignatures: rule.RequiresCommitSignatures, + } + } + if node.MergeQueueEntry != nil { + details.MergeQueue = &MergeQueue{ + State: node.MergeQueueEntry.State, Position: node.MergeQueueEntry.Position, + EnqueuedAt: node.MergeQueueEntry.EnqueuedAt, + EstimatedSeconds: node.MergeQueueEntry.EstimatedTimeToMerge, + } + details.Requirements.RequiresMergeQueue = true + } + for _, ruleset := range data.Repository.Rulesets.Nodes { + rule := Ruleset{Name: ruleset.Name, Enforcement: ruleset.Enforcement} + for _, item := range ruleset.Rules.Nodes { + rule.RuleTypes = append(rule.RuleTypes, item.Type) + if item.Type == "MERGE_QUEUE" { + details.Requirements.RequiresMergeQueue = true + } + } + rule.Applies = rulesetApplies(ruleset, node.BaseRefName, + data.Repository.DefaultBranchRef != nil && data.Repository.DefaultBranchRef.Name == node.BaseRefName) + details.Rulesets = append(details.Rulesets, rule) } for _, label := range node.Labels.Nodes { details.Labels = append(details.Labels, label.Name) @@ -347,45 +984,108 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n } sort.Slice(details.Reviewers, func(i, j int) bool { return details.Reviewers[i].Login < details.Reviewers[j].Login }) if len(node.Commits.Nodes) > 0 && node.Commits.Nodes[0].Commit.StatusCheckRollup != nil { - details.CheckState = node.Commits.Nodes[0].Commit.StatusCheckRollup.State - } - for _, thread := range node.ReviewThreads.Nodes { - item := ReviewThread{ - ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide, - IsResolved: thread.IsResolved, IsOutdated: thread.IsOutdated, - } - if thread.Line != nil { - item.Line = *thread.Line - } else if thread.OriginalLine != nil { - item.Line = *thread.OriginalLine - } - if thread.StartLine != nil { - item.StartLine = *thread.StartLine - } else if thread.OriginalStartLine != nil { - item.StartLine = *thread.OriginalStartLine - } - if item.DiffSide == "" { - item.DiffSide = thread.StartDiffSide - } - for _, comment := range thread.Comments.Nodes { - commentAuthor := "[ghost]" - if comment.Author != nil { - commentAuthor = comment.Author.Login + rollup := node.Commits.Nodes[0].Commit.StatusCheckRollup + details.CheckState = rollup.State + for _, check := range checkNodes { + name := firstNonEmpty(check.Name, check.Context) + state := firstNonEmpty(check.Conclusion, check.State, check.Status) + item := Check{ + ID: check.ID, Name: name, State: state, Conclusion: check.Conclusion, + URL: firstNonEmpty(check.DetailsURL, check.TargetURL), } - item.Comments = append(item.Comments, ReviewComment{ - ID: comment.ID, Author: commentAuthor, Body: comment.Body, - DiffHunk: comment.DiffHunk, CreatedAt: comment.CreatedAt, URL: comment.URL, - Line: intValue(comment.Line), StartLine: intValue(comment.StartLine), - OriginalLine: intValue(comment.OriginalLine), OriginalStartLine: intValue(comment.OriginalStartLine), - OriginalCommitOID: commitOID(comment.OriginalCommit), Outdated: comment.Outdated, + item.Summary = firstNonEmpty(check.Summary, check.Text, check.Title) + for _, annotation := range check.Annotations.Nodes { + item.Annotations = append(item.Annotations, CheckAnnotation{ + Path: annotation.Path, StartLine: annotation.Location.Start.Line, + EndLine: annotation.Location.End.Line, + Level: annotation.AnnotationLevel, Title: annotation.Title, Message: annotation.Message, + }) + } + details.Checks = append(details.Checks, item) + } + } + for _, event := range timelineNodes { + if event.Commit != nil { + author := "[ghost]" + if event.Commit.Author != nil { + author = event.Commit.Author.Name + if event.Commit.Author.User != nil { + author = actorLogin(event.Commit.Author.User) + } + } + details.Timeline = append(details.Timeline, TimelineEvent{ + Kind: "commit", OID: event.Commit.OID, Title: event.Commit.MessageHeadline, + Author: author, CreatedAt: event.Commit.CommittedDate, + }) + } else if event.BeforeCommit != nil || event.AfterCommit != nil { + details.Timeline = append(details.Timeline, TimelineEvent{ + Kind: "force-push", BeforeOID: commitOID(event.BeforeCommit), AfterOID: commitOID(event.AfterCommit), + Author: actorLogin(event.Actor), CreatedAt: event.CreatedAt, }) } - item.IsTruncated = thread.Comments.PageInfo.HasNextPage + } + for _, comment := range conversationNodes { + details.Conversation = append(details.Conversation, PRComment{ + ID: comment.ID, Author: actorLogin(comment.Author), Body: comment.Body, + URL: comment.URL, CreatedAt: comment.CreatedAt, + }) + } + for _, review := range reviewNodes { + details.Reviews = append(details.Reviews, ReviewSummary{ + ID: review.ID, Author: actorLogin(review.Author), Body: review.Body, + State: review.State, URL: review.URL, SubmittedAt: review.SubmittedAt, + CommitOID: commitOID(review.Commit), + }) + } + for _, thread := range threadNodes { + item := convertReviewThread(thread) + details.Permissions.CanResolveAny = details.Permissions.CanResolveAny || item.ViewerCanResolve + details.Permissions.CanUnresolveAny = details.Permissions.CanUnresolveAny || item.ViewerCanUnresolve + details.Permissions.CanReplyAny = details.Permissions.CanReplyAny || item.ViewerCanReply details.Threads = append(details.Threads, item) } return details, nil } +func convertReviewThread(thread githubReviewThread) ReviewThread { + item := ReviewThread{ + ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide, + IsResolved: thread.IsResolved, IsOutdated: thread.IsOutdated, + ViewerCanResolve: thread.ViewerCanResolve, ViewerCanUnresolve: thread.ViewerCanUnresolve, + ViewerCanReply: thread.ViewerCanReply, + } + if thread.Line != nil { + item.Line = *thread.Line + } else if thread.OriginalLine != nil { + item.Line = *thread.OriginalLine + } + if thread.StartLine != nil { + item.StartLine = *thread.StartLine + } else if thread.OriginalStartLine != nil { + item.StartLine = *thread.OriginalStartLine + } + if item.DiffSide == "" { + item.DiffSide = thread.StartDiffSide + } + for _, comment := range thread.Comments.Nodes { + item.Comments = append(item.Comments, ReviewComment{ + ID: comment.ID, Author: actorLogin(comment.Author), Body: comment.Body, + DiffHunk: comment.DiffHunk, CreatedAt: comment.CreatedAt, URL: comment.URL, + Line: intValue(comment.Line), StartLine: intValue(comment.StartLine), + OriginalLine: intValue(comment.OriginalLine), OriginalStartLine: intValue(comment.OriginalStartLine), + OriginalCommitOID: commitOID(comment.OriginalCommit), Outdated: comment.Outdated, + }) + } + return item +} + +func actorLogin(actor *githubActor) string { + if actor == nil || actor.Login == "" { + return "[ghost]" + } + return actor.Login +} + func intValue(value *int) int { if value == nil { return 0 @@ -399,3 +1099,34 @@ func commitOID(commit *struct{ OID string }) string { } return commit.OID } + +func rulesetApplies(ruleset githubRuleset, base string, isDefault bool) bool { + if ruleset.Enforcement == "DISABLED" || ruleset.Target != "" && ruleset.Target != "BRANCH" { + return false + } + if ruleset.Conditions.RefName == nil || len(ruleset.Conditions.RefName.Include) == 0 { + return true + } + ref := "refs/heads/" + base + matches := func(pattern string) bool { + switch pattern { + case "~ALL": + return true + case "~DEFAULT_BRANCH": + return isDefault + } + ok, _ := path.Match(pattern, ref) + return ok + } + for _, excluded := range ruleset.Conditions.RefName.Exclude { + if matches(excluded) { + return false + } + } + for _, included := range ruleset.Conditions.RefName.Include { + if matches(included) { + return true + } + } + return false +} diff --git a/github_test.go b/github_test.go index 7961aee..d5b3856 100644 --- a/github_test.go +++ b/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] diff --git a/highlight.go b/highlight.go index dd461b5..c2bd61d 100644 --- a/highlight.go +++ b/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 diff --git a/main.go b/main.go index 525adde..5c2d7a1 100644 --- a/main.go +++ b/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, diff --git a/markdown.go b/markdown.go index d6e314e..cc9d3af 100644 --- a/markdown.go +++ b/markdown.go @@ -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 diff --git a/theme.go b/theme.go index 4ff6905..f8522a8 100644 --- a/theme.go +++ b/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) } diff --git a/theme_test.go b/theme_test.go index e2fe82a..aac219a 100644 --- a/theme_test.go +++ b/theme_test.go @@ -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) + } +} diff --git a/tui.go b/tui.go index c81c673..5d251ba 100644 --- a/tui.go +++ b/tui.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "hash/fnv" + "slices" "sort" "strings" "time" @@ -31,8 +32,9 @@ const ( type tickMsg time.Time type pathTickMsg time.Time type prsLoadedMsg struct { - prs []PullRequest - err error + prs []PullRequest + err error + cached bool } type detailsLoadedMsg struct { owner string @@ -40,6 +42,7 @@ type detailsLoadedMsg struct { number int details PRDetails err error + cached bool } type App struct { @@ -78,6 +81,13 @@ type App struct { threadWithinStatus string dashboardMode string dashboardReturn screen + compactReviews bool + readState *readStateStore + knownThreads map[string]bool + knownComments map[string]bool + initializedPRs map[string]bool + unreadThreads map[string]bool + updatedThreads map[string]bool } type AppSettings struct { @@ -88,6 +98,8 @@ type AppSettings struct { ThreadStatusOrder []string ThreadWithinStatus string DashboardMode string + CompactReviews bool + ReadState *readStateStore } func defaultAppSettings() AppSettings { @@ -98,6 +110,7 @@ func defaultAppSettings() AppSettings { ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"}, ThreadWithinStatus: "file", DashboardMode: "hotkey", + CompactReviews: true, } } @@ -113,6 +126,10 @@ func NewAppWithSettings( poll time.Duration, settings AppSettings, ) App { + state := settings.ReadState + if state == nil { + state = &readStateStore{Data: make(map[string]readPRState)} + } return App{ service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll, folded: make(map[string]bool), loading: true, @@ -121,11 +138,16 @@ func NewAppWithSettings( threadStatusOrder: append([]string(nil), settings.ThreadStatusOrder...), threadWithinStatus: settings.ThreadWithinStatus, dashboardMode: settings.DashboardMode, dashboardReturn: prScreen, + compactReviews: settings.CompactReviews, + readState: state, + knownThreads: make(map[string]bool), knownComments: make(map[string]bool), + initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool), + updatedThreads: make(map[string]bool), } } func (m App) Init() tea.Cmd { - commands := []tea.Cmd{m.loadPRs(), m.nextTick()} + commands := []tea.Cmd{m.loadPRs(true), m.nextTick()} if m.pathScroll { commands = append(commands, m.nextPathTick()) } @@ -140,20 +162,57 @@ func (m App) nextPathTick() tea.Cmd { return tea.Tick(m.pathScrollInterval, func(t time.Time) tea.Msg { return pathTickMsg(t) }) } -func (m App) loadPRs() tea.Cmd { +func (m App) loadPRs(useCache bool) tea.Cmd { + commands := []tea.Cmd{m.loadLivePRs()} + if cache, ok := m.service.(cachedSnapshotService); ok && useCache { + commands = append([]tea.Cmd{func() tea.Msg { + prs, err := cache.CachedPullRequests(m.owner, m.repo, m.limit, m.showAll) + return prsLoadedMsg{prs: prs, err: err, cached: true} + }}, commands...) + } + return tea.Batch(commands...) +} + +func (m App) loadLivePRs() tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - prs, err := m.service.ListPullRequests(ctx, m.owner, m.repo, m.limit, m.showAll) + var prs []PullRequest + var err error + if live, ok := m.service.(liveGitHubService); ok { + prs, err = live.LivePullRequests(ctx, m.owner, m.repo, m.limit, m.showAll) + } else { + prs, err = m.service.ListPullRequests(ctx, m.owner, m.repo, m.limit, m.showAll) + } return prsLoadedMsg{prs: prs, err: err} } } -func (m App) loadDetails(pr PullRequest) tea.Cmd { +func (m App) loadDetails(pr PullRequest, useCache bool) tea.Cmd { + commands := []tea.Cmd{m.loadLiveDetails(pr)} + if cache, ok := m.service.(cachedSnapshotService); ok && useCache { + commands = append([]tea.Cmd{func() tea.Msg { + details, err := cache.CachedPullRequest(pr.Owner, pr.Repository, pr.Number) + return detailsLoadedMsg{ + owner: pr.Owner, repo: pr.Repository, number: pr.Number, + details: details, err: err, cached: true, + } + }}, commands...) + } + return tea.Batch(commands...) +} + +func (m App) loadLiveDetails(pr PullRequest) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - details, err := m.service.GetPullRequest(ctx, pr.Owner, pr.Repository, pr.Number) + var details PRDetails + var err error + if live, ok := m.service.(liveGitHubService); ok { + details, err = live.LivePullRequest(ctx, pr.Owner, pr.Repository, pr.Number) + } else { + details, err = m.service.GetPullRequest(ctx, pr.Owner, pr.Repository, pr.Number) + } return detailsLoadedMsg{ owner: pr.Owner, repo: pr.Repository, number: pr.Number, details: details, err: err, @@ -169,9 +228,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.loading { m.loading = true if (m.screen == dashboardScreen || m.screen == threadScreen) && m.details.Number != 0 { - return m, tea.Batch(m.loadDetails(m.details.PullRequest), m.nextTick()) + return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.nextTick()) } - return m, tea.Batch(m.loadPRs(), m.nextTick()) + return m, tea.Batch(m.loadPRs(false), m.nextTick()) } return m, m.nextTick() case pathTickMsg: @@ -183,8 +242,16 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case prsLoadedMsg: - m.loading = false + if m.screen != prScreen || msg.cached && !m.loading { + return m, nil + } + if !msg.cached { + m.loading = false + } if msg.err != nil { + if msg.cached { + return m, nil + } m.err = msg.err return m, nil } @@ -202,21 +269,39 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) m.prIndex = indexPR(m.prs, selected) m.err = nil - m.lastRefresh = time.Now() + if msg.cached && len(msg.prs) > 0 { + m.lastRefresh = msg.prs[0].CachedAt + } else { + m.lastRefresh = time.Now() + } case detailsLoadedMsg: - m.loading = false + if m.screen != dashboardScreen && m.screen != threadScreen { + return m, nil + } + if msg.cached && !m.loading { + return m, nil + } + if !msg.cached { + m.loading = false + } if m.details.Number != 0 && (msg.number != m.details.Number || msg.owner != m.details.Owner || msg.repo != m.details.Repository) { return m, nil } if msg.err != nil { + if msg.cached { + return m, nil + } m.err = msg.err return m, nil } selected := "" + anchor := "" if m.threadIndex < len(m.details.Threads) { selected = m.details.Threads[m.threadIndex].ID + anchor = m.detailScrollAnchor() } + m.trackThreadUpdates(msg.details) sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus) m.details = msg.details m.threadIndex = indexThread(m.details.Threads, selected) @@ -231,10 +316,18 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.screen == dashboardScreen { m.scroll = min(m.scroll, m.dashboardMaxScroll()) } else { - m.scroll = min(m.scroll, m.detailMaxScroll()) + if anchor != "" { + m.restoreDetailAnchor(anchor) + } else { + m.scroll = min(m.scroll, m.detailMaxScroll()) + } } m.err = nil - m.lastRefresh = time.Now() + if msg.cached { + m.lastRefresh = msg.details.CachedAt + } else { + m.lastRefresh = time.Now() + } } key, ok := msg.(tea.KeyMsg) @@ -272,7 +365,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) m.scroll = 0 case "enter": - m.searching, m.searchQuery = false, "" + m.searching = false case "up": m.moveSearch(-1) case "down", "tab": @@ -316,6 +409,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } switch k { + case "F": + if m.screen == threadScreen { + m.searchQuery = "" + m.threadIndex = clamp(m.threadIndex, 0, len(m.details.Threads)-1) + } case "/": if m.screen == threadScreen { m.searching, m.searchQuery, m.searchOrigin = true, "", m.threadIndex @@ -327,9 +425,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.loading = true if m.screen == dashboardScreen || m.screen == threadScreen { - return m, m.loadDetails(m.details.PullRequest) + return m, m.loadDetails(m.details.PullRequest, false) } - return m, m.loadPRs() + return m, m.loadPRs(false) case "j", "down": if m.screen == dashboardScreen { m.scrollDashboard(1) @@ -364,6 +462,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.page(1) case "ctrl+u", "pgup": m.page(-1) + case "n": + if m.screen == threadScreen { + m.moveToUnread(1) + } + case "N": + if m.screen == threadScreen { + m.moveToUnread(-1) + } case "d": if m.screen == prScreen && len(m.prs) > 0 { return m, m.openSelectedPR(dashboardScreen) @@ -382,6 +488,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.screen == threadScreen { m.focus = threadDetailPane + m.markCurrentThreadRead() } case "h": if m.screen == threadScreen { @@ -399,6 +506,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.screen == threadScreen && len(m.details.Threads) > 0 { thread := m.details.Threads[m.threadIndex] m.folded[thread.ID] = !m.folded[thread.ID] + m.markCurrentThreadRead() m.scroll = 0 } case "b", "esc": @@ -408,14 +516,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.screen, m.err, m.loading = prScreen, nil, true - return m, m.loadPRs() + return m, m.loadPRs(false) } if m.screen == dashboardScreen { m.screen, m.scroll, m.err = m.dashboardReturn, 0, nil if m.screen == prScreen { m.loading = true m.searching, m.searchQuery = false, "" - return m, m.loadPRs() + return m, m.loadPRs(false) } return m, nil } @@ -436,7 +544,98 @@ func (m *App) openSelectedPR(target screen) tea.Cmd { m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil m.searching, m.searchQuery = false, "" m.pathScrollStep = 0 - return m.loadDetails(m.details.PullRequest) + return m.loadDetails(m.details.PullRequest, true) +} + +func (m *App) trackThreadUpdates(details PRDetails) { + if m.knownThreads == nil { + m.knownThreads = make(map[string]bool) + m.knownComments = make(map[string]bool) + m.initializedPRs = make(map[string]bool) + m.unreadThreads = make(map[string]bool) + } + m.updatedThreads = make(map[string]bool) + prID := details.ID + if prID == "" { + prID = fmt.Sprintf("%s#%d", details.RepoWithOwner, details.Number) + } + state := m.readState.Data[prID] + if state.Threads == nil { + state.Threads = make(map[string]bool) + state.Comments = make(map[string]bool) + } + if !state.Initialized { + for _, thread := range details.Threads { + state.Threads[thread.ID] = true + for _, comment := range thread.Comments { + state.Comments[comment.ID] = true + } + } + state.Initialized = true + m.readState.Data[prID] = state + _ = m.readState.save() + return + } + for _, thread := range details.Threads { + updated := !state.Threads[thread.ID] + for _, comment := range thread.Comments { + if !state.Comments[comment.ID] { + updated = true + } + m.knownComments[comment.ID] = true + } + m.knownThreads[thread.ID] = true + if updated { + m.unreadThreads[thread.ID] = true + m.updatedThreads[thread.ID] = true + } + } + m.initializedPRs[prID] = true +} + +func (m *App) markCurrentThreadRead() { + if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) { + thread := m.details.Threads[m.threadIndex] + delete(m.unreadThreads, thread.ID) + prID := m.currentPRKey() + state := m.readState.Data[prID] + if state.Threads == nil { + state.Threads = make(map[string]bool) + state.Comments = make(map[string]bool) + } + state.Initialized = true + state.Threads[thread.ID] = true + for _, comment := range thread.Comments { + state.Comments[comment.ID] = true + } + m.readState.Data[prID] = state + _ = m.readState.save() + } +} + +func (m App) currentPRKey() string { + if m.details.ID != "" { + return m.details.ID + } + return fmt.Sprintf("%s#%d", m.details.RepoWithOwner, m.details.Number) +} + +func (m *App) moveToUnread(direction int) { + count := len(m.details.Threads) + if count == 0 { + return + } + for offset := 1; offset <= count; offset++ { + index := (m.threadIndex + direction*offset) % count + if index < 0 { + index += count + } + if m.unreadThreads[m.details.Threads[index].ID] { + m.threadIndex, m.scroll = index, 0 + m.markCurrentThreadRead() + return + } + } } func (m *App) move(delta int) { @@ -444,8 +643,23 @@ func (m *App) move(delta int) { m.prIndex = clamp(m.prIndex+delta, 0, len(m.prs)-1) return } + if m.searchQuery != "" { + matches := m.matchingThreadIndices() + if len(matches) == 0 { + return + } + position := slices.Index(matches, m.threadIndex) + if position < 0 { + position = 0 + } + m.threadIndex = matches[clamp(position+delta, 0, len(matches)-1)] + m.scroll = 0 + m.markCurrentThreadRead() + return + } m.threadIndex = clamp(m.threadIndex+delta, 0, len(m.details.Threads)-1) m.scroll = 0 + m.markCurrentThreadRead() } func (m *App) moveSearch(delta int) { @@ -485,9 +699,19 @@ func (m App) matchingThreadIndices() []int { index int score int } + filter := parseThreadFilter(m.searchQuery) matches := make([]match, 0, len(m.details.Threads)) for i, thread := range m.details.Threads { - if score, ok := fuzzyPathScore(thread.Path, m.searchQuery); ok { + if filter.status != "" && threadStatus(thread) != filter.status { + continue + } + if filter.updated && !m.updatedThreads[thread.ID] { + continue + } + if filter.author != "" && !threadHasAuthor(thread, filter.author) { + continue + } + if score, ok := fuzzyPathScore(thread.Path, filter.path); ok { matches = append(matches, match{index: i, score: score}) } } @@ -501,6 +725,53 @@ func (m App) matchingThreadIndices() []int { return indices } +type threadFilter struct { + path string + status string + author string + updated bool +} + +func parseThreadFilter(query string) threadFilter { + var filter threadFilter + var pathTerms []string + for _, term := range strings.Fields(query) { + key, value, found := strings.Cut(term, ":") + if !found { + pathTerms = append(pathTerms, term) + continue + } + switch strings.ToLower(key) { + case "status": + value = strings.ToLower(value) + if value == "open" { + value = "unresolved" + } + if value == "unresolved" || value == "outdated" || value == "resolved" { + filter.status = value + } + case "author": + filter.author = strings.TrimPrefix(strings.ToLower(value), "@") + case "updated", "new": + filter.updated = value == "" || value == "1" || strings.EqualFold(value, "true") || + strings.EqualFold(value, "yes") + default: + pathTerms = append(pathTerms, term) + } + } + filter.path = strings.Join(pathTerms, " ") + return filter +} + +func threadHasAuthor(thread ReviewThread, author string) bool { + for _, comment := range thread.Comments { + if strings.Contains(strings.ToLower(comment.Author), author) { + return true + } + } + return false +} + func sortReviewThreads(threads []ReviewThread, statusOrder []string, withinStatus string) { ranks := map[string]int{ "unresolved": 0, @@ -718,21 +989,33 @@ func (m App) helpBindings() []helpBinding { if m.dashboardMode == "intermediate" { backAction = "Return to PR dashboard" } - return []helpBinding{ + bindings := []helpBinding{ {"h / l", "Focus thread list / detail"}, {"j / k", "Move or scroll focused pane"}, {"↓ / ↑", "Move or scroll focused pane"}, {"g / G", "First / last item"}, {"ctrl-d / ctrl-u", "Page down / up"}, {"tab", "Hide / reveal thread list"}, - {"/", "Fuzzy-search file paths"}, + {"/", "Fuzzy-search file paths and combine status:, author:, and updated:true filters"}, + {"F", "Clear the active thread filter and show every thread"}, + {"n / N", "Next / previous new update"}, {"d", "Open pull request dashboard"}, {"enter / za", "Fold / expand thread"}, {"b / esc", backAction}, {"r", "Refresh now"}, - {"?", "Close this help"}, - {"q / ctrl-c", "Quit"}, } + for _, item := range writeCapabilities(m.details, m.selectedThread()) { + state := item.reason + if item.authorized { + state = "ready; " + item.reason + } + bindings = append(bindings, helpBinding{"write", item.name + ": " + state}) + } + bindings = append(bindings, + helpBinding{"?", "Close this help"}, + helpBinding{"q / ctrl-c", "Quit"}, + ) + return bindings } func (m App) helpVisibleRows() int { @@ -740,16 +1023,15 @@ func (m App) helpVisibleRows() int { } func (m App) helpMaxScroll() int { - return max(0, len(m.helpBindings())-m.helpVisibleRows()) + return max(0, len(m.helpRows(m.helpContentWidth()))-m.helpVisibleRows()) } func (m App) viewHelp() string { - bindings := m.helpBindings() + contentWidth := m.helpContentWidth() + rows := m.helpRows(contentWidth) visibleRows := m.helpVisibleRows() - start := clamp(m.helpScroll, 0, max(0, len(bindings)-visibleRows)) - end := min(len(bindings), start+visibleRows) - contentWidth := max(1, min(70, m.width-4)) - keyWidth := min(17, max(8, contentWidth/3)) + start := clamp(m.helpScroll, 0, max(0, len(rows)-visibleRows)) + end := min(len(rows), start+visibleRows) title := "Pull request picker keys" if m.screen == dashboardScreen { @@ -758,15 +1040,11 @@ func (m App) viewHelp() string { title = "Review thread keys" } lines := []string{titleStyle.Render(title)} - for _, binding := range bindings[start:end] { - key := pad(binding.key, keyWidth) - actionWidth := max(1, contentWidth-keyWidth-1) - lines = append(lines, titleStyle.Render(key)+" "+truncate(binding.action, actionWidth)) - } - if len(bindings) > visibleRows { + lines = append(lines, rows[start:end]...) + if len(rows) > visibleRows { lines = append(lines, dimStyle.Render(fmt.Sprintf( "%d–%d of %d • j/k scroll • ?/esc/q close", - start+1, end, len(bindings), + start+1, end, len(rows), ))) } else { lines = append(lines, dimStyle.Render("?/esc/q close")) @@ -778,6 +1056,28 @@ func (m App) viewHelp() string { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) } +func (m App) helpContentWidth() int { + return max(1, min(70, m.width-4)) +} + +func (m App) helpRows(contentWidth int) []string { + keyWidth := min(17, max(8, contentWidth/3)) + actionWidth := max(1, contentWidth-keyWidth-1) + var rows []string + for _, binding := range m.helpBindings() { + wrapped := ansi.Hardwrap(ansi.Wordwrap(binding.action, actionWidth, ""), actionWidth, false) + actionLines := strings.Split(wrapped, "\n") + for index, action := range actionLines { + key := "" + if index == 0 { + key = binding.key + } + rows = append(rows, titleStyle.Render(pad(key, keyWidth))+" "+action) + } + } + return rows +} + var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) @@ -832,6 +1132,9 @@ func (m App) viewPRs() string { if pr.IsDraft { draft = " DRAFT" } + if pr.FromCache { + draft += " CACHED" + } titleWidth := max(10, m.width-36) line := fmt.Sprintf(" #%-5d %-*s %2d threads%s", pr.Number, titleWidth, truncate(pr.Title, titleWidth), pr.ReviewCount, draft) if row.prIndex == m.prIndex { @@ -893,6 +1196,9 @@ func (m App) dashboardLines() []string { titleStyle.Render(fmt.Sprintf("%s #%d", pr.RepoWithOwner, pr.Number)) + draft, titleStyle.Render(truncate(pr.Title, width)), } + if pr.FromCache { + lines = append(lines, warnStyle.Render("OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04"))) + } if m.loading && pr.BaseRef == "" { return append(lines, "", "Loading pull request details…") } @@ -934,10 +1240,15 @@ func (m App) dashboardLines() []string { dashboardMetadata("threads", fmt.Sprintf( "%d open • %d outdated • %d resolved", open, outdated, resolved, )), + dashboardMetadata("requirements", mergeRequirementsText(pr.Requirements)), + dashboardMetadata("permission", viewerPermissionsText(pr.Permissions)), + dashboardMetadata("head sha", shortOID(pr.HeadOID)), dashboardMetadata("created", created), dashboardMetadata("updated", updated), dashboardMetadata("url", pr.URL), ) + lines = append(lines, "", titleStyle.Render("Write capability gate"), "") + lines = append(lines, writeCapabilityLines(pr, m.selectedThread())...) if pr.ThreadsTruncated { lines = append(lines, warnStyle.Render("Thread totals only include the first 100 review threads.")) } @@ -947,9 +1258,152 @@ func (m App) dashboardLines() []string { } else { lines = append(lines, renderCommentMarkdown(pr.Body, width)...) } + lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Checks (%d)", len(pr.Checks))), "") + if len(pr.Checks) == 0 { + lines = append(lines, dimStyle.Render("No individual checks reported.")) + } + for _, check := range pr.Checks { + line := coloredState(check.State) + " " + check.Name + if check.URL != "" { + line += " " + dimStyle.Render(check.URL) + } + lines = append(lines, line) + if check.Summary != "" && check.State != "SUCCESS" { + lines = append(lines, dimStyle.Render(" "+truncate(strings.Join(strings.Fields(check.Summary), " "), width-2))) + } + for _, annotation := range check.Annotations { + location := fmt.Sprintf("%s:%d", annotation.Path, annotation.StartLine) + lines = append(lines, " "+coloredState(strings.ToUpper(annotation.Level))+" "+ + location+" "+truncate(firstNonEmpty(annotation.Title, annotation.Message), max(10, width-len(location)-8))) + } + } + lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Commit and force-push timeline (%d)", len(pr.Timeline))), "") + if len(pr.Timeline) == 0 { + lines = append(lines, dimStyle.Render("No commit timeline events reported.")) + } + for _, event := range pr.Timeline { + when := event.CreatedAt.Local().Format("2006-01-02 15:04") + if event.Kind == "force-push" { + lines = append(lines, warnStyle.Render("force-push")+" "+shortOID(event.BeforeOID)+" → "+ + shortOID(event.AfterOID)+" "+authorStyle(event.Author).Render("@"+event.Author)+" "+dimStyle.Render(when)) + } else { + lines = append(lines, shortOID(event.OID)+" "+truncate(event.Title, max(10, width-35))+ + " "+authorStyle(event.Author).Render("@"+event.Author)+" "+dimStyle.Render(when)) + } + } + lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Applicable rulesets (%d)", applicableRulesetCount(pr.Rulesets))), "") + for _, ruleset := range pr.Rulesets { + if ruleset.Applies { + lines = append(lines, ruleset.Name+" "+dimStyle.Render(strings.ToLower(ruleset.Enforcement))+ + " "+strings.Join(ruleset.RuleTypes, ", ")) + } + } + if applicableRulesetCount(pr.Rulesets) == 0 { + lines = append(lines, dimStyle.Render("No applicable active rulesets reported.")) + } + if pr.MergeQueue != nil { + lines = append(lines, dashboardMetadata("merge queue", fmt.Sprintf( + "%s, position %d", strings.ToLower(pr.MergeQueue.State), pr.MergeQueue.Position, + ))) + } + lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Submitted reviews (%d)", len(pr.Reviews))), "") + if len(pr.Reviews) == 0 { + lines = append(lines, dimStyle.Render("No submitted reviews.")) + } + if m.compactReviews { + lines = append(lines, compactReviewLines(pr.Reviews, width)...) + if len(pr.Reviews) > 0 { + lines = append(lines, "") + } + } else { + for _, review := range pr.Reviews { + header := authorStyle(review.Author).Render("@"+review.Author) + " " + + coloredState(review.State) + if !review.SubmittedAt.IsZero() { + header += " " + dimStyle.Render(review.SubmittedAt.Local().Format("2006-01-02 15:04")) + } + if review.CommitOID != "" { + header += " " + dimStyle.Render(shortOID(review.CommitOID)) + } + lines = append(lines, header) + if strings.TrimSpace(review.Body) != "" { + lines = append(lines, renderCommentMarkdown(review.Body, width)...) + } + lines = append(lines, "") + } + } + lines = append(lines, titleStyle.Render(fmt.Sprintf("Conversation (%d)", len(pr.Conversation))), "") + if len(pr.Conversation) == 0 { + lines = append(lines, dimStyle.Render("No PR conversation comments.")) + } + for _, comment := range pr.Conversation { + header := authorStyle(comment.Author).Render("@" + comment.Author) + if !comment.CreatedAt.IsZero() { + header += " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")) + } + lines = append(lines, header) + lines = append(lines, renderCommentMarkdown(comment.Body, width)...) + lines = append(lines, "") + } return lines } +func compactReviewLines(reviews []ReviewSummary, width int) []string { + if len(reviews) == 0 { + return nil + } + + stateCounts := make(map[string]int) + authorCounts := make(map[string]int) + for _, review := range reviews { + stateCounts[review.State]++ + authorCounts[review.Author]++ + } + + states := make([]string, 0, len(stateCounts)) + for state := range stateCounts { + states = append(states, state) + } + sort.Strings(states) + authors := make([]string, 0, len(authorCounts)) + for author := range authorCounts { + authors = append(authors, author) + } + sort.Slice(authors, func(i, j int) bool { + if authorCounts[authors[i]] != authorCounts[authors[j]] { + return authorCounts[authors[i]] > authorCounts[authors[j]] + } + return strings.ToLower(authors[i]) < strings.ToLower(authors[j]) + }) + + summary := make([]string, 0, len(states)+len(authors)) + for _, state := range states { + summary = append(summary, coloredState(state)+dimStyle.Render(fmt.Sprintf(" ×%d", stateCounts[state]))) + } + for _, author := range authors { + summary = append(summary, + authorStyle(author).Render("@"+author)+dimStyle.Render(fmt.Sprintf(" ×%d", authorCounts[author])), + ) + } + lines := []string{ansi.Truncate(strings.Join(summary, dimStyle.Render(" • ")), width, "…")} + for _, review := range reviews { + if body := compactReviewBody(review.Body, width); body != "" { + line := authorStyle(review.Author).Render("@"+review.Author) + " " + + coloredState(review.State) + dimStyle.Render(" — ") + body + lines = append(lines, ansi.Truncate(line, width, "…")) + } + } + return lines +} + +func compactReviewBody(body string, width int) string { + if strings.TrimSpace(body) == "" { + return "" + } + rendered := ansi.Strip(strings.Join(renderCommentMarkdown(body, width), " ")) + return strings.Join(strings.Fields(rendered), " ") +} + func dashboardMetadata(label, value string) string { return titleStyle.Render(pad(label+":", 13)) + " " + value } @@ -968,12 +1422,148 @@ func threadStatusCounts(threads []ReviewThread) (open, outdated, resolved int) { return open, outdated, resolved } +func mergeRequirementsText(requirements MergeRequirements) string { + var items []string + if requirements.RequiresApprovals { + approval := "approvals" + if requirements.ApprovalsRequired == 1 { + approval = "approval" + } + items = append(items, fmt.Sprintf("%d %s", requirements.ApprovalsRequired, approval)) + } + if requirements.RequiresCodeOwnerReview { + items = append(items, "code owner review") + } + if requirements.RequiresStatusChecks { + items = append(items, "status checks") + } + if requirements.RequiresStrictChecks { + items = append(items, "up-to-date branch") + } + if requirements.RequiresDeployments { + item := "deployments" + if len(requirements.RequiredDeployments) > 0 { + item += " (" + strings.Join(requirements.RequiredDeployments, ", ") + ")" + } + items = append(items, item) + } + if requirements.RequiresLinearHistory { + items = append(items, "linear history") + } + if requirements.RequiresSignatures { + items = append(items, "signed commits") + } + if requirements.RequiresMergeQueue { + items = append(items, "merge queue") + } + if requirements.RequiresConversation { + items = append(items, "resolved conversations") + } + if len(items) == 0 { + return "none reported" + } + return strings.Join(items, ", ") +} + +func applicableRulesetCount(rulesets []Ruleset) int { + count := 0 + for _, ruleset := range rulesets { + if ruleset.Applies { + count++ + } + } + return count +} + +type writeCapability struct { + name, reason string + authorized bool +} + +func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability { + if pr.FromCache { + reason := "offline cached snapshot" + return []writeCapability{ + {name: "reply", reason: reason}, {name: "resolve", reason: reason}, + {name: "react", reason: reason}, {name: "update branch", reason: reason}, + {name: "auto-merge", reason: reason}, + } + } + threadReason := "select a review thread" + canReply, canResolve := false, false + if thread != nil { + canReply = thread.ViewerCanReply + canResolve = thread.ViewerCanResolve || thread.ViewerCanUnresolve + threadReason = "GitHub did not grant permission for this thread" + } + return []writeCapability{ + capability("reply", canReply, threadReason), + capability("resolve / unresolve", canResolve, threadReason), + capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission"), + capability("update branch", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission"), + capability("auto-merge", pr.Permissions.CanEnableMerge, "auto-merge is unavailable for this PR"), + } +} + +func capability(name string, allowed bool, denied string) writeCapability { + if allowed { + return writeCapability{name: name, authorized: true, reason: "authorized; write UI not implemented"} + } + return writeCapability{name: name, reason: denied} +} + +func writeCapabilityLines(pr PRDetails, thread *ReviewThread) []string { + var lines []string + for _, item := range writeCapabilities(pr, thread) { + marker := badStyle.Render("disabled") + if item.authorized { + marker = okStyle.Render("ready") + } + lines = append(lines, pad(item.name, 21)+" "+marker+" "+dimStyle.Render(item.reason)) + } + return lines +} + +func (m App) selectedThread() *ReviewThread { + if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) { + return nil + } + thread := m.details.Threads[m.threadIndex] + return &thread +} + +func viewerPermissionsText(permissions ViewerPermissions) string { + items := []string{strings.ToLower(firstNonEmpty(permissions.Repository, "unknown"))} + if permissions.CanUpdatePR { + items = append(items, "update") + } + if permissions.CanResolveAny { + items = append(items, "resolve") + } + if permissions.CanUnresolveAny { + items = append(items, "unresolve") + } + if permissions.CanReplyAny { + items = append(items, "reply") + } + if permissions.CanEnableMerge { + items = append(items, "auto-merge") + } + return strings.Join(items, ", ") +} + func (m App) viewThreads() string { pr := m.details header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12)))) meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr)) people := "assignees: " + handlesText(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers) top := []string{header, meta, people} + if pr.FromCache { + top = append(top, warnStyle.Render( + "CACHED snapshot • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04")+ + " • refreshing live data", + )) + } if pr.ThreadsTruncated { top = append(top, warnStyle.Render("Showing the first 100 review threads.")) } @@ -995,9 +1585,9 @@ func (m App) viewThreads() string { right := m.threadDetail(rightWidth, contentHeight) body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) } - help := "? keys • h/l focus • j/k move/scroll • d dashboard • b back • q quit" + help := "? keys • h/l focus • j/k move/scroll • n/N new • d dashboard • b back • q quit" if m.searching { - help = "type to fuzzy search • ↑/↓ choose • enter jump • esc cancel" + help = "path words • status:open • author:name • updated:true • enter apply • esc cancel" } return m.frame(append(top, body), help) } @@ -1005,16 +1595,16 @@ func (m App) viewThreads() string { func (m App) threadList(width, height int) string { innerWidth := max(1, width-2) innerHeight := max(1, height-2) - lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d)", len(m.details.Threads)))} + matches := m.matchingThreadIndices() + lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d/%d)", len(matches), len(m.details.Threads)))} if m.searching { - queryWidth := max(1, innerWidth-len("Find file: ")-1) + queryWidth := max(1, innerWidth-len("Filter: ")-1) query := ansi.Truncate(m.searchQuery, queryWidth, "…") - lines = append(lines, titleStyle.Render("Find file: ")+query+"█") + lines = append(lines, titleStyle.Render("Filter: ")+query+"█") } if m.loading && len(m.details.Threads) == 0 { lines = append(lines, "Loading…") } - matches := m.matchingThreadIndices() selectedPosition := 0 for position, index := range matches { if index == m.threadIndex { @@ -1024,8 +1614,8 @@ func (m App) threadList(width, height int) string { } available := max(1, innerHeight-len(lines)) start := windowStart(selectedPosition, len(matches), available) - if m.searching && len(matches) == 0 { - lines = append(lines, dimStyle.Render("No matching files.")) + if len(matches) == 0 && m.searchQuery != "" { + lines = append(lines, dimStyle.Render("No matching threads.")) } for _, i := range matches[start:min(len(matches), start+available)] { thread := m.details.Threads[i] @@ -1036,6 +1626,9 @@ func (m App) threadList(width, height int) string { icon = "○" } suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments)) + if m.unreadThreads[thread.ID] { + suffix += " " + warnStyle.Render("NEW") + } pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2) path := truncatePath(thread.Path, pathWidth) if m.pathScroll { @@ -1089,6 +1682,7 @@ type detailLine struct { rail string selected bool suggestionChange byte + anchor string } func (m App) detailLines(width int) []detailLine { @@ -1103,11 +1697,18 @@ func (m App) detailLines(width int) []detailLine { if thread.IsOutdated { status += ", outdated" } + if m.unreadThreads[thread.ID] { + status += ", new updates" + } if len(thread.Comments) > 0 && thread.Comments[0].OriginalCommitOID != "" { status += ", snapshot " + shortOID(thread.Comments[0].OriginalCommitOID) } + if pushedAt := latestForcePush(m.details.Timeline); !pushedAt.IsZero() && + threadOpenedAt(thread).Before(pushedAt) { + status += ", predates latest force-push" + } lines := []detailLine{ - {text: titleStyle.Render(fmt.Sprintf("[%d/%d] %s:%d", m.threadIndex+1, len(m.details.Threads), truncatePath(thread.Path, max(8, width-24)), thread.Line)) + " " + dimStyle.Render(status)}, + {anchor: "header", text: titleStyle.Render(fmt.Sprintf("[%d/%d] %s:%d", m.threadIndex+1, len(m.details.Threads), truncatePath(thread.Path, max(8, width-24)), thread.Line)) + " " + dimStyle.Render(status)}, } if m.folded[thread.ID] { lines = append(lines, detailLine{}, detailLine{text: dimStyle.Render("Thread folded. Press za or enter to expand.")}) @@ -1115,21 +1716,29 @@ func (m App) detailLines(width int) []detailLine { if len(thread.Comments) > 0 { lines = append(lines, detailLine{}) startLine, endLine := reviewAnchor(thread) - for _, codeLine := range highlightDiff(thread.Path, thread.Comments[0].DiffHunk, startLine, endLine, thread.DiffSide) { - lines = append(lines, wrapDiffLine(codeLine, max(1, width-2))...) + for codeIndex, codeLine := range highlightDiff(thread.Path, thread.Comments[0].DiffHunk, startLine, endLine, thread.DiffSide) { + wrapped := wrapDiffLine(codeLine, max(1, width-2)) + for wrapIndex := range wrapped { + wrapped[wrapIndex].anchor = fmt.Sprintf("code:%d:%d", codeIndex, wrapIndex) + } + lines = append(lines, wrapped...) } } for _, comment := range thread.Comments { content := parseCommentBody(comment.Body) rail := lipgloss.NewStyle().Foreground(authorColor(comment.Author)).Render("│ ") lines = append(lines, detailLine{}, detailLine{ - rail: rail, + rail: rail, + anchor: "comment:" + comment.ID + ":header", text: authorStyle(comment.Author).Render("@"+comment.Author) + " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")), }) if content.Prose != "" { - for _, commentLine := range renderCommentMarkdown(content.Prose, max(10, width-4)) { - lines = append(lines, detailLine{rail: rail, text: commentLine}) + for lineIndex, commentLine := range renderCommentMarkdown(content.Prose, max(10, width-4)) { + lines = append(lines, detailLine{ + rail: rail, text: commentLine, + anchor: fmt.Sprintf("comment:%s:body:%d", comment.ID, lineIndex), + }) } } for suggestionIndex, suggestion := range content.Suggestions { @@ -1163,6 +1772,38 @@ func (m App) detailLines(width int) []detailLine { return lines } +func latestForcePush(events []TimelineEvent) time.Time { + var latest time.Time + for _, event := range events { + if event.Kind == "force-push" && event.CreatedAt.After(latest) { + latest = event.CreatedAt + } + } + return latest +} + +func (m App) detailScrollAnchor() string { + width, _ := m.detailPaneSize() + lines := m.detailLines(width) + for index := min(m.scroll, len(lines)-1); index >= 0; index-- { + if lines[index].anchor != "" { + return lines[index].anchor + } + } + return "" +} + +func (m *App) restoreDetailAnchor(anchor string) { + width, _ := m.detailPaneSize() + for index, line := range m.detailLines(width) { + if line.anchor == anchor { + m.scroll = min(index, m.detailMaxScroll()) + return + } + } + m.scroll = min(m.scroll, m.detailMaxScroll()) +} + func wrapDiffLine(line highlightedDiffLine, width int) []detailLine { gutterWidth := ansi.StringWidth(line.gutter) if gutterWidth == 0 { @@ -1222,6 +1863,9 @@ func highlightCodeRange(code string, changed codeRange, change byte) string { if change == '+' { background = changedAddBackground } + if background == "" { + return code + } const reset = "\x1b[0m" before := ansi.Cut(code, 0, start) middle := ansi.Cut(code, start, end) @@ -1313,6 +1957,9 @@ func selectedBackground(line string, width int) string { const reset = "\x1b[0m" background := selectedLineBackground line = pad(ansi.Truncate(line, width, ""), width) + if background == "" { + return line + } line = strings.ReplaceAll(line, reset, reset+background) return background + line + reset } @@ -1322,6 +1969,9 @@ func suggestionHighlight(gutter, code string, width int, change byte) string { if change == '+' { background = suggestionAddBackground } + if background == "" { + return pad(ansi.Truncate(gutter+code, width, ""), width) + } const reset = "\x1b[0m" gutter = ansi.Truncate(gutter, width, "") gutter = strings.ReplaceAll(gutter, reset, reset+background) @@ -1394,6 +2044,14 @@ func reviewAndMergeState(pr PRDetails) string { switch pr.ReviewDecision { case "APPROVED": review := okStyle.Render("review: approved") + open, outdated, _ := threadStatusCounts(pr.Threads) + if pr.Requirements.RequiresConversation && open+outdated > 0 { + return review + " " + warnStyle.Render("merge: unresolved conversations") + } + if pr.Requirements.RequiresStatusChecks && + (pr.CheckState == "FAILURE" || pr.CheckState == "ERROR") { + return review + " " + badStyle.Render("merge: checks failing") + } switch pr.Mergeable { case "MERGEABLE": return review + " " + okStyle.Render("merge: ready") @@ -1413,9 +2071,9 @@ func reviewAndMergeState(pr PRDetails) string { func coloredState(state string) string { switch state { - case "SUCCESS", "EXPECTED": + case "SUCCESS", "EXPECTED", "COMPLETED", "NEUTRAL", "SKIPPED": return okStyle.Render(state) - case "FAILURE", "ERROR": + case "FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STALE": return badStyle.Render(state) default: return warnStyle.Render(state) diff --git a/tui_test.go b/tui_test.go index 96ce1e0..a272ed6 100644 --- a/tui_test.go +++ b/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) diff --git a/types.go b/types.go index 8620320..d427735 100644 --- a/types.go +++ b/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 {