Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 标签"
},
"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. 标签 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": "仓库 owner (username or organization)",
"type": "string"
},
"repo": {
"description": "仓库 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 TestGranularBatchUpdateIssue标签(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{
PostRepos问题ByOwnerByRepoByIssueNumber标签: expectRequestBody(t, []any{"bug", "priority/high"}).
andThen(mockResponse(t, http.StatusOK, []*gogithub.Label{
{Name: "bug"},
{Name: "priority/high"},
})),
DeleteRepos问题ByOwnerByRepoByIssueNumberLabel: 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: must新建GHClient(t, tc.mockedClient)}
serverTool := GranularBatchUpdateIssue标签(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 TestGranularBatchUpdateIssue标签PartialFailure(t *testing.T) {
client := must新建GHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostRepos问题ByOwnerByRepoByIssueNumber标签: func(_ http.ResponseWriter, _ *http.Request) {},
}))
deps := BaseDeps{Client: client}
serverTool := GranularBatchUpdateIssue标签(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,
GranularUpdateIssue标签,
GranularBatchUpdateIssue标签,
GranularUpdateIssueMilestone,
GranularUpdateIssueType,
GranularUpdateIssueState,
Expand Down Expand Up @@ -84,6 +85,7 @@ func Test问题GranularToolset(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 (
GetRepos问题CommentsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/comments"
PostRepos问题ByOwnerByRepo = "POST /repos/{owner}/{repo}/issues"
PostRepos问题CommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments"
PostRepos问题ByOwnerByRepoByIssueNumber标签 = "POST /repos/{owner}/{repo}/issues/{issue_number}/labels"
DeleteRepos问题ByOwnerByRepoByIssueNumberLabel = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}"
PostRepos问题ReactionsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions"
PatchRepos问题ByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}"
GetRepos问题Sub问题ByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
Expand Down
Loading