package github import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings") const pullReviewThreadsQuery = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100, after: $cursor) { nodes { id isResolved isOutdated path line startLine viewerCanResolve viewerCanUnresolve viewerCanReply comments(first: 100) { nodes { id databaseId body author { login __typename } path diffHunk createdAt updatedAt url } pageInfo { hasNextPage endCursor } } } pageInfo { hasNextPage endCursor } } } }}` const pullReviewThreadCommentsQuery = `query($threadID: ID!, $cursor: String) { node(id: $threadID) { ... on PullRequestReviewThread { comments(first: 100, after: $cursor) { nodes { id databaseId body author { login __typename } path diffHunk createdAt updatedAt url } pageInfo { hasNextPage endCursor } } } }}` type pullReviewThreadsResponse struct { Repository struct { PullRequest *struct { ReviewThreads struct { Nodes []map[string]any `json:"nodes"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` EndCursor string `json:"endCursor"` } `json:"pageInfo"` } `json:"reviewThreads"` } `json:"pullRequest"` } `json:"repository"`} type pullReviewThreadCommentsResponse struct { Node *struct { Comments reviewThreadCommentsConnection `json:"comments"` } `json:"node"`} type reviewThreadCommentsConnection struct { Nodes []map[string]any `json:"nodes"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` EndCursor string `json:"endCursor"` } `json:"pageInfo"`} type graphqlEnvelope struct { Query string `json:"query"` Variables map[string]any `json:"variables"`} type graphqlResponseEnvelope struct { Data json.RawMessage `json:"data"` Errors []struct { Message string `json:"message"` } `json:"errors"`} // ListPullReviewThreads fetches GitHub's review-thread graph for a pull request.func (c *Client) ListPullReviewThreads(ctx context.Context, owner, repo string, number int, reporter Reporter) ([]map[string]any, error) { var out []map[string]any var cursor string for { vars := map[string]any{ "owner": owner, "repo": repo, "pr": number, } if cursor != "" { vars["cursor"] = cursor } var resp pullReviewThreadsResponse if err := c.doGraphQL(ctx, pullReviewThreadsQuery, vars, reporter, &resp); err != nil { return nil, err } if resp.Repository.PullRequest == nil { return nil, fmt.Errorf("pull request #%d not found in %s/%s", number, owner, repo) } page := resp.Repository.PullRequest.ReviewThreads for _, thread := range page.Nodes { if err := c.completeReviewThreadComments(ctx, thread, reporter); err != nil { return nil, err } } out = append(out, page.Nodes...) if !page.PageInfo.HasNextPage { break } cursor = page.PageInfo.EndCursor } return out, nil} func (c *Client) completeReviewThreadComments(ctx context.Context, thread map[string]any, reporter Reporter) error { threadID, _ := thread["id"].(string) if threadID == "" { return nil } comments := reviewThreadCommentsFromMap(thread["comments"]) if !comments.PageInfo.HasNextPage { return nil } for comments.PageInfo.HasNextPage { cursor := comments.PageInfo.EndCursor if cursor == "" { return fmt.Errorf("review thread %s comments page missing endCursor", threadID) } vars := map[string]any{"threadID": threadID, "cursor": cursor} var resp pullReviewThreadCommentsResponse if err := c.doGraphQL(ctx, pullReviewThreadCommentsQuery, vars, reporter, &resp); err != nil { return err } if resp.Node == nil { return fmt.Errorf("review thread %s not found", threadID) } comments.Nodes = append(comments.Nodes, resp.Node.Comments.Nodes...) comments.PageInfo = resp.Node.Comments.PageInfo } thread["comments"] = map[string]any{ "nodes": comments.Nodes, "pageInfo": map[string]any{ "hasNextPage": comments.PageInfo.HasNextPage, "endCursor": comments.PageInfo.EndCursor, }, } return nil} func reviewThreadCommentsFromMap(value any) reviewThreadCommentsConnection { var out reviewThreadCommentsConnection raw, ok := value.(map[string]any) if !ok { return out } switch nodes := raw["nodes"].(type) { case []map[string]any: out.Nodes = append(out.Nodes, nodes...) case []any: out.Nodes = make([]map[string]any, 0, len(nodes)) for _, node := range nodes { if mapped, ok := node.(map[string]any); ok { out.Nodes = append(out.Nodes, mapped) } } } pageInfo, _ := raw["pageInfo"].(map[string]any) if hasNextPage, ok := pageInfo["hasNextPage"].(bool); ok { out.PageInfo.HasNextPage = hasNextPage } if endCursor, ok := pageInfo["endCursor"].(string); ok { out.PageInfo.EndCursor = endCursor } return out} func (c *Client) doGraphQL(ctx context.Context, query string, variables map[string]any, reporter Reporter, out any) error { payload, err := json.Marshal(graphqlEnvelope{Query: query, Variables: variables}) if err != nil { return fmt.Errorf("encode graphql request: %w", err) } var envelope graphqlResponseEnvelope if err := c.doJSON(ctx, http.MethodPost, c.graphQLURL, bytes.NewReader(payload), reporter, &envelope); err != nil { return err } if len(envelope.Errors) > 0 { messages := make([]string, 0, len(envelope.Errors)) for _, graphqlErr := range envelope.Errors { messages = append(messages, graphqlErr.Message) } return fmt.Errorf("github graphql: %s", strings.Join(messages, "; ")) } if len(envelope.Data) == 0 || string(envelope.Data) == "null" { return fmt.Errorf("github graphql response missing data") } if err := json.Unmarshal(envelope.Data, out); err != nil { return fmt.Errorf("decode github graphql data: %w", err) } return nil}
package github
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
const pullReviewThreadsQuery = `
query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
reviewThreads(first: 100, after: $cursor) {
nodes {
id
isResolved
isOutdated
path
line
startLine
viewerCanResolve
viewerCanUnresolve
viewerCanReply
comments(first: 100) {
nodes {
id
databaseId
body
author { login __typename }
path
diffHunk
createdAt
updatedAt
url
}
pageInfo {
hasNextPage
endCursor
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`
const pullReviewThreadCommentsQuery = `
query($threadID: ID!, $cursor: String) {
node(id: $threadID) {
... on PullRequestReviewThread {
comments(first: 100, after: $cursor) {
nodes {
id
databaseId
body
author { login __typename }
path
diffHunk
createdAt
updatedAt
url
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`
type pullReviewThreadsResponse struct {
Repository struct {
PullRequest *struct {
ReviewThreads struct {
Nodes []map[string]any `json:"nodes"`
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
} `json:"reviewThreads"`
} `json:"pullRequest"`
} `json:"repository"`
}
type pullReviewThreadCommentsResponse struct {
Node *struct {
Comments reviewThreadCommentsConnection `json:"comments"`
} `json:"node"`
}
type reviewThreadCommentsConnection struct {
Nodes []map[string]any `json:"nodes"`
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
}
type graphqlEnvelope struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
type graphqlResponseEnvelope struct {
Data json.RawMessage `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
// ListPullReviewThreads fetches GitHub's review-thread graph for a pull request.
func (c *Client) ListPullReviewThreads(ctx context.Context, owner, repo string, number int, reporter Reporter) ([]map[string]any, error) {
var out []map[string]any
var cursor string
for {
vars := map[string]any{
"owner": owner,
"repo": repo,
"pr": number,
}
if cursor != "" {
vars["cursor"] = cursor
}
var resp pullReviewThreadsResponse
if err := c.doGraphQL(ctx, pullReviewThreadsQuery, vars, reporter, &resp); err != nil {
return nil, err
}
if resp.Repository.PullRequest == nil {
return nil, fmt.Errorf("pull request #%d not found in %s/%s", number, owner, repo)
}
page := resp.Repository.PullRequest.ReviewThreads
for _, thread := range page.Nodes {
if err := c.completeReviewThreadComments(ctx, thread, reporter); err != nil {
return nil, err
}
}
out = append(out, page.Nodes...)
if !page.PageInfo.HasNextPage {
break
}
cursor = page.PageInfo.EndCursor
}
return out, nil
}
func (c *Client) completeReviewThreadComments(ctx context.Context, thread map[string]any, reporter Reporter) error {
threadID, _ := thread["id"].(string)
if threadID == "" {
return nil
}
comments := reviewThreadCommentsFromMap(thread["comments"])
if !comments.PageInfo.HasNextPage {
return nil
}
for comments.PageInfo.HasNextPage {
cursor := comments.PageInfo.EndCursor
if cursor == "" {
return fmt.Errorf("review thread %s comments page missing endCursor", threadID)
}
vars := map[string]any{"threadID": threadID, "cursor": cursor}
var resp pullReviewThreadCommentsResponse
if err := c.doGraphQL(ctx, pullReviewThreadCommentsQuery, vars, reporter, &resp); err != nil {
return err
}
if resp.Node == nil {
return fmt.Errorf("review thread %s not found", threadID)
}
comments.Nodes = append(comments.Nodes, resp.Node.Comments.Nodes...)
comments.PageInfo = resp.Node.Comments.PageInfo
}
thread["comments"] = map[string]any{
"nodes": comments.Nodes,
"pageInfo": map[string]any{
"hasNextPage": comments.PageInfo.HasNextPage,
"endCursor": comments.PageInfo.EndCursor,
},
}
return nil
}
func reviewThreadCommentsFromMap(value any) reviewThreadCommentsConnection {
var out reviewThreadCommentsConnection
raw, ok := value.(map[string]any)
if !ok {
return out
}
switch nodes := raw["nodes"].(type) {
case []map[string]any:
out.Nodes = append(out.Nodes, nodes...)
case []any:
out.Nodes = make([]map[string]any, 0, len(nodes))
for _, node := range nodes {
if mapped, ok := node.(map[string]any); ok {
out.Nodes = append(out.Nodes, mapped)
}
}
}
pageInfo, _ := raw["pageInfo"].(map[string]any)
if hasNextPage, ok := pageInfo["hasNextPage"].(bool); ok {
out.PageInfo.HasNextPage = hasNextPage
}
if endCursor, ok := pageInfo["endCursor"].(string); ok {
out.PageInfo.EndCursor = endCursor
}
return out
}
func (c *Client) doGraphQL(ctx context.Context, query string, variables map[string]any, reporter Reporter, out any) error {
payload, err := json.Marshal(graphqlEnvelope{Query: query, Variables: variables})
if err != nil {
return fmt.Errorf("encode graphql request: %w", err)
}
var envelope graphqlResponseEnvelope
if err := c.doJSON(ctx, http.MethodPost, c.graphQLURL, bytes.NewReader(payload), reporter, &envelope); err != nil {
return err
}
if len(envelope.Errors) > 0 {
messages := make([]string, 0, len(envelope.Errors))
for _, graphqlErr := range envelope.Errors {
messages = append(messages, graphqlErr.Message)
}
return fmt.Errorf("github graphql: %s", strings.Join(messages, "; "))
}
if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return fmt.Errorf("github graphql response missing data")
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("decode github graphql data: %w", err)
}
return nil
}