Show conflicts to parent branch

This commit is contained in:
2026-07-28 11:46:43 +02:00
parent 0e880fa9b0
commit f93c2c517a
8 changed files with 509 additions and 41 deletions

View File

@@ -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
View 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
View 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)
}
}

View File

@@ -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{

View File

@@ -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
View File

@@ -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":

View File

@@ -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

View File

@@ -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 {