Files
diple/github.go
2026-07-27 14:04:08 +02:00

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
}