Add discussion command group scaffolding

Introduce the pkg/cmd/discussion/ package with:

- DiscussionClient interface and domain types (client/)
- Generated mock via moq (client/)
- Factory function for lazy client creation (shared/)
- JSON field definitions for --json output (shared/)
- Root 'discussion' command registered in the core group

The interface defines all planned operations (list, search, get, create,
update, close, reopen, comment, lock, unlock, mark-answer, unmark-answer)
with stub implementations that will be replaced as each subcommand is
added in subsequent PRs.

Domain types are intentionally separate from API types per review guidance.
No JSON struct tags are used; serialization is handled by ExportData methods.

Refs: cli/cli#12810, github/gh-cli-and-desktop#115

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Max Beizer 2026-04-02 10:34:14 -05:00
parent a32d004d9d
commit 45c68b48da
No known key found for this signature in database
8 changed files with 1221 additions and 0 deletions

View file

@ -0,0 +1,26 @@
// Package client provides an abstraction layer for interacting with the
// GitHub Discussions GraphQL API. The DiscussionClient interface defines all
// supported operations and can be replaced with a mock in tests.
package client
import "github.com/cli/cli/v2/internal/ghrepo"
//go:generate moq -rm -out client_mock.go . DiscussionClient
// DiscussionClient defines operations for interacting with the GitHub Discussions API.
type DiscussionClient interface {
List(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error)
Search(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error)
GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error)
GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error)
ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error)
Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error)
Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error)
Close(repo ghrepo.Interface, id string, reason CloseReason) (*Discussion, error)
Reopen(repo ghrepo.Interface, id string) (*Discussion, error)
AddComment(repo ghrepo.Interface, discussionID string, body string, replyToID string) (*DiscussionComment, error)
Lock(repo ghrepo.Interface, id string, reason string) error
Unlock(repo ghrepo.Interface, id string) error
MarkAnswer(repo ghrepo.Interface, commentID string) error
UnmarkAnswer(repo ghrepo.Interface, commentID string) error
}

View file

@ -0,0 +1,76 @@
package client
import (
"fmt"
"net/http"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
)
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),
}
}
func (c *discussionClient) List(_ ghrepo.Interface, _ ListFilters, _ int) ([]Discussion, int, error) {
return nil, 0, fmt.Errorf("not implemented")
}
func (c *discussionClient) Search(_ ghrepo.Interface, _ SearchFilters, _ int) ([]Discussion, int, error) {
return nil, 0, fmt.Errorf("not implemented")
}
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(_ ghrepo.Interface) ([]DiscussionCategory, error) {
return nil, fmt.Errorf("not implemented")
}
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")
}

View file

@ -0,0 +1,773 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package client
import (
"github.com/cli/cli/v2/internal/ghrepo"
"sync"
)
// Ensure, that DiscussionClientMock does implement DiscussionClient.
// If this is not the case, regenerate this file with moq.
var _ DiscussionClient = &DiscussionClientMock{}
// DiscussionClientMock is a mock implementation of DiscussionClient.
//
// func TestSomethingThatUsesDiscussionClient(t *testing.T) {
//
// // make and configure a mocked DiscussionClient
// mockedDiscussionClient := &DiscussionClientMock{
// AddCommentFunc: func(repo ghrepo.Interface, discussionID string, body string, replyToID string) (*DiscussionComment, error) {
// panic("mock out the AddComment method")
// },
// CloseFunc: func(repo ghrepo.Interface, id string, reason CloseReason) (*Discussion, error) {
// panic("mock out the Close method")
// },
// CreateFunc: func(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) {
// panic("mock out the Create method")
// },
// GetByNumberFunc: func(repo ghrepo.Interface, number int) (*Discussion, error) {
// panic("mock out the GetByNumber method")
// },
// GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) {
// panic("mock out the GetWithComments method")
// },
// ListFunc: func(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) {
// panic("mock out the List method")
// },
// ListCategoriesFunc: func(repo ghrepo.Interface) ([]DiscussionCategory, error) {
// panic("mock out the ListCategories method")
// },
// LockFunc: func(repo ghrepo.Interface, id string, reason string) error {
// panic("mock out the Lock method")
// },
// MarkAnswerFunc: func(repo ghrepo.Interface, commentID string) error {
// panic("mock out the MarkAnswer method")
// },
// ReopenFunc: func(repo ghrepo.Interface, id string) (*Discussion, error) {
// panic("mock out the Reopen method")
// },
// SearchFunc: func(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error) {
// panic("mock out the Search method")
// },
// UnlockFunc: func(repo ghrepo.Interface, id string) error {
// panic("mock out the Unlock method")
// },
// UnmarkAnswerFunc: func(repo ghrepo.Interface, commentID string) error {
// panic("mock out the UnmarkAnswer method")
// },
// UpdateFunc: func(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) {
// panic("mock out the Update method")
// },
// }
//
// // use mockedDiscussionClient in code that requires DiscussionClient
// // and then make assertions.
//
// }
type DiscussionClientMock struct {
// AddCommentFunc mocks the AddComment method.
AddCommentFunc func(repo ghrepo.Interface, discussionID string, body string, replyToID string) (*DiscussionComment, error)
// CloseFunc mocks the Close method.
CloseFunc func(repo ghrepo.Interface, id string, reason CloseReason) (*Discussion, error)
// CreateFunc mocks the Create method.
CreateFunc func(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error)
// GetByNumberFunc mocks the GetByNumber method.
GetByNumberFunc func(repo ghrepo.Interface, number int) (*Discussion, error)
// GetWithCommentsFunc mocks the GetWithComments method.
GetWithCommentsFunc func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error)
// ListFunc mocks the List method.
ListFunc func(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error)
// ListCategoriesFunc mocks the ListCategories method.
ListCategoriesFunc func(repo ghrepo.Interface) ([]DiscussionCategory, error)
// LockFunc mocks the Lock method.
LockFunc func(repo ghrepo.Interface, id string, reason string) error
// MarkAnswerFunc mocks the MarkAnswer method.
MarkAnswerFunc func(repo ghrepo.Interface, commentID string) error
// ReopenFunc mocks the Reopen method.
ReopenFunc func(repo ghrepo.Interface, id string) (*Discussion, error)
// SearchFunc mocks the Search method.
SearchFunc func(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error)
// UnlockFunc mocks the Unlock method.
UnlockFunc func(repo ghrepo.Interface, id string) error
// UnmarkAnswerFunc mocks the UnmarkAnswer method.
UnmarkAnswerFunc func(repo ghrepo.Interface, commentID string) error
// UpdateFunc mocks the Update method.
UpdateFunc func(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error)
// calls tracks calls to the methods.
calls struct {
// AddComment holds details about calls to the AddComment method.
AddComment []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// DiscussionID is the discussionID argument value.
DiscussionID string
// Body is the body argument value.
Body string
// ReplyToID is the replyToID argument value.
ReplyToID string
}
// Close holds details about calls to the Close method.
Close []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// ID is the id argument value.
ID string
// Reason is the reason argument value.
Reason CloseReason
}
// Create holds details about calls to the Create method.
Create []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// Input is the input argument value.
Input CreateDiscussionInput
}
// GetByNumber holds details about calls to the GetByNumber method.
GetByNumber []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// Number is the number argument value.
Number int
}
// GetWithComments holds details about calls to the GetWithComments method.
GetWithComments []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// Number is the number argument value.
Number int
// CommentLimit is the commentLimit argument value.
CommentLimit int
// Order is the order argument value.
Order string
}
// List holds details about calls to the List method.
List []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// Filters is the filters argument value.
Filters ListFilters
// Limit is the limit argument value.
Limit int
}
// ListCategories holds details about calls to the ListCategories method.
ListCategories []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
}
// Lock holds details about calls to the Lock method.
Lock []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// ID is the id argument value.
ID string
// Reason is the reason argument value.
Reason string
}
// MarkAnswer holds details about calls to the MarkAnswer method.
MarkAnswer []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// CommentID is the commentID argument value.
CommentID string
}
// Reopen holds details about calls to the Reopen method.
Reopen []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// ID is the id argument value.
ID string
}
// Search holds details about calls to the Search method.
Search []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// Filters is the filters argument value.
Filters SearchFilters
// Limit is the limit argument value.
Limit int
}
// Unlock holds details about calls to the Unlock method.
Unlock []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// ID is the id argument value.
ID string
}
// UnmarkAnswer holds details about calls to the UnmarkAnswer method.
UnmarkAnswer []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// CommentID is the commentID argument value.
CommentID string
}
// Update holds details about calls to the Update method.
Update []struct {
// Repo is the repo argument value.
Repo ghrepo.Interface
// Input is the input argument value.
Input UpdateDiscussionInput
}
}
lockAddComment sync.RWMutex
lockClose sync.RWMutex
lockCreate sync.RWMutex
lockGetByNumber sync.RWMutex
lockGetWithComments sync.RWMutex
lockList sync.RWMutex
lockListCategories sync.RWMutex
lockLock sync.RWMutex
lockMarkAnswer sync.RWMutex
lockReopen sync.RWMutex
lockSearch sync.RWMutex
lockUnlock sync.RWMutex
lockUnmarkAnswer sync.RWMutex
lockUpdate sync.RWMutex
}
// AddComment calls AddCommentFunc.
func (mock *DiscussionClientMock) AddComment(repo ghrepo.Interface, discussionID string, body string, replyToID string) (*DiscussionComment, error) {
if mock.AddCommentFunc == nil {
panic("DiscussionClientMock.AddCommentFunc: method is nil but DiscussionClient.AddComment was just called")
}
callInfo := struct {
Repo ghrepo.Interface
DiscussionID string
Body string
ReplyToID string
}{
Repo: repo,
DiscussionID: discussionID,
Body: body,
ReplyToID: replyToID,
}
mock.lockAddComment.Lock()
mock.calls.AddComment = append(mock.calls.AddComment, callInfo)
mock.lockAddComment.Unlock()
return mock.AddCommentFunc(repo, discussionID, body, replyToID)
}
// AddCommentCalls gets all the calls that were made to AddComment.
// Check the length with:
//
// len(mockedDiscussionClient.AddCommentCalls())
func (mock *DiscussionClientMock) AddCommentCalls() []struct {
Repo ghrepo.Interface
DiscussionID string
Body string
ReplyToID string
} {
var calls []struct {
Repo ghrepo.Interface
DiscussionID string
Body string
ReplyToID string
}
mock.lockAddComment.RLock()
calls = mock.calls.AddComment
mock.lockAddComment.RUnlock()
return calls
}
// Close calls CloseFunc.
func (mock *DiscussionClientMock) Close(repo ghrepo.Interface, id string, reason CloseReason) (*Discussion, error) {
if mock.CloseFunc == nil {
panic("DiscussionClientMock.CloseFunc: method is nil but DiscussionClient.Close was just called")
}
callInfo := struct {
Repo ghrepo.Interface
ID string
Reason CloseReason
}{
Repo: repo,
ID: id,
Reason: reason,
}
mock.lockClose.Lock()
mock.calls.Close = append(mock.calls.Close, callInfo)
mock.lockClose.Unlock()
return mock.CloseFunc(repo, id, reason)
}
// CloseCalls gets all the calls that were made to Close.
// Check the length with:
//
// len(mockedDiscussionClient.CloseCalls())
func (mock *DiscussionClientMock) CloseCalls() []struct {
Repo ghrepo.Interface
ID string
Reason CloseReason
} {
var calls []struct {
Repo ghrepo.Interface
ID string
Reason CloseReason
}
mock.lockClose.RLock()
calls = mock.calls.Close
mock.lockClose.RUnlock()
return calls
}
// Create calls CreateFunc.
func (mock *DiscussionClientMock) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) {
if mock.CreateFunc == nil {
panic("DiscussionClientMock.CreateFunc: method is nil but DiscussionClient.Create was just called")
}
callInfo := struct {
Repo ghrepo.Interface
Input CreateDiscussionInput
}{
Repo: repo,
Input: input,
}
mock.lockCreate.Lock()
mock.calls.Create = append(mock.calls.Create, callInfo)
mock.lockCreate.Unlock()
return mock.CreateFunc(repo, input)
}
// CreateCalls gets all the calls that were made to Create.
// Check the length with:
//
// len(mockedDiscussionClient.CreateCalls())
func (mock *DiscussionClientMock) CreateCalls() []struct {
Repo ghrepo.Interface
Input CreateDiscussionInput
} {
var calls []struct {
Repo ghrepo.Interface
Input CreateDiscussionInput
}
mock.lockCreate.RLock()
calls = mock.calls.Create
mock.lockCreate.RUnlock()
return calls
}
// GetByNumber calls GetByNumberFunc.
func (mock *DiscussionClientMock) GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error) {
if mock.GetByNumberFunc == nil {
panic("DiscussionClientMock.GetByNumberFunc: method is nil but DiscussionClient.GetByNumber was just called")
}
callInfo := struct {
Repo ghrepo.Interface
Number int
}{
Repo: repo,
Number: number,
}
mock.lockGetByNumber.Lock()
mock.calls.GetByNumber = append(mock.calls.GetByNumber, callInfo)
mock.lockGetByNumber.Unlock()
return mock.GetByNumberFunc(repo, number)
}
// GetByNumberCalls gets all the calls that were made to GetByNumber.
// Check the length with:
//
// len(mockedDiscussionClient.GetByNumberCalls())
func (mock *DiscussionClientMock) GetByNumberCalls() []struct {
Repo ghrepo.Interface
Number int
} {
var calls []struct {
Repo ghrepo.Interface
Number int
}
mock.lockGetByNumber.RLock()
calls = mock.calls.GetByNumber
mock.lockGetByNumber.RUnlock()
return calls
}
// GetWithComments calls GetWithCommentsFunc.
func (mock *DiscussionClientMock) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) {
if mock.GetWithCommentsFunc == nil {
panic("DiscussionClientMock.GetWithCommentsFunc: method is nil but DiscussionClient.GetWithComments was just called")
}
callInfo := struct {
Repo ghrepo.Interface
Number int
CommentLimit int
Order string
}{
Repo: repo,
Number: number,
CommentLimit: commentLimit,
Order: order,
}
mock.lockGetWithComments.Lock()
mock.calls.GetWithComments = append(mock.calls.GetWithComments, callInfo)
mock.lockGetWithComments.Unlock()
return mock.GetWithCommentsFunc(repo, number, commentLimit, order)
}
// GetWithCommentsCalls gets all the calls that were made to GetWithComments.
// Check the length with:
//
// len(mockedDiscussionClient.GetWithCommentsCalls())
func (mock *DiscussionClientMock) GetWithCommentsCalls() []struct {
Repo ghrepo.Interface
Number int
CommentLimit int
Order string
} {
var calls []struct {
Repo ghrepo.Interface
Number int
CommentLimit int
Order string
}
mock.lockGetWithComments.RLock()
calls = mock.calls.GetWithComments
mock.lockGetWithComments.RUnlock()
return calls
}
// List calls ListFunc.
func (mock *DiscussionClientMock) List(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) {
if mock.ListFunc == nil {
panic("DiscussionClientMock.ListFunc: method is nil but DiscussionClient.List was just called")
}
callInfo := struct {
Repo ghrepo.Interface
Filters ListFilters
Limit int
}{
Repo: repo,
Filters: filters,
Limit: limit,
}
mock.lockList.Lock()
mock.calls.List = append(mock.calls.List, callInfo)
mock.lockList.Unlock()
return mock.ListFunc(repo, filters, limit)
}
// ListCalls gets all the calls that were made to List.
// Check the length with:
//
// len(mockedDiscussionClient.ListCalls())
func (mock *DiscussionClientMock) ListCalls() []struct {
Repo ghrepo.Interface
Filters ListFilters
Limit int
} {
var calls []struct {
Repo ghrepo.Interface
Filters ListFilters
Limit int
}
mock.lockList.RLock()
calls = mock.calls.List
mock.lockList.RUnlock()
return calls
}
// ListCategories calls ListCategoriesFunc.
func (mock *DiscussionClientMock) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) {
if mock.ListCategoriesFunc == nil {
panic("DiscussionClientMock.ListCategoriesFunc: method is nil but DiscussionClient.ListCategories was just called")
}
callInfo := struct {
Repo ghrepo.Interface
}{
Repo: repo,
}
mock.lockListCategories.Lock()
mock.calls.ListCategories = append(mock.calls.ListCategories, callInfo)
mock.lockListCategories.Unlock()
return mock.ListCategoriesFunc(repo)
}
// ListCategoriesCalls gets all the calls that were made to ListCategories.
// Check the length with:
//
// len(mockedDiscussionClient.ListCategoriesCalls())
func (mock *DiscussionClientMock) ListCategoriesCalls() []struct {
Repo ghrepo.Interface
} {
var calls []struct {
Repo ghrepo.Interface
}
mock.lockListCategories.RLock()
calls = mock.calls.ListCategories
mock.lockListCategories.RUnlock()
return calls
}
// Lock calls LockFunc.
func (mock *DiscussionClientMock) Lock(repo ghrepo.Interface, id string, reason string) error {
if mock.LockFunc == nil {
panic("DiscussionClientMock.LockFunc: method is nil but DiscussionClient.Lock was just called")
}
callInfo := struct {
Repo ghrepo.Interface
ID string
Reason string
}{
Repo: repo,
ID: id,
Reason: reason,
}
mock.lockLock.Lock()
mock.calls.Lock = append(mock.calls.Lock, callInfo)
mock.lockLock.Unlock()
return mock.LockFunc(repo, id, reason)
}
// LockCalls gets all the calls that were made to Lock.
// Check the length with:
//
// len(mockedDiscussionClient.LockCalls())
func (mock *DiscussionClientMock) LockCalls() []struct {
Repo ghrepo.Interface
ID string
Reason string
} {
var calls []struct {
Repo ghrepo.Interface
ID string
Reason string
}
mock.lockLock.RLock()
calls = mock.calls.Lock
mock.lockLock.RUnlock()
return calls
}
// MarkAnswer calls MarkAnswerFunc.
func (mock *DiscussionClientMock) MarkAnswer(repo ghrepo.Interface, commentID string) error {
if mock.MarkAnswerFunc == nil {
panic("DiscussionClientMock.MarkAnswerFunc: method is nil but DiscussionClient.MarkAnswer was just called")
}
callInfo := struct {
Repo ghrepo.Interface
CommentID string
}{
Repo: repo,
CommentID: commentID,
}
mock.lockMarkAnswer.Lock()
mock.calls.MarkAnswer = append(mock.calls.MarkAnswer, callInfo)
mock.lockMarkAnswer.Unlock()
return mock.MarkAnswerFunc(repo, commentID)
}
// MarkAnswerCalls gets all the calls that were made to MarkAnswer.
// Check the length with:
//
// len(mockedDiscussionClient.MarkAnswerCalls())
func (mock *DiscussionClientMock) MarkAnswerCalls() []struct {
Repo ghrepo.Interface
CommentID string
} {
var calls []struct {
Repo ghrepo.Interface
CommentID string
}
mock.lockMarkAnswer.RLock()
calls = mock.calls.MarkAnswer
mock.lockMarkAnswer.RUnlock()
return calls
}
// Reopen calls ReopenFunc.
func (mock *DiscussionClientMock) Reopen(repo ghrepo.Interface, id string) (*Discussion, error) {
if mock.ReopenFunc == nil {
panic("DiscussionClientMock.ReopenFunc: method is nil but DiscussionClient.Reopen was just called")
}
callInfo := struct {
Repo ghrepo.Interface
ID string
}{
Repo: repo,
ID: id,
}
mock.lockReopen.Lock()
mock.calls.Reopen = append(mock.calls.Reopen, callInfo)
mock.lockReopen.Unlock()
return mock.ReopenFunc(repo, id)
}
// ReopenCalls gets all the calls that were made to Reopen.
// Check the length with:
//
// len(mockedDiscussionClient.ReopenCalls())
func (mock *DiscussionClientMock) ReopenCalls() []struct {
Repo ghrepo.Interface
ID string
} {
var calls []struct {
Repo ghrepo.Interface
ID string
}
mock.lockReopen.RLock()
calls = mock.calls.Reopen
mock.lockReopen.RUnlock()
return calls
}
// Search calls SearchFunc.
func (mock *DiscussionClientMock) Search(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error) {
if mock.SearchFunc == nil {
panic("DiscussionClientMock.SearchFunc: method is nil but DiscussionClient.Search was just called")
}
callInfo := struct {
Repo ghrepo.Interface
Filters SearchFilters
Limit int
}{
Repo: repo,
Filters: filters,
Limit: limit,
}
mock.lockSearch.Lock()
mock.calls.Search = append(mock.calls.Search, callInfo)
mock.lockSearch.Unlock()
return mock.SearchFunc(repo, filters, limit)
}
// SearchCalls gets all the calls that were made to Search.
// Check the length with:
//
// len(mockedDiscussionClient.SearchCalls())
func (mock *DiscussionClientMock) SearchCalls() []struct {
Repo ghrepo.Interface
Filters SearchFilters
Limit int
} {
var calls []struct {
Repo ghrepo.Interface
Filters SearchFilters
Limit int
}
mock.lockSearch.RLock()
calls = mock.calls.Search
mock.lockSearch.RUnlock()
return calls
}
// Unlock calls UnlockFunc.
func (mock *DiscussionClientMock) Unlock(repo ghrepo.Interface, id string) error {
if mock.UnlockFunc == nil {
panic("DiscussionClientMock.UnlockFunc: method is nil but DiscussionClient.Unlock was just called")
}
callInfo := struct {
Repo ghrepo.Interface
ID string
}{
Repo: repo,
ID: id,
}
mock.lockUnlock.Lock()
mock.calls.Unlock = append(mock.calls.Unlock, callInfo)
mock.lockUnlock.Unlock()
return mock.UnlockFunc(repo, id)
}
// UnlockCalls gets all the calls that were made to Unlock.
// Check the length with:
//
// len(mockedDiscussionClient.UnlockCalls())
func (mock *DiscussionClientMock) UnlockCalls() []struct {
Repo ghrepo.Interface
ID string
} {
var calls []struct {
Repo ghrepo.Interface
ID string
}
mock.lockUnlock.RLock()
calls = mock.calls.Unlock
mock.lockUnlock.RUnlock()
return calls
}
// UnmarkAnswer calls UnmarkAnswerFunc.
func (mock *DiscussionClientMock) UnmarkAnswer(repo ghrepo.Interface, commentID string) error {
if mock.UnmarkAnswerFunc == nil {
panic("DiscussionClientMock.UnmarkAnswerFunc: method is nil but DiscussionClient.UnmarkAnswer was just called")
}
callInfo := struct {
Repo ghrepo.Interface
CommentID string
}{
Repo: repo,
CommentID: commentID,
}
mock.lockUnmarkAnswer.Lock()
mock.calls.UnmarkAnswer = append(mock.calls.UnmarkAnswer, callInfo)
mock.lockUnmarkAnswer.Unlock()
return mock.UnmarkAnswerFunc(repo, commentID)
}
// UnmarkAnswerCalls gets all the calls that were made to UnmarkAnswer.
// Check the length with:
//
// len(mockedDiscussionClient.UnmarkAnswerCalls())
func (mock *DiscussionClientMock) UnmarkAnswerCalls() []struct {
Repo ghrepo.Interface
CommentID string
} {
var calls []struct {
Repo ghrepo.Interface
CommentID string
}
mock.lockUnmarkAnswer.RLock()
calls = mock.calls.UnmarkAnswer
mock.lockUnmarkAnswer.RUnlock()
return calls
}
// Update calls UpdateFunc.
func (mock *DiscussionClientMock) Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) {
if mock.UpdateFunc == nil {
panic("DiscussionClientMock.UpdateFunc: method is nil but DiscussionClient.Update was just called")
}
callInfo := struct {
Repo ghrepo.Interface
Input UpdateDiscussionInput
}{
Repo: repo,
Input: input,
}
mock.lockUpdate.Lock()
mock.calls.Update = append(mock.calls.Update, callInfo)
mock.lockUpdate.Unlock()
return mock.UpdateFunc(repo, input)
}
// UpdateCalls gets all the calls that were made to Update.
// Check the length with:
//
// len(mockedDiscussionClient.UpdateCalls())
func (mock *DiscussionClientMock) UpdateCalls() []struct {
Repo ghrepo.Interface
Input UpdateDiscussionInput
} {
var calls []struct {
Repo ghrepo.Interface
Input UpdateDiscussionInput
}
mock.lockUpdate.RLock()
calls = mock.calls.Update
mock.lockUpdate.RUnlock()
return calls
}

View file

@ -0,0 +1,265 @@
package client
import "time"
// Discussion represents a GitHub Discussion as a domain object.
// Fields carry no JSON tags; serialization is handled by ExportData.
type Discussion struct {
ID string
Number int
Title string
Body string
URL string
State string
StateReason string
Author DiscussionAuthor
Category DiscussionCategory
Labels []DiscussionLabel
Answered bool
AnswerChosenAt time.Time
AnswerChosenBy *DiscussionAuthor
Comments DiscussionCommentList
ReactionGroups []ReactionGroup
CreatedAt time.Time
UpdatedAt time.Time
ClosedAt time.Time
Locked bool
}
// ExportData returns a map of the requested fields for JSON output.
// Because domain types carry no JSON struct tags, each field is mapped
// explicitly rather than using reflection.
func (d Discussion) ExportData(fields []string) map[string]interface{} {
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "id":
data[f] = d.ID
case "number":
data[f] = d.Number
case "title":
data[f] = d.Title
case "body":
data[f] = d.Body
case "url":
data[f] = d.URL
case "state":
data[f] = d.State
case "stateReason":
data[f] = d.StateReason
case "author":
data[f] = d.Author.Export()
case "category":
data[f] = d.Category.Export()
case "labels":
labels := make([]interface{}, len(d.Labels))
for i, l := range d.Labels {
labels[i] = l.Export()
}
data[f] = labels
case "answered":
data[f] = d.Answered
case "answerChosenAt":
if d.AnswerChosenAt.IsZero() {
data[f] = nil
} else {
data[f] = d.AnswerChosenAt
}
case "answerChosenBy":
if d.AnswerChosenBy == nil {
data[f] = nil
} else {
data[f] = d.AnswerChosenBy.Export()
}
case "comments":
comments := make([]interface{}, len(d.Comments.Comments))
for i, c := range d.Comments.Comments {
comments[i] = c.Export()
}
data[f] = map[string]interface{}{
"totalCount": d.Comments.TotalCount,
"nodes": comments,
}
case "reactionGroups":
groups := make([]interface{}, len(d.ReactionGroups))
for i, rg := range d.ReactionGroups {
groups[i] = rg.Export()
}
data[f] = groups
case "createdAt":
data[f] = d.CreatedAt
case "updatedAt":
data[f] = d.UpdatedAt
case "closedAt":
if d.ClosedAt.IsZero() {
data[f] = nil
} else {
data[f] = d.ClosedAt
}
case "locked":
data[f] = d.Locked
}
}
return data
}
// DiscussionAuthor represents the author of a discussion or comment.
type DiscussionAuthor struct {
ID string
Login string
Name string
}
// Export returns the author as a map for JSON output.
func (a DiscussionAuthor) Export() map[string]interface{} {
return map[string]interface{}{
"id": a.ID,
"login": a.Login,
"name": a.Name,
}
}
// DiscussionCategory represents a discussion category within a repository.
type DiscussionCategory struct {
ID string
Name string
Slug string
Emoji string
IsAnswerable bool
}
// Export returns the category as a map for JSON output.
func (c DiscussionCategory) Export() map[string]interface{} {
return map[string]interface{}{
"id": c.ID,
"name": c.Name,
"slug": c.Slug,
"emoji": c.Emoji,
"isAnswerable": c.IsAnswerable,
}
}
// DiscussionLabel represents a label applied to a discussion.
type DiscussionLabel struct {
ID string
Name string
Color string
}
// Export returns the label as a map for JSON output.
func (l DiscussionLabel) Export() map[string]interface{} {
return map[string]interface{}{
"id": l.ID,
"name": l.Name,
"color": l.Color,
}
}
// DiscussionComment represents a comment or reply on a discussion.
type DiscussionComment struct {
ID string
URL string
Author DiscussionAuthor
Body string
CreatedAt time.Time
IsAnswer bool
UpvoteCount int
ReactionGroups []ReactionGroup
Replies []DiscussionComment
TotalReplies int
}
// Export returns the comment as a map for JSON output.
func (c DiscussionComment) Export() map[string]interface{} {
replies := make([]interface{}, len(c.Replies))
for i, r := range c.Replies {
replies[i] = r.Export()
}
reactions := make([]interface{}, len(c.ReactionGroups))
for i, rg := range c.ReactionGroups {
reactions[i] = rg.Export()
}
return map[string]interface{}{
"id": c.ID,
"url": c.URL,
"author": c.Author.Export(),
"body": c.Body,
"createdAt": c.CreatedAt,
"isAnswer": c.IsAnswer,
"upvoteCount": c.UpvoteCount,
"reactionGroups": reactions,
"replies": replies,
"totalReplies": c.TotalReplies,
}
}
// DiscussionCommentList represents a paginated list of comments on a discussion.
type DiscussionCommentList struct {
Comments []DiscussionComment
TotalCount int
}
// ReactionGroup represents a set of reactions of the same type.
type ReactionGroup struct {
Content string
TotalCount int
}
// Export returns the reaction group as a map for JSON output.
func (rg ReactionGroup) Export() map[string]interface{} {
return map[string]interface{}{
"content": rg.Content,
"totalCount": rg.TotalCount,
}
}
// CloseReason represents the reason for closing a discussion.
type CloseReason string
const (
// CloseReasonResolved indicates the discussion topic has been resolved.
CloseReasonResolved CloseReason = "RESOLVED"
// CloseReasonOutdated indicates the discussion is no longer relevant.
CloseReasonOutdated CloseReason = "OUTDATED"
// CloseReasonDuplicate indicates the discussion is a duplicate of another.
CloseReasonDuplicate CloseReason = "DUPLICATE"
)
// ListFilters holds parameters for the repository.discussions query.
// CategoryID must be resolved by the caller before passing to List.
type ListFilters struct {
State string
CategoryID string
Answered *bool
OrderBy string
Direction string
}
// SearchFilters holds parameters for the search query used when
// author or label filtering is required.
type SearchFilters struct {
Author string
Labels []string
State string
Category string
Answered *bool
OrderBy string
Direction string
}
// CreateDiscussionInput holds the parameters for creating a discussion.
type CreateDiscussionInput struct {
RepositoryID string
CategoryID string
Title string
Body string
}
// UpdateDiscussionInput holds optional parameters for updating a discussion.
// Nil pointer fields are left unchanged.
type UpdateDiscussionInput struct {
DiscussionID string
Title *string
Body *string
CategoryID *string
}

View file

@ -0,0 +1,33 @@
package discussion
import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
// NewCmdDiscussion returns the top-level "discussion" command.
func NewCmdDiscussion(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "discussion <command>",
Short: "Manage discussions",
Long: "Work with GitHub Discussions.",
Example: heredoc.Doc(`
$ gh discussion list
$ gh discussion create --category "General" --title "Hello"
$ gh discussion view 123
`),
Annotations: map[string]string{
"help:arguments": heredoc.Doc(`
A discussion can be supplied as argument in any of the following formats:
- by number, e.g. "123"; or
- by URL, e.g. "https://github.com/OWNER/REPO/discussions/123".
`),
},
GroupID: "core",
}
cmdutil.EnableRepoOverride(cmd, f)
return cmd
}

View file

@ -0,0 +1,21 @@
// Package shared provides factory functions, field definitions, and display
// helpers used across discussion subcommands.
package shared
import (
"github.com/cli/cli/v2/pkg/cmd/discussion/client"
"github.com/cli/cli/v2/pkg/cmdutil"
)
// DiscussionClientFunc returns a factory function that creates a DiscussionClient
// from the given Factory. The returned function is intended to be stored in
// command Options structs and called lazily inside RunE.
func DiscussionClientFunc(f *cmdutil.Factory) func() (client.DiscussionClient, error) {
return func() (client.DiscussionClient, error) {
httpClient, err := f.HttpClient()
if err != nil {
return nil, err
}
return client.NewDiscussionClient(httpClient), nil
}
}

View file

@ -0,0 +1,25 @@
package shared
// DiscussionFields lists the field names available for --json output on
// discussion commands.
var DiscussionFields = []string{
"id",
"number",
"title",
"body",
"url",
"state",
"stateReason",
"author",
"category",
"labels",
"answered",
"answerChosenAt",
"answerChosenBy",
"comments",
"reactionGroups",
"createdAt",
"updatedAt",
"closedAt",
"locked",
}

View file

@ -20,6 +20,7 @@ import (
completionCmd "github.com/cli/cli/v2/pkg/cmd/completion"
configCmd "github.com/cli/cli/v2/pkg/cmd/config"
copilotCmd "github.com/cli/cli/v2/pkg/cmd/copilot"
discussionCmd "github.com/cli/cli/v2/pkg/cmd/discussion"
extensionCmd "github.com/cli/cli/v2/pkg/cmd/extension"
"github.com/cli/cli/v2/pkg/cmd/factory"
gistCmd "github.com/cli/cli/v2/pkg/cmd/gist"
@ -157,6 +158,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
cmd.AddCommand(agentTaskCmd.NewCmdAgentTask(&repoResolvingCmdFactory))
cmd.AddCommand(browseCmd.NewCmdBrowse(&repoResolvingCmdFactory, nil))
cmd.AddCommand(discussionCmd.NewCmdDiscussion(&repoResolvingCmdFactory))
cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory))
cmd.AddCommand(orgCmd.NewCmdOrg(&repoResolvingCmdFactory))
cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory))