From 4a2935d89832ca45f313f0b00971b3c2a41a9389 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 25 Feb 2026 19:05:09 +0000 Subject: [PATCH] feat: add title parameter to merge_pull_request tool (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add missing `title` parameter to `merge_pull_request` tool for custom merge commit titles - Use `params.GetIndex()` for consistent index parameter handling (supports both string and number inputs) - Add test for `MergePullRequestFn` Closes #120 ## Test plan - [x] `go test ./operation/pull/ -run TestMergePullRequestFn` passes - [x] All existing tests pass (`go test ./...`) - [x] Build succeeds (`make build`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/134 Reviewed-by: Lunny Xiao Co-authored-by: silverwind Co-committed-by: silverwind --- operation/pull/pull.go | 25 +++++--- operation/pull/pull_test.go | 117 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 9 deletions(-) diff --git a/operation/pull/pull.go b/operation/pull/pull.go index 54a1daf..fdca9ef 100644 --- a/operation/pull/pull.go +++ b/operation/pull/pull.go @@ -182,7 +182,8 @@ var ( mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")), mcp.WithString("merge_style", mcp.Description("merge style: merge, rebase, rebase-merge, squash, fast-forward-only"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")), - mcp.WithString("message", mcp.Description("custom merge commit message (optional)")), + mcp.WithString("title", mcp.Description("custom merge commit title")), + mcp.WithString("message", mcp.Description("custom merge commit message")), mcp.WithBoolean("delete_branch", mcp.Description("delete the branch after merge"), mcp.DefaultBool(false)), ) @@ -845,9 +846,9 @@ func MergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call if !ok { return to.ErrorResult(errors.New("repo is required")) } - index, ok := req.GetArguments()["index"].(float64) - if !ok { - return to.ErrorResult(errors.New("index is required")) + index, err := params.GetIndex(req.GetArguments(), "index") + if err != nil { + return to.ErrorResult(err) } mergeStyle := "merge" @@ -855,6 +856,11 @@ func MergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call mergeStyle = style } + title := "" + if t, exists := req.GetArguments()["title"].(string); exists { + title = t + } + message := "" if msg, exists := req.GetArguments()["message"].(string); exists { message = msg @@ -872,26 +878,27 @@ func MergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call opt := gitea_sdk.MergePullRequestOption{ Style: gitea_sdk.MergeStyle(mergeStyle), + Title: title, Message: message, DeleteBranchAfterMerge: deleteBranch, } - merged, resp, err := client.MergePullRequest(owner, repo, int64(index), opt) + merged, resp, err := client.MergePullRequest(owner, repo, index, opt) if err != nil { - return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v err: %v", owner, repo, int64(index), err)) + return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v err: %v", owner, repo, index, err)) } if !merged && resp != nil && resp.StatusCode >= 400 { - return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v failed: HTTP %d %s", owner, repo, int64(index), resp.StatusCode, resp.Status)) + return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v failed: HTTP %d %s", owner, repo, index, resp.StatusCode, resp.Status)) } if !merged { - return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v returned merged=false", owner, repo, int64(index))) + return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v returned merged=false", owner, repo, index)) } successMsg := map[string]any{ "merged": merged, - "pr_index": int64(index), + "pr_index": index, "repository": fmt.Sprintf("%s/%s", owner, repo), "merge_style": mergeStyle, "branch_deleted": deleteBranch, diff --git a/operation/pull/pull_test.go b/operation/pull/pull_test.go index 86f97ee..84343b9 100644 --- a/operation/pull/pull_test.go +++ b/operation/pull/pull_test.go @@ -117,6 +117,123 @@ func TestEditPullRequestFn(t *testing.T) { } } +func TestMergePullRequestFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 5 + ) + + var ( + mu sync.Mutex + gotMethod string + gotPath string + gotBody map[string]any + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index): + mu.Lock() + gotMethod = r.Method + gotPath = r.URL.Path + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotBody = body + mu.Unlock() + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + origVersion := flag.Version + flag.Host = server.URL + flag.Token = "" + flag.Version = "test" + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.Version = origVersion + }() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "owner": owner, + "repo": repo, + "index": float64(index), + "merge_style": "squash", + "title": "feat: my squashed commit", + "message": "Squash merge of PR #5", + "delete_branch": true, + }, + }, + } + + result, err := MergePullRequestFn(context.Background(), req) + if err != nil { + t.Fatalf("MergePullRequestFn() error = %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if gotMethod != http.MethodPost { + t.Fatalf("expected POST request, got %s", gotMethod) + } + if gotPath != fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["Do"] != "squash" { + t.Fatalf("expected Do 'squash', got %v", gotBody["Do"]) + } + if gotBody["MergeTitleField"] != "feat: my squashed commit" { + t.Fatalf("expected MergeTitleField 'feat: my squashed commit', got %v", gotBody["MergeTitleField"]) + } + if gotBody["MergeMessageField"] != "Squash merge of PR #5" { + t.Fatalf("expected MergeMessageField 'Squash merge of PR #5', got %v", gotBody["MergeMessageField"]) + } + if gotBody["delete_branch_after_merge"] != true { + t.Fatalf("expected delete_branch_after_merge true, got %v", gotBody["delete_branch_after_merge"]) + } + + if len(result.Content) == 0 { + t.Fatalf("expected content in result") + } + textContent, ok := mcp.AsTextContent(result.Content[0]) + if !ok { + t.Fatalf("expected text content, got %T", result.Content[0]) + } + + var parsed struct { + Result map[string]any `json:"Result"` + } + if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil { + t.Fatalf("unmarshal result text: %v", err) + } + if parsed.Result["merged"] != true { + t.Fatalf("expected merged=true, got %v", parsed.Result["merged"]) + } + if parsed.Result["merge_style"] != "squash" { + t.Fatalf("expected merge_style 'squash', got %v", parsed.Result["merge_style"]) + } + if parsed.Result["branch_deleted"] != true { + t.Fatalf("expected branch_deleted=true, got %v", parsed.Result["branch_deleted"]) + } +} + func TestGetPullRequestDiffFn(t *testing.T) { const ( owner = "octo"