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