diff --git a/operation/issue/issue.go b/operation/issue/issue.go index 5158150..4d33f1b 100644 --- a/operation/issue/issue.go +++ b/operation/issue/issue.go @@ -30,6 +30,9 @@ var ( mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), 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("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.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.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.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 { state = "all" } + labels := params.GetStringSlice(req.GetArguments(), "labels") page, pageSize := params.GetPagination(req.GetArguments(), 30) opt := gitea_sdk.ListIssueOption{ - State: gitea_sdk.StateType(state), + State: gitea_sdk.StateType(state), + Labels: labels, ListOptions: gitea_sdk.ListOptions{ Page: page, 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) if err != nil { 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 } } + 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) if err != nil { 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 { 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) if err != nil { diff --git a/operation/issue/issue_test.go b/operation/issue/issue_test.go new file mode 100644 index 0000000..0964357 --- /dev/null +++ b/operation/issue/issue_test.go @@ -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") + } +} diff --git a/operation/label/label.go b/operation/label/label.go index f9b4f1d..b9c6fe6 100644 --- a/operation/label/label.go +++ b/operation/label/label.go @@ -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("description", mcp.Description("label description")), 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 + isArchived, _ := req.GetArguments()["is_archived"].(bool) + opt := gitea_sdk.CreateLabelOption{ Name: name, Color: color, Description: description, + IsArchived: isArchived, } 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 { opt.Description = new(description) } + if isArchived, ok := req.GetArguments()["is_archived"].(bool); ok { + opt.IsArchived = &isArchived + } client, err := gitea.ClientFromContext(ctx) if err != nil { diff --git a/operation/pull/pull.go b/operation/pull/pull.go index 1c52022..d0fbebd 100644 --- a/operation/pull/pull.go +++ b/operation/pull/pull.go @@ -67,9 +67,15 @@ var ( mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")), 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.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("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("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("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.")), @@ -331,12 +337,17 @@ func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal if err != nil { 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, Body: body, Head: head, 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 { 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)) } + forceMerge, _ := args["force_merge"].(bool) + mergeWhenChecksSucceed, _ := args["merge_when_checks_succeed"].(bool) + headCommitID, _ := args["head_commit_id"].(string) + opt := gitea_sdk.MergePullRequestOption{ Style: gitea_sdk.MergeStyle(mergeStyle), Title: title, Message: message, DeleteBranchAfterMerge: deleteBranch, + ForceMerge: forceMerge, + MergeWhenChecksSucceed: mergeWhenChecksSucceed, + HeadCommitId: headCommitID, } 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 { 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) if err != nil { diff --git a/operation/pull/pull_test.go b/operation/pull/pull_test.go index 56fb48d..f54c461 100644 --- a/operation/pull/pull_test.go +++ b/operation/pull/pull_test.go @@ -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) { tests := []struct { name string diff --git a/operation/repo/branch.go b/operation/repo/branch.go index 95099bd..37e54ca 100644 --- a/operation/repo/branch.go +++ b/operation/repo/branch.go @@ -43,6 +43,8 @@ var ( mcp.WithDescription("List branches"), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), 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 { return to.ErrorResult(err) } + page, pageSize := params.GetPagination(args, 30) opt := gitea_sdk.ListRepoBranchesOptions{ ListOptions: gitea_sdk.ListOptions{ - Page: 1, - PageSize: 30, + Page: page, + PageSize: pageSize, }, } client, err := gitea.ClientFromContext(ctx) diff --git a/operation/repo/repo.go b/operation/repo/repo.go index 88aabc1..174c6d3 100644 --- a/operation/repo/repo.go +++ b/operation/repo/repo.go @@ -39,6 +39,8 @@ var ( mcp.WithString("license", mcp.Description("License to use")), 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("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)")), ) @@ -102,19 +104,23 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe license, _ := args["license"].(string) readme, _ := args["readme"].(string) defaultBranch, _ := args["default_branch"].(string) + trustModel, _ := args["trust_model"].(string) + objectFormatName, _ := args["object_format_name"].(string) organization, _ := args["organization"].(string) opt := gitea_sdk.CreateRepoOption{ - Name: name, - Description: description, - Private: private, - IssueLabels: issueLabels, - AutoInit: autoInit, - Template: template, - Gitignores: gitignores, - License: license, - Readme: readme, - DefaultBranch: defaultBranch, + Name: name, + Description: description, + Private: private, + IssueLabels: issueLabels, + AutoInit: autoInit, + Template: template, + Gitignores: gitignores, + License: license, + Readme: readme, + DefaultBranch: defaultBranch, + TrustModel: gitea_sdk.TrustModel(trustModel), + ObjectFormatName: objectFormatName, } var repo *gitea_sdk.Repository diff --git a/pkg/params/params.go b/pkg/params/params.go index d96e47c..0edb31a 100644 --- a/pkg/params/params.go +++ b/pkg/params/params.go @@ -3,6 +3,7 @@ package params import ( "fmt" "strconv" + "time" ) // 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 } +// 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. // Returns defaultVal if the key is missing or the value cannot be parsed. // Accepts both float64 (JSON number) and string representations.