package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "path" "sort" "strconv" "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 GitHubPullRequestPeopleWriteService interface { UpdatePullRequestPeople( context.Context, string, string, int, PullRequestPeopleUpdate, ) (PullRequestPeople, error) } type GitHubMergeService interface { SetPullRequestAutoMerge(context.Context, string, string, string, bool) (*AutoMergeRequest, error) MergePullRequest(context.Context, string, string, string) (PullRequestMergeResult, error) } type GitHubBranchService interface { ListBranches(context.Context, string, string) ([]RepositoryBranch, error) } type GitHubRepositoryPeopleService interface { ListRepositoryUsers(context.Context, string, string) ([]RepositoryUser, error) } type GitHubEnrichmentService interface { EnrichPullRequest(context.Context, PRDetails) PRDetailsEnrichment } type GitHubClient struct { endpoint string token string http *http.Client conflicts conflictFileLoader conflictMu sync.Mutex conflictCache map[string]conflictFileResult health healthTracker annotationMu sync.Mutex annotationCache map[string][]CheckAnnotation } 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), annotationCache: make(map[string][]CheckAnnotation), } } 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, ) (resultErr error) { started := time.Now() defer func() { component := HealthComponent{ Name: "GitHub API", Level: healthOK, Summary: "last request succeeded", Detail: time.Since(started).Round(time.Millisecond).String(), UpdatedAt: time.Now(), } if resultErr != nil { component.Level = healthError component.Summary = resultErr.Error() } c.health.set(component) }() 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", "diple") resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("GitHub request: %w", err) } defer resp.Body.Close() c.recordRateLimit(resp) 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 } func (c *GitHubClient) recordRateLimit(response *http.Response) { parseInt := func(name string) int { value, _ := strconv.Atoi(response.Header.Get(name)) return value } rate := RateLimitSnapshot{ Limit: parseInt("X-RateLimit-Limit"), Remaining: parseInt("X-RateLimit-Remaining"), Used: parseInt("X-RateLimit-Used"), UpdatedAt: time.Now(), } if reset, err := strconv.ParseInt(response.Header.Get("X-RateLimit-Reset"), 10, 64); err == nil { rate.ResetAt = time.Unix(reset, 0) } if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 { rate.RetryAfter = time.Now().Add(time.Duration(seconds) * time.Second) } if (response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusTooManyRequests) && rate.RetryAfter.IsZero() { if rate.Remaining == 0 && !rate.ResetAt.IsZero() { rate.RetryAfter = rate.ResetAt } else { rate.RetryAfter = time.Now().Add(time.Minute) } } c.health.setRate(rate) } func (c *GitHubClient) HealthReport() []HealthComponent { return c.health.report() } func (c *GitHubClient) RateLimit() RateLimitSnapshot { return c.health.rateLimit() } 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!) { viewer { login } repository(owner: $owner, name: $name) { url mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed 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 state merged mergedAt mergeable mergeStateStatus reviewDecision autoMergeRequest { mergeMethod enabledAt enabledBy { login } } mergeQueueEntry { state position enqueuedAt estimatedTimeToMerge } baseRefName headRefName headRefOid viewerCanUpdate viewerCanReact viewerCanSubscribe viewerCanEnableAutoMerge viewerCanDisableAutoMerge baseRef { target { ... on Commit { oid } } branchProtectionRule { requiresApprovingReviews requiredApprovingReviewCount requiresStatusChecks requiresConversationResolution requiresCodeOwnerReviews requiresDeployments requiredDeploymentEnvironments requiresStrictStatusChecks requiresLinearHistory requiresCommitSignatures } } author { login } assignees(first: 100) { pageInfo { hasNextPage endCursor } nodes { id login name } } 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 assigneesPageQuery = ` query AssigneesPage($owner: String!, $name: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { assignees(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id login name } } } } }` 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 } } }` const enableAutoMergeMutation = ` mutation EnablePullRequestAutoMerge($input: EnablePullRequestAutoMergeInput!) { enablePullRequestAutoMerge(input: $input) { pullRequest { autoMergeRequest { mergeMethod enabledAt enabledBy { login } } } } }` const disableAutoMergeMutation = ` mutation DisablePullRequestAutoMerge($input: DisablePullRequestAutoMergeInput!) { disablePullRequestAutoMerge(input: $input) { pullRequest { id autoMergeRequest { mergeMethod } } } }` const mergePullRequestMutation = ` mutation MergePullRequest($input: MergePullRequestInput!) { mergePullRequest(input: $input) { pullRequest { merged mergedAt } } }` 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 githubUserConnection struct { PageInfo githubPageInfo `json:"pageInfo"` Nodes []githubActor `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, State, Mergeable, MergeStateStatus string ReviewDecision, BaseRefName, HeadRefName, HeadRefOID string Number, Additions, Deletions, ChangedFiles int IsDraft, Merged bool CreatedAt, UpdatedAt, MergedAt time.Time Author *githubActor ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe bool ViewerCanEnableAutoMerge, ViewerCanDisableAutoMerge bool AutoMergeRequest *struct { MergeMethod string EnabledAt time.Time EnabledBy *githubActor } 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 githubUserConnection 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) allAssignees( ctx context.Context, owner, name string, number int, connection githubUserConnection, ) ([]githubActor, error) { nodes := append([]githubActor(nil), connection.Nodes...) for pages := 0; connection.PageInfo.HasNextPage; pages++ { if pages >= 100 { return nil, errors.New("assignee pagination exceeded 100 pages") } var data struct { Repository *struct { PullRequest *struct { Assignees githubUserConnection `json:"assignees"` } `json:"pullRequest"` } `json:"repository"` } if err := c.query(ctx, assigneesPageQuery, map[string]any{ "owner": owner, "name": name, "number": number, "after": connection.PageInfo.EndCursor, }, &data); err != nil { return nil, fmt.Errorf("load more assignees: %w", err) } if data.Repository == nil || data.Repository.PullRequest == nil { return nil, errors.New("pull request disappeared while loading assignees") } connection = data.Repository.PullRequest.Assignees 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...) } return nodes, nil } 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 { Viewer githubActor Repository *struct { URL string ViewerPermission string `json:"viewerPermission"` MergeCommitAllowed, SquashMergeAllowed, RebaseMergeAllowed bool 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 assigneeNodes []githubActor conversationNodes []githubPRComment reviewNodes []githubReviewSummary timelineNodes []githubTimelineNode checkNodes []githubCheckContext threadErr error assigneeErr error conversationErr error reviewErr error timelineErr error checkErr error wait sync.WaitGroup ) wait.Add(5) go func() { defer wait.Done() threadNodes, threadErr = c.allReviewThreads(ctx, owner, name, number, node.ReviewThreads) }() go func() { defer wait.Done() assigneeNodes, assigneeErr = c.allAssignees( ctx, owner, name, number, node.Assignees, ) }() 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) }() } wait.Wait() if threadErr != nil { threadNodes = append([]githubReviewThread(nil), node.ReviewThreads.Nodes...) } if assigneeErr != nil { assigneeNodes = append([]githubActor(nil), node.Assignees.Nodes...) } if conversationErr != nil { conversationNodes = append([]githubPRComment(nil), node.Comments.Nodes...) } if reviewErr != nil { reviewNodes = append([]githubReviewSummary(nil), node.Reviews.Nodes...) } if timelineErr != nil { timelineNodes = append([]githubTimelineNode(nil), node.TimelineItems.Nodes...) } if checkErr != nil && len(node.Commits.Nodes) > 0 && node.Commits.Nodes[0].Commit.StatusCheckRollup != nil { checkNodes = append( []githubCheckContext(nil), node.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes..., ) } 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), ViewerAuthored: actorLogin(node.Author) == data.Viewer.Login, }, ViewerLogin: data.Viewer.Login, Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName, HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus, State: node.State, Merged: node.Merged, MergedAt: node.MergedAt, RepositoryURL: data.Repository.URL, 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, CanAssign: viewerCanAssign(data.Repository.ViewerPermission), CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge, CanDisableMerge: node.ViewerCanDisableAutoMerge, }, } if data.Repository.SquashMergeAllowed { details.AllowedMergeMethods = append(details.AllowedMergeMethods, "SQUASH") } if data.Repository.MergeCommitAllowed { details.AllowedMergeMethods = append(details.AllowedMergeMethods, "MERGE") } if data.Repository.RebaseMergeAllowed { details.AllowedMergeMethods = append(details.AllowedMergeMethods, "REBASE") } if node.AutoMergeRequest != nil { details.AutoMerge = &AutoMergeRequest{ MergeMethod: node.AutoMergeRequest.MergeMethod, EnabledAt: node.AutoMergeRequest.EnabledAt, EnabledBy: actorLogin(node.AutoMergeRequest.EnabledBy), } } if node.BaseRef != nil && node.BaseRef.Target != nil { details.BaseOID = node.BaseRef.Target.OID } for component, err := range map[string]error{ "review threads": threadErr, "conversation": conversationErr, "submitted reviews": reviewErr, "assignees": assigneeErr, "timeline": timelineErr, "checks": checkErr, } { if err != nil { details.DataIssues = append(details.DataIssues, DataIssue{ Component: component, Message: err.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 assigneeNodes { 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" } if request.RequestedReviewer.Login != "" { details.RequestedReviewers = append( details.RequestedReviewers, request.RequestedReviewer.Login, ) } } 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 }) sort.Strings(details.RequestedReviewers) 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) EnrichPullRequest( ctx context.Context, details PRDetails, ) PRDetailsEnrichment { result := PRDetailsEnrichment{ Owner: details.Owner, Repository: details.Repository, Number: details.Number, HeadOID: details.HeadOID, CheckAnnotations: make(map[string][]CheckAnnotation), } type annotationJob struct { index int check Check } type annotationResult struct { checkID string annotations []CheckAnnotation err error } var jobs []annotationJob var annotations []annotationResult for _, check := range details.Checks { if check.ID == "" || !checkStateMayHaveUsefulAnnotations(check) { continue } if annotations, ok := c.cachedAnnotations(check.ID); ok { result.CheckAnnotations[check.ID] = annotations continue } jobs = append(jobs, annotationJob{index: len(annotations), check: check}) annotations = append(annotations, annotationResult{checkID: check.ID}) } var wait sync.WaitGroup queue := make(chan annotationJob, len(jobs)) for _, job := range jobs { queue <- job } close(queue) for range min(4, len(jobs)) { wait.Add(1) go func() { defer wait.Done() for job := range queue { nodes, err := c.checkAnnotations(ctx, job.check.ID) if err != nil { annotations[job.index].err = err continue } converted := make([]CheckAnnotation, 0, len(nodes)) for _, annotation := range nodes { converted = append(converted, CheckAnnotation{ Path: annotation.Path, StartLine: annotation.Location.Start.Line, EndLine: annotation.Location.End.Line, Level: annotation.AnnotationLevel, Title: annotation.Title, Message: annotation.Message, }) } c.storeAnnotations(job.check.ID, converted) annotations[job.index].annotations = converted } }() } var conflictFiles []string var conflictErr error if details.Mergeable == "CONFLICTING" && c.conflicts != nil { wait.Add(1) go func() { defer wait.Done() conflictFiles, conflictErr = c.loadConflictFiles( ctx, details.RepositoryURL, details.Number, details.BaseRef, details.BaseOID, details.HeadOID, ) }() } wait.Wait() for _, loaded := range annotations { if loaded.err != nil { result.Issues = append(result.Issues, DataIssue{ Component: "check annotations", Message: loaded.err.Error(), }) } else { result.CheckAnnotations[loaded.checkID] = loaded.annotations } } if conflictErr != nil { result.Issues = append(result.Issues, DataIssue{ Component: "conflict file scan", Message: conflictErr.Error(), }) } else if conflictFiles != nil { result.ConflictFiles = conflictFiles } level, summary := healthOK, "secondary PR data loaded" if len(result.Issues) > 0 { level, summary = healthWarning, fmt.Sprintf( "%d secondary data source(s) failed", len(result.Issues), ) } c.health.set(HealthComponent{ Name: "PR enrichment", Level: level, Summary: summary, UpdatedAt: time.Now(), }) return result } func checkStateMayHaveUsefulAnnotations(check Check) bool { state := strings.ToUpper(firstNonEmpty(check.Conclusion, check.State)) switch state { case "FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STALE": return true default: return false } } func (c *GitHubClient) cachedAnnotations(checkID string) ([]CheckAnnotation, bool) { c.annotationMu.Lock() defer c.annotationMu.Unlock() annotations, ok := c.annotationCache[checkID] return append([]CheckAnnotation(nil), annotations...), ok } func (c *GitHubClient) storeAnnotations(checkID string, annotations []CheckAnnotation) { c.annotationMu.Lock() defer c.annotationMu.Unlock() if len(c.annotationCache) >= 256 { c.annotationCache = make(map[string][]CheckAnnotation) } c.annotationCache[checkID] = append([]CheckAnnotation(nil), annotations...) } 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 (c *GitHubClient) SetPullRequestAutoMerge( ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool, ) (*AutoMergeRequest, error) { if !enabled { var data struct { DisablePullRequestAutoMerge *struct{ PullRequest *struct{ ID string } } } if err := c.query(ctx, disableAutoMergeMutation, map[string]any{ "input": map[string]any{"pullRequestId": pullRequestID}, }, &data); err != nil { return nil, err } if data.DisablePullRequestAutoMerge == nil || data.DisablePullRequestAutoMerge.PullRequest == nil { return nil, errors.New("GitHub returned no pull request after disabling auto-merge") } return nil, nil } var data struct { EnablePullRequestAutoMerge *struct { PullRequest *struct { AutoMergeRequest *struct { MergeMethod string EnabledAt time.Time EnabledBy *githubActor } } } } input := map[string]any{ "pullRequestId": pullRequestID, "expectedHeadOid": expectedHeadOID, "mergeMethod": mergeMethod, } if err := c.query(ctx, enableAutoMergeMutation, map[string]any{"input": input}, &data); err != nil { return nil, err } if data.EnablePullRequestAutoMerge == nil || data.EnablePullRequestAutoMerge.PullRequest == nil || data.EnablePullRequestAutoMerge.PullRequest.AutoMergeRequest == nil { return nil, errors.New("GitHub returned no auto-merge request") } node := data.EnablePullRequestAutoMerge.PullRequest.AutoMergeRequest return &AutoMergeRequest{ MergeMethod: node.MergeMethod, EnabledAt: node.EnabledAt, EnabledBy: actorLogin(node.EnabledBy), }, nil } func (c *GitHubClient) MergePullRequest( ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, ) (PullRequestMergeResult, error) { var data struct { MergePullRequest *struct { PullRequest *struct { Merged bool MergedAt time.Time } } } if err := c.query(ctx, mergePullRequestMutation, map[string]any{ "input": map[string]any{ "pullRequestId": pullRequestID, "expectedHeadOid": expectedHeadOID, "mergeMethod": mergeMethod, }, }, &data); err != nil { return PullRequestMergeResult{}, err } if data.MergePullRequest == nil || data.MergePullRequest.PullRequest == nil || !data.MergePullRequest.PullRequest.Merged { return PullRequestMergeResult{}, errors.New("GitHub did not merge the pull request") } return PullRequestMergeResult{ Merged: true, MergedAt: data.MergePullRequest.PullRequest.MergedAt, }, 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 viewerCanAssign(permission string) bool { switch strings.ToUpper(permission) { case "TRIAGE", "WRITE", "MAINTAIN", "ADMIN": return true default: return false } } 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 }