feat(discussion/client): implement Create mutation with tests

Implement the createDiscussion GraphQL mutation in the discussion client.

- Add getRepositoryMeta helper to resolve repo node ID and check
  discussions-enabled flag before mutating
- Skip repo lookup when CreateDiscussionInput.RepositoryID is provided
- Reuse discussionListNode mapping for consistent field coverage
- Table-driven tests: field mapping, pre-resolved repo ID, discussions
  disabled, repo not found, mutation error

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Max Beizer 2026-04-29 12:36:03 -05:00
parent a92d0c667a
commit 2e5623180a
No known key found for this signature in database
2 changed files with 330 additions and 2 deletions

View file

@ -775,8 +775,92 @@ func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCa
return categories, nil
}
func (c *discussionClient) Create(_ ghrepo.Interface, _ CreateDiscussionInput) (*Discussion, error) {
return nil, fmt.Errorf("not implemented")
// repositoryMeta holds the node ID and feature flags fetched for a repository.
type repositoryMeta struct {
ID string
HasDiscussionsEnabled bool
}
// getRepositoryMeta fetches the node ID and discussion-enabled flag for a repository.
func (c *discussionClient) getRepositoryMeta(repo ghrepo.Interface) (*repositoryMeta, error) {
var query struct {
Repository struct {
ID string
HasDiscussionsEnabled bool
} `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(), "RepositoryMeta", &query, variables); err != nil {
return nil, err
}
return &repositoryMeta{
ID: query.Repository.ID,
HasDiscussionsEnabled: query.Repository.HasDiscussionsEnabled,
}, nil
}
// createDiscussionGQLInput is the typed input for the createDiscussion GraphQL mutation.
type createDiscussionGQLInput struct {
RepositoryID githubv4.ID `json:"repositoryId"`
CategoryID githubv4.ID `json:"categoryId"`
Title githubv4.String `json:"title"`
Body githubv4.String `json:"body"`
}
func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) {
repoID := input.RepositoryID
if repoID == "" {
meta, err := c.getRepositoryMeta(repo)
if err != nil {
return nil, err
}
if !meta.HasDiscussionsEnabled {
return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName())
}
repoID = meta.ID
}
var mutation struct {
CreateDiscussion struct {
Discussion struct {
discussionListNode
Comments struct {
TotalCount int
}
}
} `graphql:"createDiscussion(input: $input)"`
}
variables := map[string]interface{}{
"input": createDiscussionGQLInput{
RepositoryID: githubv4.ID(repoID),
CategoryID: githubv4.ID(input.CategoryID),
Title: githubv4.String(input.Title),
Body: githubv4.String(input.Body),
},
}
if err := c.gql.Mutate(repo.RepoHost(), "CreateDiscussion", &mutation, variables); err != nil {
return nil, err
}
d := mapDiscussionFromListNode(mutation.CreateDiscussion.Discussion.discussionListNode)
d.Comments = DiscussionCommentList{TotalCount: mutation.CreateDiscussion.Discussion.Comments.TotalCount}
for _, rg := range mutation.CreateDiscussion.Discussion.ReactionGroups {
d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{
Content: rg.Content,
TotalCount: rg.Users.TotalCount,
})
}
return &d, nil
}
func (c *discussionClient) Update(_ ghrepo.Interface, _ UpdateDiscussionInput) (*Discussion, error) {

View file

@ -2475,3 +2475,247 @@ func TestGetCommentReplies(t *testing.T) {
})
}
}
func repoMetaResp(id string, discussionsEnabled bool) string {
return fmt.Sprintf(`{
"data": {
"repository": {
"id": %q,
"hasDiscussionsEnabled": %t
}
}
}`, id, discussionsEnabled)
}
func TestCreate(t *testing.T) {
repo := ghrepo.New("OWNER", "REPO")
tests := []struct {
name string
input CreateDiscussionInput
httpStubs func(*testing.T, *httpmock.Registry)
wantErr string
assertDisc *Discussion
}{
{
name: "maps all fields",
input: CreateDiscussionInput{
CategoryID: "CAT_1",
Title: "New Discussion",
Body: "Discussion body",
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryMeta\b`),
httpmock.StringResponse(repoMetaResp("R_1", true)),
)
reg.Register(
httpmock.GraphQL(`mutation CreateDiscussion\b`),
httpmock.StringResponse(heredoc.Doc(`
{
"data": {
"createDiscussion": {
"discussion": {
"id": "D_new",
"number": 99,
"title": "New Discussion",
"body": "Discussion body",
"url": "https://github.com/OWNER/REPO/discussions/99",
"closed": false,
"stateReason": "",
"isAnswered": false,
"answerChosenAt": "0001-01-01T00:00:00Z",
"author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"},
"category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": ":speech_balloon:", "isAnswerable": false},
"answerChosenBy": null,
"labels": {"nodes": []},
"reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 0}}],
"createdAt": "2025-06-01T00:00:00Z",
"updatedAt": "2025-06-01T00:00:00Z",
"closedAt": "0001-01-01T00:00:00Z",
"locked": false,
"comments": {"totalCount": 0}
}
}
}
}
`)),
)
},
assertDisc: &Discussion{
ID: "D_new",
Number: 99,
Title: "New Discussion",
Body: "Discussion body",
URL: "https://github.com/OWNER/REPO/discussions/99",
Author: DiscussionActor{ID: "U1", Login: "alice", Name: "Alice"},
Category: DiscussionCategory{
ID: "CAT_1",
Name: "General",
Slug: "general",
Emoji: ":speech_balloon:",
},
Labels: []DiscussionLabel{},
ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 0}},
CreatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
},
},
{
name: "skips repo lookup when RepositoryID provided",
input: CreateDiscussionInput{
RepositoryID: "R_existing",
CategoryID: "CAT_1",
Title: "Pre-resolved",
Body: "Body",
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
// No RepositoryMeta query should be made.
reg.Register(
httpmock.GraphQL(`mutation CreateDiscussion\b`),
httpmock.StringResponse(heredoc.Doc(`
{
"data": {
"createDiscussion": {
"discussion": {
"id": "D_pre",
"number": 1,
"title": "Pre-resolved",
"body": "Body",
"url": "https://github.com/OWNER/REPO/discussions/1",
"closed": false,
"stateReason": "",
"isAnswered": false,
"answerChosenAt": "0001-01-01T00:00:00Z",
"author": {"__typename": "User", "login": "alice"},
"category": {"id": "CAT_1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false},
"answerChosenBy": null,
"labels": {"nodes": []},
"reactionGroups": [],
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-01-01T00:00:00Z",
"closedAt": "0001-01-01T00:00:00Z",
"locked": false,
"comments": {"totalCount": 0}
}
}
}
}
`)),
)
},
assertDisc: &Discussion{
ID: "D_pre",
Number: 1,
Title: "Pre-resolved",
Body: "Body",
URL: "https://github.com/OWNER/REPO/discussions/1",
Author: DiscussionActor{Login: "alice"},
Category: DiscussionCategory{
ID: "CAT_1",
Name: "General",
Slug: "general",
},
Labels: []DiscussionLabel{},
CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
{
name: "discussions disabled",
input: CreateDiscussionInput{
CategoryID: "CAT_1",
Title: "Test",
Body: "Body",
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryMeta\b`),
httpmock.StringResponse(repoMetaResp("R_1", false)),
)
},
wantErr: "has discussions disabled",
},
{
name: "repo not found",
input: CreateDiscussionInput{
CategoryID: "CAT_1",
Title: "Test",
Body: "Body",
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryMeta\b`),
httpmock.StringResponse(heredoc.Doc(`
{
"data": {
"repository": null
},
"errors": [
{
"type": "NOT_FOUND",
"path": ["repository"],
"message": "Could not resolve to a Repository with the name 'OWNER/REPO'."
}
]
}
`)),
)
},
wantErr: "Could not resolve to a Repository with the name 'OWNER/REPO'.",
},
{
name: "mutation error",
input: CreateDiscussionInput{
RepositoryID: "R_1",
CategoryID: "BAD_CAT",
Title: "Test",
Body: "Body",
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation CreateDiscussion\b`),
httpmock.StringResponse(heredoc.Doc(`
{
"data": {
"createDiscussion": null
},
"errors": [
{
"type": "NOT_FOUND",
"message": "Could not resolve to a node with the global id of 'BAD_CAT'."
}
]
}
`)),
)
},
wantErr: "Could not resolve to a node with the global id of 'BAD_CAT'.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
if tt.httpStubs != nil {
tt.httpStubs(t, reg)
}
c := newTestDiscussionClient(reg)
d, err := c.Create(repo, tt.input)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
require.NotNil(t, d)
require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases")
assert.Equal(t, tt.assertDisc, d)
})
}
}