Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions pkg/github/__toolsnaps__/batch_update_issue_labels.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"annotations": {
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true,
"readOnlyHint": false,
"title": "Batch Update Issue Labels"
},
"description": "Apply label changes to multiple issues in one call. Each entry in operations targets one issue and can add labels, remove labels, or both. Labels not named in the operation are left untouched. Invalid input fails the whole call before any change is applied; per-issue API errors are reported individually.",
"inputSchema": {
"properties": {
"operations": {
"description": "One entry per issue, in any order. Each entry requires issue_number plus at least one non-empty of add or remove (arrays of label names). Duplicate issue numbers are rejected.",
"items": {
"additionalProperties": false,
"properties": {
"add": {
"description": "Label names to add to this issue (GitHub creates missing labels implicitly)",
"items": {
"minLength": 1,
"type": "string"
},
"type": "array"
},
"issue_number": {
"description": "The issue number to update",
"minimum": 1,
"type": "number"
},
"remove": {
"description": "Label names to remove from this issue",
"items": {
"minLength": 1,
"type": "string"
},
"type": "array"
}
},
"required": [
"issue_number"
],
"type": "object"
},
"minItems": 1,
"type": "array"
},
"owner": {
"description": "Repository owner (username or organization)",
"type": "string"
},
"repo": {
"description": "Repository name",
"type": "string"
}
},
"required": [
"owner",
"repo",
"operations"
],
"type": "object"
},
"name": "batch_update_issue_labels"
}
156 changes: 156 additions & 0 deletions pkg/github/batch_labels_granular_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package github

import (
"context"
"net/http"
"strings"
"testing"

"github.com/github/github-mcp-server/pkg/translations"
gogithub "github.com/google/go-github/v89/github"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGranularBatchUpdateIssueLabels(t *testing.T) {
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectToolErr bool
expectedErrMsg string
}{
{
name: "add and remove across multiple issues",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesByOwnerByRepoByIssueNumberLabels: expectRequestBody(t, []any{"bug", "priority/high"}).
andThen(mockResponse(t, http.StatusOK, []*gogithub.Label{
{Name: "bug"},
{Name: "priority/high"},
})),
DeleteReposIssuesByOwnerByRepoByIssueNumberLabel: mockResponse(t, http.StatusNoContent, nil),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"operations": []any{
map[string]any{
"issue_number": float64(1),
"add": []any{"bug", "priority/high"},
},
map[string]any{
"issue_number": float64(2),
"remove": []any{"wontfix"},
},
},
},
expectToolErr: false,
},
{
name: "empty operations array rejected",
mockedClient: MockHTTPClientWithHandlers(nil),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"operations": []any{},
},
expectToolErr: true,
expectedErrMsg: "operations must contain at least one entry",
},
{
name: "missing operations parameter rejected",
mockedClient: MockHTTPClientWithHandlers(nil),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
},
expectToolErr: true,
expectedErrMsg: "missing required parameter: operations",
},
{
name: "operation without add or remove rejected",
mockedClient: MockHTTPClientWithHandlers(nil),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"operations": []any{
map[string]any{"issue_number": float64(1)},
},
},
expectToolErr: true,
expectedErrMsg: "at least one non-empty of add or remove is required",
},
{
name: "duplicate issue numbers rejected",
mockedClient: MockHTTPClientWithHandlers(nil),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"operations": []any{
map[string]any{"issue_number": float64(1), "add": []any{"bug"}},
map[string]any{"issue_number": float64(1), "remove": []any{"wontfix"}},
},
},
expectToolErr: true,
expectedErrMsg: "duplicate issue_number 1",
},
{
name: "empty label name in add rejected",
mockedClient: MockHTTPClientWithHandlers(nil),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"operations": []any{
map[string]any{"issue_number": float64(1), "add": []any{""}},
},
},
expectToolErr: true,
expectedErrMsg: "add contains an empty label name",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
deps := BaseDeps{Client: mustNewGHClient(t, tc.mockedClient)}
serverTool := GranularBatchUpdateIssueLabels(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)

request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
if tc.expectToolErr {
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
assert.False(t, result.IsError)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, `"issue_number":1`)
assert.Contains(t, textContent.Text, `"applied":true`)
})
}
}

func TestGranularBatchUpdateIssueLabelsPartialFailure(t *testing.T) {
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesByOwnerByRepoByIssueNumberLabels: func(_ http.ResponseWriter, _ *http.Request) {},
}))
deps := BaseDeps{Client: client}
serverTool := GranularBatchUpdateIssueLabels(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)

request := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"operations": []any{
map[string]any{"issue_number": float64(1), "add": []any{"bug"}},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
// A per-issue API failure must surface as a tool error with a JSON body
// identifying the failing issue.
assert.True(t, result.IsError, "expected IsError on API failure")
textContent := getTextResult(t, result)
assert.True(t, strings.Contains(textContent.Text, "add failed"))
}
2 changes: 2 additions & 0 deletions pkg/github/granular_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func TestGranularToolSnaps(t *testing.T) {
GranularUpdateIssueBody,
GranularUpdateIssueAssignees,
GranularUpdateIssueLabels,
GranularBatchUpdateIssueLabels,
GranularUpdateIssueMilestone,
GranularUpdateIssueType,
GranularUpdateIssueState,
Expand Down Expand Up @@ -84,6 +85,7 @@ func TestIssuesGranularToolset(t *testing.T) {
"update_issue_body",
"update_issue_assignees",
"update_issue_labels",
"batch_update_issue_labels",
"update_issue_milestone",
"update_issue_type",
"update_issue_state",
Expand Down
2 changes: 2 additions & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const (
GetReposIssuesCommentsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/comments"
PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues"
PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments"
PostReposIssuesByOwnerByRepoByIssueNumberLabels = "POST /repos/{owner}/{repo}/issues/{issue_number}/labels"
DeleteReposIssuesByOwnerByRepoByIssueNumberLabel = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}"
PostReposIssuesReactionsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions"
PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}"
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
Expand Down
Loading