Skip to content
Next Next commit
fix: Empty assignees array should clear assignees (#2600)
* fix: Empty assignees array should clear assignees
  • Loading branch information
RossTarrant authored Jun 4, 2026
commit 33849e98eb4797107d033b6d29ffbd5f6d9e8a2b
41 changes: 36 additions & 5 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -1972,12 +1972,16 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
assigneesValue, assigneesProvided := args["assignees"]
assigneesProvided = assigneesProvided && assigneesValue != nil

// Get labels
labels, err := OptionalStringArrayParam(args, "labels")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
labelsValue, labelsProvided := args["labels"]
labelsProvided = labelsProvided && labelsValue != nil

// Get optional milestone
milestone, err := OptionalIntParam(args, "milestone")
Expand Down Expand Up @@ -2049,7 +2053,10 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf)
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{
AssigneesProvided: assigneesProvided,
LabelsProvided: labelsProvided,
})
return result, nil, err
default:
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
Expand Down Expand Up @@ -2204,12 +2211,16 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
assigneesValue, assigneesProvided := args["assignees"]
assigneesProvided = assigneesProvided && assigneesValue != nil

// Get labels
labels, err := OptionalStringArrayParam(args, "labels")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
labelsValue, labelsProvided := args["labels"]
labelsProvided = labelsProvided && labelsValue != nil

// Get optional milestone
milestone, err := OptionalIntParam(args, "milestone")
Expand Down Expand Up @@ -2266,7 +2277,10 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, nil, nil, state, stateReason, duplicateOf)
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, nil, nil, state, stateReason, duplicateOf, UpdateIssueOptions{
AssigneesProvided: assigneesProvided,
LabelsProvided: labelsProvided,
})
return result, nil, err
default:
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
Expand Down Expand Up @@ -2330,7 +2344,24 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo
return utils.NewToolResultText(string(r)), nil
}

func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) {
// UpdateIssueOptions controls which optional fields are included in an issue update request.
type UpdateIssueOptions struct {
// AssigneesProvided sends the assignees field even when the slice is empty.
AssigneesProvided bool
// LabelsProvided sends the labels field even when the slice is empty.
LabelsProvided bool
}

func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int, opts ...UpdateIssueOptions) (*mcp.CallToolResult, error) {
updateOptions := UpdateIssueOptions{
AssigneesProvided: len(assignees) > 0,
LabelsProvided: len(labels) > 0,
}
for _, opt := range opts {
updateOptions.AssigneesProvided = updateOptions.AssigneesProvided || opt.AssigneesProvided
updateOptions.LabelsProvided = updateOptions.LabelsProvided || opt.LabelsProvided
}

// Create the issue request with only provided fields
issueRequest := &github.IssueRequest{}

Expand All @@ -2343,11 +2374,11 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
issueRequest.Body = github.Ptr(body)
}

if len(labels) > 0 {
if updateOptions.LabelsProvided {
issueRequest.Labels = &labels
}

if len(assignees) > 0 {
if updateOptions.AssigneesProvided {
issueRequest.Assignees = &assignees
}

Expand Down
68 changes: 68 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2987,6 +2987,33 @@ func Test_UpdateIssue(t *testing.T) {
expectError: false,
expectedIssue: mockUpdatedIssue,
},
{
name: "partial update clears labels and assignees",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"labels": []any{},
"assignees": []any{},
}).andThen(
mockResponse(t, http.StatusOK, &github.Issue{
Number: github.Ptr(123),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
}),
),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"labels": []any{},
"assignees": []any{},
},
expectError: false,
expectedIssue: &github.Issue{
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
},
},
{
name: "partial update with issue fields reconciled by names",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
Expand Down Expand Up @@ -3406,6 +3433,47 @@ func Test_UpdateIssue(t *testing.T) {
}
}

func Test_LegacyUpdateIssueClearsLabelsAndAssignees(t *testing.T) {
serverTool := LegacyIssueWrite(translations.NullTranslationHelper)
updatedIssue := &github.Issue{
Number: github.Ptr(8),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/8"),
}

client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"labels": []any{},
"assignees": []any{},
}).andThen(mockResponse(t, http.StatusOK, updatedIssue)),
}))
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)

request := createMCPRequest(map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(8),
"labels": []any{},
"assignees": []any{},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)

require.NoError(t, err)
if result.IsError {
t.Fatalf("Unexpected error result: %s", getErrorResult(t, result).Text)
}
textContent := getTextResult(t, result)

var updateResp MinimalResponse
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &updateResp))
assert.Equal(t, updatedIssue.GetHTMLURL(), updateResp.URL)
}

func Test_ParseISOTimestamp(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading