blob: 9cf6560185e692defd10386a79db1bda0197244d [file] [log] [blame]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package discussion
import (
"time"
gql "github.com/shurcooL/githubv4"
"golang.org/x/oscar/internal/github"
)
// query is a query to the GitHub GraphQL API.
type query[N node] interface {
// Page returns the current page.
Page() page[N]
// CursorName returns the name of the cursor variable for this query.
CursorName() string
}
// page is a single page in a query to the GitHub GraphQL API.
type page[N node] struct {
Nodes []N
PageInfo struct {
EndCursor gql.String
HasNextPage bool
}
}
// connection is a connection to another data source
// in the GitHub GraphQL API.
// e.g. https://docs.github.com/en/graphql/reference/objects#discussioncommentconnection
type connection[N any] struct {
page[N]
TotalCount gql.Int
}
// node is a data node in a query to the GitHub GraphQL API.
type node any
// max number of items per page allowed by GitHub
const githubPageLimit = 100
// max number of items per page
// (may be modified for testing).
var maxItemsPerPage = githubPageLimit
// The "vars" input to [gql.Query].
type varsMap map[string]any
// The key names for maps of type [varsMap].
const (
discCursor = "cursor"
discPerPage = "perPage"
orderBy = "orderBy"
ownerKey = "owner"
repoKey = "repo"
nodeId = "node"
discNumber = "number"
commentsCursor = "commentsCursor"
commentsPerPage = "commentsPerPage"
repliesCursor = "repliesCursor"
repliesPerPage = "repliesPerPage"
labelsCursor = "labelsCursor"
labelsPerPage = "labelsPerPage"
)
var _ query[*discussion] = (*listQuery)(nil)
// listQuery is a query to list dicussions for a repo.
// https://docs.github.com/en/graphql/guides/using-the-graphql-api-for-discussions#repositorydiscussions
type listQuery struct {
Repository struct {
Discussions page[*discussion] `graphql:"discussions(first: $perPage, after: $cursor, orderBy: $orderBy)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
// newListQuery returns a query and vars to input to [gql.Query],
// in order to list discussions for the given project.
func newListQuery(owner, repo string) (*listQuery, varsMap) {
labelsPerPageValue := min(maxItemsPerPage, 10)
q := &listQuery{}
return q, varsMap{
ownerKey: gql.String(owner),
repoKey: gql.String(repo),
discCursor: (*gql.String)(nil),
labelsCursor: (*gql.String)(nil),
discPerPage: gql.Int(maxItemsPerPage),
labelsPerPage: (gql.Int)(labelsPerPageValue),
orderBy: gql.DiscussionOrder{
Field: gql.DiscussionOrderFieldUpdatedAt,
Direction: gql.OrderDirectionDesc,
},
}
}
// Page implements [query.Page].
func (q *listQuery) Page() page[*discussion] {
return q.Repository.Discussions
}
// CursorName implements [query.CursorName].
func (*listQuery) CursorName() string {
return discCursor
}
var _ query[*discWithComments] = (*listDiscWithCommentsQuery)(nil)
// listDiscWithCommentsQuery is a query to list all dicussions for a repo, with
// a focus on accessing comments instead of the discussions themselves.
// https://docs.github.com/en/graphql/guides/using-the-graphql-api-for-discussions#repositorydiscussions
type listDiscWithCommentsQuery struct {
Repository struct {
Discussions page[*discWithComments] `graphql:"discussions(first: $perPage, after: $cursor, orderBy: $orderBy)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
// newListQuery returns a query and vars to input to [gql.Query],
// in order to list all discussions and comments for the given project.
func newListDiscWithCommentsQuery(owner, repo string) (*listDiscWithCommentsQuery, varsMap) {
// The API times out if we try to request too many nodes at once,
// so restrict the number of items per page values on the first query.
dpp := min(maxItemsPerPage, 20)
cpp := min(maxItemsPerPage, 10)
rpp := min(maxItemsPerPage, 10)
q := &listDiscWithCommentsQuery{}
return q, varsMap{
ownerKey: gql.String(owner),
repoKey: gql.String(repo),
discCursor: (*gql.String)(nil),
commentsCursor: (*gql.String)(nil),
repliesCursor: (*gql.String)(nil),
orderBy: gql.DiscussionOrder{
Field: gql.DiscussionOrderFieldUpdatedAt,
Direction: gql.OrderDirectionDesc,
},
discPerPage: (gql.Int)(dpp),
commentsPerPage: (gql.Int)(cpp),
repliesPerPage: (gql.Int)(rpp),
}
}
// Page implements [query.Page].
func (q *listDiscWithCommentsQuery) Page() page[*discWithComments] {
return q.Repository.Discussions
}
// CursorName implements [query.CursorName].
func (*listDiscWithCommentsQuery) CursorName() string {
return discCursor
}
var _ query[*comment] = (*listCommentsQuery)(nil)
// listCommentsQuery is a query to list all comments for a discussion.
// https://docs.github.com/en/graphql/guides/using-the-graphql-api-for-discussions#repositorydiscussion
type listCommentsQuery struct {
Repository struct {
Discussion discWithComments `graphql:"discussion(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
// newListCommentsQuery returns a query and vars to input to [gql.Query],
// in order to list all comments for a single discussion in the given project.
// number is the discussion number, and cursor is the comments cursor to use
// in the initial query.
func newListCommentsQuery(owner, repo string, number gql.Int, cursor gql.String) (*listCommentsQuery, varsMap) {
return &listCommentsQuery{}, varsMap{
ownerKey: gql.String(owner),
repoKey: gql.String(repo),
discNumber: number,
commentsCursor: cursor,
repliesCursor: (*gql.String)(nil),
commentsPerPage: gql.Int(maxItemsPerPage),
repliesPerPage: gql.Int(maxItemsPerPage),
}
}
// Page implements [query.Page].
func (q *listCommentsQuery) Page() page[*comment] {
return q.Repository.Discussion.Comments.page
}
// CursorName implements [query.CursorName].
func (*listCommentsQuery) CursorName() string {
return commentsCursor
}
var _ query[*reply] = (*commentQuery)(nil)
// commentQuery is a query to get a specific discussion comment,
// with a focus on accessing its replies.
type commentQuery struct {
Node struct {
Comment comment `graphql:"... on DiscussionComment"`
} `graphql:"node(id: $node)"`
}
// newCommentQuery returns a query and vars to input to [gql.Query],
// in order to get a single comment by node ID.
// cursor is the replies cursor to use in the initial query.
func newCommentQuery(nodeID gql.ID, cursor gql.String) (*commentQuery, varsMap) {
q := &commentQuery{}
return q, varsMap{
nodeId: nodeID,
repliesCursor: cursor,
repliesPerPage: gql.Int(maxItemsPerPage),
}
}
// Page implements [query.Page].
func (q *commentQuery) Page() page[*reply] {
return q.Node.Comment.Replies.page
}
// CursorName implements [query.CursorName].
func (*commentQuery) CursorName() string {
return repliesCursor
}
// discWithComments is minimal representation of a discussion used
// to query for comments.
type discWithComments struct {
URL gql.URI
Number gql.Int
UpdatedAt gql.DateTime
Comments connection[*comment] `graphql:"comments(first: $commentsPerPage, after: $commentsCursor)"`
}
// A discussion is a GitHub discussion, as returned by the GitHub
// GraphQL API.
// https://docs.github.com/en/graphql/reference/objects#discussion
type discussion struct {
ActiveLockReason *gql.LockReason
IsAnswered gql.Boolean
Answer *commentRef
AnswerChosenAt *gql.DateTime
Author actor
AuthorAssociation *gql.CommentAuthorAssociation
Body gql.String // markdown
Category discussionCategory
ClosedAt gql.DateTime
CreatedAt gql.DateTime
ID gql.ID
Labels connection[label] `graphql:"labels(first:$labelsPerPage, after:$labelsCursor)"`
LastEditedAt *gql.DateTime
Locked gql.Boolean
Number gql.Int
ResourcePath gql.String
Title gql.String
UpdatedAt gql.DateTime
UpvoteCount gql.Int
URL gql.URI
}
// A label is a GitHub label, as returned by the GitHub
// GraphQL API.
// https://docs.github.com/en/graphql/reference/objects#label
type label struct {
Name gql.String
}
// convert converts the GitHub GraphQL representation of
// a discussion to a format intended to be stored in a database.
func (d *discussion) convert() *Discussion {
lastEditedAt, activeLockReason := "", ""
if d.LastEditedAt != nil {
lastEditedAt = timeToStr(*d.LastEditedAt)
}
if d.ActiveLockReason != nil {
activeLockReason = string(*d.ActiveLockReason)
}
return &Discussion{
URL: string(d.URL.String()),
Number: int64(d.Number),
Author: d.Author.convert(),
Title: string(d.Title),
CreatedAt: timeToStr(d.CreatedAt),
UpdatedAt: timeToStr(d.UpdatedAt),
LastEditedAt: lastEditedAt,
Body: string(d.Body),
UpvoteCount: int(d.UpvoteCount),
Locked: bool(d.Locked),
ActiveLockReason: activeLockReason,
Labels: toLabels(d.Labels),
}
}
// toLabels converts the labels on the first page
// to a list of [Label]s.
// It does not page through the labels.
func toLabels(ls connection[label]) []github.Label {
var labels []github.Label
for _, n := range ls.Nodes {
labels = append(labels, github.Label{Name: string(n.Name)})
}
return labels
}
// discussionRef is a minimal representation of a discussion,
// used to refer back to a discussion (to avoid infinite recursion).
type discussionRef struct {
Number gql.Int
UpdatedAt gql.DateTime
URL gql.URI
}
// actor is a GitHub user or organization.
// https://docs.github.com/en/graphql/reference/interfaces#actor
type actor struct {
Login gql.String
}
// convert converts the GitHub GraphQL representation of
// an actor to a format intended to be stored in a database.
func (a *actor) convert() github.User {
return github.User{
Login: string(a.Login),
}
}
// discussionCategory is a GitHub discussion category.
// https://docs.github.com/en/graphql/reference/objects#discussioncategory
type discussionCategory struct {
Name gql.String
}
// comment is the GitHub GraphQL representation of a discussion comment
// with replies.
// https://docs.github.com/en/graphql/reference/objects#discussioncomment
type comment struct {
commentBase
Replies connection[*reply] `graphql:"replies(first: $repliesPerPage, after: $repliesCursor)"`
}
// comment is the GitHub GraphQL representation of a discussion comment
// that is itself a reply to another comment.
// https://docs.github.com/en/graphql/reference/objects#discussioncomment
type reply struct {
commentBase
ReplyTo *commentRef
}
// commentBase is the data that [comment] and [reply] have in common.
// https://docs.github.com/en/graphql/reference/objects#discussioncomment
type commentBase struct {
Author actor
AuthorAssociation gql.CommentAuthorAssociation
Body gql.String // markdown
CreatedAt gql.DateTime
DeletedAt *gql.DateTime
Discussion *discussionRef
Editor *actor
ID gql.ID
IncludesCreatedEdit gql.Boolean
IsAnswer gql.Boolean
IsMinimized gql.Boolean
LastEditedAt *gql.DateTime
MinimizedReason *gql.String
PublishedAt *gql.DateTime
UpdatedAt gql.DateTime
UpvoteCount gql.Int
URL gql.URI
}
// convert converts the GitHub GraphQL representation of
// a comment to a format intended to be stored in a database.
func (c *commentBase) convert() *Comment {
return &Comment{
URL: c.URL.String(),
DiscussionURL: c.Discussion.URL.String(),
Author: c.Author.convert(),
CreatedAt: timeToStr(c.CreatedAt),
UpdatedAt: timeToStr(c.UpdatedAt),
Body: string(c.Body),
}
}
func timeToStr(t gql.DateTime) string {
return t.Format(time.RFC3339)
}
// convert converts the GitHub GraphQL representation of
// a reply to a format intended to be stored in a database.
func (r *reply) convert() *Comment {
c := r.commentBase.convert()
c.ReplyToURL = r.ReplyTo.URL.String()
return c
}
// commentRef is a minimal representation of a discussion comment, used
// to refer back to a comment (to avoid infinite recursion).
type commentRef struct {
ID gql.ID
URL gql.URI
}