1559 lines
49 KiB
Go
1559 lines
49 KiB
Go
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 GitHubBranchService interface {
|
|
ListBranches(context.Context, string, string) ([]RepositoryBranch, 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", "gh-threads")
|
|
|
|
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!) {
|
|
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...)
|
|
}
|
|
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
|
|
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)
|
|
}()
|
|
}
|
|
wait.Wait()
|
|
if threadErr != nil {
|
|
threadNodes = append([]githubReviewThread(nil), node.ReviewThreads.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),
|
|
},
|
|
Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
|
|
HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
|
|
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,
|
|
CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge,
|
|
},
|
|
}
|
|
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, "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 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) 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),
|
|
}
|
|
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
|
|
}
|
|
nodes, err := c.checkAnnotations(ctx, check.ID)
|
|
if err != nil {
|
|
result.Issues = append(result.Issues, DataIssue{
|
|
Component: "check annotations", Message: err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
annotations := make([]CheckAnnotation, 0, len(nodes))
|
|
for _, annotation := range nodes {
|
|
annotations = append(annotations, 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(check.ID, annotations)
|
|
result.CheckAnnotations[check.ID] = annotations
|
|
}
|
|
if details.Mergeable == "CONFLICTING" && c.conflicts != nil {
|
|
files, err := c.loadConflictFiles(
|
|
ctx, details.RepositoryURL, details.Number, details.BaseRef,
|
|
details.BaseOID, details.HeadOID,
|
|
)
|
|
if err != nil {
|
|
result.Issues = append(result.Issues, DataIssue{
|
|
Component: "conflict file scan", Message: err.Error(),
|
|
})
|
|
} else {
|
|
result.ConflictFiles = files
|
|
}
|
|
}
|
|
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 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
|
|
}
|