1259 lines
40 KiB
Go
1259 lines
40 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 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
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 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
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 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
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
|
|
}
|
|
|
|
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 {
|
|
return 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,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|