Files
diple/github.go
2026-07-28 12:44:15 +02:00

1289 lines
41 KiB
Go

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 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 }
}
}
}
}`
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 }
}
}
}`
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 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
}