discussion create: add label support via two-step GraphQL

- Add Labels []string field to CreateDiscussionInput
- Implement resolveLabels helper: paginated RepositoryLabels query,
  case-insensitive match, error if any label not found
- Implement addLabelsToDiscussion helper: calls addLabelsToLabelable
  mutation after createDiscussion
- Wire label logic into Create: resolve labels, apply them, populate
  d.Labels from resolved values
- Add three TestCreate cases: success with labels, label not found,
  mutation failure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Max Beizer 2026-05-01 14:17:38 -05:00
parent d6b46f75d4
commit fd06ad7556
No known key found for this signature in database
3 changed files with 323 additions and 0 deletions

View file

@ -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
}

View file

@ -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 {

View file

@ -330,6 +330,7 @@ type CreateDiscussionInput struct {
CategoryID string
Title string
Body string
Labels []string
}
// UpdateDiscussionInput holds optional parameters for updating a discussion.