diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 88aa7f170..ead75da6e 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -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) { diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 16c0813a2..38b5934e3 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -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) + }) + } +}