forgejo-mcp/operation/pull/pull.go
Christoph Görn dd9116fbca
fix: 🚨 clear golangci-lint v2 backlog (closes bead forgejo-mcp-hc9)
- errorlint: fmt.Errorf %v→%w across all handlers (errors now wrap the
  underlying SDK/HTTP error for errors.Is/As); errors.Is for
  context.Canceled in cmd
- staticcheck quickfixes: Fprintf over WriteString(Sprintf), tagged
  switches, omit inferred 'any'; QF1001 resolved by hoisting hex checks
  into isHex predicates (normalizeColor, validateSHA)
- govet: reflect.Ptr → reflect.Pointer in pkg/ptr
- errcheck: explicit '_ =' for fs.Parse (ExitOnError) and deferred
  zap Sync
- repair four test assertions the autofix broke (it dropped the
  're.Code != X' half of combined type-assert checks, inverting the
  test); all errors.As sites use 'var re *resource.ResourceError'

golangci-lint: 0 issues. staticcheck, vet, tests: clean.
2026-06-11 00:03:36 +02:00

466 lines
17 KiB
Go

package pull
import (
"context"
"fmt"
"strconv"
"codeberg.org/goern/forgejo-mcp/v2/operation/params"
"codeberg.org/goern/forgejo-mcp/v2/pkg/diff"
"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/v3"
"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.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 unified diff of a pull request. Pass an optional file_path to receive only the hunks for that file (match is exact on either the pre- or post-rename path). Use list_pull_request_files first to discover the file paths in the PR."),
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("file_path", mcp.Description("Optional. Return only the diff section for this file (matched on the diff --git boundary). Omit for the full diff.")),
)
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"])
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
pr, _, err := client.GetPullRequest(owner, repo, int64(index))
if err != nil {
return to.ErrorResult(fmt.Errorf("get pull request err: %w", 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)
milestone, _ := req.GetArguments()["milestone"].(string)
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.ListPullRequestsOptions{
State: forgejo_sdk.StateType(state),
Sort: sort,
ListOptions: forgejo_sdk.ListOptions{
Page: int(page),
PageSize: int(limit),
},
}
if milestone != "" {
milestoneID, err := strconv.ParseInt(milestone, 10, 64)
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid milestone ID: %w", err))
}
opt.Milestone = milestoneID
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
prs, _, err := client.ListRepoPullRequests(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get pull request list err: %w", 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,
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
pr, _, err := client.CreatePullRequest(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create pull request err: %w", 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: %w", err))
}
opt.Milestone = milestoneID
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
pr, _, err := client.EditPullRequest(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update pull request err: %w", 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),
},
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
reviews, _, err := client.ListPullReviews(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list pull reviews err: %w", 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"])
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
review, _, err := client.GetPullReview(owner, repo, int64(index), int64(id))
if err != nil {
return to.ErrorResult(fmt.Errorf("get pull review err: %w", 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
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
merged, resp, err := client.MergePullRequest(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("merge pull request err: %w", err))
}
if !merged && !mergeWhenChecks {
statusCode := 0
if resp != nil {
statusCode = resp.StatusCode
}
return to.ErrorResult(fmt.Errorf("merge pull request: server returned HTTP %d (expected 200)", statusCode))
}
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),
},
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
files, _, err := client.ListPullRequestFiles(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list pull request files err: %w", err))
}
return to.TextResult(files)
}
func GetPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetPullRequestDiffFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
index, _ := to.Float64(args["index"])
filePath, _ := args["file_path"].(string)
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
diffBytes, _, err := client.GetPullRequestDiff(owner, repo, int64(index), forgejo_sdk.PullRequestDiffOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get pull request diff err: %w", err))
}
body := string(diffBytes)
if filePath != "" {
slice, found := diff.FileSlice(body, filePath)
if !found {
return to.ErrorResult(fmt.Errorf("file_path %q not found in pull request diff", filePath))
}
body = slice
}
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.NewTextContent(body)},
}, 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"])
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
comments, _, err := client.ListPullReviewComments(owner, repo, int64(index), int64(id))
if err != nil {
return to.ErrorResult(fmt.Errorf("list pull review comments err: %w", err))
}
return to.TextResult(comments)
}