cli/pkg/cmd/discussion/client/client_impl.go
Max Beizer 0687a29e51
Fix GQL schema: Discussion uses closed bool, not state string
The GraphQL Discussion type has a `closed` boolean field, not a
`state` string. Updated the API response struct and GQL fragment
to query `closed` instead of `state`, and derive the domain-level
State string ("OPEN"/"CLOSED") from the boolean in mapDiscussion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 11:51:22 -05:00

496 lines
14 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"
)
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),
}
}
// discussionNode is the shared GraphQL response shape for a single discussion,
// used by both List and Search to avoid duplicating the field mapping.
type discussionNode struct {
ID string `json:"id"`
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
Closed bool `json:"closed"`
StateReason string `json:"stateReason"`
Author struct {
Login string `json:"login"`
} `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 *struct {
Login string `json:"login"`
} `json:"answerChosenBy"`
ReactionGroups []struct {
Content string `json:"content"`
Users struct {
TotalCount int `json:"totalCount"`
} `json:"users"`
} `json:"reactionGroups"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ClosedAt time.Time `json:"closedAt"`
Locked bool `json:"locked"`
}
// mapDiscussion converts a GraphQL discussionNode response into the domain Discussion type.
func mapDiscussion(n discussionNode) Discussion {
state := "OPEN"
if n.Closed {
state = "CLOSED"
}
d := Discussion{
ID: n.ID,
Number: n.Number,
Title: n.Title,
URL: n.URL,
State: state,
StateReason: n.StateReason,
Author: DiscussionAuthor{Login: n.Author.Login},
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 {
d.AnswerChosenBy = &DiscussionAuthor{Login: n.AnswerChosenBy.Login}
}
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}
}
d.ReactionGroups = make([]ReactionGroup, len(n.ReactionGroups))
for i, rg := range n.ReactionGroups {
d.ReactionGroups[i] = ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount}
}
return d
}
// discussionFields is the GraphQL fragment selecting fields for discussion queries.
// It is shared by both List (repository.discussions) and Search queries.
const discussionFields = `
id number title url closed stateReason
author { login }
category { id name slug emoji isAnswerable }
labels(first: 20) { nodes { id name color } }
isAnswered answerChosenAt answerChosenBy { login }
reactionGroups { content users { totalCount } }
createdAt updatedAt closedAt locked
`
func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, after string, limit int) (DiscussionListResult, error) {
if limit <= 0 {
return DiscussionListResult{}, fmt.Errorf("limit argument must be positive: %v", limit)
}
type response struct {
Repository struct {
HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"`
Discussions struct {
TotalCount int `json:"totalCount"`
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
Nodes []discussionNode `json:"nodes"`
} `json:"discussions"`
} `json:"repository"`
}
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
}
orderField := "UPDATED_AT"
orderDir := "DESC"
if filters.OrderBy != "" {
switch filters.OrderBy {
case OrderByCreated:
orderField = "CREATED_AT"
case OrderByUpdated:
orderField = "UPDATED_AT"
default:
return DiscussionListResult{}, 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 DiscussionListResult{}, fmt.Errorf("unknown order direction: %q", filters.Direction)
}
}
variables["orderBy"] = map[string]string{
"field": orderField,
"direction": orderDir,
}
if filters.CategoryID != "" {
variables["categoryId"] = filters.CategoryID
}
if filters.State != nil {
switch *filters.State {
case FilterStateOpen:
variables["states"] = []string{"OPEN"}
case FilterStateClosed:
variables["states"] = []string{"CLOSED"}
default:
return DiscussionListResult{}, fmt.Errorf("unknown state filter: %q; should be one of %q, %q", *filters.State, FilterStateOpen, FilterStateClosed)
}
}
if filters.Answered != nil {
variables["answered"] = *filters.Answered
}
// Build optional parameter declarations
paramParts := []string{
"$owner: String!",
"$name: String!",
"$first: Int!",
"$after: String",
"$orderBy: DiscussionOrder",
}
argParts := []string{
"first: $first",
"after: $after",
"orderBy: $orderBy",
}
if filters.CategoryID != "" {
paramParts = append(paramParts, "$categoryId: ID")
argParts = append(argParts, "categoryId: $categoryId")
}
if _, ok := variables["states"]; ok {
paramParts = append(paramParts, "$states: [DiscussionState!]")
argParts = append(argParts, "states: $states")
}
if filters.Answered != nil {
paramParts = append(paramParts, "$answered: Boolean")
argParts = append(argParts, "answered: $answered")
}
query := fmt.Sprintf(`query DiscussionList(%s) {
repository(owner: $owner, name: $name) {
hasDiscussionsEnabled
discussions(%s) {
totalCount
pageInfo { hasNextPage endCursor }
nodes { %s }
}
}
}`, strings.Join(paramParts, ", "), strings.Join(argParts, ", "), discussionFields)
if after != "" {
variables["after"] = after
}
var discussions []Discussion
var totalCount int
var nextCursor string
remaining := limit
// Check hasDiscussionsEnabled on first request only
firstPage := true
for {
perPage := remaining
if perPage > 100 {
perPage = 100
}
variables["first"] = perPage
var data response
if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil {
return DiscussionListResult{}, err
}
if firstPage && !data.Repository.HasDiscussionsEnabled {
return DiscussionListResult{}, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName())
}
firstPage = false
totalCount = data.Repository.Discussions.TotalCount
for _, n := range data.Repository.Discussions.Nodes {
discussions = append(discussions, mapDiscussion(n))
}
remaining -= len(data.Repository.Discussions.Nodes)
if remaining <= 0 || !data.Repository.Discussions.PageInfo.HasNextPage {
if data.Repository.Discussions.PageInfo.HasNextPage {
nextCursor = data.Repository.Discussions.PageInfo.EndCursor
}
break
}
variables["after"] = data.Repository.Discussions.PageInfo.EndCursor
}
return DiscussionListResult{
Discussions: discussions,
TotalCount: totalCount,
NextCursor: nextCursor,
}, nil
}
func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (DiscussionListResult, error) {
if limit <= 0 {
return DiscussionListResult{}, fmt.Errorf("limit argument must be positive: %v", limit)
}
type response struct {
Search struct {
DiscussionCount int `json:"discussionCount"`
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
Nodes []discussionNode `json:"nodes"`
} `json:"search"`
}
qualifiers := []string{fmt.Sprintf("repo:%s/%s", repo.RepoOwner(), repo.RepoName())}
if filters.State != nil {
switch *filters.State {
case FilterStateOpen:
qualifiers = append(qualifiers, "state:open")
case FilterStateClosed:
qualifiers = append(qualifiers, "state:closed")
default:
return DiscussionListResult{}, 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 := OrderByUpdated
orderDir := OrderDirectionDesc
if filters.OrderBy != "" {
switch filters.OrderBy {
case OrderByCreated, OrderByUpdated:
orderField = filters.OrderBy
default:
return DiscussionListResult{}, fmt.Errorf("unknown order-by field: %q", filters.OrderBy)
}
}
if filters.Direction != "" {
switch filters.Direction {
case OrderDirectionAsc, OrderDirectionDesc:
orderDir = filters.Direction
default:
return DiscussionListResult{}, 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
}
query := fmt.Sprintf(`query DiscussionSearch($query: String!, $first: Int!, $after: String) {
search(query: $query, type: DISCUSSION, first: $first, after: $after) {
discussionCount
pageInfo { hasNextPage endCursor }
nodes { ... on Discussion { %s } }
}
}`, discussionFields)
variables := map[string]interface{}{
"query": searchQuery,
}
if after != "" {
variables["after"] = after
}
var discussions []Discussion
var totalCount int
var nextCursor string
remaining := limit
for {
perPage := remaining
if perPage > 100 {
perPage = 100
}
variables["first"] = perPage
var data response
if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil {
return DiscussionListResult{}, err
}
totalCount = data.Search.DiscussionCount
for _, n := range data.Search.Nodes {
discussions = append(discussions, mapDiscussion(n))
}
remaining -= len(data.Search.Nodes)
if remaining <= 0 || !data.Search.PageInfo.HasNextPage {
if data.Search.PageInfo.HasNextPage {
nextCursor = data.Search.PageInfo.EndCursor
}
break
}
variables["after"] = data.Search.PageInfo.EndCursor
}
return DiscussionListResult{
Discussions: discussions,
TotalCount: totalCount,
NextCursor: nextCursor,
}, nil
}
func (c *discussionClient) GetByNumber(_ ghrepo.Interface, _ int) (*Discussion, error) {
return nil, fmt.Errorf("not implemented")
}
func (c *discussionClient) GetWithComments(_ ghrepo.Interface, _ int, _ int, _ string) (*Discussion, error) {
return nil, fmt.Errorf("not implemented")
}
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")
}