diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index 8b56948c9..6d71ab9ef 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -805,6 +805,90 @@ func (c *discussionClient) getRepositoryMeta(repo ghrepo.Interface) (*repository }, nil } +// resolveLabels fetches all labels for a repository and matches the requested names +// case-insensitively. Returns an error if any requested label name is not found. +func (c *discussionClient) resolveLabels(repo ghrepo.Interface, labelNames []string) ([]DiscussionLabel, error) { + if len(labelNames) == 0 { + return nil, nil + } + + var query struct { + Repository struct { + Labels struct { + Nodes []struct { + ID string + Name string + Color string + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"labels(first: 100, after: $endCursor)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "endCursor": (*githubv4.String)(nil), + } + + wanted := make(map[string]bool, len(labelNames)) + for _, n := range labelNames { + wanted[strings.ToLower(n)] = true + } + + found := make(map[string]DiscussionLabel, len(labelNames)) + for { + if err := c.gql.Query(repo.RepoHost(), "RepositoryLabels", &query, variables); err != nil { + return nil, err + } + for _, n := range query.Repository.Labels.Nodes { + if wanted[strings.ToLower(n.Name)] { + found[strings.ToLower(n.Name)] = DiscussionLabel{ID: n.ID, Name: n.Name, Color: n.Color} + } + } + if !query.Repository.Labels.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.Labels.PageInfo.EndCursor) + } + + result := make([]DiscussionLabel, 0, len(labelNames)) + for _, name := range labelNames { + label, ok := found[strings.ToLower(name)] + if !ok { + return nil, fmt.Errorf("label not found: %q", name) + } + result = append(result, label) + } + return result, nil +} + +// addLabelsToDiscussion applies labels to a discussion via the addLabelsToLabelable mutation. +func (c *discussionClient) addLabelsToDiscussion(repo ghrepo.Interface, discussionID string, labelIDs []string) error { + ids := make([]githubv4.ID, len(labelIDs)) + for i, id := range labelIDs { + ids[i] = githubv4.ID(id) + } + + var mutation struct { + AddLabelsToLabelable struct { + Typename string `graphql:"__typename"` + } `graphql:"addLabelsToLabelable(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.AddLabelsToLabelableInput{ + LabelableID: githubv4.ID(discussionID), + LabelIDs: ids, + }, + } + + return c.gql.Mutate(repo.RepoHost(), "AddLabelsToDiscussion", &mutation, variables) +} + func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) { meta, err := c.getRepositoryMeta(repo) if err != nil { @@ -848,6 +932,21 @@ func (c *discussionClient) Create(repo ghrepo.Interface, input CreateDiscussionI }) } + if len(input.Labels) > 0 { + resolvedLabels, err := c.resolveLabels(repo, input.Labels) + if err != nil { + return nil, err + } + labelIDs := make([]string, len(resolvedLabels)) + for i, l := range resolvedLabels { + labelIDs[i] = l.ID + } + if err := c.addLabelsToDiscussion(repo, d.ID, labelIDs); err != nil { + return nil, err + } + d.Labels = resolvedLabels + } + return &d, nil } diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 9687b5892..66852d8b3 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -2641,6 +2641,229 @@ func TestCreate(t *testing.T) { }, wantErr: "Could not resolve to a node with the global id of 'BAD_CAT'.", }, + { + name: "creates discussion with labels", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "New Discussion", + Body: "Discussion body", + Labels: []string{"bug"}, + }, + 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} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"} + ], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), + httpmock.StringResponse(`{"data":{"addLabelsToLabelable":{"__typename":"Discussion"}}}`), + ) + }, + 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{{ID: "L_bug", Name: "bug", Color: "d73a4a"}}, + 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: "label not found returns error", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "Test", + Body: "Body", + Labels: []string{"nonexistent"}, + }, + 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": "Test", + "body": "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": [], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + }, + wantErr: `label not found: "nonexistent"`, + }, + { + name: "add labels mutation failure returns error", + input: CreateDiscussionInput{ + CategoryID: "CAT_1", + Title: "Test", + Body: "Body", + Labels: []string{"bug"}, + }, + 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": "Test", + "body": "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": [], + "createdAt": "2025-06-01T00:00:00Z", + "updatedAt": "2025-06-01T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false, + "comments": {"totalCount": 0} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryLabels\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": { + "repository": { + "labels": { + "nodes": [ + {"id": "L_bug", "name": "bug", "color": "d73a4a"} + ], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + } + } + } + `)), + ) + reg.Register( + httpmock.GraphQL(`mutation AddLabelsToDiscussion\b`), + httpmock.StringResponse(heredoc.Doc(` + { + "data": null, + "errors": [{"message": "could not apply labels"}] + } + `)), + ) + }, + wantErr: "could not apply labels", + }, } for _, tt := range tests { diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index 6e964bc9c..d725ee92c 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -330,6 +330,7 @@ type CreateDiscussionInput struct { CategoryID string Title string Body string + Labels []string } // UpdateDiscussionInput holds optional parameters for updating a discussion.