348 lines
11 KiB
Go
348 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type GitHubService interface {
|
|
ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error)
|
|
GetPullRequest(context.Context, string, string, int) (PRDetails, error)
|
|
}
|
|
|
|
type GitHubClient struct {
|
|
endpoint string
|
|
token string
|
|
http *http.Client
|
|
}
|
|
|
|
func NewGitHubClient(endpoint, token string) *GitHubClient {
|
|
return &GitHubClient{
|
|
endpoint: endpoint,
|
|
token: token,
|
|
http: &http.Client{Timeout: 20 * time.Second},
|
|
}
|
|
}
|
|
|
|
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($owner: String!, $name: String!, $limit: Int!) {
|
|
viewer { login }
|
|
repository(owner: $owner, name: $name) {
|
|
pullRequests(first: $limit, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
nodes {
|
|
id number title url isDraft updatedAt
|
|
author { login }
|
|
reviewThreads(first: 1) { totalCount }
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
func (c *GitHubClient) ListPullRequests(ctx context.Context, owner, name string, limit int, showAll bool) ([]PullRequest, error) {
|
|
var data struct {
|
|
Viewer struct {
|
|
Login string `json:"login"`
|
|
} `json:"viewer"`
|
|
Repository *struct {
|
|
PullRequests struct {
|
|
Nodes []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 *struct {
|
|
Login string `json:"login"`
|
|
} `json:"author"`
|
|
ReviewThreads struct {
|
|
TotalCount int `json:"totalCount"`
|
|
} `json:"reviewThreads"`
|
|
} `json:"nodes"`
|
|
} `json:"pullRequests"`
|
|
} `json:"repository"`
|
|
}
|
|
if err := c.query(ctx, listPRsQuery, map[string]any{"owner": owner, "name": name, "limit": limit}, &data); err != nil {
|
|
return nil, err
|
|
}
|
|
if data.Repository == nil {
|
|
return nil, fmt.Errorf("repository %s/%s was not found or is not accessible", owner, name)
|
|
}
|
|
|
|
prs := make([]PullRequest, 0, len(data.Repository.PullRequests.Nodes))
|
|
for _, node := range data.Repository.PullRequests.Nodes {
|
|
author := "[ghost]"
|
|
if node.Author != nil {
|
|
author = node.Author.Login
|
|
}
|
|
mine := author == data.Viewer.Login
|
|
if !showAll && !mine {
|
|
continue
|
|
}
|
|
prs = append(prs, PullRequest{
|
|
ID: node.ID, Number: node.Number, Title: node.Title, URL: node.URL,
|
|
Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt,
|
|
ReviewCount: node.ReviewThreads.TotalCount, ViewerAuthored: mine,
|
|
})
|
|
}
|
|
return prs, nil
|
|
}
|
|
|
|
const detailsQuery = `
|
|
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
|
repository(owner: $owner, name: $name) {
|
|
pullRequest(number: $number) {
|
|
id number title url body isDraft updatedAt mergeable reviewDecision
|
|
baseRefName headRefName
|
|
author { login }
|
|
assignees(first: 20) { nodes { login } }
|
|
reviewRequests(first: 50) {
|
|
nodes {
|
|
requestedReviewer {
|
|
... on User { login }
|
|
... on Team { name }
|
|
}
|
|
}
|
|
}
|
|
latestReviews(first: 50) { nodes { state author { login } } }
|
|
commits(last: 1) {
|
|
nodes { commit { statusCheckRollup { state } } }
|
|
}
|
|
reviewThreads(first: 100) {
|
|
pageInfo { hasNextPage }
|
|
nodes {
|
|
id isResolved isOutdated path
|
|
line originalLine diffSide
|
|
startLine originalStartLine startDiffSide
|
|
comments(first: 100) {
|
|
pageInfo { hasNextPage }
|
|
nodes {
|
|
id body diffHunk createdAt url outdated
|
|
line startLine originalLine originalStartLine
|
|
originalCommit { oid }
|
|
author { login }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) {
|
|
type actor struct {
|
|
Login string `json:"login"`
|
|
Name string `json:"name"`
|
|
}
|
|
var data struct {
|
|
Repository *struct {
|
|
PullRequest *struct {
|
|
ID, Title, URL, Body, Mergeable, ReviewDecision, BaseRefName, HeadRefName string
|
|
Number int
|
|
IsDraft bool
|
|
UpdatedAt time.Time
|
|
Author *actor
|
|
Assignees struct {
|
|
Nodes []actor `json:"nodes"`
|
|
}
|
|
ReviewRequests struct {
|
|
Nodes []struct {
|
|
RequestedReviewer actor `json:"requestedReviewer"`
|
|
} `json:"nodes"`
|
|
}
|
|
LatestReviews struct {
|
|
Nodes []struct {
|
|
State string
|
|
Author *actor
|
|
} `json:"nodes"`
|
|
}
|
|
Commits struct {
|
|
Nodes []struct {
|
|
Commit struct {
|
|
StatusCheckRollup *struct{ State string }
|
|
}
|
|
} `json:"nodes"`
|
|
}
|
|
ReviewThreads struct {
|
|
PageInfo struct{ HasNextPage bool }
|
|
Nodes []struct {
|
|
ID, Path string
|
|
DiffSide, StartDiffSide string
|
|
Line, OriginalLine, StartLine, OriginalStartLine *int
|
|
IsResolved, IsOutdated bool
|
|
Comments struct {
|
|
PageInfo struct{ HasNextPage bool }
|
|
Nodes []struct {
|
|
ID, Body, DiffHunk, URL string
|
|
Line, StartLine *int
|
|
OriginalLine, OriginalStartLine *int
|
|
Outdated bool
|
|
OriginalCommit *struct{ OID string }
|
|
CreatedAt time.Time
|
|
Author *actor
|
|
} `json:"nodes"`
|
|
}
|
|
} `json:"nodes"`
|
|
}
|
|
} `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
|
|
author := "[ghost]"
|
|
if node.Author != nil {
|
|
author = node.Author.Login
|
|
}
|
|
details := PRDetails{
|
|
PullRequest: PullRequest{
|
|
ID: node.ID, Number: node.Number, Title: node.Title, URL: node.URL,
|
|
Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt,
|
|
ReviewCount: len(node.ReviewThreads.Nodes),
|
|
},
|
|
Body: node.Body, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
|
|
Mergeable: node.Mergeable, ThreadsTruncated: node.ReviewThreads.PageInfo.HasNextPage,
|
|
CheckState: "NONE", ReviewDecision: node.ReviewDecision,
|
|
}
|
|
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 {
|
|
details.CheckState = node.Commits.Nodes[0].Commit.StatusCheckRollup.State
|
|
}
|
|
for _, thread := range node.ReviewThreads.Nodes {
|
|
item := ReviewThread{
|
|
ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide,
|
|
IsResolved: thread.IsResolved, IsOutdated: thread.IsOutdated,
|
|
}
|
|
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 {
|
|
commentAuthor := "[ghost]"
|
|
if comment.Author != nil {
|
|
commentAuthor = comment.Author.Login
|
|
}
|
|
item.Comments = append(item.Comments, ReviewComment{
|
|
ID: comment.ID, Author: commentAuthor, 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,
|
|
})
|
|
}
|
|
item.IsTruncated = thread.Comments.PageInfo.HasNextPage
|
|
details.Threads = append(details.Threads, item)
|
|
}
|
|
return details, nil
|
|
}
|
|
|
|
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
|
|
}
|