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>
496 lines
14 KiB
Go
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")
|
|
}
|