Skip to content

Instantly share code, notes, and snippets.

@myitcv

myitcv/repro.txt Secret

Created June 7, 2021 16:31
Show Gist options
  • Save myitcv/f7da6cb073de3c48c9f88b6b95225b4c to your computer and use it in GitHub Desktop.
Save myitcv/f7da6cb073de3c48c9f88b6b95225b4c to your computer and use it in GitHub Desktop.
Exploring GraphQL nested cursors
# Set GITHUB_PAT
go run . cuelang/cue
-- main.go --
// graphqlfun extracts all the discussions + comments + replies from a GitHub
// repository, printing the JSON-marshalled results to stdout. This is a toy
// program designed to explore the problems associated with nested cursors in
// GraphQL. graphqlfun minimises the number of calls to the GitHub GraphQL
// endpoint.
//
// Usage:
// graphqlfun OWNER/REPOSITORY
//
// Example:
// graphqlfun cuelang/cue
//
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/shurcooL/graphql"
"golang.org/x/oauth2"
)
func main() {
flag.Parse()
args := flag.Args()
if len(args) != 1 {
log.Fatalf("expected a single arg: the GitHub repository to query (e.g. cuelang/cue)")
}
parts := strings.Split(args[0], "/")
if len(parts) != 2 {
log.Fatalf("incorrect format for repository argument; expected owner/repo")
}
owner, repo := parts[0], parts[1]
src := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: os.Getenv("GITHUB_PAT")},
)
httpClient := oauth2.NewClient(context.Background(), src)
client := graphql.NewClient("https://api.github.com/graphql", httpClient)
// Get discussions with as many comments and replies as possible
var res outer
{
var after *graphql.String
for {
var query outer
err := client.Query(context.Background(), &query, map[string]interface{}{
"repo": graphql.String(repo),
"owner": graphql.String(owner),
"after": after,
})
if err != nil {
log.Fatalf("failed to run query: %v", err)
}
for _, disc := range query.Repository.Discussions.Edges {
res.Repository.Discussions.Edges = append(res.Repository.Discussions.Edges, disc)
after = &disc.Cursor
}
if !query.Repository.Discussions.PageInfo.HasNextPage {
break
}
}
}
// Ensure we have all comments for all discussions
{
for _, disc := range res.Repository.Discussions.Edges {
if !disc.Node.Comments.PageInfo.HasNextPage {
continue
}
afterComments := &disc.Node.Comments.Edges[len(disc.Node.Comments.Edges)-1].Cursor
for {
var query comments
err := client.Query(context.Background(), &query, map[string]interface{}{
"repo": graphql.String(repo),
"owner": graphql.String(owner),
"number": graphql.Int(disc.Node.Number),
"after": afterComments,
})
if err != nil {
log.Fatalf("failed to run query: %v", err)
}
for _, comm := range query.Repository.Discussion.Comments.Edges {
disc.Node.Comments.Edges = append(disc.Node.Comments.Edges, comm)
afterComments = &comm.Cursor
}
if !query.Repository.Discussion.Comments.PageInfo.HasNextPage {
break
}
}
}
}
// Ensure we have all replies for all comments
{
for _, disc := range res.Repository.Discussions.Edges {
for i, comm := range disc.Node.Comments.Edges {
var afterComment *graphql.String
if !comm.Node.Replies.PageInfo.HasNextPage {
continue
}
if i > 0 {
afterComment = &disc.Node.Comments.Edges[i-1].Cursor
}
afterReply := &comm.Node.Replies.Edges[len(comm.Node.Replies.Edges)-1].Cursor
for {
var query replies
err := client.Query(context.Background(), &query, map[string]interface{}{
"repo": graphql.String(repo),
"owner": graphql.String(owner),
"number": graphql.Int(disc.Node.Number),
"afterComment": afterComment,
"afterReply": afterReply,
})
if err != nil {
log.Fatalf("failed to run query: %v", err)
}
if l := len(query.Repository.Discussion.Comments.Edges); l != 1 {
panic(fmt.Errorf("expected a single comment; saw %v", l))
}
rescomm := query.Repository.Discussion.Comments.Edges[0]
if rescomm.Node.ID != comm.Node.ID {
panic(fmt.Errorf("expected comment %v; got %v", comm.Node.ID, rescomm.Node.ID))
}
for _, rep := range rescomm.Node.Replies.Edges {
comm.Node.Replies.Edges = append(comm.Node.Replies.Edges, rep)
afterReply = &rep.Cursor
}
if !rescomm.Node.Replies.PageInfo.HasNextPage {
break
}
}
}
}
}
fmt.Printf("%s\n", encjson(res))
}
// outer is the query that gives us discussions + their comments + the
// comments' replies
type outer struct {
Repository struct {
ID graphql.String
Discussions struct {
PageInfo PageInfo
Edges []*struct {
Cursor graphql.String
Node struct {
discussionDetail
Comments struct {
PageInfo PageInfo
Edges []*struct {
Cursor graphql.String
Node struct {
commentDetail
Replies struct {
PageInfo PageInfo
Edges []*struct {
Cursor graphql.String
Node struct {
commentDetail
}
}
} `graphql:"replies(first:50)"`
}
}
} `graphql:"comments(first:50)"`
}
}
} `graphql:"discussions(first:100, after:$after, orderBy: {field: CREATED_AT, direction: ASC})"`
} `graphql:"repository(name: $repo, owner: $owner)"`
}
type discussionDetail struct {
ID graphql.String
Title graphql.String
Body graphql.String
Number graphql.Int
Author struct {
Login graphql.String
}
CreatedAt dateTime
}
type commentDetail struct {
ID graphql.String
Body graphql.String
CreatedAt dateTime
Author struct {
Login graphql.String
}
ReplyTo struct {
ID graphql.String
}
}
// comments is the query that gives us the comments + the comments' replies for
// a given discussion
type comments struct {
Repository struct {
ID graphql.String
Discussion struct {
ID graphql.String
Comments struct {
PageInfo PageInfo
Edges []*struct {
Cursor graphql.String
Node struct {
commentDetail
Replies struct {
PageInfo PageInfo
Edges []*struct {
Cursor graphql.String
Node struct {
commentDetail
}
}
} `graphql:"replies(first:50)"`
}
}
} `graphql:"comments(first:50, after:$after)"`
} `graphql:"discussion(number:$number)"`
} `graphql:"repository(name: $repo, owner: $owner)"`
}
// replies is the query that gives us the replies for a given discussion comment
type replies struct {
Repository struct {
ID graphql.String
Discussion struct {
ID graphql.String
Comments struct {
PageInfo PageInfo
Edges []*struct {
Cursor graphql.String
Node struct {
ID graphql.String
Replies struct {
PageInfo PageInfo
Edges []*struct {
Cursor graphql.String
Node struct {
commentDetail
}
}
} `graphql:"replies(first:50, after:$afterReply)"`
}
}
} `graphql:"comments(first:1, after:$afterComment)"`
} `graphql:"discussion(number:$number)"`
} `graphql:"repository(name: $repo, owner: $owner)"`
}
type PageInfo struct {
HasNextPage graphql.Boolean
}
// **********
// Other util
// **********
func encjson(v interface{}) string {
byts, err := json.MarshalIndent(v, "", " ")
if err != nil {
panic(err)
}
return string(byts)
}
type dateTime time.Time
func (d *dateTime) UnmarshalJSON(b []byte) error {
var timeAsString string
if err := json.Unmarshal(b, &timeAsString); err != nil {
return err
}
t, err := time.Parse(time.RFC3339, timeAsString)
if err != nil {
return fmt.Errorf("failed to parse time: %v", err)
}
*d = dateTime(t)
return nil
}
func (d *dateTime) MarshalJSON() ([]byte, error) {
t := time.Time(*d)
return []byte(strconv.Quote(t.Format(time.RFC3339))), nil
}
func (d dateTime) before(d2 dateTime) bool {
return time.Time(d).Before(time.Time(d2))
}
-- go.mod --
module myitcv.io/graphqlfun
go 1.15
require (
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment