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 }