forgejo-mcp/operation/pull/pull.go
Dmitry Mikushin 9f87831c14 Fix number parameter parsing: handle string-encoded numbers from MCP clients
MCP clients (e.g. Claude Code) may serialize number parameters as JSON
strings instead of JSON numbers. The Go type assertion .(float64) silently
returns 0 for string values, causing "issue does not exist [index: 0]"
errors.

Add to.Float64() helper that handles float64, int, int64, and string types
via strconv.ParseFloat. Replace all 62 bare .(float64) assertions across
9 operation files with to.Float64() calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:08:59 +01:00

406 lines
15 KiB
Go

package pull
import (
"context"
"fmt"
"strconv"
"codeberg.org/goern/forgejo-mcp/v2/operation/params"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
"codeberg.org/goern/forgejo-mcp/v2/pkg/log"
"codeberg.org/goern/forgejo-mcp/v2/pkg/ptr"
"codeberg.org/goern/forgejo-mcp/v2/pkg/to"
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetPullRequestByIndexToolName = "get_pull_request_by_index"
ListRepoPullRequestsToolName = "list_repo_pull_requests"
CreatePullRequestToolName = "create_pull_request"
UpdatePullRequestToolName = "update_pull_request"
ListPullReviewsToolName = "list_pull_reviews"
GetPullReviewToolName = "get_pull_review"
ListPullReviewCommentsToolName = "list_pull_review_comments"
MergePullRequestToolName = "merge_pull_request"
ListPullRequestFilesToolName = "list_pull_request_files"
GetPullRequestDiffToolName = "get_pull_request_diff"
)
var (
GetPullRequestByIndexTool = mcp.NewTool(
GetPullRequestByIndexToolName,
mcp.WithDescription("Get pull request by index"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.PRIndex)),
)
ListRepoPullRequestsTool = mcp.NewTool(
ListRepoPullRequestsToolName,
mcp.WithDescription("List repo pull requests"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithString("state", mcp.Description("State (open|closed|all)"), mcp.DefaultString("open")),
mcp.WithString("sort", mcp.Description("Sort (oldest|recentupdate|leastupdate|mostcomment)")),
mcp.WithString("milestone", mcp.Description(params.Milestone)),
mcp.WithString("labels", mcp.Description(params.Labels)),
mcp.WithNumber("page", mcp.Description(params.Page), mcp.DefaultNumber(1)),
mcp.WithNumber("limit", mcp.Description(params.Limit), mcp.DefaultNumber(20)),
)
CreatePullRequestTool = mcp.NewTool(
CreatePullRequestToolName,
mcp.WithDescription("Create pull request"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithString("head", mcp.Required(), mcp.Description(params.Head)),
mcp.WithString("base", mcp.Required(), mcp.Description(params.Base)),
mcp.WithString("title", mcp.Required(), mcp.Description(params.Title)),
mcp.WithString("body", mcp.Description(params.Body)),
)
UpdatePullRequestTool = mcp.NewTool(
UpdatePullRequestToolName,
mcp.WithDescription("Update pull request"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.PRIndex)),
mcp.WithString("title", mcp.Description(params.Title)),
mcp.WithString("body", mcp.Description(params.Body)),
mcp.WithString("base", mcp.Description(params.Base)),
mcp.WithString("assignee", mcp.Description("Assignee username")),
mcp.WithString("milestone", mcp.Description(params.Milestone)),
)
ListPullReviewsTool = mcp.NewTool(
ListPullReviewsToolName,
mcp.WithDescription("List reviews for a pull request"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.PRIndex)),
mcp.WithNumber("page", mcp.Description(params.Page), mcp.DefaultNumber(1)),
mcp.WithNumber("limit", mcp.Description(params.Limit), mcp.DefaultNumber(20)),
)
GetPullReviewTool = mcp.NewTool(
GetPullReviewToolName,
mcp.WithDescription("Get a specific pull request review"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.PRIndex)),
mcp.WithNumber("id", mcp.Required(), mcp.Description("Review ID")),
)
ListPullReviewCommentsTool = mcp.NewTool(
ListPullReviewCommentsToolName,
mcp.WithDescription("List comments on a pull request review"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.PRIndex)),
mcp.WithNumber("id", mcp.Required(), mcp.Description("Review ID")),
)
ListPullRequestFilesTool = mcp.NewTool(
ListPullRequestFilesToolName,
mcp.WithDescription("List changed files in a pull request"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.PRIndex)),
mcp.WithNumber("page", mcp.Description(params.Page), mcp.DefaultNumber(1)),
mcp.WithNumber("limit", mcp.Description(params.Limit), mcp.DefaultNumber(50)),
)
GetPullRequestDiffTool = mcp.NewTool(
GetPullRequestDiffToolName,
mcp.WithDescription("Get the diff of a pull request"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.PRIndex)),
)
MergePullRequestTool = mcp.NewTool(
MergePullRequestToolName,
mcp.WithDescription("Merge a pull request"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.PRIndex)),
mcp.WithString("style", mcp.Required(), mcp.Description("Merge style (merge, rebase, rebase-merge, squash)")),
mcp.WithString("title", mcp.Description("Merge commit title")),
mcp.WithString("message", mcp.Description("Merge commit message")),
mcp.WithBoolean("delete_branch_after_merge", mcp.Description("Delete head branch after merge")),
mcp.WithBoolean("force_merge", mcp.Description("Force merge even if checks have not passed")),
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("Schedule merge for when all checks succeed")),
)
)
func RegisterTool(s *server.MCPServer) {
s.AddTool(GetPullRequestByIndexTool, GetPullRequestByIndexFn)
s.AddTool(ListRepoPullRequestsTool, ListRepoPullRequestsFn)
s.AddTool(CreatePullRequestTool, CreatePullRequestFn)
s.AddTool(UpdatePullRequestTool, UpdatePullRequestFn)
s.AddTool(ListPullReviewsTool, ListPullReviewsFn)
s.AddTool(GetPullReviewTool, GetPullReviewFn)
s.AddTool(ListPullReviewCommentsTool, ListPullReviewCommentsFn)
s.AddTool(MergePullRequestTool, MergePullRequestFn)
s.AddTool(ListPullRequestFilesTool, ListPullRequestFilesFn)
s.AddTool(GetPullRequestDiffTool, GetPullRequestDiffFn)
}
func GetPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetPullRequestByIndexFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, _ := to.Float64(req.GetArguments()["index"])
pr, _, err := forgejo.Client().GetPullRequest(owner, repo, int64(index))
if err != nil {
return to.ErrorResult(fmt.Errorf("get pull request err: %v", err))
}
return to.TextResult(pr)
}
func ListRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoPullRequestsFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
state, ok := req.GetArguments()["state"].(string)
if !ok {
state = "open"
}
sort, _ := req.GetArguments()["sort"].(string)
page, _ := to.Float64(req.GetArguments()["page"])
if !ok {
page = 1
}
limit, _ := to.Float64(req.GetArguments()["limit"])
if !ok {
limit = 20
}
// Convert milestone from string to int64 if provided
// Note: Not using milestoneID since it's not supported in the current Forgejo SDK
// Labels - not used directly in query per API, will be handled in the API call
opt := forgejo_sdk.ListPullRequestsOptions{
State: forgejo_sdk.StateType(state),
Sort: sort,
ListOptions: forgejo_sdk.ListOptions{
Page: int(page),
PageSize: int(limit),
},
}
// Only set milestone if provided and valid
// Note: Not using milestone as it's not supported in the current Forgejo SDK
prs, _, err := forgejo.Client().ListRepoPullRequests(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get pull request list err: %v", err))
}
return to.TextResult(prs)
}
func CreatePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreatePullRequestFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
head, _ := req.GetArguments()["head"].(string)
base, _ := req.GetArguments()["base"].(string)
title, _ := req.GetArguments()["title"].(string)
body, _ := req.GetArguments()["body"].(string)
opt := forgejo_sdk.CreatePullRequestOption{
Head: head,
Base: base,
Title: title,
Body: body,
}
pr, _, err := forgejo.Client().CreatePullRequest(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create pull request err: %v", err))
}
return to.TextResult(pr)
}
func UpdatePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UpdatePullRequestFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, _ := to.Float64(req.GetArguments()["index"])
title, _ := req.GetArguments()["title"].(string)
body, _ := req.GetArguments()["body"].(string)
base, _ := req.GetArguments()["base"].(string)
assignee, _ := req.GetArguments()["assignee"].(string)
milestone, _ := req.GetArguments()["milestone"].(string)
opt := forgejo_sdk.EditPullRequestOption{}
if title != "" {
opt.Title = title
}
if body != "" {
opt.Body = ptr.To(body)
}
if base != "" {
opt.Base = base
}
if assignee != "" {
opt.Assignee = assignee
}
if milestone != "" {
milestoneID, err := strconv.ParseInt(milestone, 10, 64)
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid milestone ID: %v", err))
}
opt.Milestone = milestoneID
}
pr, _, err := forgejo.Client().EditPullRequest(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update pull request err: %v", err))
}
return to.TextResult(pr)
}
func ListPullReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListPullReviewsFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, _ := to.Float64(req.GetArguments()["index"])
page, _ := to.Float64(req.GetArguments()["page"])
if page == 0 {
page = 1
}
limit, _ := to.Float64(req.GetArguments()["limit"])
if limit == 0 {
limit = 20
}
opt := forgejo_sdk.ListPullReviewsOptions{
ListOptions: forgejo_sdk.ListOptions{
Page: int(page),
PageSize: int(limit),
},
}
reviews, _, err := forgejo.Client().ListPullReviews(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list pull reviews err: %v", err))
}
return to.TextResult(reviews)
}
func GetPullReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetPullReviewFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, _ := to.Float64(req.GetArguments()["index"])
id, _ := to.Float64(req.GetArguments()["id"])
review, _, err := forgejo.Client().GetPullReview(owner, repo, int64(index), int64(id))
if err != nil {
return to.ErrorResult(fmt.Errorf("get pull review err: %v", err))
}
return to.TextResult(review)
}
func MergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called MergePullRequestFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, _ := to.Float64(req.GetArguments()["index"])
style, _ := req.GetArguments()["style"].(string)
title, _ := req.GetArguments()["title"].(string)
message, _ := req.GetArguments()["message"].(string)
deleteBranch, _ := req.GetArguments()["delete_branch_after_merge"].(bool)
forceMerge, _ := req.GetArguments()["force_merge"].(bool)
mergeWhenChecks, _ := req.GetArguments()["merge_when_checks_succeed"].(bool)
opt := forgejo_sdk.MergePullRequestOption{
Style: forgejo_sdk.MergeStyle(style),
DeleteBranchAfterMerge: deleteBranch,
ForceMerge: forceMerge,
MergeWhenChecksSucceed: mergeWhenChecks,
}
if title != "" {
opt.Title = title
}
if message != "" {
opt.Message = message
}
_, _, err := forgejo.Client().MergePullRequest(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("merge pull request err: %v", err))
}
result := "Pull request merged successfully"
if mergeWhenChecks {
result = "Pull request scheduled to merge when all checks succeed"
}
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.NewTextContent(result)},
}, nil
}
func ListPullRequestFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListPullRequestFilesFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, _ := to.Float64(req.GetArguments()["index"])
page, _ := to.Float64(req.GetArguments()["page"])
if page == 0 {
page = 1
}
limit, _ := to.Float64(req.GetArguments()["limit"])
if limit == 0 {
limit = 50
}
opt := forgejo_sdk.ListPullRequestFilesOptions{
ListOptions: forgejo_sdk.ListOptions{
Page: int(page),
PageSize: int(limit),
},
}
files, _, err := forgejo.Client().ListPullRequestFiles(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list pull request files err: %v", err))
}
return to.TextResult(files)
}
func GetPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetPullRequestDiffFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, _ := to.Float64(req.GetArguments()["index"])
diff, _, err := forgejo.Client().GetPullRequestDiff(owner, repo, int64(index), forgejo_sdk.PullRequestDiffOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get pull request diff err: %v", err))
}
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.NewTextContent(string(diff))},
}, nil
}
func ListPullReviewCommentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListPullReviewCommentsFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, _ := to.Float64(req.GetArguments()["index"])
id, _ := to.Float64(req.GetArguments()["id"])
comments, _, err := forgejo.Client().ListPullReviewComments(owner, repo, int64(index), int64(id))
if err != nil {
return to.ErrorResult(fmt.Errorf("list pull review comments err: %v", err))
}
return to.TextResult(comments)
}