initial working read only state
This commit is contained in:
347
github.go
Normal file
347
github.go
Normal file
@@ -0,0 +1,347 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user