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
|
||||
shows open PRs and a scrollable PR dashboard with the description, branches,
|
||||
review state, checks, people, labels, milestone, activity, change statistics,
|
||||
thread totals, submitted reviews, and the PR conversation. Review threads and
|
||||
comments are paginated rather than silently stopping at the first page. The
|
||||
review state, merge conflicts and affected files, checks, people, labels,
|
||||
milestone, activity, change statistics, thread totals, submitted reviews, and
|
||||
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
|
||||
threads start folded. GitHub suggestion blocks are shown as
|
||||
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
|
||||
|
||||
Requires Go 1.24+ and an authenticated GitHub CLI:
|
||||
Requires Go 1.24+, Git 2.38+, and an authenticated GitHub CLI:
|
||||
|
||||
```sh
|
||||
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
|
||||
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
|
||||
configured repository when `--repo` is not provided. The corresponding flags
|
||||
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)
|
||||
}
|
||||
}
|
||||
28
github.go
28
github.go
@@ -29,6 +29,9 @@ type GitHubClient struct {
|
||||
endpoint string
|
||||
token string
|
||||
http *http.Client
|
||||
conflicts conflictFileLoader
|
||||
conflictMu sync.Mutex
|
||||
conflictCache map[string]conflictFileResult
|
||||
}
|
||||
|
||||
func NewGitHubClient(endpoint, token string) *GitHubClient {
|
||||
@@ -36,6 +39,8 @@ func NewGitHubClient(endpoint, token string) *GitHubClient {
|
||||
endpoint: endpoint,
|
||||
token: token,
|
||||
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 = `
|
||||
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
url
|
||||
viewerPermission
|
||||
defaultBranchRef { name }
|
||||
rulesets(first: 100, includeParents: true, targets: [BRANCH]) {
|
||||
@@ -288,6 +294,7 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
||||
baseRefName headRefName headRefOid
|
||||
viewerCanUpdate viewerCanReact viewerCanSubscribe viewerCanEnableAutoMerge
|
||||
baseRef {
|
||||
target { ... on Commit { oid } }
|
||||
branchProtectionRule {
|
||||
requiresApprovingReviews requiredApprovingReviewCount
|
||||
requiresStatusChecks requiresConversationResolution
|
||||
@@ -586,6 +593,7 @@ type githubPullRequestDetails struct {
|
||||
Author *githubActor
|
||||
ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe, ViewerCanEnableAutoMerge bool
|
||||
BaseRef *struct {
|
||||
Target *struct{ OID string }
|
||||
BranchProtectionRule *struct {
|
||||
RequiresApprovingReviews, RequiresStatusChecks 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) {
|
||||
var data struct {
|
||||
Repository *struct {
|
||||
URL string
|
||||
ViewerPermission string `json:"viewerPermission"`
|
||||
DefaultBranchRef *struct{ Name string }
|
||||
Rulesets struct{ Nodes []githubRuleset }
|
||||
@@ -909,6 +918,8 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
||||
reviewErr error
|
||||
timelineErr error
|
||||
checkErr error
|
||||
conflictFiles []string
|
||||
conflictFileErr error
|
||||
wait sync.WaitGroup
|
||||
)
|
||||
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)
|
||||
}()
|
||||
}
|
||||
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()
|
||||
for _, err := range []error{threadErr, conversationErr, reviewErr, timelineErr, checkErr} {
|
||||
if err != nil {
|
||||
@@ -951,6 +975,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
||||
},
|
||||
Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
|
||||
HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
|
||||
ConflictFiles: conflictFiles,
|
||||
Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles,
|
||||
CommitCount: node.Commits.TotalCount, CommentCount: node.Comments.TotalCount,
|
||||
CheckState: "NONE", ReviewDecision: node.ReviewDecision,
|
||||
@@ -960,6 +985,9 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
||||
CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge,
|
||||
},
|
||||
}
|
||||
if conflictFileErr != nil {
|
||||
details.ConflictFileError = conflictFileErr.Error()
|
||||
}
|
||||
if node.BaseRef != nil && node.BaseRef.BranchProtectionRule != nil {
|
||||
rule := node.BaseRef.BranchProtectionRule
|
||||
details.Requirements = MergeRequirements{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -375,3 +376,46 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
||||
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("checks", coloredState(pr.CheckState)),
|
||||
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("reviewers", reviewersText(pr.Reviewers)),
|
||||
dashboardMetadata("labels", labels),
|
||||
@@ -1527,6 +1530,21 @@ func (m App) dashboardLines() []string {
|
||||
dashboardMetadata("updated", updated),
|
||||
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, writeCapabilityLines(pr, m.selectedThread())...)
|
||||
if pr.ThreadsTruncated {
|
||||
@@ -1688,6 +1706,22 @@ func dashboardMetadata(label, value string) string {
|
||||
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) {
|
||||
for _, thread := range threads {
|
||||
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 {
|
||||
switch state {
|
||||
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) {
|
||||
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
|
||||
m.width = 100
|
||||
|
||||
Reference in New Issue
Block a user