From f93c2c517a68e38f6f5418aec960e43204d28084 Mon Sep 17 00:00:00 2001 From: Pablu Date: Tue, 28 Jul 2026 11:46:43 +0200 Subject: [PATCH] Show conflicts to parent branch --- README.md | 18 ++++- conflicts.go | 190 ++++++++++++++++++++++++++++++++++++++++++++++ conflicts_test.go | 86 +++++++++++++++++++++ github.go | 42 ++++++++-- github_test.go | 44 +++++++++++ tui.go | 60 +++++++++++++++ tui_test.go | 48 ++++++++++++ types.go | 62 +++++++-------- 8 files changed, 509 insertions(+), 41 deletions(-) create mode 100644 conflicts.go create mode 100644 conflicts_test.go diff --git a/README.md b/README.md index e827337..8c9a74d 100644 --- a/README.md +++ b/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`, diff --git a/conflicts.go b/conflicts.go new file mode 100644 index 0000000..ad9bcd8 --- /dev/null +++ b/conflicts.go @@ -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, + ) +} diff --git a/conflicts_test.go b/conflicts_test.go new file mode 100644 index 0000000..adb4f50 --- /dev/null +++ b/conflicts_test.go @@ -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) + } +} diff --git a/github.go b/github.go index a1dd75d..ac8c8be 100644 --- a/github.go +++ b/github.go @@ -26,16 +26,21 @@ type GitHubWriteService interface { } type GitHubClient struct { - endpoint string - token string - http *http.Client + endpoint string + token string + http *http.Client + conflicts conflictFileLoader + conflictMu sync.Mutex + conflictCache map[string]conflictFileResult } func NewGitHubClient(endpoint, token string) *GitHubClient { return &GitHubClient{ - endpoint: endpoint, - token: token, - http: &http.Client{Timeout: 20 * time.Second}, + 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,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, 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, CheckState: "NONE", ReviewDecision: node.ReviewDecision, Permissions: ViewerPermissions{ @@ -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{ diff --git a/github_test.go b/github_test.go index 15f1d9f..813c584 100644 --- a/github_test.go +++ b/github_test.go @@ -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) + } +} diff --git a/tui.go b/tui.go index be79f4a..c7474c8 100644 --- a/tui.go +++ b/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": diff --git a/tui_test.go b/tui_test.go index ba07a03..f792e80 100644 --- a/tui_test.go +++ b/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 diff --git a/types.go b/types.go index d427735..820369a 100644 --- a/types.go +++ b/types.go @@ -21,36 +21,38 @@ type PullRequest struct { type PRDetails struct { PullRequest - Body string - CreatedAt time.Time - BaseRef string - HeadRef string - Mergeable string - MergeState string - Assignees []string - Reviewers []Reviewer - Labels []string - Milestone string - Additions int - Deletions int - ChangedFiles int - CommitCount int - CommentCount int - HeadOID string - CheckState string - Checks []Check - ReviewDecision string - Conversation []PRComment - Reviews []ReviewSummary - Permissions ViewerPermissions - Requirements MergeRequirements - Threads []ReviewThread - ThreadsTruncated bool - Timeline []TimelineEvent - Rulesets []Ruleset - MergeQueue *MergeQueue - FromCache bool - CachedAt time.Time + Body string + CreatedAt time.Time + BaseRef string + HeadRef string + Mergeable string + MergeState string + ConflictFiles []string + ConflictFileError string + Assignees []string + Reviewers []Reviewer + Labels []string + Milestone string + Additions int + Deletions int + ChangedFiles int + CommitCount int + CommentCount int + HeadOID string + CheckState string + Checks []Check + ReviewDecision string + Conversation []PRComment + Reviews []ReviewSummary + Permissions ViewerPermissions + Requirements MergeRequirements + Threads []ReviewThread + ThreadsTruncated bool + Timeline []TimelineEvent + Rulesets []Ruleset + MergeQueue *MergeQueue + FromCache bool + CachedAt time.Time } type Check struct {