Show conflicts to parent branch
This commit is contained in:
18
README.md
18
README.md
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
A terminal UI for people receiving GitHub pull-request reviews. It
|
A terminal UI for people receiving GitHub pull-request reviews. It
|
||||||
shows open PRs and a scrollable PR dashboard with the description, branches,
|
shows open PRs and a scrollable PR dashboard with the description, branches,
|
||||||
review state, checks, people, labels, milestone, activity, change statistics,
|
review state, merge conflicts and affected files, checks, people, labels,
|
||||||
thread totals, submitted reviews, and the PR conversation. Review threads and
|
milestone, activity, change statistics, thread totals, submitted reviews, and
|
||||||
comments are paginated rather than silently stopping at the first page. The
|
the PR conversation. Review threads and comments are paginated rather than
|
||||||
|
silently stopping at the first page. The
|
||||||
thread viewer includes highlighted diff hunks and comment authors. Resolved
|
thread viewer includes highlighted diff hunks and comment authors. Resolved
|
||||||
threads start folded. GitHub suggestion blocks are shown as
|
threads start folded. GitHub suggestion blocks are shown as
|
||||||
syntax-highlighted remove/add previews. Comments and PR descriptions render
|
syntax-highlighted remove/add previews. Comments and PR descriptions render
|
||||||
@@ -14,7 +15,7 @@ alerts. The current PR is refreshed in the background.
|
|||||||
|
|
||||||
## Install and run
|
## Install and run
|
||||||
|
|
||||||
Requires Go 1.24+ and an authenticated GitHub CLI:
|
Requires Go 1.24+, Git 2.38+, and an authenticated GitHub CLI:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go install .
|
go install .
|
||||||
@@ -110,6 +111,15 @@ files. Their modification time is touched at most once per day (or half the
|
|||||||
configured maximum age, when shorter) so recently validated snapshots remain
|
configured maximum age, when shorter) so recently validated snapshots remain
|
||||||
usable without writing on every poll. Changed files are replaced atomically.
|
usable without writing on every poll. Changed files are replaced atomically.
|
||||||
|
|
||||||
|
GitHub's public APIs report whether a PR conflicts but do not expose its
|
||||||
|
conflicting file paths. For conflicting PRs only, `gh-threads` performs a
|
||||||
|
read-only `git merge-tree` analysis in a temporary bare repository. It never
|
||||||
|
touches or inspects the current checkout, so Git, Jujutsu (`jj`), and directories
|
||||||
|
without a local repository behave identically. The analysis fetches the exact
|
||||||
|
remote base branch and pull-request head ref using the existing GitHub
|
||||||
|
credential. Results are memoized by the base and head commit, and failed scans
|
||||||
|
are retried after one minute.
|
||||||
|
|
||||||
Command-line flags override the configuration. `GH_REPO` overrides the
|
Command-line flags override the configuration. `GH_REPO` overrides the
|
||||||
configured repository when `--repo` is not provided. The corresponding flags
|
configured repository when `--repo` is not provided. The corresponding flags
|
||||||
include `--config`, `--theme`, `--poll`, `--fold-resolved`,
|
include `--config`, `--theme`, `--poll`, `--fold-resolved`,
|
||||||
|
|||||||
190
conflicts.go
Normal file
190
conflicts.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type conflictFileLoader func(
|
||||||
|
context.Context, string, int, string, string, string, string,
|
||||||
|
) ([]string, error)
|
||||||
|
|
||||||
|
type conflictFileResult struct {
|
||||||
|
files []string
|
||||||
|
err error
|
||||||
|
checkedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) loadConflictFiles(
|
||||||
|
ctx context.Context,
|
||||||
|
repositoryURL string,
|
||||||
|
number int,
|
||||||
|
baseRef, baseOID, headOID string,
|
||||||
|
) ([]string, error) {
|
||||||
|
key := strings.Join([]string{repositoryURL, baseOID, headOID}, "\x00")
|
||||||
|
c.conflictMu.Lock()
|
||||||
|
cached, ok := c.conflictCache[key]
|
||||||
|
c.conflictMu.Unlock()
|
||||||
|
if ok && (cached.err == nil || time.Since(cached.checkedAt) < time.Minute) {
|
||||||
|
return append([]string(nil), cached.files...), cached.err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := c.conflicts(ctx, repositoryURL, number, baseRef, baseOID, headOID, c.token)
|
||||||
|
result := conflictFileResult{
|
||||||
|
files: append([]string(nil), files...), err: err, checkedAt: time.Now(),
|
||||||
|
}
|
||||||
|
c.conflictMu.Lock()
|
||||||
|
c.conflictCache[key] = result
|
||||||
|
c.conflictMu.Unlock()
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeConflictFiles(
|
||||||
|
ctx context.Context,
|
||||||
|
repositoryURL string,
|
||||||
|
number int,
|
||||||
|
baseRef, _, _, token string,
|
||||||
|
) ([]string, error) {
|
||||||
|
if repositoryURL == "" || baseRef == "" || number <= 0 {
|
||||||
|
return nil, errors.New("missing repository merge metadata")
|
||||||
|
}
|
||||||
|
gitDir, err := os.MkdirTemp("", "gh-threads-conflicts-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create temporary merge repository: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(gitDir)
|
||||||
|
|
||||||
|
run := func(args ...string) ([]byte, error) {
|
||||||
|
command := exec.CommandContext(ctx, "git", args...)
|
||||||
|
command.Env = gitAuthenticationEnvironment(callerEnvironment(), token)
|
||||||
|
return command.CombinedOutput()
|
||||||
|
}
|
||||||
|
if output, runErr := run("init", "--bare", gitDir); runErr != nil {
|
||||||
|
return nil, commandError("initialize merge analysis", output, runErr)
|
||||||
|
}
|
||||||
|
cloneURL := strings.TrimSuffix(strings.TrimSuffix(repositoryURL, "/"), ".git") + ".git"
|
||||||
|
if output, runErr := run("-C", gitDir, "remote", "add", "origin", cloneURL); runErr != nil {
|
||||||
|
return nil, commandError("configure merge analysis remote", output, runErr)
|
||||||
|
}
|
||||||
|
if output, runErr := run("-C", gitDir, "config", "remote.origin.promisor", "true"); runErr != nil {
|
||||||
|
return nil, commandError("configure partial clone", output, runErr)
|
||||||
|
}
|
||||||
|
if output, runErr := run("-C", gitDir, "config", "remote.origin.partialclonefilter", "blob:none"); runErr != nil {
|
||||||
|
return nil, commandError("configure partial clone filter", output, runErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
refspecs := []string{
|
||||||
|
"+refs/heads/" + baseRef + ":refs/gh-threads/base",
|
||||||
|
"+refs/pull/" + strconv.Itoa(number) + "/head:refs/gh-threads/head",
|
||||||
|
}
|
||||||
|
fetch := func(depthArgs ...string) error {
|
||||||
|
args := []string{"-C", gitDir, "fetch", "--quiet", "--no-tags", "--filter=blob:none"}
|
||||||
|
args = append(args, depthArgs...)
|
||||||
|
args = append(args, "origin")
|
||||||
|
args = append(args, refspecs...)
|
||||||
|
output, runErr := run(args...)
|
||||||
|
return commandError("fetch merge inputs", output, runErr)
|
||||||
|
}
|
||||||
|
if err := fetch("--depth=64"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, deepen := range []string{"192", "768"} {
|
||||||
|
if mergeBaseExists(run, gitDir) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := fetch("--deepen=" + deepen); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !mergeBaseExists(run, gitDir) {
|
||||||
|
if err := fetch("--unshallow"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output, runErr := run(
|
||||||
|
"-C", gitDir, "merge-tree", "--write-tree", "--name-only", "--no-messages", "-z",
|
||||||
|
"refs/gh-threads/base", "refs/gh-threads/head",
|
||||||
|
)
|
||||||
|
if runErr == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(runErr, &exitErr) || exitErr.ExitCode() != 1 {
|
||||||
|
return nil, commandError("analyze merge conflicts", output, runErr)
|
||||||
|
}
|
||||||
|
return parseConflictFiles(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeBaseExists(
|
||||||
|
run func(...string) ([]byte, error),
|
||||||
|
gitDir string,
|
||||||
|
) bool {
|
||||||
|
_, err := run(
|
||||||
|
"-C", gitDir, "merge-base", "refs/gh-threads/base", "refs/gh-threads/head",
|
||||||
|
)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConflictFiles(output []byte) ([]string, error) {
|
||||||
|
parts := bytes.Split(output, []byte{0})
|
||||||
|
if len(parts) < 2 || len(parts[0]) == 0 {
|
||||||
|
return nil, errors.New("git merge-tree returned malformed conflict data")
|
||||||
|
}
|
||||||
|
files := make([]string, 0, len(parts)-2)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, raw := range parts[1:] {
|
||||||
|
name := string(raw)
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
files = append(files, name)
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandError(action string, output []byte, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
message := strings.TrimSpace(string(output))
|
||||||
|
if message == "" {
|
||||||
|
return fmt.Errorf("%s: %w", action, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %s", action, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func callerEnvironment() []string {
|
||||||
|
const prefix = "GIT_CONFIG_"
|
||||||
|
environment := make([]string, 0, len(os.Environ())+5)
|
||||||
|
for _, item := range os.Environ() {
|
||||||
|
if !strings.HasPrefix(item, prefix) && !strings.HasPrefix(item, "GIT_TERMINAL_PROMPT=") {
|
||||||
|
environment = append(environment, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return environment
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitAuthenticationEnvironment(environment []string, token string) []string {
|
||||||
|
environment = append(environment, "GIT_TERMINAL_PROMPT=0")
|
||||||
|
if token == "" {
|
||||||
|
return environment
|
||||||
|
}
|
||||||
|
credentials := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token))
|
||||||
|
return append(environment,
|
||||||
|
"GIT_CONFIG_COUNT=1",
|
||||||
|
"GIT_CONFIG_KEY_0=http.extraHeader",
|
||||||
|
"GIT_CONFIG_VALUE_0=Authorization: Basic "+credentials,
|
||||||
|
)
|
||||||
|
}
|
||||||
86
conflicts_test.go
Normal file
86
conflicts_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseConflictFiles(t *testing.T) {
|
||||||
|
output := []byte("0123456789abcdef\x00src/a.go\x00docs/name with spaces.md\x00src/a.go\x00")
|
||||||
|
got, err := parseConflictFiles(output)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"docs/name with spaces.md", "src/a.go"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("conflict files = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConflictFilesRejectsMalformedOutput(t *testing.T) {
|
||||||
|
if _, err := parseConflictFiles([]byte("not-delimited")); err == nil {
|
||||||
|
t.Fatal("malformed merge-tree output was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictFileCacheUsesCommitPairAndRetriesErrors(t *testing.T) {
|
||||||
|
client := NewGitHubClient("https://api.github.com/graphql", "secret")
|
||||||
|
calls := 0
|
||||||
|
client.conflicts = func(
|
||||||
|
_ context.Context, _ string, _ int, _ string, _, _, _ string,
|
||||||
|
) ([]string, error) {
|
||||||
|
calls++
|
||||||
|
return []string{"main.go"}, nil
|
||||||
|
}
|
||||||
|
first, err := client.loadConflictFiles(
|
||||||
|
context.Background(), "https://github.com/o/r", 1, "main", "base", "head",
|
||||||
|
)
|
||||||
|
if err != nil || len(first) != 1 {
|
||||||
|
t.Fatalf("first load = %#v, %v", first, err)
|
||||||
|
}
|
||||||
|
first[0] = "mutated"
|
||||||
|
second, err := client.loadConflictFiles(
|
||||||
|
context.Background(), "https://github.com/o/r", 1, "main", "base", "head",
|
||||||
|
)
|
||||||
|
if err != nil || !reflect.DeepEqual(second, []string{"main.go"}) || calls != 1 {
|
||||||
|
t.Fatalf("cached load = %#v, %v, calls=%d", second, err, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
failing := NewGitHubClient("https://api.github.com/graphql", "secret")
|
||||||
|
failedCalls := 0
|
||||||
|
failing.conflicts = func(
|
||||||
|
_ context.Context, _ string, _ int, _ string, _, _, _ string,
|
||||||
|
) ([]string, error) {
|
||||||
|
failedCalls++
|
||||||
|
return nil, errors.New("temporary")
|
||||||
|
}
|
||||||
|
const repositoryURL = "https://github.com/o/r"
|
||||||
|
_, _ = failing.loadConflictFiles(
|
||||||
|
context.Background(), repositoryURL, 1, "main", "base", "head",
|
||||||
|
)
|
||||||
|
key := strings.Join([]string{repositoryURL, "base", "head"}, "\x00")
|
||||||
|
entry := failing.conflictCache[key]
|
||||||
|
entry.checkedAt = time.Now().Add(-2 * time.Minute)
|
||||||
|
failing.conflictCache[key] = entry
|
||||||
|
_, _ = failing.loadConflictFiles(
|
||||||
|
context.Background(), repositoryURL, 1, "main", "base", "head",
|
||||||
|
)
|
||||||
|
if failedCalls != 2 {
|
||||||
|
t.Fatalf("expired conflict error was not retried; calls=%d", failedCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitAuthenticationEnvironmentDoesNotExposeTokenInArguments(t *testing.T) {
|
||||||
|
got := gitAuthenticationEnvironment([]string{"PATH=/bin"}, "token value")
|
||||||
|
joined := strings.Join(got, "\n")
|
||||||
|
credentials := base64.StdEncoding.EncodeToString([]byte("x-access-token:token value"))
|
||||||
|
if !strings.Contains(joined, "GIT_TERMINAL_PROMPT=0") ||
|
||||||
|
!strings.Contains(joined, "Authorization: Basic "+credentials) {
|
||||||
|
t.Fatalf("authentication environment = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
github.go
42
github.go
@@ -26,16 +26,21 @@ type GitHubWriteService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GitHubClient struct {
|
type GitHubClient struct {
|
||||||
endpoint string
|
endpoint string
|
||||||
token string
|
token string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
conflicts conflictFileLoader
|
||||||
|
conflictMu sync.Mutex
|
||||||
|
conflictCache map[string]conflictFileResult
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGitHubClient(endpoint, token string) *GitHubClient {
|
func NewGitHubClient(endpoint, token string) *GitHubClient {
|
||||||
return &GitHubClient{
|
return &GitHubClient{
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
token: token,
|
token: token,
|
||||||
http: &http.Client{Timeout: 20 * time.Second},
|
http: &http.Client{Timeout: 20 * time.Second},
|
||||||
|
conflicts: analyzeConflictFiles,
|
||||||
|
conflictCache: make(map[string]conflictFileResult),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +277,7 @@ func nullableCursor(cursor string) any {
|
|||||||
const detailsQuery = `
|
const detailsQuery = `
|
||||||
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
||||||
repository(owner: $owner, name: $name) {
|
repository(owner: $owner, name: $name) {
|
||||||
|
url
|
||||||
viewerPermission
|
viewerPermission
|
||||||
defaultBranchRef { name }
|
defaultBranchRef { name }
|
||||||
rulesets(first: 100, includeParents: true, targets: [BRANCH]) {
|
rulesets(first: 100, includeParents: true, targets: [BRANCH]) {
|
||||||
@@ -288,6 +294,7 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
|||||||
baseRefName headRefName headRefOid
|
baseRefName headRefName headRefOid
|
||||||
viewerCanUpdate viewerCanReact viewerCanSubscribe viewerCanEnableAutoMerge
|
viewerCanUpdate viewerCanReact viewerCanSubscribe viewerCanEnableAutoMerge
|
||||||
baseRef {
|
baseRef {
|
||||||
|
target { ... on Commit { oid } }
|
||||||
branchProtectionRule {
|
branchProtectionRule {
|
||||||
requiresApprovingReviews requiredApprovingReviewCount
|
requiresApprovingReviews requiredApprovingReviewCount
|
||||||
requiresStatusChecks requiresConversationResolution
|
requiresStatusChecks requiresConversationResolution
|
||||||
@@ -586,6 +593,7 @@ type githubPullRequestDetails struct {
|
|||||||
Author *githubActor
|
Author *githubActor
|
||||||
ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe, ViewerCanEnableAutoMerge bool
|
ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe, ViewerCanEnableAutoMerge bool
|
||||||
BaseRef *struct {
|
BaseRef *struct {
|
||||||
|
Target *struct{ OID string }
|
||||||
BranchProtectionRule *struct {
|
BranchProtectionRule *struct {
|
||||||
RequiresApprovingReviews, RequiresStatusChecks bool
|
RequiresApprovingReviews, RequiresStatusChecks bool
|
||||||
RequiresConversationResolution, RequiresCodeOwnerReviews bool
|
RequiresConversationResolution, RequiresCodeOwnerReviews bool
|
||||||
@@ -885,6 +893,7 @@ func (c *GitHubClient) allCheckAnnotations(
|
|||||||
func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) {
|
func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) {
|
||||||
var data struct {
|
var data struct {
|
||||||
Repository *struct {
|
Repository *struct {
|
||||||
|
URL string
|
||||||
ViewerPermission string `json:"viewerPermission"`
|
ViewerPermission string `json:"viewerPermission"`
|
||||||
DefaultBranchRef *struct{ Name string }
|
DefaultBranchRef *struct{ Name string }
|
||||||
Rulesets struct{ Nodes []githubRuleset }
|
Rulesets struct{ Nodes []githubRuleset }
|
||||||
@@ -909,6 +918,8 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
reviewErr error
|
reviewErr error
|
||||||
timelineErr error
|
timelineErr error
|
||||||
checkErr error
|
checkErr error
|
||||||
|
conflictFiles []string
|
||||||
|
conflictFileErr error
|
||||||
wait sync.WaitGroup
|
wait sync.WaitGroup
|
||||||
)
|
)
|
||||||
wait.Add(4)
|
wait.Add(4)
|
||||||
@@ -936,6 +947,19 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
checkNodes, checkErr = c.allCheckContexts(ctx, rollup.Contexts, rollup.ID)
|
checkNodes, checkErr = c.allCheckContexts(ctx, rollup.Contexts, rollup.ID)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
if node.Mergeable == "CONFLICTING" && c.conflicts != nil {
|
||||||
|
wait.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
baseOID := ""
|
||||||
|
if node.BaseRef != nil && node.BaseRef.Target != nil {
|
||||||
|
baseOID = node.BaseRef.Target.OID
|
||||||
|
}
|
||||||
|
conflictFiles, conflictFileErr = c.loadConflictFiles(
|
||||||
|
ctx, data.Repository.URL, number, node.BaseRefName, baseOID, node.HeadRefOID,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
}
|
||||||
wait.Wait()
|
wait.Wait()
|
||||||
for _, err := range []error{threadErr, conversationErr, reviewErr, timelineErr, checkErr} {
|
for _, err := range []error{threadErr, conversationErr, reviewErr, timelineErr, checkErr} {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -951,7 +975,8 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
},
|
},
|
||||||
Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
|
Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
|
||||||
HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
|
HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
|
||||||
Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles,
|
ConflictFiles: conflictFiles,
|
||||||
|
Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles,
|
||||||
CommitCount: node.Commits.TotalCount, CommentCount: node.Comments.TotalCount,
|
CommitCount: node.Commits.TotalCount, CommentCount: node.Comments.TotalCount,
|
||||||
CheckState: "NONE", ReviewDecision: node.ReviewDecision,
|
CheckState: "NONE", ReviewDecision: node.ReviewDecision,
|
||||||
Permissions: ViewerPermissions{
|
Permissions: ViewerPermissions{
|
||||||
@@ -960,6 +985,9 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge,
|
CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if conflictFileErr != nil {
|
||||||
|
details.ConflictFileError = conflictFileErr.Error()
|
||||||
|
}
|
||||||
if node.BaseRef != nil && node.BaseRef.BranchProtectionRule != nil {
|
if node.BaseRef != nil && node.BaseRef.BranchProtectionRule != nil {
|
||||||
rule := node.BaseRef.BranchProtectionRule
|
rule := node.BaseRef.BranchProtectionRule
|
||||||
details.Requirements = MergeRequirements{
|
details.Requirements = MergeRequirements{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -375,3 +376,46 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
|||||||
t.Fatalf("unexpected comment snapshot: %#v", comment)
|
t.Fatalf("unexpected comment snapshot: %#v", comment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetPullRequestLoadsConflictFilesForConflictingPR(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{
|
||||||
|
"url":"https://github.com/o/r","pullRequest":{
|
||||||
|
"id":"pr","number":3,"title":"Conflict","url":"u",
|
||||||
|
"mergeable":"CONFLICTING","baseRefName":"main","headRefName":"feature",
|
||||||
|
"headRefOid":"head123","baseRef":{"target":{"oid":"base123"}},
|
||||||
|
"comments":{"pageInfo":{"hasNextPage":false}},
|
||||||
|
"reviews":{"pageInfo":{"hasNextPage":false}},
|
||||||
|
"timelineItems":{"pageInfo":{"hasNextPage":false}},
|
||||||
|
"reviewThreads":{"pageInfo":{"hasNextPage":false}}
|
||||||
|
}
|
||||||
|
}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
client.conflicts = func(
|
||||||
|
_ context.Context,
|
||||||
|
repositoryURL string,
|
||||||
|
number int,
|
||||||
|
baseRef, baseOID, headOID, token string,
|
||||||
|
) ([]string, error) {
|
||||||
|
if repositoryURL != "https://github.com/o/r" || number != 3 ||
|
||||||
|
baseRef != "main" || baseOID != "base123" || headOID != "head123" ||
|
||||||
|
token != "secret" {
|
||||||
|
t.Fatalf(
|
||||||
|
"conflict loader arguments = %q, %d, %q, %q, %q, %q",
|
||||||
|
repositoryURL, number, baseRef, baseOID, headOID, token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return []string{"src/conflict.go", "README.md"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := client.GetPullRequest(context.Background(), "o", "r", 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.ConflictFiles, []string{"src/conflict.go", "README.md"}) {
|
||||||
|
t.Fatalf("conflict files = %#v", got.ConflictFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
60
tui.go
60
tui.go
@@ -1504,6 +1504,9 @@ func (m App) dashboardLines() []string {
|
|||||||
dashboardMetadata("review", reviewAndMergeState(pr)),
|
dashboardMetadata("review", reviewAndMergeState(pr)),
|
||||||
dashboardMetadata("checks", coloredState(pr.CheckState)),
|
dashboardMetadata("checks", coloredState(pr.CheckState)),
|
||||||
dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")),
|
dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")),
|
||||||
|
)
|
||||||
|
lines = append(lines, dashboardMetadataLines("conflicts", conflictStateText(pr), width)...)
|
||||||
|
lines = append(lines,
|
||||||
dashboardMetadata("assignees", handlesText(pr.Assignees)),
|
dashboardMetadata("assignees", handlesText(pr.Assignees)),
|
||||||
dashboardMetadata("reviewers", reviewersText(pr.Reviewers)),
|
dashboardMetadata("reviewers", reviewersText(pr.Reviewers)),
|
||||||
dashboardMetadata("labels", labels),
|
dashboardMetadata("labels", labels),
|
||||||
@@ -1527,6 +1530,21 @@ func (m App) dashboardLines() []string {
|
|||||||
dashboardMetadata("updated", updated),
|
dashboardMetadata("updated", updated),
|
||||||
dashboardMetadata("url", pr.URL),
|
dashboardMetadata("url", pr.URL),
|
||||||
)
|
)
|
||||||
|
if len(pr.ConflictFiles) > 0 {
|
||||||
|
lines = append(lines, "", titleStyle.Render(fmt.Sprintf(
|
||||||
|
"Conflicting files (%d)", len(pr.ConflictFiles),
|
||||||
|
)), "")
|
||||||
|
for _, file := range pr.ConflictFiles {
|
||||||
|
parts := strings.Split(ansi.Hardwrap(file, max(1, width-4), false), "\n")
|
||||||
|
for index, part := range parts {
|
||||||
|
prefix := " "
|
||||||
|
if index == 0 {
|
||||||
|
prefix = "• "
|
||||||
|
}
|
||||||
|
lines = append(lines, badStyle.Render(prefix)+part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
lines = append(lines, "", titleStyle.Render("Write capability gate"), "")
|
lines = append(lines, "", titleStyle.Render("Write capability gate"), "")
|
||||||
lines = append(lines, writeCapabilityLines(pr, m.selectedThread())...)
|
lines = append(lines, writeCapabilityLines(pr, m.selectedThread())...)
|
||||||
if pr.ThreadsTruncated {
|
if pr.ThreadsTruncated {
|
||||||
@@ -1688,6 +1706,22 @@ func dashboardMetadata(label, value string) string {
|
|||||||
return titleStyle.Render(pad(label+":", 13)) + " " + value
|
return titleStyle.Render(pad(label+":", 13)) + " " + value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dashboardMetadataLines(label, value string, width int) []string {
|
||||||
|
const labelWidth = 13
|
||||||
|
valueWidth := max(1, width-labelWidth-1)
|
||||||
|
wrapped := ansi.Hardwrap(ansi.Wordwrap(value, valueWidth, ""), valueWidth, false)
|
||||||
|
parts := strings.Split(wrapped, "\n")
|
||||||
|
lines := make([]string, 0, len(parts))
|
||||||
|
for index, part := range parts {
|
||||||
|
currentLabel := ""
|
||||||
|
if index == 0 {
|
||||||
|
currentLabel = label + ":"
|
||||||
|
}
|
||||||
|
lines = append(lines, titleStyle.Render(pad(currentLabel, labelWidth))+" "+part)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
func threadStatusCounts(threads []ReviewThread) (open, outdated, resolved int) {
|
func threadStatusCounts(threads []ReviewThread) (open, outdated, resolved int) {
|
||||||
for _, thread := range threads {
|
for _, thread := range threads {
|
||||||
switch threadStatus(thread) {
|
switch threadStatus(thread) {
|
||||||
@@ -2391,6 +2425,32 @@ func reviewAndMergeState(pr PRDetails) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func conflictStateText(pr PRDetails) string {
|
||||||
|
switch pr.Mergeable {
|
||||||
|
case "MERGEABLE":
|
||||||
|
return okStyle.Render("none")
|
||||||
|
case "CONFLICTING":
|
||||||
|
if len(pr.ConflictFiles) > 0 {
|
||||||
|
return badStyle.Render(fmt.Sprintf("%d conflicting files", len(pr.ConflictFiles)))
|
||||||
|
}
|
||||||
|
if pr.ConflictFileError != "" {
|
||||||
|
return badStyle.Render("detected") + " " +
|
||||||
|
warnStyle.Render("file scan unavailable: "+firstLine(pr.ConflictFileError))
|
||||||
|
}
|
||||||
|
return badStyle.Render("detected") + " " +
|
||||||
|
dimStyle.Render("no individual file paths reported")
|
||||||
|
default:
|
||||||
|
return warnStyle.Render("checking")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(value string) string {
|
||||||
|
if line, _, found := strings.Cut(value, "\n"); found {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func coloredState(state string) string {
|
func coloredState(state string) string {
|
||||||
switch state {
|
switch state {
|
||||||
case "SUCCESS", "EXPECTED", "COMPLETED", "NEUTRAL", "SKIPPED":
|
case "SUCCESS", "EXPECTED", "COMPLETED", "NEUTRAL", "SKIPPED":
|
||||||
|
|||||||
48
tui_test.go
48
tui_test.go
@@ -327,6 +327,54 @@ func TestDashboardRendersDescriptionAndMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDashboardShowsConflictStateAndFiles(t *testing.T) {
|
||||||
|
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
||||||
|
m.screen, m.width, m.height = dashboardScreen, 100, 40
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Conflict"},
|
||||||
|
BaseRef: "main", HeadRef: "feature", Mergeable: "CONFLICTING",
|
||||||
|
ConflictFiles: []string{"src/one.go", "docs/two.md"},
|
||||||
|
}
|
||||||
|
plain := ansi.Strip(strings.Join(m.dashboardLines(), "\n"))
|
||||||
|
for _, wanted := range []string{
|
||||||
|
"conflicts", "2 conflicting files", "Conflicting files (2)", "src/one.go", "docs/two.md",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(plain, wanted) {
|
||||||
|
t.Fatalf("conflict dashboard is missing %q:\n%s", wanted, plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.details.Mergeable = "MERGEABLE"
|
||||||
|
m.details.ConflictFiles = nil
|
||||||
|
plain = ansi.Strip(strings.Join(m.dashboardLines(), "\n"))
|
||||||
|
if !strings.Contains(plain, "conflicts") || !strings.Contains(plain, "none") ||
|
||||||
|
strings.Contains(plain, "Conflicting files (") {
|
||||||
|
t.Fatalf("clean dashboard conflict state is unclear:\n%s", plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardWrapsConflictScanWarning(t *testing.T) {
|
||||||
|
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
||||||
|
m.screen, m.width, m.height = dashboardScreen, 48, 30
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{RepoWithOwner: "o/r", Number: 1, Title: "Conflict"},
|
||||||
|
BaseRef: "main", HeadRef: "feature", Mergeable: "CONFLICTING",
|
||||||
|
ConflictFileError: "fetch merge inputs: fatal: could not read Username for 'https://github.com': terminal prompts disabled",
|
||||||
|
}
|
||||||
|
lines := dashboardMetadataLines("conflicts", conflictStateText(m.details), m.width-2)
|
||||||
|
plain := ansi.Strip(strings.Join(lines, "\n"))
|
||||||
|
normalized := strings.Join(strings.Fields(plain), " ")
|
||||||
|
if !strings.Contains(normalized, "file scan unavailable") ||
|
||||||
|
!strings.Contains(normalized, "terminal prompts disabled") {
|
||||||
|
t.Fatalf("wrapped conflict warning lost information:\n%s", plain)
|
||||||
|
}
|
||||||
|
for index, line := range lines {
|
||||||
|
if width := ansi.StringWidth(line); width > m.width-2 {
|
||||||
|
t.Fatalf("dashboard line %d width = %d:\n%s", index, width, plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDashboardCompactsSubmittedReviewsByDefault(t *testing.T) {
|
func TestDashboardCompactsSubmittedReviewsByDefault(t *testing.T) {
|
||||||
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
||||||
m.width = 100
|
m.width = 100
|
||||||
|
|||||||
62
types.go
62
types.go
@@ -21,36 +21,38 @@ type PullRequest struct {
|
|||||||
|
|
||||||
type PRDetails struct {
|
type PRDetails struct {
|
||||||
PullRequest
|
PullRequest
|
||||||
Body string
|
Body string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
BaseRef string
|
BaseRef string
|
||||||
HeadRef string
|
HeadRef string
|
||||||
Mergeable string
|
Mergeable string
|
||||||
MergeState string
|
MergeState string
|
||||||
Assignees []string
|
ConflictFiles []string
|
||||||
Reviewers []Reviewer
|
ConflictFileError string
|
||||||
Labels []string
|
Assignees []string
|
||||||
Milestone string
|
Reviewers []Reviewer
|
||||||
Additions int
|
Labels []string
|
||||||
Deletions int
|
Milestone string
|
||||||
ChangedFiles int
|
Additions int
|
||||||
CommitCount int
|
Deletions int
|
||||||
CommentCount int
|
ChangedFiles int
|
||||||
HeadOID string
|
CommitCount int
|
||||||
CheckState string
|
CommentCount int
|
||||||
Checks []Check
|
HeadOID string
|
||||||
ReviewDecision string
|
CheckState string
|
||||||
Conversation []PRComment
|
Checks []Check
|
||||||
Reviews []ReviewSummary
|
ReviewDecision string
|
||||||
Permissions ViewerPermissions
|
Conversation []PRComment
|
||||||
Requirements MergeRequirements
|
Reviews []ReviewSummary
|
||||||
Threads []ReviewThread
|
Permissions ViewerPermissions
|
||||||
ThreadsTruncated bool
|
Requirements MergeRequirements
|
||||||
Timeline []TimelineEvent
|
Threads []ReviewThread
|
||||||
Rulesets []Ruleset
|
ThreadsTruncated bool
|
||||||
MergeQueue *MergeQueue
|
Timeline []TimelineEvent
|
||||||
FromCache bool
|
Rulesets []Ruleset
|
||||||
CachedAt time.Time
|
MergeQueue *MergeQueue
|
||||||
|
FromCache bool
|
||||||
|
CachedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Check struct {
|
type Check struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user