mirror of
https://gitea.com/gitea/gitea-mcp.git
synced 2026-03-25 14:25:13 +00:00
Compare commits
5 Commits
main
...
add-missin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606f387da6 | ||
|
|
ac71207af9 | ||
|
|
5bfab0dc74 | ||
|
|
3f28aa3614 | ||
|
|
c04d9314d3 |
16
Makefile
16
Makefile
@@ -8,7 +8,7 @@ GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
|
|||||||
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
|
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
|
||||||
|
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
help: ## Print this help message.
|
help: ## print this help message
|
||||||
@echo "Usage: make [target]"
|
@echo "Usage: make [target]"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
@@ -16,7 +16,7 @@ help: ## Print this help message.
|
|||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: build ## Install the application.
|
install: build ## install the application
|
||||||
@echo "Installing $(EXECUTABLE)..."
|
@echo "Installing $(EXECUTABLE)..."
|
||||||
@mkdir -p $(GOPATH)/bin
|
@mkdir -p $(GOPATH)/bin
|
||||||
@cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE)
|
@cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE)
|
||||||
@@ -24,23 +24,23 @@ install: build ## Install the application.
|
|||||||
@echo "Please add $(GOPATH)/bin to your PATH if it is not already there."
|
@echo "Please add $(GOPATH)/bin to your PATH if it is not already there."
|
||||||
|
|
||||||
.PHONY: uninstall
|
.PHONY: uninstall
|
||||||
uninstall: ## Uninstall the application.
|
uninstall: ## uninstall the application
|
||||||
@echo "Uninstalling $(EXECUTABLE)..."
|
@echo "Uninstalling $(EXECUTABLE)..."
|
||||||
@rm -f $(GOPATH)/bin/$(EXECUTABLE)
|
@rm -f $(GOPATH)/bin/$(EXECUTABLE)
|
||||||
@echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)"
|
@echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)"
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean: ## Clean the build artifacts.
|
clean: ## delete build artifacts
|
||||||
@echo "Cleaning up build artifacts..."
|
@echo "Cleaning up build artifacts..."
|
||||||
@rm -f $(EXECUTABLE)
|
@rm -f $(EXECUTABLE)
|
||||||
@echo "Cleaned up $(EXECUTABLE)"
|
@echo "Cleaned up $(EXECUTABLE)"
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: ## Build the application.
|
build: ## build the application
|
||||||
$(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE)
|
$(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE)
|
||||||
|
|
||||||
.PHONY: air
|
.PHONY: air
|
||||||
air: ## Install air for hot reload.
|
air: ## install air for hot reload
|
||||||
@hash air > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash air > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GO) install github.com/air-verse/air@latest; \
|
$(GO) install github.com/air-verse/air@latest; \
|
||||||
fi
|
fi
|
||||||
@@ -49,6 +49,10 @@ air: ## Install air for hot reload.
|
|||||||
dev: air ## run the application with hot reload
|
dev: air ## run the application with hot reload
|
||||||
air --build.cmd "make build" --build.bin ./gitea-mcp
|
air --build.cmd "make build" --build.bin ./gitea-mcp
|
||||||
|
|
||||||
|
.PHONY: fmt
|
||||||
|
fmt: ## format the Go code
|
||||||
|
$(GO) run $(GOFUMPT_PACKAGE) -w .
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go ## lint everything
|
lint: lint-go ## lint everything
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ var (
|
|||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||||
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
|
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
|
||||||
|
mcp.WithArray("labels", mcp.Description("filter by label names"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
|
mcp.WithString("since", mcp.Description("filter issues updated after this ISO 8601 timestamp")),
|
||||||
|
mcp.WithString("before", mcp.Description("filter issues updated before this ISO 8601 timestamp")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
@@ -56,8 +59,10 @@ var (
|
|||||||
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")),
|
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")),
|
||||||
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")),
|
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")),
|
||||||
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")),
|
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")),
|
||||||
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
|
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
|
||||||
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
|
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
|
||||||
|
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
|
||||||
|
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,14 +167,22 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
|
|||||||
if !ok {
|
if !ok {
|
||||||
state = "all"
|
state = "all"
|
||||||
}
|
}
|
||||||
|
labels := params.GetStringSlice(req.GetArguments(), "labels")
|
||||||
page, pageSize := params.GetPagination(req.GetArguments(), 30)
|
page, pageSize := params.GetPagination(req.GetArguments(), 30)
|
||||||
opt := gitea_sdk.ListIssueOption{
|
opt := gitea_sdk.ListIssueOption{
|
||||||
State: gitea_sdk.StateType(state),
|
State: gitea_sdk.StateType(state),
|
||||||
|
Labels: labels,
|
||||||
ListOptions: gitea_sdk.ListOptions{
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil {
|
||||||
|
opt.Since = *t
|
||||||
|
}
|
||||||
|
if t := params.GetOptionalTime(req.GetArguments(), "before"); t != nil {
|
||||||
|
opt.Before = *t
|
||||||
|
}
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
@@ -213,6 +226,10 @@ func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
|
|||||||
opt.Milestone = milestone
|
opt.Milestone = milestone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if labelIDs, err := params.GetInt64Slice(req.GetArguments(), "labels"); err == nil {
|
||||||
|
opt.Labels = labelIDs
|
||||||
|
}
|
||||||
|
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline")
|
||||||
issue, _, err := client.CreateIssue(owner, repo, opt)
|
issue, _, err := client.CreateIssue(owner, repo, opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
|
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
|
||||||
@@ -289,6 +306,10 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
|
|||||||
if ok {
|
if ok {
|
||||||
opt.State = new(gitea_sdk.StateType(state))
|
opt.State = new(gitea_sdk.StateType(state))
|
||||||
}
|
}
|
||||||
|
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline")
|
||||||
|
if removeDeadline, ok := req.GetArguments()["remove_deadline"].(bool); ok {
|
||||||
|
opt.RemoveDeadline = &removeDeadline
|
||||||
|
}
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
167
operation/issue/issue_test.go
Normal file
167
operation/issue/issue_test.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package issue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_listRepoIssuesFn_filters(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
gotQuery string
|
||||||
|
)
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/v1/version":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
|
||||||
|
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"private":false}`))
|
||||||
|
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo):
|
||||||
|
mu.Lock()
|
||||||
|
gotQuery = r.URL.RawQuery
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
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,
|
||||||
|
"labels": []any{"bug", "enhancement"},
|
||||||
|
"since": "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := listRepoIssuesFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listRepoIssuesFn() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if !strings.Contains(gotQuery, "labels=bug%2Cenhancement") {
|
||||||
|
t.Fatalf("expected labels query param, got %s", gotQuery)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotQuery, "since=2026-01-01") {
|
||||||
|
t.Fatalf("expected since query param, got %s", gotQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_createIssueFn_labels(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
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/issues", owner, repo):
|
||||||
|
mu.Lock()
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
gotBody = body
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`))
|
||||||
|
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,
|
||||||
|
"title": "test issue",
|
||||||
|
"body": "body",
|
||||||
|
"labels": []any{float64(10), float64(20)},
|
||||||
|
"deadline": "2026-06-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := createIssueFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createIssueFn() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
labels, ok := gotBody["labels"].([]any)
|
||||||
|
if !ok || len(labels) != 2 {
|
||||||
|
t.Fatalf("expected 2 labels, got %v", gotBody["labels"])
|
||||||
|
}
|
||||||
|
if labels[0] != float64(10) || labels[1] != float64(20) {
|
||||||
|
t.Fatalf("expected labels [10,20], got %v", labels)
|
||||||
|
}
|
||||||
|
if gotBody["due_date"] == nil {
|
||||||
|
t.Fatalf("expected due_date to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ var (
|
|||||||
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
|
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
|
||||||
mcp.WithString("description", mcp.Description("label description")),
|
mcp.WithString("description", mcp.Description("label description")),
|
||||||
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
|
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
|
||||||
|
mcp.WithBoolean("is_archived", mcp.Description("whether the label is archived (for create/edit repo label methods)")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,10 +179,13 @@ func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
}
|
}
|
||||||
description, _ := req.GetArguments()["description"].(string) // Optional
|
description, _ := req.GetArguments()["description"].(string) // Optional
|
||||||
|
|
||||||
|
isArchived, _ := req.GetArguments()["is_archived"].(bool)
|
||||||
|
|
||||||
opt := gitea_sdk.CreateLabelOption{
|
opt := gitea_sdk.CreateLabelOption{
|
||||||
Name: name,
|
Name: name,
|
||||||
Color: color,
|
Color: color,
|
||||||
Description: description,
|
Description: description,
|
||||||
|
IsArchived: isArchived,
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
@@ -220,6 +224,9 @@ func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
|
|||||||
if description, ok := req.GetArguments()["description"].(string); ok {
|
if description, ok := req.GetArguments()["description"].(string); ok {
|
||||||
opt.Description = new(description)
|
opt.Description = new(description)
|
||||||
}
|
}
|
||||||
|
if isArchived, ok := req.GetArguments()["is_archived"].(bool); ok {
|
||||||
|
opt.IsArchived = &isArchived
|
||||||
|
}
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -67,9 +67,15 @@ var (
|
|||||||
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
|
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
|
||||||
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")),
|
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")),
|
||||||
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")),
|
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")),
|
||||||
|
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'update')"), mcp.Items(map[string]any{"type": "number"})),
|
||||||
|
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
|
||||||
|
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
|
||||||
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
|
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
|
||||||
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")),
|
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")),
|
||||||
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
|
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
|
||||||
|
mcp.WithBoolean("force_merge", mcp.Description("force merge even if checks are not passing (for 'merge')")),
|
||||||
|
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("auto-merge when checks succeed (for 'merge')")),
|
||||||
|
mcp.WithString("head_commit_id", mcp.Description("expected head commit SHA for merge conflict detection (for 'merge')")),
|
||||||
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
|
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
|
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
mcp.WithBoolean("draft", mcp.Description("mark PR as draft (for 'create', 'update'). Gitea uses a 'WIP: ' title prefix for drafts.")),
|
mcp.WithBoolean("draft", mcp.Description("mark PR as draft (for 'create', 'update'). Gitea uses a 'WIP: ' title prefix for drafts.")),
|
||||||
@@ -331,12 +337,17 @@ func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
}
|
}
|
||||||
pr, _, err := client.CreatePullRequest(owner, repo, gitea_sdk.CreatePullRequestOption{
|
opt := gitea_sdk.CreatePullRequestOption{
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: body,
|
Body: body,
|
||||||
Head: head,
|
Head: head,
|
||||||
Base: base,
|
Base: base,
|
||||||
})
|
}
|
||||||
|
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
|
||||||
|
opt.Labels = labelIDs
|
||||||
|
}
|
||||||
|
opt.Deadline = params.GetOptionalTime(args, "deadline")
|
||||||
|
pr, _, err := client.CreatePullRequest(owner, repo, opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
|
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
|
||||||
}
|
}
|
||||||
@@ -751,11 +762,18 @@ func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
|
|||||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
forceMerge, _ := args["force_merge"].(bool)
|
||||||
|
mergeWhenChecksSucceed, _ := args["merge_when_checks_succeed"].(bool)
|
||||||
|
headCommitID, _ := args["head_commit_id"].(string)
|
||||||
|
|
||||||
opt := gitea_sdk.MergePullRequestOption{
|
opt := gitea_sdk.MergePullRequestOption{
|
||||||
Style: gitea_sdk.MergeStyle(mergeStyle),
|
Style: gitea_sdk.MergeStyle(mergeStyle),
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
DeleteBranchAfterMerge: deleteBranch,
|
DeleteBranchAfterMerge: deleteBranch,
|
||||||
|
ForceMerge: forceMerge,
|
||||||
|
MergeWhenChecksSucceed: mergeWhenChecksSucceed,
|
||||||
|
HeadCommitId: headCommitID,
|
||||||
}
|
}
|
||||||
|
|
||||||
merged, resp, err := client.MergePullRequest(owner, repo, index, opt)
|
merged, resp, err := client.MergePullRequest(owner, repo, index, opt)
|
||||||
@@ -842,6 +860,13 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok {
|
if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok {
|
||||||
opt.AllowMaintainerEdit = new(allowMaintainerEdit)
|
opt.AllowMaintainerEdit = new(allowMaintainerEdit)
|
||||||
}
|
}
|
||||||
|
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
|
||||||
|
opt.Labels = labelIDs
|
||||||
|
}
|
||||||
|
opt.Deadline = params.GetOptionalTime(args, "deadline")
|
||||||
|
if removeDeadline, ok := args["remove_deadline"].(bool); ok {
|
||||||
|
opt.RemoveDeadline = &removeDeadline
|
||||||
|
}
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -254,6 +254,168 @@ func Test_mergePullRequestFn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_mergePullRequestFn_newParams(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
index = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
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()
|
||||||
|
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": "merge",
|
||||||
|
"force_merge": true,
|
||||||
|
"merge_when_checks_succeed": true,
|
||||||
|
"head_commit_id": "abc123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := mergePullRequestFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mergePullRequestFn() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if gotBody["force_merge"] != true {
|
||||||
|
t.Fatalf("expected force_merge true, got %v", gotBody["force_merge"])
|
||||||
|
}
|
||||||
|
if gotBody["merge_when_checks_succeed"] != true {
|
||||||
|
t.Fatalf("expected merge_when_checks_succeed true, got %v", gotBody["merge_when_checks_succeed"])
|
||||||
|
}
|
||||||
|
if gotBody["head_commit_id"] != "abc123" {
|
||||||
|
t.Fatalf("expected head_commit_id 'abc123', got %v", gotBody["head_commit_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_createPullRequestFn_labels(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
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", owner, repo):
|
||||||
|
mu.Lock()
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
gotBody = body
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`))
|
||||||
|
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,
|
||||||
|
"title": "test",
|
||||||
|
"body": "body",
|
||||||
|
"head": "feature",
|
||||||
|
"base": "main",
|
||||||
|
"labels": []any{float64(1), float64(2)},
|
||||||
|
"deadline": "2026-06-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := createPullRequestFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createPullRequestFn() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
labels, ok := gotBody["labels"].([]any)
|
||||||
|
if !ok || len(labels) != 2 {
|
||||||
|
t.Fatalf("expected 2 labels, got %v", gotBody["labels"])
|
||||||
|
}
|
||||||
|
if labels[0] != float64(1) || labels[1] != float64(2) {
|
||||||
|
t.Fatalf("expected labels [1,2], got %v", labels)
|
||||||
|
}
|
||||||
|
if gotBody["due_date"] == nil {
|
||||||
|
t.Fatalf("expected due_date to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_applyDraftPrefix(t *testing.T) {
|
func Test_applyDraftPrefix(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ var (
|
|||||||
mcp.WithDescription("List branches"),
|
mcp.WithDescription("List branches"),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||||
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
||||||
|
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,10 +133,11 @@ func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
|
page, pageSize := params.GetPagination(args, 30)
|
||||||
opt := gitea_sdk.ListRepoBranchesOptions{
|
opt := gitea_sdk.ListRepoBranchesOptions{
|
||||||
ListOptions: gitea_sdk.ListOptions{
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
Page: 1,
|
Page: page,
|
||||||
PageSize: 30,
|
PageSize: pageSize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ var (
|
|||||||
mcp.WithString("license", mcp.Description("License to use")),
|
mcp.WithString("license", mcp.Description("License to use")),
|
||||||
mcp.WithString("readme", mcp.Description("Readme of the repository to create")),
|
mcp.WithString("readme", mcp.Description("Readme of the repository to create")),
|
||||||
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")),
|
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")),
|
||||||
|
mcp.WithString("trust_model", mcp.Description("Trust model for verifying GPG signatures"), mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")),
|
||||||
|
mcp.WithString("object_format_name", mcp.Description("Object format: sha1 or sha256"), mcp.Enum("sha1", "sha256")),
|
||||||
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
|
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,19 +104,23 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
|
|||||||
license, _ := args["license"].(string)
|
license, _ := args["license"].(string)
|
||||||
readme, _ := args["readme"].(string)
|
readme, _ := args["readme"].(string)
|
||||||
defaultBranch, _ := args["default_branch"].(string)
|
defaultBranch, _ := args["default_branch"].(string)
|
||||||
|
trustModel, _ := args["trust_model"].(string)
|
||||||
|
objectFormatName, _ := args["object_format_name"].(string)
|
||||||
organization, _ := args["organization"].(string)
|
organization, _ := args["organization"].(string)
|
||||||
|
|
||||||
opt := gitea_sdk.CreateRepoOption{
|
opt := gitea_sdk.CreateRepoOption{
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: description,
|
Description: description,
|
||||||
Private: private,
|
Private: private,
|
||||||
IssueLabels: issueLabels,
|
IssueLabels: issueLabels,
|
||||||
AutoInit: autoInit,
|
AutoInit: autoInit,
|
||||||
Template: template,
|
Template: template,
|
||||||
Gitignores: gitignores,
|
Gitignores: gitignores,
|
||||||
License: license,
|
License: license,
|
||||||
Readme: readme,
|
Readme: readme,
|
||||||
DefaultBranch: defaultBranch,
|
DefaultBranch: defaultBranch,
|
||||||
|
TrustModel: gitea_sdk.TrustModel(trustModel),
|
||||||
|
ObjectFormatName: objectFormatName,
|
||||||
}
|
}
|
||||||
|
|
||||||
var repo *gitea_sdk.Repository
|
var repo *gitea_sdk.Repository
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package params
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetString extracts a required string parameter from MCP tool arguments.
|
// GetString extracts a required string parameter from MCP tool arguments.
|
||||||
@@ -101,6 +102,18 @@ func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOptionalTime extracts an optional RFC3339 timestamp parameter, returning nil if missing or unparseable.
|
||||||
|
func GetOptionalTime(args map[string]any, key string) *time.Time {
|
||||||
|
val, ok := args[key].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, val); err == nil {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetOptionalInt extracts an optional integer parameter from MCP tool arguments.
|
// GetOptionalInt extracts an optional integer parameter from MCP tool arguments.
|
||||||
// Returns defaultVal if the key is missing or the value cannot be parsed.
|
// Returns defaultVal if the key is missing or the value cannot be parsed.
|
||||||
// Accepts both float64 (JSON number) and string representations.
|
// Accepts both float64 (JSON number) and string representations.
|
||||||
|
|||||||
Reference in New Issue
Block a user