- 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.
466 lines
17 KiB
Go
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)
|
|
}
|