initial working read only state
This commit is contained in:
67
README.md
Normal file
67
README.md
Normal 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
66
auth.go
Normal 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
98
auth_test.go
Normal 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
347
github.go
Normal 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
91
github_test.go
Normal 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
30
go.mod
Normal 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
53
go.sum
Normal 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
238
highlight.go
Normal 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
108
highlight_test.go
Normal 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
60
main.go
Normal 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
791
tui.go
Normal 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
276
tui_test.go
Normal 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
61
types.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user