package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "path" "sort" "strings" "sync" "time" ) type GitHubService interface { ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) GetPullRequest(context.Context, string, string, int) (PRDetails, error) } type GitHubWriteService interface { SetThreadResolved(context.Context, string, bool) (ReviewThread, error) ReplyToThread(context.Context, string, string) (ReviewComment, error) } type GitHubPullRequestWriteService interface { UpdatePullRequest(context.Context, string, PullRequestMetadata) (PullRequestMetadata, error) } type GitHubBranchService interface { ListBranches(context.Context, string, string) ([]RepositoryBranch, error) } 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 { return &GitHubClient{ endpoint: endpoint, token: token, http: &http.Client{Timeout: 20 * time.Second}, conflicts: analyzeConflictFiles, conflictCache: make(map[string]conflictFileResult), } } type graphQLRequest struct { Query string `json:"query"` Variables map[string]any `json:"variables"` } type graphQLError struct { Message string `json:"message"` } type graphQLResponse[T any] struct { Data T `json:"data"` Errors []graphQLError `json:"errors"` } func (c *GitHubClient) query(ctx context.Context, query string, variables map[string]any, target any) error { payload, err := json.Marshal(graphQLRequest{Query: query, Variables: variables}) if err != nil { return fmt.Errorf("encode GraphQL request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("create GraphQL request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "gh-threads") resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("GitHub request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { return fmt.Errorf("read GitHub response: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("GitHub returned %s: %s", resp.Status, strings.TrimSpace(string(body))) } envelope := graphQLResponse[json.RawMessage]{} if err := json.Unmarshal(body, &envelope); err != nil { return fmt.Errorf("decode GitHub response: %w", err) } if len(envelope.Errors) > 0 { messages := make([]string, len(envelope.Errors)) for i, item := range envelope.Errors { messages[i] = item.Message } return errors.New(strings.Join(messages, "; ")) } if err := json.Unmarshal(envelope.Data, target); err != nil { return fmt.Errorf("decode GitHub data: %w", err) } return nil } const listPRsQuery = ` query PullRequests($query: String!, $first: Int!, $after: String) { viewer { login } search(query: $query, type: ISSUE, first: $first, after: $after) { pageInfo { hasNextPage endCursor } nodes { ... on PullRequest { id number title url isDraft updatedAt author { login } repository { name nameWithOwner owner { login } } reviewThreads(first: 1) { totalCount } } } } }` const repositoryBranchesQuery = ` query RepositoryBranches($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { defaultBranchRef { name } refs(refPrefix: "refs/heads/", first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { name target { ... on Commit { committedDate } } } } } }` func (c *GitHubClient) ListBranches( ctx context.Context, owner, name string, ) ([]RepositoryBranch, error) { var branches []RepositoryBranch after := "" defaultBranch := "" for { var data struct { Repository *struct { DefaultBranchRef *struct{ Name string } Refs struct { PageInfo githubPageInfo Nodes []struct { Name string Target *struct{ CommittedDate time.Time } } } } } if err := c.query(ctx, repositoryBranchesQuery, map[string]any{ "owner": owner, "name": name, "after": nullableCursor(after), }, &data); err != nil { return nil, err } if data.Repository == nil { return nil, fmt.Errorf("repository %s/%s was not found", owner, name) } if data.Repository.DefaultBranchRef != nil { defaultBranch = data.Repository.DefaultBranchRef.Name } for _, node := range data.Repository.Refs.Nodes { if node.Name == "" { continue } branch := RepositoryBranch{Name: node.Name, IsDefault: node.Name == defaultBranch} if node.Target != nil { branch.UpdatedAt = node.Target.CommittedDate } branches = append(branches, branch) } if !data.Repository.Refs.PageInfo.HasNextPage { break } after = data.Repository.Refs.PageInfo.EndCursor if after == "" { return nil, errors.New("GitHub returned an empty branch pagination cursor") } } sort.SliceStable(branches, func(i, j int) bool { if branches[i].IsDefault != branches[j].IsDefault { return branches[i].IsDefault } if !branches[i].UpdatedAt.Equal(branches[j].UpdatedAt) { return branches[i].UpdatedAt.After(branches[j].UpdatedAt) } return strings.ToLower(branches[i].Name) < strings.ToLower(branches[j].Name) }) return branches, nil } type githubPRSearchNode struct { ID string `json:"id"` Number int `json:"number"` Title string `json:"title"` URL string `json:"url"` IsDraft bool `json:"isDraft"` UpdatedAt time.Time `json:"updatedAt"` Author *githubActor `json:"author"` Repository struct { Name, NameWithOwner string Owner githubActor } ReviewThreads struct{ TotalCount int } } const reviewThreadsPageQuery = ` query ReviewThreadsPage($owner: String!, $name: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id isResolved isOutdated viewerCanResolve viewerCanUnresolve viewerCanReply path line originalLine diffSide startLine originalStartLine startDiffSide comments(first: 100) { pageInfo { hasNextPage endCursor } nodes { id body diffHunk createdAt url outdated line startLine originalLine originalStartLine originalCommit { oid } author { login } reactionGroups { content viewerHasReacted reactors { totalCount } } } } } } } } }` const reviewCommentsPageQuery = ` query ReviewCommentsPage($id: ID!, $after: String) { node(id: $id) { ... on PullRequestReviewThread { comments(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id body diffHunk createdAt url outdated line startLine originalLine originalStartLine originalCommit { oid } author { login } reactionGroups { content viewerHasReacted reactors { totalCount } } } } } } }` const conversationPageQuery = ` query ConversationPage($owner: String!, $name: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { comments(first: 100, after: $after) { totalCount pageInfo { hasNextPage endCursor } nodes { id body url createdAt author { login } } } } } }` const reviewsPageQuery = ` query ReviewsPage($owner: String!, $name: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviews(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id body state url submittedAt author { login } commit { oid } } } } } }` func (c *GitHubClient) ListPullRequests(ctx context.Context, owner, name string, limit int, showAll bool) ([]PullRequest, error) { if showAll && owner == "" { return nil, errors.New("--all requires --repo to avoid an unbounded global search") } search := "is:pr is:open sort:updated-desc" if owner != "" { search += " repo:" + owner + "/" + name } if !showAll { search += " assignee:@me" } var nodes []githubPRSearchNode viewer := "" after := "" loaded := 0 for loaded < limit { var data struct { Viewer githubActor Search struct { PageInfo githubPageInfo Nodes []githubPRSearchNode } } first := min(100, limit-loaded) if err := c.query(ctx, listPRsQuery, map[string]any{ "query": search, "first": first, "after": nullableCursor(after), }, &data); err != nil { return nil, err } viewer = data.Viewer.Login nodes = append(nodes, data.Search.Nodes...) loaded += first if !data.Search.PageInfo.HasNextPage || data.Search.PageInfo.EndCursor == "" { break } after = data.Search.PageInfo.EndCursor } prs := make([]PullRequest, 0, len(nodes)) for _, node := range nodes { if node.Repository.NameWithOwner == "" { continue } author := "[ghost]" if node.Author != nil { author = node.Author.Login } prs = append(prs, PullRequest{ ID: node.ID, Owner: node.Repository.Owner.Login, Repository: node.Repository.Name, RepoWithOwner: node.Repository.NameWithOwner, Number: node.Number, Title: node.Title, URL: node.URL, Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, ReviewCount: node.ReviewThreads.TotalCount, ViewerAuthored: author == viewer, }) } sort.SliceStable(prs, func(i, j int) bool { left, right := strings.ToLower(prs[i].RepoWithOwner), strings.ToLower(prs[j].RepoWithOwner) if left != right { return left < right } return prs[i].UpdatedAt.After(prs[j].UpdatedAt) }) return prs, nil } func nullableCursor(cursor string) any { if cursor == "" { return nil } return cursor } 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]) { nodes { name enforcement target conditions { refName { include exclude } } rules(first: 100) { nodes { type } } } } pullRequest(number: $number) { id number title url body isDraft createdAt updatedAt mergeable mergeStateStatus reviewDecision mergeQueueEntry { state position enqueuedAt estimatedTimeToMerge } baseRefName headRefName headRefOid viewerCanUpdate viewerCanReact viewerCanSubscribe viewerCanEnableAutoMerge baseRef { target { ... on Commit { oid } } branchProtectionRule { requiresApprovingReviews requiredApprovingReviewCount requiresStatusChecks requiresConversationResolution requiresCodeOwnerReviews requiresDeployments requiredDeploymentEnvironments requiresStrictStatusChecks requiresLinearHistory requiresCommitSignatures } } author { login } assignees(first: 20) { nodes { login } } labels(first: 20) { nodes { name } } milestone { title } additions deletions changedFiles comments(first: 100) { totalCount pageInfo { hasNextPage endCursor } nodes { id body url createdAt author { login } } } reviews(first: 100) { pageInfo { hasNextPage endCursor } nodes { id body state url submittedAt author { login } commit { oid } } } reviewRequests(first: 50) { nodes { requestedReviewer { ... on User { login } ... on Team { name } } } } latestReviews(first: 50) { nodes { state author { login } } } commits(last: 1) { totalCount nodes { commit { oid statusCheckRollup { id state contexts(first: 100) { pageInfo { hasNextPage endCursor } nodes { ... on CheckRun { id name status conclusion detailsUrl title summary text } ... on StatusContext { context state targetUrl } } } } } } } timelineItems(first: 100, itemTypes: [PULL_REQUEST_COMMIT, HEAD_REF_FORCE_PUSHED_EVENT]) { pageInfo { hasNextPage endCursor } nodes { ... on PullRequestCommit { commit { oid committedDate messageHeadline author { user { login } name } } } ... on HeadRefForcePushedEvent { id createdAt actor { login } beforeCommit { oid } afterCommit { oid } } } } reviewThreads(first: 100) { pageInfo { hasNextPage endCursor } nodes { id isResolved isOutdated viewerCanResolve viewerCanUnresolve viewerCanReply path line originalLine diffSide startLine originalStartLine startDiffSide comments(first: 100) { pageInfo { hasNextPage endCursor } nodes { id body diffHunk createdAt url outdated line startLine originalLine originalStartLine originalCommit { oid } author { login } reactionGroups { content viewerHasReacted reactors { totalCount } } } } } } } } }` const timelinePageQuery = ` query TimelinePage($owner: String!, $name: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { timelineItems(first: 100, after: $after, itemTypes: [PULL_REQUEST_COMMIT, HEAD_REF_FORCE_PUSHED_EVENT]) { pageInfo { hasNextPage endCursor } nodes { ... on PullRequestCommit { commit { oid committedDate messageHeadline author { user { login } name } } } ... on HeadRefForcePushedEvent { id createdAt actor { login } beforeCommit { oid } afterCommit { oid } } } } } } }` const checkContextsPageQuery = ` query CheckContextsPage($id: ID!, $after: String) { node(id: $id) { ... on StatusCheckRollup { contexts(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { ... on CheckRun { id name status conclusion detailsUrl title summary text } ... on StatusContext { context state targetUrl } } } } } }` const checkAnnotationsPageQuery = ` query CheckAnnotationsPage($id: ID!, $after: String) { node(id: $id) { ... on CheckRun { annotations(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { path annotationLevel message title location { start { line column } end { line column } } } } } } }` const resolveThreadMutation = ` mutation ResolveReviewThread($input: ResolveReviewThreadInput!) { resolveReviewThread(input: $input) { thread { id isResolved isOutdated viewerCanResolve viewerCanUnresolve viewerCanReply path line originalLine diffSide startLine originalStartLine startDiffSide } } }` const unresolveThreadMutation = ` mutation UnresolveReviewThread($input: UnresolveReviewThreadInput!) { unresolveReviewThread(input: $input) { thread { id isResolved isOutdated viewerCanResolve viewerCanUnresolve viewerCanReply path line originalLine diffSide startLine originalStartLine startDiffSide } } }` const replyToThreadMutation = ` mutation ReplyToReviewThread($input: AddPullRequestReviewThreadReplyInput!) { addPullRequestReviewThreadReply(input: $input) { comment { id body diffHunk createdAt url outdated line startLine originalLine originalStartLine originalCommit { oid } author { login } } } }` const updatePullRequestMutation = ` mutation UpdatePullRequest($input: UpdatePullRequestInput!) { updatePullRequest(input: $input) { pullRequest { id title body baseRefName updatedAt mergeable mergeStateStatus } } }` type githubActor struct { Login string `json:"login"` Name string `json:"name"` } type githubPageInfo struct { HasNextPage bool `json:"hasNextPage"` EndCursor string `json:"endCursor"` } type githubReviewComment struct { ID, Body, DiffHunk, URL string Line, StartLine *int OriginalLine, OriginalStartLine *int Outdated bool OriginalCommit *struct{ OID string } CreatedAt time.Time Author *githubActor ReactionGroups []githubReactionGroup } type githubReactionGroup struct { Content string ViewerHasReacted bool Reactors struct { TotalCount int } } type githubReviewCommentConnection struct { PageInfo githubPageInfo `json:"pageInfo"` Nodes []githubReviewComment `json:"nodes"` } type githubReviewThread struct { ID, Path string DiffSide, StartDiffSide string Line, OriginalLine, StartLine, OriginalStartLine *int IsResolved, IsOutdated bool ViewerCanResolve, ViewerCanUnresolve, ViewerCanReply bool Comments githubReviewCommentConnection } type githubReviewThreadConnection struct { PageInfo githubPageInfo `json:"pageInfo"` Nodes []githubReviewThread `json:"nodes"` } type githubPRComment struct { ID, Body, URL string CreatedAt time.Time Author *githubActor } type githubPRCommentConnection struct { TotalCount int `json:"totalCount"` PageInfo githubPageInfo `json:"pageInfo"` Nodes []githubPRComment `json:"nodes"` } type githubReviewSummary struct { ID, Body, State, URL string SubmittedAt time.Time Author *githubActor Commit *struct{ OID string } } type githubReviewSummaryConnection struct { PageInfo githubPageInfo `json:"pageInfo"` Nodes []githubReviewSummary `json:"nodes"` } type githubCheckContext struct { ID string Name, Status, Conclusion, DetailsURL string Context, State, TargetURL string Title, Summary, Text string Annotations githubCheckAnnotationConnection } type githubCheckAnnotation struct { Path, AnnotationLevel, Message, Title string Location struct { Start, End struct { Line, Column int } } } type githubCheckAnnotationConnection struct { PageInfo githubPageInfo Nodes []githubCheckAnnotation } type githubCheckContextConnection struct { PageInfo githubPageInfo Nodes []githubCheckContext } type githubTimelineNode struct { ID, CreatedAtRaw string CreatedAt time.Time Actor *githubActor BeforeCommit, AfterCommit *struct{ OID string } Commit *struct { OID, MessageHeadline string CommittedDate time.Time Author *struct { Name string User *githubActor } } } type githubTimelineConnection struct { PageInfo githubPageInfo Nodes []githubTimelineNode } type githubRuleset struct { Name, Enforcement, Target string Conditions struct { RefName *struct{ Include, Exclude []string } } Rules struct{ Nodes []struct{ Type string } } } type githubPullRequestDetails struct { ID, Title, URL, Body, Mergeable, MergeStateStatus string ReviewDecision, BaseRefName, HeadRefName, HeadRefOID string Number, Additions, Deletions, ChangedFiles int IsDraft bool CreatedAt, UpdatedAt time.Time Author *githubActor ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe, ViewerCanEnableAutoMerge bool BaseRef *struct { Target *struct{ OID string } BranchProtectionRule *struct { RequiresApprovingReviews, RequiresStatusChecks bool RequiresConversationResolution, RequiresCodeOwnerReviews bool RequiredApprovingReviewCount int RequiresDeployments, RequiresStrictStatusChecks bool RequiresLinearHistory, RequiresCommitSignatures bool RequiredDeploymentEnvironments []string } } MergeQueueEntry *struct { State string Position, EstimatedTimeToMerge int EnqueuedAt time.Time } Assignees struct { Nodes []githubActor `json:"nodes"` } Labels struct { Nodes []struct { Name string `json:"name"` } `json:"nodes"` } Milestone *struct { Title string `json:"title"` } Comments githubPRCommentConnection Reviews githubReviewSummaryConnection ReviewRequests struct { Nodes []struct { RequestedReviewer githubActor `json:"requestedReviewer"` } `json:"nodes"` } LatestReviews struct { Nodes []struct { State string Author *githubActor } `json:"nodes"` } Commits struct { TotalCount int `json:"totalCount"` Nodes []struct { Commit struct { OID string StatusCheckRollup *struct { ID string State string Contexts githubCheckContextConnection } } } `json:"nodes"` } ReviewThreads githubReviewThreadConnection TimelineItems githubTimelineConnection } func (c *GitHubClient) allReviewThreads( ctx context.Context, owner, name string, number int, connection githubReviewThreadConnection, ) ([]githubReviewThread, error) { nodes := append([]githubReviewThread(nil), connection.Nodes...) for pages := 0; connection.PageInfo.HasNextPage; pages++ { if pages >= 100 { return nil, errors.New("review thread pagination exceeded 100 pages") } var data struct { Repository *struct { PullRequest *struct { ReviewThreads githubReviewThreadConnection `json:"reviewThreads"` } `json:"pullRequest"` } `json:"repository"` } variables := map[string]any{ "owner": owner, "name": name, "number": number, "after": connection.PageInfo.EndCursor, } if err := c.query(ctx, reviewThreadsPageQuery, variables, &data); err != nil { return nil, fmt.Errorf("load more review threads: %w", err) } if data.Repository == nil || data.Repository.PullRequest == nil { return nil, errors.New("pull request disappeared while loading review threads") } connection = data.Repository.PullRequest.ReviewThreads nodes = append(nodes, connection.Nodes...) } for i := range nodes { comments, err := c.allReviewComments(ctx, nodes[i].ID, nodes[i].Comments) if err != nil { return nil, err } nodes[i].Comments.Nodes = comments nodes[i].Comments.PageInfo = githubPageInfo{} } return nodes, nil } func (c *GitHubClient) allReviewComments( ctx context.Context, threadID string, connection githubReviewCommentConnection, ) ([]githubReviewComment, error) { nodes := append([]githubReviewComment(nil), connection.Nodes...) for pages := 0; connection.PageInfo.HasNextPage; pages++ { if pages >= 100 { return nil, fmt.Errorf("review comments for thread %s exceeded 100 pages", threadID) } var data struct { Node *struct { Comments githubReviewCommentConnection `json:"comments"` } `json:"node"` } if err := c.query(ctx, reviewCommentsPageQuery, map[string]any{ "id": threadID, "after": connection.PageInfo.EndCursor, }, &data); err != nil { return nil, fmt.Errorf("load more comments for review thread %s: %w", threadID, err) } if data.Node == nil { return nil, fmt.Errorf("review thread %s disappeared while loading comments", threadID) } connection = data.Node.Comments nodes = append(nodes, connection.Nodes...) } return nodes, nil } func (c *GitHubClient) allConversationComments( ctx context.Context, owner, name string, number int, connection githubPRCommentConnection, ) ([]githubPRComment, error) { nodes := append([]githubPRComment(nil), connection.Nodes...) for pages := 0; connection.PageInfo.HasNextPage; pages++ { if pages >= 100 { return nil, errors.New("PR conversation pagination exceeded 100 pages") } var data struct { Repository *struct { PullRequest *struct { Comments githubPRCommentConnection `json:"comments"` } `json:"pullRequest"` } `json:"repository"` } if err := c.query(ctx, conversationPageQuery, map[string]any{ "owner": owner, "name": name, "number": number, "after": connection.PageInfo.EndCursor, }, &data); err != nil { return nil, fmt.Errorf("load more PR conversation comments: %w", err) } if data.Repository == nil || data.Repository.PullRequest == nil { return nil, errors.New("pull request disappeared while loading conversation") } connection = data.Repository.PullRequest.Comments nodes = append(nodes, connection.Nodes...) } return nodes, nil } func (c *GitHubClient) allReviewSummaries( ctx context.Context, owner, name string, number int, connection githubReviewSummaryConnection, ) ([]githubReviewSummary, error) { nodes := append([]githubReviewSummary(nil), connection.Nodes...) for pages := 0; connection.PageInfo.HasNextPage; pages++ { if pages >= 100 { return nil, errors.New("review summary pagination exceeded 100 pages") } var data struct { Repository *struct { PullRequest *struct { Reviews githubReviewSummaryConnection `json:"reviews"` } `json:"pullRequest"` } `json:"repository"` } if err := c.query(ctx, reviewsPageQuery, map[string]any{ "owner": owner, "name": name, "number": number, "after": connection.PageInfo.EndCursor, }, &data); err != nil { return nil, fmt.Errorf("load more submitted reviews: %w", err) } if data.Repository == nil || data.Repository.PullRequest == nil { return nil, errors.New("pull request disappeared while loading reviews") } connection = data.Repository.PullRequest.Reviews nodes = append(nodes, connection.Nodes...) } return nodes, nil } func (c *GitHubClient) allTimelineItems( ctx context.Context, owner, name string, number int, connection githubTimelineConnection, ) ([]githubTimelineNode, error) { nodes := append([]githubTimelineNode(nil), connection.Nodes...) for pages := 0; connection.PageInfo.HasNextPage; pages++ { if pages >= 100 { return nil, errors.New("PR timeline pagination exceeded 100 pages") } var data struct { Repository *struct { PullRequest *struct{ TimelineItems githubTimelineConnection } } } if err := c.query(ctx, timelinePageQuery, map[string]any{ "owner": owner, "name": name, "number": number, "after": connection.PageInfo.EndCursor, }, &data); err != nil { return nil, fmt.Errorf("load more PR timeline events: %w", err) } if data.Repository == nil || data.Repository.PullRequest == nil { return nil, errors.New("pull request disappeared while loading timeline") } connection = data.Repository.PullRequest.TimelineItems nodes = append(nodes, connection.Nodes...) } return nodes, nil } func (c *GitHubClient) allCheckContexts( ctx context.Context, connection githubCheckContextConnection, rollupID string, ) ([]githubCheckContext, error) { nodes := append([]githubCheckContext(nil), connection.Nodes...) for pages := 0; connection.PageInfo.HasNextPage; pages++ { if pages >= 100 { return nil, errors.New("check context pagination exceeded 100 pages") } var data struct { Node *struct{ Contexts githubCheckContextConnection } } if err := c.query(ctx, checkContextsPageQuery, map[string]any{ "id": rollupID, "after": connection.PageInfo.EndCursor, }, &data); err != nil { return nil, fmt.Errorf("load more check contexts: %w", err) } if data.Node == nil { return nil, errors.New("check rollup disappeared while loading contexts") } connection = data.Node.Contexts nodes = append(nodes, connection.Nodes...) } for index := range nodes { if nodes[index].ID == "" || !checkMayHaveUsefulAnnotations(nodes[index]) { continue } annotations, err := c.checkAnnotations(ctx, nodes[index].ID) if err != nil { return nil, err } nodes[index].Annotations.Nodes = annotations } return nodes, nil } func checkMayHaveUsefulAnnotations(check githubCheckContext) bool { state := strings.ToUpper(firstNonEmpty(check.Conclusion, check.State, check.Status)) switch state { case "FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STALE": return true default: return false } } func (c *GitHubClient) checkAnnotations( ctx context.Context, checkID string, ) ([]githubCheckAnnotation, error) { var data struct { Node *struct { Annotations githubCheckAnnotationConnection } } if err := c.query(ctx, checkAnnotationsPageQuery, map[string]any{ "id": checkID, "after": nil, }, &data); err != nil { return nil, fmt.Errorf("load annotations for check %s: %w", checkID, err) } if data.Node == nil { return nil, fmt.Errorf("check %s disappeared while loading annotations", checkID) } return c.allCheckAnnotations(ctx, checkID, data.Node.Annotations) } func (c *GitHubClient) allCheckAnnotations( ctx context.Context, checkID string, connection githubCheckAnnotationConnection, ) ([]githubCheckAnnotation, error) { nodes := append([]githubCheckAnnotation(nil), connection.Nodes...) for pages := 0; connection.PageInfo.HasNextPage; pages++ { if pages >= 100 { return nil, fmt.Errorf("annotations for check %s exceeded 100 pages", checkID) } var data struct { Node *struct { Annotations githubCheckAnnotationConnection } } if err := c.query(ctx, checkAnnotationsPageQuery, map[string]any{ "id": checkID, "after": connection.PageInfo.EndCursor, }, &data); err != nil { return nil, fmt.Errorf("load more annotations for check %s: %w", checkID, err) } if data.Node == nil { return nil, fmt.Errorf("check %s disappeared while loading annotations", checkID) } connection = data.Node.Annotations nodes = append(nodes, connection.Nodes...) } return nodes, nil } 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 } PullRequest *githubPullRequestDetails `json:"pullRequest"` } `json:"repository"` } if err := c.query(ctx, detailsQuery, map[string]any{"owner": owner, "name": name, "number": number}, &data); err != nil { return PRDetails{}, err } if data.Repository == nil || data.Repository.PullRequest == nil { return PRDetails{}, fmt.Errorf("pull request #%d was not found", number) } node := data.Repository.PullRequest var ( threadNodes []githubReviewThread conversationNodes []githubPRComment reviewNodes []githubReviewSummary timelineNodes []githubTimelineNode checkNodes []githubCheckContext threadErr error conversationErr error reviewErr error timelineErr error checkErr error conflictFiles []string conflictFileErr error wait sync.WaitGroup ) wait.Add(4) go func() { defer wait.Done() threadNodes, threadErr = c.allReviewThreads(ctx, owner, name, number, node.ReviewThreads) }() go func() { defer wait.Done() conversationNodes, conversationErr = c.allConversationComments(ctx, owner, name, number, node.Comments) }() go func() { defer wait.Done() reviewNodes, reviewErr = c.allReviewSummaries(ctx, owner, name, number, node.Reviews) }() go func() { defer wait.Done() timelineNodes, timelineErr = c.allTimelineItems(ctx, owner, name, number, node.TimelineItems) }() if len(node.Commits.Nodes) > 0 && node.Commits.Nodes[0].Commit.StatusCheckRollup != nil { wait.Add(1) go func() { defer wait.Done() rollup := node.Commits.Nodes[0].Commit.StatusCheckRollup 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 { return PRDetails{}, err } } details := PRDetails{ PullRequest: PullRequest{ ID: node.ID, Owner: owner, Repository: name, RepoWithOwner: owner + "/" + name, Number: node.Number, Title: node.Title, URL: node.URL, Author: actorLogin(node.Author), IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, ReviewCount: len(threadNodes), }, 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, Permissions: ViewerPermissions{ Repository: data.Repository.ViewerPermission, CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact, 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{ ApprovalsRequired: rule.RequiredApprovingReviewCount, RequiresApprovals: rule.RequiresApprovingReviews, RequiresStatusChecks: rule.RequiresStatusChecks, RequiresConversation: rule.RequiresConversationResolution, RequiresCodeOwnerReview: rule.RequiresCodeOwnerReviews, RequiresDeployments: rule.RequiresDeployments, RequiredDeployments: append([]string(nil), rule.RequiredDeploymentEnvironments...), RequiresStrictChecks: rule.RequiresStrictStatusChecks, RequiresLinearHistory: rule.RequiresLinearHistory, RequiresSignatures: rule.RequiresCommitSignatures, } } if node.MergeQueueEntry != nil { details.MergeQueue = &MergeQueue{ State: node.MergeQueueEntry.State, Position: node.MergeQueueEntry.Position, EnqueuedAt: node.MergeQueueEntry.EnqueuedAt, EstimatedSeconds: node.MergeQueueEntry.EstimatedTimeToMerge, } details.Requirements.RequiresMergeQueue = true } for _, ruleset := range data.Repository.Rulesets.Nodes { rule := Ruleset{Name: ruleset.Name, Enforcement: ruleset.Enforcement} for _, item := range ruleset.Rules.Nodes { rule.RuleTypes = append(rule.RuleTypes, item.Type) if item.Type == "MERGE_QUEUE" { details.Requirements.RequiresMergeQueue = true } } rule.Applies = rulesetApplies(ruleset, node.BaseRefName, data.Repository.DefaultBranchRef != nil && data.Repository.DefaultBranchRef.Name == node.BaseRefName) details.Rulesets = append(details.Rulesets, rule) } for _, label := range node.Labels.Nodes { details.Labels = append(details.Labels, label.Name) } if node.Milestone != nil { details.Milestone = node.Milestone.Title } for _, assignee := range node.Assignees.Nodes { details.Assignees = append(details.Assignees, assignee.Login) } reviewers := map[string]string{} for _, request := range node.ReviewRequests.Nodes { login := firstNonEmpty(request.RequestedReviewer.Login, request.RequestedReviewer.Name) if login != "" { reviewers[login] = "REVIEW_REQUESTED" } } for _, review := range node.LatestReviews.Nodes { if review.Author != nil { reviewers[review.Author.Login] = review.State } } for login, state := range reviewers { details.Reviewers = append(details.Reviewers, Reviewer{Login: login, State: state}) } sort.Slice(details.Reviewers, func(i, j int) bool { return details.Reviewers[i].Login < details.Reviewers[j].Login }) if len(node.Commits.Nodes) > 0 && node.Commits.Nodes[0].Commit.StatusCheckRollup != nil { rollup := node.Commits.Nodes[0].Commit.StatusCheckRollup details.CheckState = rollup.State for _, check := range checkNodes { name := firstNonEmpty(check.Name, check.Context) state := firstNonEmpty(check.Conclusion, check.State, check.Status) item := Check{ ID: check.ID, Name: name, State: state, Conclusion: check.Conclusion, URL: firstNonEmpty(check.DetailsURL, check.TargetURL), } item.Summary = firstNonEmpty(check.Summary, check.Text, check.Title) for _, annotation := range check.Annotations.Nodes { item.Annotations = append(item.Annotations, CheckAnnotation{ Path: annotation.Path, StartLine: annotation.Location.Start.Line, EndLine: annotation.Location.End.Line, Level: annotation.AnnotationLevel, Title: annotation.Title, Message: annotation.Message, }) } details.Checks = append(details.Checks, item) } } for _, event := range timelineNodes { if event.Commit != nil { author := "[ghost]" if event.Commit.Author != nil { author = event.Commit.Author.Name if event.Commit.Author.User != nil { author = actorLogin(event.Commit.Author.User) } } details.Timeline = append(details.Timeline, TimelineEvent{ Kind: "commit", OID: event.Commit.OID, Title: event.Commit.MessageHeadline, Author: author, CreatedAt: event.Commit.CommittedDate, }) } else if event.BeforeCommit != nil || event.AfterCommit != nil { details.Timeline = append(details.Timeline, TimelineEvent{ Kind: "force-push", BeforeOID: commitOID(event.BeforeCommit), AfterOID: commitOID(event.AfterCommit), Author: actorLogin(event.Actor), CreatedAt: event.CreatedAt, }) } } for _, comment := range conversationNodes { details.Conversation = append(details.Conversation, PRComment{ ID: comment.ID, Author: actorLogin(comment.Author), Body: comment.Body, URL: comment.URL, CreatedAt: comment.CreatedAt, }) } for _, review := range reviewNodes { details.Reviews = append(details.Reviews, ReviewSummary{ ID: review.ID, Author: actorLogin(review.Author), Body: review.Body, State: review.State, URL: review.URL, SubmittedAt: review.SubmittedAt, CommitOID: commitOID(review.Commit), }) } for _, thread := range threadNodes { item := convertReviewThread(thread) details.Permissions.CanResolveAny = details.Permissions.CanResolveAny || item.ViewerCanResolve details.Permissions.CanUnresolveAny = details.Permissions.CanUnresolveAny || item.ViewerCanUnresolve details.Permissions.CanReplyAny = details.Permissions.CanReplyAny || item.ViewerCanReply details.Threads = append(details.Threads, item) } return details, nil } func (c *GitHubClient) SetThreadResolved( ctx context.Context, threadID string, resolved bool, ) (ReviewThread, error) { query := resolveThreadMutation variables := map[string]any{"input": map[string]any{"threadId": threadID}} if resolved { var data struct { ResolveReviewThread *struct { Thread *githubReviewThread } } if err := c.query(ctx, query, variables, &data); err != nil { return ReviewThread{}, err } if data.ResolveReviewThread == nil || data.ResolveReviewThread.Thread == nil { return ReviewThread{}, errors.New("GitHub returned no resolved review thread") } return convertReviewThread(*data.ResolveReviewThread.Thread), nil } var data struct { UnresolveReviewThread *struct { Thread *githubReviewThread } } if err := c.query(ctx, unresolveThreadMutation, variables, &data); err != nil { return ReviewThread{}, err } if data.UnresolveReviewThread == nil || data.UnresolveReviewThread.Thread == nil { return ReviewThread{}, errors.New("GitHub returned no unresolved review thread") } return convertReviewThread(*data.UnresolveReviewThread.Thread), nil } func (c *GitHubClient) ReplyToThread( ctx context.Context, threadID, body string, ) (ReviewComment, error) { var data struct { AddPullRequestReviewThreadReply *struct { Comment *githubReviewComment } } if err := c.query(ctx, replyToThreadMutation, map[string]any{ "input": map[string]any{ "pullRequestReviewThreadId": threadID, "body": body, }, }, &data); err != nil { return ReviewComment{}, err } if data.AddPullRequestReviewThreadReply == nil || data.AddPullRequestReviewThreadReply.Comment == nil { return ReviewComment{}, errors.New("GitHub returned no review reply") } return convertReviewComment(*data.AddPullRequestReviewThreadReply.Comment), nil } func (c *GitHubClient) UpdatePullRequest( ctx context.Context, pullRequestID string, update PullRequestMetadata, ) (PullRequestMetadata, error) { var data struct { UpdatePullRequest *struct { PullRequest *struct { ID, Title, Body, BaseRefName, Mergeable, MergeStateStatus string UpdatedAt time.Time } } } if err := c.query(ctx, updatePullRequestMutation, map[string]any{ "input": map[string]any{ "pullRequestId": pullRequestID, "title": update.Title, "body": update.Body, "baseRefName": update.BaseRef, }, }, &data); err != nil { return PullRequestMetadata{}, err } if data.UpdatePullRequest == nil || data.UpdatePullRequest.PullRequest == nil { return PullRequestMetadata{}, errors.New("GitHub returned no updated pull request") } node := data.UpdatePullRequest.PullRequest return PullRequestMetadata{ Title: node.Title, Body: node.Body, BaseRef: node.BaseRefName, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus, UpdatedAt: node.UpdatedAt, }, nil } func convertReviewThread(thread githubReviewThread) ReviewThread { item := ReviewThread{ ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide, IsResolved: thread.IsResolved, IsOutdated: thread.IsOutdated, ViewerCanResolve: thread.ViewerCanResolve, ViewerCanUnresolve: thread.ViewerCanUnresolve, ViewerCanReply: thread.ViewerCanReply, } if thread.Line != nil { item.Line = *thread.Line } else if thread.OriginalLine != nil { item.Line = *thread.OriginalLine } if thread.StartLine != nil { item.StartLine = *thread.StartLine } else if thread.OriginalStartLine != nil { item.StartLine = *thread.OriginalStartLine } if item.DiffSide == "" { item.DiffSide = thread.StartDiffSide } for _, comment := range thread.Comments.Nodes { item.Comments = append(item.Comments, convertReviewComment(comment)) } return item } func convertReviewComment(comment githubReviewComment) ReviewComment { item := ReviewComment{ ID: comment.ID, Author: actorLogin(comment.Author), Body: comment.Body, DiffHunk: comment.DiffHunk, CreatedAt: comment.CreatedAt, URL: comment.URL, Line: intValue(comment.Line), StartLine: intValue(comment.StartLine), OriginalLine: intValue(comment.OriginalLine), OriginalStartLine: intValue(comment.OriginalStartLine), OriginalCommitOID: commitOID(comment.OriginalCommit), Outdated: comment.Outdated, } for _, group := range comment.ReactionGroups { if group.Reactors.TotalCount > 0 { item.Reactions = append(item.Reactions, ReactionSummary{ Content: group.Content, Count: group.Reactors.TotalCount, ViewerHasReacted: group.ViewerHasReacted, }) } } return item } func actorLogin(actor *githubActor) string { if actor == nil || actor.Login == "" { return "[ghost]" } return actor.Login } func intValue(value *int) int { if value == nil { return 0 } return *value } func commitOID(commit *struct{ OID string }) string { if commit == nil { return "" } return commit.OID } func rulesetApplies(ruleset githubRuleset, base string, isDefault bool) bool { if ruleset.Enforcement == "DISABLED" || ruleset.Target != "" && ruleset.Target != "BRANCH" { return false } if ruleset.Conditions.RefName == nil || len(ruleset.Conditions.RefName.Include) == 0 { return true } ref := "refs/heads/" + base matches := func(pattern string) bool { switch pattern { case "~ALL": return true case "~DEFAULT_BRANCH": return isDefault } ok, _ := path.Match(pattern, ref) return ok } for _, excluded := range ruleset.Conditions.RefName.Exclude { if matches(excluded) { return false } } for _, included := range ruleset.Conditions.RefName.Include { if matches(included) { return true } } return false }