cli/pkg/cmd/discussion/client/client_impl.go
Max Beizer dea54ab3ab
fix: gofmt alignment in GetWithComments response struct
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 21:24:31 +01:00

723 lines
21 KiB
Go

package client
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
// maxPageSize is the maximum number of items per page allowed by the GitHub GraphQL API.
const maxPageSize = 100
type discussionClient struct {
gql *api.Client
}
// NewDiscussionClient creates a DiscussionClient backed by the given HTTP client.
func NewDiscussionClient(httpClient *http.Client) DiscussionClient {
return &discussionClient{
gql: api.NewClientFromHTTP(httpClient),
}
}
// actorNode is the GraphQL response shape for an Actor union (User or Bot)
// used in discussionListNode fields like Author and AnswerChosenBy.
type actorNode struct {
TypeName string `graphql:"__typename"`
Login string
User struct {
ID string
Name string
} `graphql:"... on User"`
Bot struct {
ID string
} `graphql:"... on Bot"`
}
// mapActorFromListNode converts an actorNode into the domain DiscussionActor type.
func mapActorFromListNode(n actorNode) DiscussionActor {
a := DiscussionActor{Login: n.Login}
switch n.TypeName {
case "User":
a.ID = n.User.ID
a.Name = n.User.Name
case "Bot":
a.ID = n.Bot.ID
}
return a
}
// discussionListNode is the GraphQL response shape for a discussion in
// list and search results. It covers high-level fields only (no comments, or
// other detail-level data that commands like view would need).
type discussionListNode struct {
ID string
Number int
Title string
Body string
URL string `graphql:"url"`
Closed bool
StateReason string
Author actorNode
Category struct {
ID string
Name string
Slug string
Emoji string
IsAnswerable bool
}
Labels struct {
Nodes []struct {
ID string
Name string
Color string
}
} `graphql:"labels(first: 20)"`
IsAnswered bool
AnswerChosenAt time.Time
AnswerChosenBy *actorNode
ReactionGroups []struct {
Content string
Users struct {
TotalCount int
}
} `graphql:"reactionGroups"`
CreatedAt time.Time
UpdatedAt time.Time
ClosedAt time.Time
Locked bool
}
// mapDiscussionFromListNode converts a discussionListNode into the domain Discussion type.
func mapDiscussionFromListNode(n discussionListNode) Discussion {
d := Discussion{
ID: n.ID,
Number: n.Number,
Title: n.Title,
Body: n.Body,
URL: n.URL,
Closed: n.Closed,
StateReason: n.StateReason,
Author: mapActorFromListNode(n.Author),
Category: DiscussionCategory{
ID: n.Category.ID,
Name: n.Category.Name,
Slug: n.Category.Slug,
Emoji: n.Category.Emoji,
IsAnswerable: n.Category.IsAnswerable,
},
Answered: n.IsAnswered,
AnswerChosenAt: n.AnswerChosenAt,
CreatedAt: n.CreatedAt,
UpdatedAt: n.UpdatedAt,
ClosedAt: n.ClosedAt,
Locked: n.Locked,
}
if n.AnswerChosenBy != nil {
a := mapActorFromListNode(*n.AnswerChosenBy)
d.AnswerChosenBy = &a
}
d.Labels = make([]DiscussionLabel, len(n.Labels.Nodes))
for i, l := range n.Labels.Nodes {
d.Labels[i] = DiscussionLabel{ID: l.ID, Name: l.Name, Color: l.Color}
}
return d
}
func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, after string, limit int) (*DiscussionListResult, error) {
if limit <= 0 {
return nil, fmt.Errorf("limit argument must be positive: %v", limit)
}
var query struct {
Repository struct {
HasDiscussionsEnabled bool
Discussions struct {
TotalCount int
PageInfo struct {
HasNextPage bool
EndCursor string
}
Nodes []discussionListNode
} `graphql:"discussions(first: $first, after: $after, orderBy: $orderBy, categoryId: $categoryId, states: $states, answered: $answered)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
orderField := githubv4.DiscussionOrderFieldUpdatedAt
orderDir := githubv4.OrderDirectionDesc
if filters.OrderBy != "" {
switch filters.OrderBy {
case OrderByCreated:
orderField = githubv4.DiscussionOrderFieldCreatedAt
case OrderByUpdated:
orderField = githubv4.DiscussionOrderFieldUpdatedAt
default:
return nil, fmt.Errorf("unknown order-by field: %q", filters.OrderBy)
}
}
if filters.Direction != "" {
switch filters.Direction {
case OrderDirectionAsc:
orderDir = githubv4.OrderDirectionAsc
case OrderDirectionDesc:
orderDir = githubv4.OrderDirectionDesc
default:
return nil, fmt.Errorf("unknown order direction: %q", filters.Direction)
}
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"after": (*githubv4.String)(nil),
"orderBy": githubv4.DiscussionOrder{Field: orderField, Direction: orderDir},
"categoryId": (*githubv4.ID)(nil),
"states": (*[]githubv4.DiscussionState)(nil),
"answered": (*githubv4.Boolean)(nil),
}
if after != "" {
variables["after"] = githubv4.String(after)
}
if filters.CategoryID != "" {
variables["categoryId"] = githubv4.ID(filters.CategoryID)
}
if filters.State != nil {
switch *filters.State {
case FilterStateOpen:
variables["states"] = &[]githubv4.DiscussionState{githubv4.DiscussionStateOpen}
case FilterStateClosed:
variables["states"] = &[]githubv4.DiscussionState{githubv4.DiscussionStateClosed}
default:
return nil, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed)
}
}
if filters.Answered != nil {
variables["answered"] = githubv4.Boolean(*filters.Answered)
}
var result DiscussionListResult
remaining := limit
for {
variables["first"] = githubv4.Int(min(remaining, maxPageSize))
if err := c.gql.Query(repo.RepoHost(), "DiscussionList", &query, variables); err != nil {
return nil, err
}
if !query.Repository.HasDiscussionsEnabled {
// This would be the same over every iteration, so if we're going to return we will at the first page.
return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName())
}
result.TotalCount = query.Repository.Discussions.TotalCount
for _, n := range query.Repository.Discussions.Nodes {
result.Discussions = append(result.Discussions, mapDiscussionFromListNode(n))
}
remaining -= len(query.Repository.Discussions.Nodes)
if remaining <= 0 || !query.Repository.Discussions.PageInfo.HasNextPage {
if query.Repository.Discussions.PageInfo.HasNextPage {
result.NextCursor = query.Repository.Discussions.PageInfo.EndCursor
}
break
}
variables["after"] = githubv4.String(query.Repository.Discussions.PageInfo.EndCursor)
}
return &result, nil
}
func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (*DiscussionListResult, error) {
if limit <= 0 {
return nil, fmt.Errorf("limit argument must be positive: %v", limit)
}
var query struct {
Search struct {
DiscussionCount int
PageInfo struct {
HasNextPage bool
EndCursor string
}
Nodes []struct {
Discussion discussionListNode `graphql:"... on Discussion"`
}
} `graphql:"search(query: $query, type: DISCUSSION, first: $first, after: $after)"`
}
qualifiers := []string{fmt.Sprintf("repo:%s/%s", repo.RepoOwner(), repo.RepoName())}
if filters.State != nil {
switch *filters.State {
case FilterStateOpen:
qualifiers = append(qualifiers, "is:open")
case FilterStateClosed:
qualifiers = append(qualifiers, "is:closed")
default:
return nil, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed)
}
}
if filters.Author != "" {
qualifiers = append(qualifiers, fmt.Sprintf("author:%q", filters.Author))
}
for _, l := range filters.Labels {
qualifiers = append(qualifiers, fmt.Sprintf("label:%q", l))
}
if filters.Category != "" {
qualifiers = append(qualifiers, fmt.Sprintf("category:%q", filters.Category))
}
if filters.Answered != nil {
if *filters.Answered {
qualifiers = append(qualifiers, "is:answered")
} else {
qualifiers = append(qualifiers, "is:unanswered")
}
}
orderField := "updated"
orderDir := "desc"
if filters.OrderBy != "" {
switch filters.OrderBy {
case OrderByCreated:
orderField = "created"
case OrderByUpdated:
orderField = "updated"
default:
return nil, fmt.Errorf("unknown order-by field: %q", filters.OrderBy)
}
}
if filters.Direction != "" {
switch filters.Direction {
case OrderDirectionAsc:
orderDir = "asc"
case OrderDirectionDesc:
orderDir = "desc"
default:
return nil, fmt.Errorf("unknown order direction: %q", filters.Direction)
}
}
qualifiers = append(qualifiers, fmt.Sprintf("sort:%s-%s", orderField, orderDir))
searchQuery := strings.Join(qualifiers, " ")
if filters.Keywords != "" {
searchQuery += " " + filters.Keywords
}
variables := map[string]interface{}{
"query": githubv4.String(searchQuery),
"after": (*githubv4.String)(nil),
}
if after != "" {
variables["after"] = githubv4.String(after)
}
var result DiscussionListResult
remaining := limit
for {
variables["first"] = githubv4.Int(min(remaining, maxPageSize))
if err := c.gql.Query(repo.RepoHost(), "DiscussionListSearch", &query, variables); err != nil {
return nil, err
}
result.TotalCount = query.Search.DiscussionCount
for _, n := range query.Search.Nodes {
result.Discussions = append(result.Discussions, mapDiscussionFromListNode(n.Discussion))
}
remaining -= len(query.Search.Nodes)
if remaining <= 0 || !query.Search.PageInfo.HasNextPage {
if query.Search.PageInfo.HasNextPage {
result.NextCursor = query.Search.PageInfo.EndCursor
}
break
}
variables["after"] = githubv4.String(query.Search.PageInfo.EndCursor)
}
return &result, nil
}
func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error) {
var query struct {
Repository struct {
HasDiscussionsEnabled bool
Discussion *struct {
discussionListNode
Body string
Comments struct {
TotalCount int
}
} `graphql:"discussion(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"number": githubv4.Int(number),
}
err := c.gql.Query(repo.RepoHost(), "DiscussionByNumber", &query, variables)
if err != nil {
return nil, err
}
if !query.Repository.HasDiscussionsEnabled {
return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName())
}
if query.Repository.Discussion == nil {
return nil, fmt.Errorf("discussion #%d not found in '%s/%s'", number, repo.RepoOwner(), repo.RepoName())
}
d := mapDiscussionFromListNode(query.Repository.Discussion.discussionListNode)
d.Body = query.Repository.Discussion.Body
d.Comments = DiscussionCommentList{TotalCount: query.Repository.Discussion.Comments.TotalCount}
for _, rg := range query.Repository.Discussion.ReactionGroups {
d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{
Content: rg.Content,
TotalCount: rg.Users.TotalCount,
})
}
return &d, nil
}
func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) {
// Build the comments field with first/last based on order.
// "oldest" uses first (chronological), "newest" uses last (reverse chronological).
commentDirection := "first"
if order == "newest" {
commentDirection = "last"
}
query := fmt.Sprintf(`query DiscussionWithComments($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
hasDiscussionsEnabled
discussion(number: $number) {
id
number
title
body
url
closed
stateReason
author { login ... on User { id name } ... on Bot { id } }
category { id name slug emoji isAnswerable }
labels(first: 20) { nodes { id name color } }
isAnswered
answerChosenAt
answerChosenBy { login ... on User { id name } ... on Bot { id } }
reactionGroups { content users { totalCount } }
createdAt
updatedAt
closedAt
locked
comments(%s: %d) {
totalCount
nodes {
id
url
author { login ... on User { id name } ... on Bot { id } }
body
createdAt
isAnswer
upvoteCount
reactionGroups { content users { totalCount } }
replies(first: 4) {
totalCount
nodes {
id
url
author { login ... on User { id name } ... on Bot { id } }
body
createdAt
isAnswer
upvoteCount
reactionGroups { content users { totalCount } }
}
}
}
}
}
}
}`, commentDirection, commentLimit)
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
"number": number,
}
type actorJSON struct {
Login string `json:"login"`
ID string `json:"id"`
Name string `json:"name"`
}
type reactionGroupJSON struct {
Content string `json:"content"`
Users struct {
TotalCount int `json:"totalCount"`
} `json:"users"`
}
type commentJSON struct {
ID string `json:"id"`
URL string `json:"url"`
Author actorJSON `json:"author"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
IsAnswer bool `json:"isAnswer"`
UpvoteCount int `json:"upvoteCount"`
ReactionGroups []reactionGroupJSON `json:"reactionGroups"`
Replies *struct {
TotalCount int `json:"totalCount"`
Nodes []commentJSON `json:"nodes"`
} `json:"replies"`
}
type response struct {
Repository struct {
HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"`
Discussion *struct {
ID string `json:"id"`
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
Closed bool `json:"closed"`
StateReason string `json:"stateReason"`
Author actorJSON `json:"author"`
Category struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Emoji string `json:"emoji"`
IsAnswerable bool `json:"isAnswerable"`
} `json:"category"`
Labels struct {
Nodes []struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
} `json:"nodes"`
} `json:"labels"`
IsAnswered bool `json:"isAnswered"`
AnswerChosenAt time.Time `json:"answerChosenAt"`
AnswerChosenBy *actorJSON `json:"answerChosenBy"`
ReactionGroups []reactionGroupJSON `json:"reactionGroups"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ClosedAt time.Time `json:"closedAt"`
Locked bool `json:"locked"`
Comments struct {
TotalCount int `json:"totalCount"`
Nodes []commentJSON `json:"nodes"`
} `json:"comments"`
} `json:"discussion"`
} `json:"repository"`
}
var data response
err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data)
if err != nil {
return nil, err
}
if !data.Repository.HasDiscussionsEnabled {
return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName())
}
if data.Repository.Discussion == nil {
return nil, fmt.Errorf("discussion #%d not found in '%s/%s'", number, repo.RepoOwner(), repo.RepoName())
}
src := data.Repository.Discussion
mapActor := func(a actorJSON) DiscussionActor {
return DiscussionActor{ID: a.ID, Login: a.Login, Name: a.Name}
}
mapReactions := func(groups []reactionGroupJSON) []ReactionGroup {
out := make([]ReactionGroup, len(groups))
for i, rg := range groups {
out[i] = ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount}
}
return out
}
mapComment := func(c commentJSON) DiscussionComment {
dc := DiscussionComment{
ID: c.ID,
URL: c.URL,
Author: mapActor(c.Author),
Body: c.Body,
CreatedAt: c.CreatedAt,
IsAnswer: c.IsAnswer,
UpvoteCount: c.UpvoteCount,
ReactionGroups: mapReactions(c.ReactionGroups),
}
if c.Replies != nil {
dc.TotalReplies = c.Replies.TotalCount
for _, r := range c.Replies.Nodes {
dc.Replies = append(dc.Replies, DiscussionComment{
ID: r.ID,
URL: r.URL,
Author: mapActor(r.Author),
Body: r.Body,
CreatedAt: r.CreatedAt,
IsAnswer: r.IsAnswer,
UpvoteCount: r.UpvoteCount,
ReactionGroups: mapReactions(r.ReactionGroups),
})
}
}
return dc
}
d := Discussion{
ID: src.ID,
Number: src.Number,
Title: src.Title,
Body: src.Body,
URL: src.URL,
Closed: src.Closed,
StateReason: src.StateReason,
Author: mapActor(src.Author),
Category: DiscussionCategory{
ID: src.Category.ID,
Name: src.Category.Name,
Slug: src.Category.Slug,
Emoji: src.Category.Emoji,
IsAnswerable: src.Category.IsAnswerable,
},
Answered: src.IsAnswered,
AnswerChosenAt: src.AnswerChosenAt,
ReactionGroups: mapReactions(src.ReactionGroups),
CreatedAt: src.CreatedAt,
UpdatedAt: src.UpdatedAt,
ClosedAt: src.ClosedAt,
Locked: src.Locked,
}
if src.AnswerChosenBy != nil {
a := mapActor(*src.AnswerChosenBy)
d.AnswerChosenBy = &a
}
d.Labels = make([]DiscussionLabel, len(src.Labels.Nodes))
for i, l := range src.Labels.Nodes {
d.Labels[i] = DiscussionLabel{ID: l.ID, Name: l.Name, Color: l.Color}
}
comments := make([]DiscussionComment, len(src.Comments.Nodes))
for i, c := range src.Comments.Nodes {
comments[i] = mapComment(c)
}
// When using "last" (newest order), the API returns items in chronological
// order. Reverse them so the newest comment appears first.
if order == "newest" {
for i, j := 0, len(comments)-1; i < j; i, j = i+1, j-1 {
comments[i], comments[j] = comments[j], comments[i]
}
}
d.Comments = DiscussionCommentList{
Comments: comments,
TotalCount: src.Comments.TotalCount,
}
return &d, nil
}
func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) {
var query struct {
Repository struct {
HasDiscussionsEnabled bool
DiscussionCategories struct {
Nodes []struct {
ID string
Name string
Slug string
Emoji string
IsAnswerable bool
}
} `graphql:"discussionCategories(first: 100)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
}
if err := c.gql.Query(repo.RepoHost(), "DiscussionCategoryList", &query, variables); err != nil {
return nil, err
}
if !query.Repository.HasDiscussionsEnabled {
return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName())
}
categories := make([]DiscussionCategory, len(query.Repository.DiscussionCategories.Nodes))
for i, n := range query.Repository.DiscussionCategories.Nodes {
categories[i] = DiscussionCategory{
ID: n.ID,
Name: n.Name,
Slug: n.Slug,
Emoji: n.Emoji,
IsAnswerable: n.IsAnswerable,
}
}
return categories, nil
}
func (c *discussionClient) Create(_ ghrepo.Interface, _ CreateDiscussionInput) (*Discussion, error) {
return nil, fmt.Errorf("not implemented")
}
func (c *discussionClient) Update(_ ghrepo.Interface, _ UpdateDiscussionInput) (*Discussion, error) {
return nil, fmt.Errorf("not implemented")
}
func (c *discussionClient) Close(_ ghrepo.Interface, _ string, _ CloseReason) (*Discussion, error) {
return nil, fmt.Errorf("not implemented")
}
func (c *discussionClient) Reopen(_ ghrepo.Interface, _ string) (*Discussion, error) {
return nil, fmt.Errorf("not implemented")
}
func (c *discussionClient) AddComment(_ ghrepo.Interface, _ string, _ string, _ string) (*DiscussionComment, error) {
return nil, fmt.Errorf("not implemented")
}
func (c *discussionClient) Lock(_ ghrepo.Interface, _ string, _ string) error {
return fmt.Errorf("not implemented")
}
func (c *discussionClient) Unlock(_ ghrepo.Interface, _ string) error {
return fmt.Errorf("not implemented")
}
func (c *discussionClient) MarkAnswer(_ ghrepo.Interface, _ string) error {
return fmt.Errorf("not implemented")
}
func (c *discussionClient) UnmarkAnswer(_ ghrepo.Interface, _ string) error {
return fmt.Errorf("not implemented")
}