initial working read only state

This commit is contained in:
2026-07-27 14:03:54 +02:00
commit b0ac2f3e9a
13 changed files with 2286 additions and 0 deletions

67
README.md Normal file
View File

@@ -0,0 +1,67 @@
# gh-threads
A read-only terminal UI for people receiving GitHub pull-request reviews. It
shows open PRs, review threads with highlighted diff hunks and comment authors,
reviewer/assignee state, and the latest commit's check rollup. Resolved threads
start folded. The current PR is refreshed in the background.
## Install and run
Requires Go 1.24+ and an authenticated GitHub CLI:
```sh
go install .
gh auth login
gh-threads --repo owner/repository
```
To run directly from a source checkout instead:
```sh
go run . --repo owner/repository
```
Use `go run .`, not `go run main.go`: the latter compiles only `main.go` and
omits the other files in the package.
For automation, `GH_TOKEN` or `GITHUB_TOKEN` can still be provided and takes
precedence over the GitHub CLI credential. Enterprise token environment
variables are also supported.
By default the PR picker only includes open PRs authored by the authenticated
user. Pass `--all` to include every open PR:
```sh
gh-threads --repo owner/repository --all --poll 15s
```
GitHub Enterprise Server can be used after authenticating that host:
```sh
gh auth login --hostname github.example.com
gh-threads --repo owner/repository \
--endpoint https://github.example.com/api/graphql
```
## Keys
| Key | Action |
| --- | --- |
| `h` / `l` | Focus the thread list / thread detail |
| `j` / `k` | Move between threads or scroll the focused detail |
| `g` / `G` | First / last item |
| `enter` / `l` | Open a PR |
| `enter` | Toggle the selected review thread |
| `za` | Toggle the selected thread |
| `ctrl-d` / `ctrl-u` | Scroll thread detail or page through lists |
| `tab` | Hide or reveal the thread list |
| `b` / `esc` | Return to the PR picker |
| `r` | Refresh now |
| `q` | Quit |
## 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. Markdown in comments is
displayed as readable wrapped text rather than fully rendered Markdown.

66
auth.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"strings"
"time"
)
type commandRunner func(context.Context, string, ...string) ([]byte, error)
func resolveToken(endpoint string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return resolveTokenWith(ctx, endpoint, os.Getenv, runCommand)
}
func resolveTokenWith(ctx context.Context, endpoint string, getenv func(string) string, run commandRunner) (string, error) {
if token := firstNonEmpty(
getenv("GH_TOKEN"),
getenv("GITHUB_TOKEN"),
getenv("GH_ENTERPRISE_TOKEN"),
getenv("GITHUB_ENTERPRISE_TOKEN"),
); token != "" {
return token, nil
}
host, err := githubHost(endpoint)
if err != nil {
return "", err
}
output, err := run(ctx, "gh", "auth", "token", "--hostname", host)
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return "", errors.New("no environment token found and gh is not installed; install gh and run `gh auth login`, or set GH_TOKEN")
}
if ctx.Err() != nil {
return "", fmt.Errorf("read authentication from gh: %w", ctx.Err())
}
return "", fmt.Errorf("no usable authentication for %s; run `gh auth login --hostname %s`, or set GH_TOKEN: %w", host, host, err)
}
token := strings.TrimSpace(string(output))
if token == "" {
return "", fmt.Errorf("gh returned an empty token for %s; run `gh auth login --hostname %s`", host, host)
}
return token, nil
}
func githubHost(endpoint string) (string, error) {
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" {
return "", fmt.Errorf("invalid GitHub GraphQL endpoint %q", endpoint)
}
if parsed.Hostname() == "api.github.com" {
return "github.com", nil
}
return parsed.Hostname(), nil
}
func runCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).Output()
}

98
auth_test.go Normal file
View File

@@ -0,0 +1,98 @@
package main
import (
"context"
"errors"
"reflect"
"strings"
"testing"
)
func TestResolveTokenPrefersEnvironment(t *testing.T) {
called := false
token, err := resolveTokenWith(
context.Background(),
"https://api.github.com/graphql",
func(name string) string {
if name == "GH_TOKEN" {
return "from-environment"
}
return ""
},
func(context.Context, string, ...string) ([]byte, error) {
called = true
return nil, nil
},
)
if err != nil {
t.Fatal(err)
}
if token != "from-environment" {
t.Fatalf("token = %q", token)
}
if called {
t.Fatal("gh was called even though an environment token was available")
}
}
func TestResolveTokenFallsBackToActiveGHAccount(t *testing.T) {
var command string
var args []string
token, err := resolveTokenWith(
context.Background(),
"https://api.github.com/graphql",
func(string) string { return "" },
func(_ context.Context, name string, values ...string) ([]byte, error) {
command, args = name, values
return []byte("from-gh\n"), nil
},
)
if err != nil {
t.Fatal(err)
}
if token != "from-gh" {
t.Fatalf("token = %q", token)
}
if command != "gh" || !reflect.DeepEqual(args, []string{"auth", "token", "--hostname", "github.com"}) {
t.Fatalf("command = %q, args = %#v", command, args)
}
}
func TestResolveTokenUsesEnterpriseHostname(t *testing.T) {
var args []string
_, err := resolveTokenWith(
context.Background(),
"https://github.example.com/api/graphql",
func(string) string { return "" },
func(_ context.Context, _ string, values ...string) ([]byte, error) {
args = values
return []byte("token"), nil
},
)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(args, []string{"auth", "token", "--hostname", "github.example.com"}) {
t.Fatalf("args = %#v", args)
}
}
func TestResolveTokenExplainsGHAuthenticationFailure(t *testing.T) {
_, err := resolveTokenWith(
context.Background(),
"https://api.github.com/graphql",
func(string) string { return "" },
func(context.Context, string, ...string) ([]byte, error) {
return nil, errors.New("exit status 4")
},
)
if err == nil || !strings.Contains(err.Error(), "gh auth login --hostname github.com") {
t.Fatalf("error = %v", err)
}
}
func TestGitHubHostRejectsInvalidEndpoint(t *testing.T) {
if _, err := githubHost("not-a-url"); err == nil {
t.Fatal("expected invalid endpoint error")
}
}

347
github.go Normal file
View File

@@ -0,0 +1,347 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)
type GitHubService interface {
ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error)
GetPullRequest(context.Context, string, string, int) (PRDetails, error)
}
type GitHubClient struct {
endpoint string
token string
http *http.Client
}
func NewGitHubClient(endpoint, token string) *GitHubClient {
return &GitHubClient{
endpoint: endpoint,
token: token,
http: &http.Client{Timeout: 20 * time.Second},
}
}
type graphQLRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
type graphQLError struct {
Message string `json:"message"`
}
type graphQLResponse[T any] struct {
Data T `json:"data"`
Errors []graphQLError `json:"errors"`
}
func (c *GitHubClient) query(ctx context.Context, query string, variables map[string]any, target any) error {
payload, err := json.Marshal(graphQLRequest{Query: query, Variables: variables})
if err != nil {
return fmt.Errorf("encode GraphQL request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("create GraphQL request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "gh-threads")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("GitHub request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return fmt.Errorf("read GitHub response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
envelope := graphQLResponse[json.RawMessage]{}
if err := json.Unmarshal(body, &envelope); err != nil {
return fmt.Errorf("decode GitHub response: %w", err)
}
if len(envelope.Errors) > 0 {
messages := make([]string, len(envelope.Errors))
for i, item := range envelope.Errors {
messages[i] = item.Message
}
return errors.New(strings.Join(messages, "; "))
}
if err := json.Unmarshal(envelope.Data, target); err != nil {
return fmt.Errorf("decode GitHub data: %w", err)
}
return nil
}
const listPRsQuery = `
query PullRequests($owner: String!, $name: String!, $limit: Int!) {
viewer { login }
repository(owner: $owner, name: $name) {
pullRequests(first: $limit, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
id number title url isDraft updatedAt
author { login }
reviewThreads(first: 1) { totalCount }
}
}
}
}`
func (c *GitHubClient) ListPullRequests(ctx context.Context, owner, name string, limit int, showAll bool) ([]PullRequest, error) {
var data struct {
Viewer struct {
Login string `json:"login"`
} `json:"viewer"`
Repository *struct {
PullRequests 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"`
ReviewThreads struct {
TotalCount int `json:"totalCount"`
} `json:"reviewThreads"`
} `json:"nodes"`
} `json:"pullRequests"`
} `json:"repository"`
}
if err := c.query(ctx, listPRsQuery, map[string]any{"owner": owner, "name": name, "limit": limit}, &data); err != nil {
return nil, err
}
if data.Repository == nil {
return nil, fmt.Errorf("repository %s/%s was not found or is not accessible", owner, name)
}
prs := make([]PullRequest, 0, len(data.Repository.PullRequests.Nodes))
for _, node := range data.Repository.PullRequests.Nodes {
author := "[ghost]"
if node.Author != nil {
author = node.Author.Login
}
mine := author == data.Viewer.Login
if !showAll && !mine {
continue
}
prs = append(prs, PullRequest{
ID: node.ID, Number: node.Number, Title: node.Title, URL: node.URL,
Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt,
ReviewCount: node.ReviewThreads.TotalCount, ViewerAuthored: mine,
})
}
return prs, nil
}
const detailsQuery = `
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
id number title url body isDraft updatedAt mergeable reviewDecision
baseRefName headRefName
author { login }
assignees(first: 20) { nodes { login } }
reviewRequests(first: 50) {
nodes {
requestedReviewer {
... on User { login }
... on Team { name }
}
}
}
latestReviews(first: 50) { nodes { state author { login } } }
commits(last: 1) {
nodes { commit { statusCheckRollup { state } } }
}
reviewThreads(first: 100) {
pageInfo { hasNextPage }
nodes {
id isResolved isOutdated path
line originalLine diffSide
startLine originalStartLine startDiffSide
comments(first: 100) {
pageInfo { hasNextPage }
nodes {
id body diffHunk createdAt url outdated
line startLine originalLine originalStartLine
originalCommit { oid }
author { login }
}
}
}
}
}
}
}`
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"`
}
var data struct {
Repository *struct {
PullRequest *struct {
ID, Title, URL, Body, Mergeable, ReviewDecision, BaseRefName, HeadRefName string
Number int
IsDraft bool
UpdatedAt time.Time
Author *actor
Assignees struct {
Nodes []actor `json:"nodes"`
}
ReviewRequests struct {
Nodes []struct {
RequestedReviewer actor `json:"requestedReviewer"`
} `json:"nodes"`
}
LatestReviews struct {
Nodes []struct {
State string
Author *actor
} `json:"nodes"`
}
Commits struct {
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"`
} `json:"repository"`
}
if err := c.query(ctx, detailsQuery, map[string]any{"owner": owner, "name": name, "number": number}, &data); err != nil {
return PRDetails{}, err
}
if data.Repository == nil || data.Repository.PullRequest == nil {
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
}
details := PRDetails{
PullRequest: PullRequest{
ID: node.ID, Number: node.Number, Title: node.Title, URL: node.URL,
Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt,
ReviewCount: len(node.ReviewThreads.Nodes),
},
Body: node.Body, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
Mergeable: node.Mergeable, ThreadsTruncated: node.ReviewThreads.PageInfo.HasNextPage,
CheckState: "NONE", ReviewDecision: node.ReviewDecision,
}
for _, assignee := range node.Assignees.Nodes {
details.Assignees = append(details.Assignees, assignee.Login)
}
reviewers := map[string]string{}
for _, request := range node.ReviewRequests.Nodes {
login := firstNonEmpty(request.RequestedReviewer.Login, request.RequestedReviewer.Name)
if login != "" {
reviewers[login] = "REVIEW_REQUESTED"
}
}
for _, review := range node.LatestReviews.Nodes {
if review.Author != nil {
reviewers[review.Author.Login] = review.State
}
}
for login, state := range reviewers {
details.Reviewers = append(details.Reviewers, Reviewer{Login: login, State: state})
}
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
}
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.IsTruncated = thread.Comments.PageInfo.HasNextPage
details.Threads = append(details.Threads, item)
}
return details, nil
}
func intValue(value *int) int {
if value == nil {
return 0
}
return *value
}
func commitOID(commit *struct{ OID string }) string {
if commit == nil {
return ""
}
return commit.OID
}

91
github_test.go Normal file
View File

@@ -0,0 +1,91 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestListPullRequestsFiltersToViewer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
t.Fatalf("authorization = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{
"viewer": map[string]any{"login": "zam"},
"repository": map[string]any{"pullRequests": map[string]any{"nodes": []any{
map[string]any{"id": "1", "number": 1, "title": "mine", "url": "u", "isDraft": false, "updatedAt": "2026-01-01T00:00:00Z", "author": map[string]any{"login": "zam"}, "reviewThreads": map[string]any{"totalCount": 2}},
map[string]any{"id": "2", "number": 2, "title": "theirs", "url": "u", "isDraft": false, "updatedAt": "2026-01-01T00:00:00Z", "author": map[string]any{"login": "other"}, "reviewThreads": map[string]any{"totalCount": 1}},
}}},
}})
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
prs, err := client.ListPullRequests(context.Background(), "o", "r", 50, false)
if err != nil {
t.Fatal(err)
}
if len(prs) != 1 || prs[0].Number != 1 || prs[0].ReviewCount != 2 {
t.Fatalf("unexpected PRs: %#v", prs)
}
}
func TestGraphQLErrorsAreReturned(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"errors":[{"message":"no access"}]}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
_, err := client.ListPullRequests(context.Background(), "o", "r", 50, false)
if err == nil || !strings.Contains(err.Error(), "no access") {
t.Fatalf("error = %v", err)
}
}
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{
"id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false,
"updatedAt":"2026-01-01T00:00:00Z","mergeable":"MERGEABLE","reviewDecision":"APPROVED",
"baseRefName":"main","headRefName":"fix","author":{"login":"zam"},
"assignees":{"nodes":[{"login":"sam"}]},
"reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]},
"latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]},
"commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE"}}}]},
"reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{
"id":"t","isResolved":false,"isOutdated":true,"path":"main.go",
"line":null,"originalLine":42,"diffSide":"RIGHT",
"startLine":null,"originalStartLine":40,"startDiffSide":"RIGHT",
"comments":{"pageInfo":{"hasNextPage":true},"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,
"originalCommit":{"oid":"0123456789abcdef"}
}]}
}]}
}}}}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
got, err := client.GetPullRequest(context.Background(), "o", "r", 9)
if err != nil {
t.Fatal(err)
}
if got.CheckState != "FAILURE" || got.BaseRef != "main" || got.HeadRef != "fix" || got.ReviewDecision != "APPROVED" {
t.Fatalf("unexpected metadata: %#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 {
t.Fatalf("unexpected thread: %#v", got.Threads)
}
comment := got.Threads[0].Comments[0]
if comment.OriginalLine != 42 || comment.OriginalStartLine != 40 ||
comment.OriginalCommitOID != "0123456789abcdef" || !comment.Outdated {
t.Fatalf("unexpected comment snapshot: %#v", comment)
}
}

30
go.mod Normal file
View File

@@ -0,0 +1,30 @@
module git.pablu.de/Pablu/gh-threads
go 1.24.0
require (
github.com/alecthomas/chroma/v2 v2.20.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.10.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

53
go.sum Normal file
View File

@@ -0,0 +1,53 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

238
highlight.go Normal file
View File

@@ -0,0 +1,238 @@
package main
import (
"bytes"
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/alecthomas/chroma/v2/quick"
)
const reviewContextLines = 3
var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
type highlightedDiffLine struct {
gutter string
code string
selected bool
}
type parsedDiffLine struct {
raw string
oldLine int
newLine int
header bool
notice bool
selected bool
}
func highlightDiff(path, hunk string, startLine, endLine int, side string) []highlightedDiffLine {
if hunk == "" {
return []highlightedDiffLine{{code: "(GitHub did not return a diff hunk)"}}
}
if endLine <= 0 {
endLine = startLine
}
if startLine <= 0 {
startLine = endLine
}
if startLine > endLine {
startLine, endLine = endLine, startLine
}
parsed := parseDiff(hunk, startLine, endLine, side)
visible := reviewedWindow(parsed)
padding := commonIndent(visible)
lexer := lexerForPath(path)
out := make([]highlightedDiffLine, 0, len(visible))
for _, line := range visible {
if line.raw == "⋯" {
out = append(out, highlightedDiffLine{code: "\x1b[38;5;245m⋯\x1b[0m"})
continue
}
if line.notice {
out = append(out, highlightedDiffLine{code: "\x1b[38;5;245m" + line.raw + "\x1b[0m"})
continue
}
if line.header {
out = append(out, highlightedDiffLine{code: "\x1b[38;5;141m" + line.raw + "\x1b[0m"})
continue
}
out = append(out, renderDiffLine(lexer, line, padding))
}
return out
}
func parseDiff(hunk string, startLine, endLine int, side string) []parsedDiffLine {
oldLine, newLine := 0, 0
lines := make([]parsedDiffLine, 0, strings.Count(hunk, "\n")+1)
for _, raw := range strings.Split(hunk, "\n") {
if match := hunkHeaderPattern.FindStringSubmatch(raw); match != nil {
oldLine, _ = strconv.Atoi(match[1])
newLine, _ = strconv.Atoi(match[2])
lines = append(lines, parsedDiffLine{raw: raw, header: true})
continue
}
line := parsedDiffLine{raw: raw}
switch {
case strings.HasPrefix(raw, "+"):
line.newLine = newLine
newLine++
case strings.HasPrefix(raw, "-"):
line.oldLine = oldLine
oldLine++
case strings.HasPrefix(raw, `\`):
// "\ No newline at end of file" has no source coordinate.
default:
line.oldLine, line.newLine = oldLine, newLine
oldLine++
newLine++
}
coordinate := line.newLine
if side == "LEFT" {
coordinate = line.oldLine
}
line.selected = coordinate > 0 && coordinate >= startLine && coordinate <= endLine
lines = append(lines, line)
}
return lines
}
func reviewedWindow(lines []parsedDiffLine) []parsedDiffLine {
first, last := -1, -1
for i, line := range lines {
if line.selected {
if first == -1 {
first = i
}
last = i
}
}
if first == -1 {
out := make([]parsedDiffLine, 0, 2)
for _, line := range lines {
if line.header {
out = append(out, line)
break
}
}
return append(out, parsedDiffLine{
raw: "(original reviewed lines unavailable in GitHub's historical diff)",
notice: true,
})
}
start := max(0, first-reviewContextLines)
end := min(len(lines), last+reviewContextLines+1)
header := -1
for i := first; i >= 0; i-- {
if lines[i].header {
header = i
break
}
}
out := make([]parsedDiffLine, 0, end-start+3)
if header >= 0 && header < start {
out = append(out, lines[header])
}
if start > 0 && start != header {
out = append(out, parsedDiffLine{raw: "⋯"})
}
out = append(out, lines[start:end]...)
if end < len(lines) {
out = append(out, parsedDiffLine{raw: "⋯"})
}
return out
}
func renderDiffLine(lexer string, line parsedDiffLine, padding int) highlightedDiffLine {
marker, source := " ", line.raw
lineNumber := ""
markerStyle := "\x1b[38;5;245m"
switch {
case strings.HasPrefix(line.raw, "+"):
marker, source = "+", strings.TrimPrefix(line.raw, "+")
lineNumber = coordinateText(line.newLine)
markerStyle = "\x1b[38;5;114m"
case strings.HasPrefix(line.raw, "-"):
marker, source = "-", strings.TrimPrefix(line.raw, "-")
lineNumber = coordinateText(line.oldLine)
markerStyle = "\x1b[38;5;203m"
default:
source = strings.TrimPrefix(line.raw, " ")
lineNumber = coordinateText(line.newLine)
if lineNumber == "" {
lineNumber = coordinateText(line.oldLine)
}
}
source = strings.ReplaceAll(source, "\t", " ")
source = trimIndent(source, padding)
return highlightedDiffLine{
gutter: fmt.Sprintf(
"\x1b[38;5;245m%5s\x1b[0m %s%s\x1b[0m ",
lineNumber, markerStyle, marker,
),
code: highlightedSource(lexer, source),
selected: line.selected,
}
}
func commonIndent(lines []parsedDiffLine) int {
padding := -1
for _, line := range lines {
if line.header || line.notice || line.raw == "⋯" || strings.HasPrefix(line.raw, `\`) {
continue
}
source := line.raw
if strings.HasPrefix(source, "+") || strings.HasPrefix(source, "-") || strings.HasPrefix(source, " ") {
source = source[1:]
}
source = strings.ReplaceAll(source, "\t", " ")
if strings.TrimSpace(source) == "" {
continue
}
indent := len(source) - len(strings.TrimLeft(source, " "))
if padding == -1 || indent < padding {
padding = indent
}
}
return max(0, padding)
}
func trimIndent(source string, padding int) string {
for padding > 0 && strings.HasPrefix(source, " ") {
source = source[1:]
padding--
}
return source
}
func coordinateText(line int) string {
if line <= 0 {
return ""
}
return strconv.Itoa(line)
}
func highlightedSource(lexer, source string) string {
var highlighted bytes.Buffer
if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", "github-dark"); err != nil {
return source
}
return strings.TrimSuffix(highlighted.String(), "\n")
}
func lexerForPath(path string) string {
ext := strings.TrimPrefix(filepath.Ext(path), ".")
if ext == "" {
return "plaintext"
}
return ext
}

108
highlight_test.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"fmt"
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestHighlightDiffAddsOldAndNewLineNumbers(t *testing.T) {
lines := highlightDiff("main.go", "@@ -10,2 +20,2 @@\n unchanged\n-old\n+new", 20, 20, "RIGHT")
rendered := make([]string, 0, len(lines))
for _, line := range lines {
rendered = append(rendered, line.gutter+line.code)
}
plain := ansi.Strip(strings.Join(rendered, "\n"))
for _, want := range []string{"20 unchanged", "11 - old", "21 + new"} {
if !strings.Contains(plain, want) {
t.Fatalf("diff did not contain %q:\n%s", want, plain)
}
}
if !lines[1].selected {
t.Fatal("reviewed line was not marked for background highlighting")
}
}
func TestHighlightDiffOnlyShowsReviewedRangeAndContext(t *testing.T) {
var hunk strings.Builder
hunk.WriteString("@@ -1,30 +1,30 @@")
for i := 1; i <= 30; i++ {
fmt.Fprintf(&hunk, "\n line %d", i)
}
lines := highlightDiff("main.go", hunk.String(), 20, 20, "RIGHT")
rendered := make([]string, 0, len(lines))
for _, line := range lines {
rendered = append(rendered, line.gutter+line.code)
}
plain := ansi.Strip(strings.Join(rendered, "\n"))
if strings.Contains(plain, "line 1\n") || strings.Contains(plain, "line 30") {
t.Fatalf("unrelated parts of the hunk were rendered:\n%s", plain)
}
for _, want := range []string{"line 17", "line 20", "line 23"} {
if !strings.Contains(plain, want) {
t.Fatalf("review window did not contain %q:\n%s", want, plain)
}
}
selected := 0
for _, line := range lines {
if line.selected {
selected++
}
}
if selected != 1 {
t.Fatalf("selected lines = %d, want 1", selected)
}
if len(lines) > 11 {
t.Fatalf("review window contains %d lines, expected a compact excerpt", len(lines))
}
}
func TestHighlightDiffTargetsDeletedLineOnLeft(t *testing.T) {
lines := highlightDiff("main.go", "@@ -8,3 +8,2 @@\n keep\n-remove\n keep", 9, 9, "LEFT")
var plain strings.Builder
selected := false
for _, line := range lines {
plain.WriteString(ansi.Strip(line.gutter + line.code))
plain.WriteByte('\n')
if line.selected && strings.Contains(ansi.Strip(line.code), "remove") {
selected = true
}
}
if !strings.Contains(plain.String(), "9 - remove") || !selected {
t.Fatalf("deleted review line was not selected:\n%s", plain.String())
}
}
func TestHighlightDiffNeverFallsBackToUnrelatedCode(t *testing.T) {
lines := highlightDiff("main.go", "@@ -1,2 +1,2 @@\n unrelated one\n unrelated two", 100, 100, "RIGHT")
var plain strings.Builder
for _, line := range lines {
plain.WriteString(ansi.Strip(line.gutter + line.code))
plain.WriteByte('\n')
}
if strings.Contains(plain.String(), "unrelated one") ||
!strings.Contains(plain.String(), "original reviewed lines unavailable") {
t.Fatalf("viewer substituted unrelated code:\n%s", plain.String())
}
}
func TestHighlightDiffRemovesOnlyCommonIndent(t *testing.T) {
lines := highlightDiff(
"example.py",
"@@ -10,3 +10,3 @@\n if ready:\n run()\n finish()",
10, 12, "RIGHT",
)
var code []string
for _, line := range lines {
if line.gutter != "" {
code = append(code, ansi.Strip(line.code))
}
}
if got, want := strings.Join(code, "\n"), "if ready:\n run()\nfinish()"; got != want {
t.Fatalf("dedented code:\n%q\nwant:\n%q", got, want)
}
}

60
main.go Normal file
View File

@@ -0,0 +1,60 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
var (
repo = flag.String("repo", os.Getenv("GH_REPO"), "GitHub repository as owner/name (or GH_REPO)")
poll = flag.Duration("poll", 10*time.Second, "refresh interval")
showAll = flag.Bool("all", false, "show all open PRs, not only PRs authored by you")
limit = flag.Int("limit", 50, "maximum open PRs to load (1-100)")
endpoint = flag.String("endpoint", "https://api.github.com/graphql", "GitHub GraphQL endpoint")
)
flag.Parse()
parts := strings.Split(*repo, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
exitf("--repo owner/name (or GH_REPO) is required")
}
if *limit < 1 || *limit > 100 {
exitf("--limit must be between 1 and 100")
}
if *poll < 2*time.Second {
exitf("--poll must be at least 2s")
}
token, err := resolveToken(*endpoint)
if err != nil {
exitf("authenticate: %v", err)
}
client := NewGitHubClient(*endpoint, token)
app := NewApp(client, parts[0], parts[1], *showAll, *limit, *poll)
if _, err := tea.NewProgram(app, tea.WithAltScreen()).Run(); err != nil {
exitf("run TUI: %v", err)
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func exitf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "gh-threads: "+format+"\n", args...)
os.Exit(1)
}
// Keep interface drift visible at compile time.
var _ GitHubService = (*GitHubClient)(nil)

791
tui.go Normal file
View File

@@ -0,0 +1,791 @@
package main
import (
"context"
"fmt"
"hash/fnv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
type screen int
const (
prScreen screen = iota
threadScreen
)
type pane int
const (
threadListPane pane = iota
threadDetailPane
)
type tickMsg time.Time
type prsLoadedMsg struct {
prs []PullRequest
err error
}
type detailsLoadedMsg struct {
number int
details PRDetails
err error
}
type App struct {
service GitHubService
owner, repo string
showAll bool
limit int
poll time.Duration
screen screen
prs []PullRequest
prIndex int
details PRDetails
threadIndex int
folded map[string]bool
focus pane
listHidden bool
scroll int
width, height int
loading bool
err error
lastRefresh time.Time
pendingZ bool
}
func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App {
return App{
service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll,
folded: make(map[string]bool), loading: true,
}
}
func (m App) Init() tea.Cmd {
return tea.Batch(m.loadPRs(), m.nextTick())
}
func (m App) nextTick() tea.Cmd {
return tea.Tick(m.poll, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m App) loadPRs() 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)
return prsLoadedMsg{prs: prs, err: err}
}
}
func (m App) loadDetails(number int) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
details, err := m.service.GetPullRequest(ctx, m.owner, m.repo, number)
return detailsLoadedMsg{number: number, details: details, err: err}
}
}
func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tickMsg:
if !m.loading {
m.loading = true
if m.screen == threadScreen && m.details.Number != 0 {
return m, tea.Batch(m.loadDetails(m.details.Number), m.nextTick())
}
return m, tea.Batch(m.loadPRs(), m.nextTick())
}
return m, m.nextTick()
case prsLoadedMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err
return m, nil
}
selected := 0
if len(m.prs) > 0 && m.prIndex < len(m.prs) {
selected = m.prs[m.prIndex].Number
}
m.prs = msg.prs
m.prIndex = indexPR(m.prs, selected)
m.err = nil
m.lastRefresh = time.Now()
case detailsLoadedMsg:
m.loading = false
if msg.number != m.details.Number && m.details.Number != 0 {
return m, nil
}
if msg.err != nil {
m.err = msg.err
return m, nil
}
selected := ""
if m.threadIndex < len(m.details.Threads) {
selected = m.details.Threads[m.threadIndex].ID
}
m.details = msg.details
m.threadIndex = indexThread(m.details.Threads, selected)
if selected != "" && (len(m.details.Threads) == 0 || m.details.Threads[m.threadIndex].ID != selected) {
m.scroll = 0
}
for _, thread := range m.details.Threads {
if _, set := m.folded[thread.ID]; !set && thread.IsResolved {
m.folded[thread.ID] = true
}
}
m.scroll = min(m.scroll, m.detailMaxScroll())
m.err = nil
m.lastRefresh = time.Now()
}
key, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
k := key.String()
if k == "ctrl+c" || k == "q" {
return m, tea.Quit
}
if m.pendingZ {
m.pendingZ = false
if k == "a" && m.screen == threadScreen && len(m.details.Threads) > 0 {
thread := m.details.Threads[m.threadIndex]
m.folded[thread.ID] = !m.folded[thread.ID]
m.scroll = 0
}
return m, nil
}
if k == "z" && m.screen == threadScreen {
m.pendingZ = true
return m, nil
}
switch k {
case "r":
if m.loading {
return m, nil
}
m.loading = true
if m.screen == threadScreen {
return m, m.loadDetails(m.details.Number)
}
return m, m.loadPRs()
case "j", "down":
if m.screen == threadScreen && m.focus == threadDetailPane {
m.scrollDetail(1)
} else {
m.move(1)
}
case "k", "up":
if m.screen == threadScreen && m.focus == threadDetailPane {
m.scrollDetail(-1)
} else {
m.move(-1)
}
case "g":
m.toStart()
case "G":
m.toEnd()
case "tab":
if m.screen == threadScreen {
m.listHidden = !m.listHidden
if m.listHidden {
m.focus = threadDetailPane
} else {
m.focus = threadListPane
}
m.scroll = 0
}
case "ctrl+d", "pgdown":
m.page(1)
case "ctrl+u", "pgup":
m.page(-1)
case "l":
if m.screen == prScreen && len(m.prs) > 0 {
m.screen = threadScreen
m.details = PRDetails{PullRequest: m.prs[m.prIndex]}
m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil
return m, m.loadDetails(m.details.Number)
}
if m.screen == threadScreen {
m.focus = threadDetailPane
}
case "h":
if m.screen == threadScreen {
m.focus = threadListPane
m.listHidden = false
}
case "enter":
if m.screen == prScreen && len(m.prs) > 0 {
m.screen = threadScreen
m.details = PRDetails{PullRequest: m.prs[m.prIndex]}
m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil
return m, m.loadDetails(m.details.Number)
}
if m.screen == threadScreen && len(m.details.Threads) > 0 {
thread := m.details.Threads[m.threadIndex]
m.folded[thread.ID] = !m.folded[thread.ID]
m.scroll = 0
}
case "b", "esc":
if m.screen == threadScreen {
m.screen, m.err, m.loading = prScreen, nil, true
return m, m.loadPRs()
}
}
return m, nil
}
func (m *App) move(delta int) {
if m.screen == prScreen {
m.prIndex = clamp(m.prIndex+delta, 0, len(m.prs)-1)
return
}
m.threadIndex = clamp(m.threadIndex+delta, 0, len(m.details.Threads)-1)
m.scroll = 0
}
func (m *App) toStart() {
if m.screen == prScreen {
m.prIndex = 0
} else if m.focus == threadDetailPane {
m.scroll = 0
} else {
m.threadIndex, m.scroll = 0, 0
}
}
func (m *App) toEnd() {
if m.screen == prScreen {
m.prIndex = max(0, len(m.prs)-1)
} else if m.focus == threadDetailPane {
m.scroll = m.detailMaxScroll()
} else {
m.threadIndex, m.scroll = max(0, len(m.details.Threads)-1), 0
}
}
func (m *App) page(direction int) {
if m.screen == threadScreen && m.focus == threadDetailPane {
m.scrollDetail(direction * max(3, m.detailViewportHeight()/2))
return
}
m.move(direction * max(3, m.height/2))
}
func (m *App) scrollDetail(delta int) {
m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll())
}
func (m App) detailPaneSize() (int, int) {
topLines := 3
if m.details.ThreadsTruncated {
topLines++
}
height := max(3, m.height-topLines-1)
if m.width < 70 || m.listHidden {
return max(3, m.width), height
}
leftWidth := clamp(m.width/3, 30, 48)
return max(20, m.width-leftWidth-1), height
}
func (m App) detailViewportHeight() int {
_, height := m.detailPaneSize()
return max(1, height-2)
}
func (m App) detailMaxScroll() int {
width, _ := m.detailPaneSize()
return max(0, len(m.detailLines(width))-m.detailViewportHeight())
}
func (m App) View() string {
if m.width == 0 {
return "Loading…"
}
if m.screen == prScreen {
return m.viewPRs()
}
return m.viewThreads()
}
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
)
func paneStyle(active bool) lipgloss.Style {
color := lipgloss.Color("#50566F")
if active {
color = lipgloss.Color("#F0B72F")
}
return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(color)
}
func (m App) viewPRs() string {
header := titleStyle.Render("gh-threads") + " " + m.owner + "/" + m.repo
if !m.showAll {
header += dimStyle.Render(" authored by you")
}
lines := []string{header, ""}
if m.loading && len(m.prs) == 0 {
lines = append(lines, "Loading open pull requests…")
} else if len(m.prs) == 0 && m.err == nil {
lines = append(lines, "No matching open pull requests.")
}
available := max(1, m.height-6)
start := windowStart(m.prIndex, len(m.prs), available)
for i := start; i < min(len(m.prs), start+available); i++ {
pr := m.prs[i]
draft := ""
if pr.IsDraft {
draft = " DRAFT"
}
line := fmt.Sprintf("#%-5d %-*s %2d threads%s", pr.Number, max(10, m.width-34), truncate(pr.Title, max(10, m.width-34)), pr.ReviewCount, draft)
if i == m.prIndex {
line = activeStyle.Render(line)
}
lines = append(lines, line)
}
return m.frame(lines, "j/k move • enter/l open • g/G top/bottom • r refresh • q quit")
}
func (m App) viewThreads() string {
pr := m.details
header := titleStyle.Render(fmt.Sprintf("#%d %s", pr.Number, truncate(pr.Title, max(10, m.width-10))))
meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr))
people := "assignees: " + joinOrNone(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers)
top := []string{header, meta, people}
if pr.ThreadsTruncated {
top = append(top, warnStyle.Render("Showing the first 100 review threads."))
}
contentHeight := max(3, m.height-len(top)-1)
var body string
if m.width < 70 {
if m.focus == threadListPane {
body = m.threadList(max(3, m.width), contentHeight)
} else {
body = m.threadDetail(max(3, m.width), contentHeight)
}
} else if m.listHidden {
body = m.threadDetail(m.width, contentHeight)
} else {
leftWidth := clamp(m.width/3, 30, 48)
rightWidth := max(20, m.width-leftWidth-1)
left := m.threadList(leftWidth, contentHeight)
right := m.threadDetail(rightWidth, contentHeight)
body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
}
return m.frame(append(top, body), "tab list • h/l focus • j/k move/scroll • ctrl-d/u page • za fold • b back • q quit")
}
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)))}
if m.loading && len(m.details.Threads) == 0 {
lines = append(lines, "Loading…")
}
available := max(1, innerHeight-1)
start := windowStart(m.threadIndex, len(m.details.Threads), available)
for i := start; i < min(len(m.details.Threads), start+available); i++ {
thread := m.details.Threads[i]
icon := "●"
if thread.IsResolved {
icon = "✓"
}
if thread.IsOutdated {
icon = "○"
}
suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments))
pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2)
line := icon + " " + pad(truncatePath(thread.Path, pathWidth), pathWidth) + suffix
line = ansi.Truncate(line, innerWidth, "")
if thread.IsResolved {
line = dimStyle.Render(line)
}
if i == m.threadIndex {
line = activeStyle.Render(pad(line, innerWidth))
}
lines = append(lines, line)
}
return renderPane(lines, width, height, m.focus == threadListPane)
}
func (m App) threadDetail(width, height int) string {
if len(m.details.Threads) == 0 {
return renderPane([]string{"No review threads."}, width, height, m.focus == threadDetailPane)
}
lines := m.detailLines(width)
viewportHeight := max(1, height-2)
maxScroll := max(0, len(lines)-viewportHeight)
scroll := min(m.scroll, maxScroll)
visible := lines[scroll:min(len(lines), scroll+viewportHeight)]
rendered := make([]string, 0, len(visible))
innerWidth := max(1, width-2)
for _, line := range visible {
renderedLine := ansi.Truncate(line.fixed+line.text, innerWidth, "…")
if line.selected {
renderedLine = selectedBackground(renderedLine, innerWidth)
}
rendered = append(rendered, renderedLine)
}
return renderPane(rendered, width, height, m.focus == threadDetailPane)
}
type detailLine struct {
text string
fixed string
selected bool
}
func (m App) detailLines(width int) []detailLine {
if len(m.details.Threads) == 0 {
return nil
}
thread := m.details.Threads[m.threadIndex]
status := "open"
if thread.IsResolved {
status = "resolved"
}
if thread.IsOutdated {
status += ", outdated"
}
if len(thread.Comments) > 0 && thread.Comments[0].OriginalCommitOID != "" {
status += ", snapshot " + shortOID(thread.Comments[0].OriginalCommitOID)
}
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)},
}
if m.folded[thread.ID] {
lines = append(lines, detailLine{}, detailLine{text: dimStyle.Render("Thread folded. Press za or enter to expand.")})
} else {
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 _, comment := range thread.Comments {
lines = append(lines, detailLine{}, detailLine{text: authorStyle(comment.Author).Render("@"+comment.Author) + " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04"))})
for _, commentLine := range strings.Split(wrap(comment.Body, max(10, width-4)), "\n") {
lines = append(lines, detailLine{text: commentLine})
}
}
if thread.IsTruncated {
lines = append(lines, detailLine{}, detailLine{text: warnStyle.Render("Showing the first 100 comments in this thread.")})
}
}
return lines
}
func wrapDiffLine(line highlightedDiffLine, width int) []detailLine {
gutterWidth := ansi.StringWidth(line.gutter)
if gutterWidth == 0 {
wrapped := ansi.Hardwrap(line.code, width, true)
parts := strings.Split(wrapped, "\n")
result := make([]detailLine, 0, len(parts))
for _, part := range parts {
result = append(result, detailLine{text: part, selected: line.selected})
}
return result
}
codeWidth := max(1, width-gutterWidth)
parts := wrapCodeWithIndent(line.code, codeWidth)
result := make([]detailLine, 0, len(parts))
for i, part := range parts {
gutter := line.gutter
if i > 0 {
gutter = strings.Repeat(" ", gutterWidth)
}
result = append(result, detailLine{
text: part, fixed: gutter, selected: line.selected,
})
}
return result
}
func wrapCodeWithIndent(code string, width int) []string {
if width <= 0 || ansi.StringWidth(code) <= width {
return []string{code}
}
plain := ansi.Strip(code)
indent := len(plain) - len(strings.TrimLeft(plain, " "))
totalWidth := ansi.StringWidth(code)
continuationPrefix := strings.Repeat(" ", indent+2) + dimStyle.Render("↳ ")
continuationWidth := max(1, width-ansi.StringWidth(continuationPrefix))
parts := make([]string, 0, totalWidth/width+1)
offset := 0
for offset < totalWidth {
available := width
prefix := ""
if offset > 0 {
available = continuationWidth
prefix = continuationPrefix
}
end := syntaxBreakColumn(plain, offset, available)
if end <= offset {
end = min(totalWidth, offset+available)
}
parts = append(parts, prefix+ansi.Cut(code, offset, end))
offset = end
}
return parts
}
func syntaxBreakColumn(code string, start, width int) int {
limit := start + width
column := 0
best := -1
for _, r := range code {
column += ansi.StringWidth(string(r))
if column <= start {
continue
}
if column > limit {
break
}
if isCodeBreakpoint(r) {
best = column
}
}
if best > start {
return best
}
return min(ansi.StringWidth(code), limit)
}
func isCodeBreakpoint(r rune) bool {
if r == ' ' || r == '\t' {
return true
}
return strings.ContainsRune(",.;:()[]{}+-*/%=<>|&", r)
}
func reviewAnchor(thread ReviewThread) (int, int) {
if len(thread.Comments) > 0 {
comment := thread.Comments[0]
end := comment.OriginalLine
start := comment.OriginalStartLine
if end == 0 {
end = comment.Line
start = comment.StartLine
}
if end > 0 {
if start == 0 {
start = end
}
return start, end
}
}
start := thread.StartLine
if start == 0 {
start = thread.Line
}
return start, thread.Line
}
func shortOID(oid string) string {
if len(oid) <= 7 {
return oid
}
return oid[:7]
}
func selectedBackground(line string, width int) string {
const (
background = "\x1b[48;5;24m"
reset = "\x1b[0m"
)
line = pad(ansi.Truncate(line, width, ""), width)
line = strings.ReplaceAll(line, reset, reset+background)
return background + line + reset
}
var authorPalette = []lipgloss.Color{
"#61AFEF", "#C678DD", "#56B6C2", "#E5C07B",
"#E06C75", "#98C379", "#D19A66", "#7FC8FF",
}
func authorStyle(login string) lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Foreground(authorColor(login))
}
func authorColor(login string) lipgloss.Color {
hash := fnv.New32a()
_, _ = hash.Write([]byte(strings.ToLower(login)))
return authorPalette[int(hash.Sum32())%len(authorPalette)]
}
func (m App) frame(lines []string, help string) string {
body := strings.Join(lines, "\n")
status := ""
if m.err != nil {
status = badStyle.Render("error: " + truncate(m.err.Error(), max(20, m.width-8)))
}
if m.loading {
status = warnStyle.Render("refreshing…")
}
if status == "" && !m.lastRefresh.IsZero() {
status = dimStyle.Render("updated " + m.lastRefresh.Format("15:04:05"))
}
footer := truncate(help, m.width)
if status != "" {
footer = truncate(help, max(0, m.width-lipgloss.Width(status)-2)) + " " + status
}
bodyLines := strings.Split(body, "\n")
if len(bodyLines) > max(0, m.height-1) {
bodyLines = bodyLines[:max(0, m.height-1)]
}
for i := range bodyLines {
bodyLines[i] = ansi.Truncate(bodyLines[i], m.width, "")
}
bodyLines = append(bodyLines, ansi.Truncate(dimStyle.Render(footer), m.width, ""))
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n"))
}
func renderPane(lines []string, width, height int, active bool) string {
innerWidth := max(1, width-2)
innerHeight := max(1, height-2)
if len(lines) > innerHeight {
lines = lines[:innerHeight]
}
for i := range lines {
lines[i] = ansi.Truncate(lines[i], innerWidth, "")
}
return paneStyle(active).Width(innerWidth).Height(innerHeight).Render(strings.Join(lines, "\n"))
}
func reviewAndMergeState(pr PRDetails) string {
switch pr.ReviewDecision {
case "APPROVED":
review := okStyle.Render("review: approved")
switch pr.Mergeable {
case "MERGEABLE":
return review + " " + okStyle.Render("merge: ready")
case "CONFLICTING":
return review + " " + badStyle.Render("merge: conflicts")
default:
return review + " " + warnStyle.Render("merge: checking")
}
case "CHANGES_REQUESTED":
return badStyle.Render("review: changes requested")
case "REVIEW_REQUIRED":
return warnStyle.Render("review: required")
default:
return warnStyle.Render("review: pending")
}
}
func coloredState(state string) string {
switch state {
case "SUCCESS", "EXPECTED":
return okStyle.Render(state)
case "FAILURE", "ERROR":
return badStyle.Render(state)
default:
return warnStyle.Render(state)
}
}
func reviewersText(reviewers []Reviewer) string {
if len(reviewers) == 0 {
return "none"
}
items := make([]string, 0, len(reviewers))
for _, reviewer := range reviewers {
items = append(items, reviewer.Login+"("+strings.ToLower(reviewer.State)+")")
}
return strings.Join(items, ", ")
}
func joinOrNone(items []string) string {
if len(items) == 0 {
return "none"
}
return strings.Join(items, ", ")
}
func indexPR(items []PullRequest, number int) int {
for i, item := range items {
if item.Number == number {
return i
}
}
return 0
}
func indexThread(items []ReviewThread, id string) int {
for i, item := range items {
if item.ID == id {
return i
}
}
return 0
}
func clamp(value, low, high int) int {
if high < low {
return low
}
return min(max(value, low), high)
}
func windowStart(index, count, size int) int {
if count <= size {
return 0
}
return clamp(index-size/2, 0, count-size)
}
func truncate(s string, width int) string {
if width <= 0 {
return ""
}
return ansi.Truncate(strings.ReplaceAll(s, "\n", " "), width, "…")
}
func truncatePath(path string, width int) string {
if width <= 0 {
return ""
}
pathWidth := ansi.StringWidth(path)
if pathWidth <= width {
return path
}
if width == 1 {
return "…"
}
return "…" + ansi.Cut(path, pathWidth-width+1, pathWidth)
}
func pad(s string, width int) string {
n := width - lipgloss.Width(s)
if n <= 0 {
return truncate(s, width)
}
return s + strings.Repeat(" ", n)
}
func wrap(text string, width int) string {
words := strings.Fields(text)
if len(words) == 0 {
return ""
}
lines, line := []string{}, words[0]
for _, word := range words[1:] {
if len([]rune(line))+1+len([]rune(word)) > width {
lines, line = append(lines, line), word
} else {
line += " " + word
}
}
return strings.Join(append(lines, line), "\n")
}

276
tui_test.go Normal file
View File

@@ -0,0 +1,276 @@
package main
import (
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
func TestResolvedThreadsStartFolded(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10)
m.details = PRDetails{PullRequest: PullRequest{Number: 7}}
updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{
PullRequest: PullRequest{Number: 7},
Threads: []ReviewThread{{ID: "open"}, {ID: "done", IsResolved: true}},
}})
got := updated.(App)
if got.folded["open"] {
t.Fatal("open thread was folded")
}
if !got.folded["done"] {
t.Fatal("resolved thread was not folded")
}
}
func TestSelectionSurvivesRefresh(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10)
m.details = PRDetails{
PullRequest: PullRequest{Number: 7},
Threads: []ReviewThread{{ID: "a"}, {ID: "b"}},
}
m.threadIndex = 1
updated, _ := m.Update(detailsLoadedMsg{number: 7, details: PRDetails{
PullRequest: PullRequest{Number: 7},
Threads: []ReviewThread{{ID: "new"}, {ID: "a"}, {ID: "b"}},
}})
if got := updated.(App).threadIndex; got != 2 {
t.Fatalf("thread index = %d, want 2", got)
}
}
func TestPaneFocusAndNavigation(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
m.width, m.height = 100, 12
m.details = PRDetails{
PullRequest: PullRequest{Number: 7},
Threads: []ReviewThread{
{ID: "a", Path: "a.go", Comments: []ReviewComment{{Body: strings.Repeat("word ", 100)}}},
{ID: "b", Path: "b.go"},
},
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
m = updated.(App)
if m.threadIndex != 1 {
t.Fatalf("thread index = %d", m.threadIndex)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")})
m = updated.(App)
if m.screen != threadScreen || m.focus != threadListPane {
t.Fatal("h should focus the list without leaving the thread screen")
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")})
m = updated.(App)
if m.focus != threadDetailPane {
t.Fatal("l did not focus the detail pane")
}
}
func TestViewNeverExceedsTerminalWidth(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
m.width, m.height = 90, 18
m.details = PRDetails{
PullRequest: PullRequest{Number: 7, Title: strings.Repeat("title", 50)},
Threads: []ReviewThread{{
ID: "a",
Path: strings.Repeat("very-long-directory/", 8) + "important_filename.go",
Comments: []ReviewComment{{
DiffHunk: "@@ -1 +1 @@\n+" + strings.Repeat("reallyLongIdentifier", 20),
Body: strings.Repeat("comment ", 100),
}},
}},
}
for i, line := range strings.Split(m.View(), "\n") {
if got := ansi.StringWidth(line); got > m.width {
t.Fatalf("line %d width = %d, terminal width = %d", i, got, m.width)
}
}
}
func TestTruncatePathPreservesFilename(t *testing.T) {
got := truncatePath("a/very/long/directory/important_filename.go", 22)
if !strings.HasSuffix(got, "important_filename.go") {
t.Fatalf("truncated path %q lost filename", got)
}
}
func TestMergeReadyRequiresApproval(t *testing.T) {
notApproved := reviewAndMergeState(PRDetails{Mergeable: "MERGEABLE", ReviewDecision: "REVIEW_REQUIRED"})
if strings.Contains(notApproved, "merge: ready") {
t.Fatalf("unapproved PR shown as ready: %q", notApproved)
}
approved := reviewAndMergeState(PRDetails{Mergeable: "MERGEABLE", ReviewDecision: "APPROVED"})
if !strings.Contains(approved, "merge: ready") {
t.Fatalf("approved PR not shown as ready: %q", approved)
}
}
func TestThreadCommentCountsUseSameColumn(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.width, m.height = 100, 20
m.details = PRDetails{Threads: []ReviewThread{
{ID: "short", Path: "a.go", Line: 12, Comments: make([]ReviewComment, 2)},
{ID: "long", Path: "a/very/long/directory/with/a/descriptive/important_filename.go", Line: 12, Comments: make([]ReviewComment, 2)},
}}
plain := ansi.Strip(m.threadList(48, 10))
var columns []int
for _, line := range strings.Split(plain, "\n") {
if column := strings.Index(line, "· 2"); column >= 0 {
columns = append(columns, ansi.StringWidth(line[:column]))
}
}
if len(columns) != 2 || columns[0] != columns[1] {
t.Fatalf("comment columns = %v\n%s", columns, plain)
}
}
func TestReviewAnchorUsesCommentSnapshotCoordinates(t *testing.T) {
thread := ReviewThread{
Line: 150,
StartLine: 148,
Comments: []ReviewComment{{
Line: 150,
StartLine: 148,
OriginalLine: 42,
OriginalStartLine: 40,
Outdated: true,
}},
}
start, end := reviewAnchor(thread)
if start != 40 || end != 42 {
t.Fatalf("anchor = %d-%d, want original snapshot range 40-42", start, end)
}
}
func TestOutdatedDetailHighlightsOriginalCodeRange(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.width = 100
m.details = PRDetails{Threads: []ReviewThread{{
Path: "main.go",
Line: 150,
StartLine: 148,
DiffSide: "RIGHT",
IsOutdated: true,
Comments: []ReviewComment{{
DiffHunk: "@@ -38,7 +38,7 @@\n old 38\n old 39\n reviewed 40\n reviewed 41\n reviewed 42\n old 43\n old 44",
OriginalLine: 42,
OriginalStartLine: 40,
OriginalCommitOID: "0123456789abcdef",
}},
}}}
var rendered strings.Builder
selected := 0
for _, line := range m.detailLines(80) {
rendered.WriteString(ansi.Strip(line.fixed + line.text))
rendered.WriteByte('\n')
if line.selected {
selected++
}
}
output := rendered.String()
if selected != 3 || !strings.Contains(output, "reviewed 40") ||
!strings.Contains(output, "snapshot 0123456") {
t.Fatalf("detail was not anchored to the original snapshot:\n%s", output)
}
}
func TestTabHidesListAndHRestoresIt(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
m.width, m.height = 100, 20
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(App)
if !m.listHidden || m.focus != threadDetailPane {
t.Fatal("tab did not hide the list and focus detail")
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")})
m = updated.(App)
if m.listHidden || m.focus != threadListPane {
t.Fatal("h did not reveal and focus the list")
}
}
func TestSelectedBackgroundFillsLine(t *testing.T) {
rendered := selectedBackground("code", 12)
if !strings.Contains(rendered, "\x1b[48;5;24m") || ansi.StringWidth(rendered) != 12 {
t.Fatalf("selected background was not full width: %q", rendered)
}
}
func TestWrappedDiffContinuationKeepsIndentWithoutLineNumber(t *testing.T) {
original := " some_really_long_function_call(argument)"
lines := wrapDiffLine(highlightedDiffLine{
gutter: " 10 + ",
code: original,
selected: true,
}, 22)
if len(lines) < 2 {
t.Fatalf("long code was not wrapped: %#v", lines)
}
for i, line := range lines {
if ansi.StringWidth(line.fixed+line.text) > 22 {
t.Fatalf("wrapped line %d exceeds width: %q", i, line.fixed+line.text)
}
if !line.selected {
t.Fatalf("continuation line %d lost selection state", i)
}
if i == 0 {
if !strings.Contains(line.fixed, "10") {
t.Fatalf("first line lost its line number: %q", line.fixed)
}
continue
}
if strings.TrimSpace(line.fixed) != "" {
t.Fatalf("continuation repeated a line number: %q", line.fixed)
}
plain := ansi.Strip(line.text)
if !strings.HasPrefix(plain, " ↳ ") {
t.Fatalf("continuation lacks extra indent and marker: %q", plain)
}
}
}
func TestCodeWrapPrefersSyntaxBoundaries(t *testing.T) {
code := " result = client.fetch(first_argument, second_argument)"
lines := wrapCodeWithIndent(code, 38)
if len(lines) < 2 {
t.Fatalf("code was not wrapped: %#v", lines)
}
plain := make([]string, len(lines))
for i, line := range lines {
plain[i] = ansi.Strip(line)
}
rendered := strings.Join(plain, "\n")
if !strings.Contains(rendered, "first_argument") || !strings.Contains(rendered, "second_argument") {
t.Fatalf("wrapper split identifiers despite syntax boundaries:\n%s", rendered)
}
if !strings.Contains(rendered, "↳ ") {
t.Fatalf("continuation marker missing:\n%s", rendered)
}
}
func TestAuthorColorsAreVariedAndDeterministic(t *testing.T) {
if authorColor("Alice") != authorColor("alice") {
t.Fatal("same login received different colors")
}
colors := map[lipgloss.Color]bool{}
for _, login := range []string{"alice", "bob", "carol", "dave", "eve", "frank"} {
colors[authorColor(login)] = true
}
if len(colors) < 2 {
t.Fatalf("author palette did not vary: %#v", colors)
}
}

61
types.go Normal file
View File

@@ -0,0 +1,61 @@
package main
import "time"
type PullRequest struct {
ID string
Number int
Title string
URL string
Author string
IsDraft bool
UpdatedAt time.Time
ReviewCount int
ViewerAuthored bool
}
type PRDetails struct {
PullRequest
Body string
BaseRef string
HeadRef string
Mergeable string
Assignees []string
Reviewers []Reviewer
CheckState string
ReviewDecision string
Threads []ReviewThread
ThreadsTruncated bool
}
type Reviewer struct {
Login string
State string
}
type ReviewThread struct {
ID string
Path string
Line int
StartLine int
DiffSide string
IsResolved bool
IsOutdated bool
IsTruncated bool
Comments []ReviewComment
}
type ReviewComment struct {
ID string
Author string
Body string
DiffHunk string
Line int
StartLine int
OriginalLine int
OriginalStartLine int
OriginalCommitOID string
Outdated bool
CreatedAt time.Time
URL string
}