commit b0ac2f3e9aff1fc6f0347ebf5d54992a739c3c45 Author: Pablu Date: Mon Jul 27 14:03:54 2026 +0200 initial working read only state diff --git a/README.md b/README.md new file mode 100644 index 0000000..4bca5d9 --- /dev/null +++ b/README.md @@ -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. diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..a61453e --- /dev/null +++ b/auth.go @@ -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() +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..4813915 --- /dev/null +++ b/auth_test.go @@ -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") + } +} diff --git a/github.go b/github.go new file mode 100644 index 0000000..eb2bd8d --- /dev/null +++ b/github.go @@ -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 +} diff --git a/github_test.go b/github_test.go new file mode 100644 index 0000000..0b9e6f0 --- /dev/null +++ b/github_test.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b5ea1f0 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d6cff89 --- /dev/null +++ b/go.sum @@ -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= diff --git a/highlight.go b/highlight.go new file mode 100644 index 0000000..0c7be49 --- /dev/null +++ b/highlight.go @@ -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 +} diff --git a/highlight_test.go b/highlight_test.go new file mode 100644 index 0000000..69cffc1 --- /dev/null +++ b/highlight_test.go @@ -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) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a9028b2 --- /dev/null +++ b/main.go @@ -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) diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..10133dc --- /dev/null +++ b/tui.go @@ -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") +} diff --git a/tui_test.go b/tui_test.go new file mode 100644 index 0000000..de60ccb --- /dev/null +++ b/tui_test.go @@ -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) + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..10b4b59 --- /dev/null +++ b/types.go @@ -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 +}