diff --git a/.gitignore b/.gitignore index ac6b87b..c487929 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ gitea-mcp gitea-mcp.exe *.log tmp +gitea-mcp-new diff --git a/operation/operation.go b/operation/operation.go index 665beb1..860747f 100644 --- a/operation/operation.go +++ b/operation/operation.go @@ -16,6 +16,7 @@ import ( "gitea.com/gitea/gitea-mcp/operation/label" "gitea.com/gitea/gitea-mcp/operation/milestone" "gitea.com/gitea/gitea-mcp/operation/notification" + "gitea.com/gitea/gitea-mcp/operation/org" "gitea.com/gitea/gitea-mcp/operation/pull" "gitea.com/gitea/gitea-mcp/operation/repo" "gitea.com/gitea/gitea-mcp/operation/search" @@ -69,6 +70,9 @@ func RegisterTool(s *server.MCPServer) { // Time Tracking Tool s.AddTools(timetracking.Tool.Tools()...) + // Organization Tool + s.AddTools(org.Tool.Tools()...) + s.DeleteTools("") } diff --git a/operation/org/org.go b/operation/org/org.go new file mode 100644 index 0000000..501f66a --- /dev/null +++ b/operation/org/org.go @@ -0,0 +1,379 @@ +package org + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "code.gitea.io/sdk/gitea" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + OrgReadToolName = "org_read" + OrgWriteToolName = "org_write" +) + +var ( + OrgReadTool = mcp.NewTool( + OrgReadToolName, + mcp.WithDescription("Read organization and team information. Methods: 'get' org details, 'list_teams', 'get_team', 'list_team_members', 'list_team_repos'."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list_teams", "get_team", "list_team_members", "list_team_repos")), + mcp.WithString("org", mcp.Description("organization name (required for 'get', 'list_teams')")), + mcp.WithNumber("team_id", mcp.Description("team ID (required for 'get_team', 'list_team_members', 'list_team_repos')")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), + mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), + ) + + OrgWriteTool = mcp.NewTool( + OrgWriteToolName, + mcp.WithDescription("Manage organization teams and membership. Methods: 'create_team', 'delete_team', 'add_team_member', 'remove_team_member', 'add_team_repo', 'remove_team_repo'."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create_team", "delete_team", "add_team_member", "remove_team_member", "add_team_repo", "remove_team_repo")), + mcp.WithString("org", mcp.Description("organization name (required for 'create_team', 'add_team_repo', 'remove_team_repo')")), + mcp.WithNumber("team_id", mcp.Description("team ID (required for 'delete_team', 'add_team_member', 'remove_team_member', 'add_team_repo', 'remove_team_repo')")), + mcp.WithString("username", mcp.Description("username (required for 'add_team_member', 'remove_team_member')")), + mcp.WithString("repo", mcp.Description("repository name (required for 'add_team_repo', 'remove_team_repo')")), + mcp.WithString("name", mcp.Description("team name (required for 'create_team')")), + mcp.WithString("description", mcp.Description("team description (for 'create_team')")), + mcp.WithString("permission", mcp.Description("team permission level (for 'create_team')"), mcp.Enum("read", "write", "admin")), + mcp.WithArray("units", mcp.Description("team units (for 'create_team'), e.g. repo.code, repo.issues, repo.pulls"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithBoolean("includes_all_repositories", mcp.Description("include all org repos (for 'create_team')")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: OrgReadTool, + Handler: orgReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: OrgWriteTool, + Handler: orgWriteFn, + }) +} + +func orgReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "get": + return getOrgFn(ctx, req) + case "list_teams": + return listTeamsFn(ctx, req) + case "get_team": + return getTeamFn(ctx, req) + case "list_team_members": + return listTeamMembersFn(ctx, req) + case "list_team_repos": + return listTeamReposFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func getOrgFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called getOrgFn") + args := req.GetArguments() + orgName, err := params.GetString(args, "org") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + o, _, err := client.GetOrg(orgName) + if err != nil { + return to.ErrorResult(fmt.Errorf("get org %v err: %v", orgName, err)) + } + return to.TextResult(slimOrg(o)) +} + +func listTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called listTeamsFn") + args := req.GetArguments() + orgName, err := params.GetString(args, "org") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + teams, _, err := client.ListOrgTeams(orgName, gitea_sdk.ListTeamsOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list teams for org %v err: %v", orgName, err)) + } + return to.TextResult(slimTeams(teams)) +} + +func getTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called getTeamFn") + args := req.GetArguments() + teamID, err := params.GetIndex(args, "team_id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + team, _, err := client.GetTeam(teamID) + if err != nil { + return to.ErrorResult(fmt.Errorf("get team %v err: %v", teamID, err)) + } + return to.TextResult(slimTeam(team)) +} + +func listTeamMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called listTeamMembersFn") + args := req.GetArguments() + teamID, err := params.GetIndex(args, "team_id") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + members, _, err := client.ListTeamMembers(teamID, gitea_sdk.ListTeamMembersOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list team %v members err: %v", teamID, err)) + } + return to.TextResult(slimUsers(members)) +} + +func listTeamReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called listTeamReposFn") + args := req.GetArguments() + teamID, err := params.GetIndex(args, "team_id") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + repos, _, err := client.ListTeamRepositories(teamID, gitea_sdk.ListTeamRepositoriesOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list team %v repos err: %v", teamID, err)) + } + return to.TextResult(slimRepos(repos)) +} + +func orgWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create_team": + return createTeamFn(ctx, req) + case "delete_team": + return deleteTeamFn(ctx, req) + case "add_team_member": + return addTeamMemberFn(ctx, req) + case "remove_team_member": + return removeTeamMemberFn(ctx, req) + case "add_team_repo": + return addTeamRepoFn(ctx, req) + case "remove_team_repo": + return removeTeamRepoFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func createTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called createTeamFn") + args := req.GetArguments() + orgName, err := params.GetString(args, "org") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(args, "name") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + opt := gitea_sdk.CreateTeamOption{ + Name: name, + } + if v, ok := args["description"].(string); ok { + opt.Description = v + } + if v, ok := args["permission"].(string); ok { + switch v { + case "read": + opt.Permission = gitea_sdk.AccessModeRead + case "write": + opt.Permission = gitea_sdk.AccessModeWrite + case "admin": + opt.Permission = gitea_sdk.AccessModeAdmin + } + } + if unitStrings := params.GetStringSlice(args, "units"); unitStrings != nil { + units := make([]gitea_sdk.RepoUnitType, 0, len(unitStrings)) + for _, u := range unitStrings { + units = append(units, gitea_sdk.RepoUnitType(u)) + } + opt.Units = units + } + if v, ok := args["includes_all_repositories"].(bool); ok { + opt.IncludesAllRepositories = v + } + + team, _, err := client.CreateTeam(orgName, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create team in org %v err: %v", orgName, err)) + } + return to.TextResult(slimTeam(team)) +} + +func deleteTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called deleteTeamFn") + args := req.GetArguments() + teamID, err := params.GetIndex(args, "team_id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.DeleteTeam(teamID) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete team %v err: %v", teamID, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Team %d deleted", teamID)), nil +} + +func addTeamMemberFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called addTeamMemberFn") + args := req.GetArguments() + teamID, err := params.GetIndex(args, "team_id") + if err != nil { + return to.ErrorResult(err) + } + username, err := params.GetString(args, "username") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.AddTeamMember(teamID, username) + if err != nil { + return to.ErrorResult(fmt.Errorf("add %v to team %v err: %v", username, teamID, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("User %s added to team %d", username, teamID)), nil +} + +func removeTeamMemberFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called removeTeamMemberFn") + args := req.GetArguments() + teamID, err := params.GetIndex(args, "team_id") + if err != nil { + return to.ErrorResult(err) + } + username, err := params.GetString(args, "username") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.RemoveTeamMember(teamID, username) + if err != nil { + return to.ErrorResult(fmt.Errorf("remove %v from team %v err: %v", username, teamID, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("User %s removed from team %d", username, teamID)), nil +} + +func addTeamRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called addTeamRepoFn") + args := req.GetArguments() + teamID, err := params.GetIndex(args, "team_id") + if err != nil { + return to.ErrorResult(err) + } + orgName, err := params.GetString(args, "org") + if err != nil { + return to.ErrorResult(err) + } + repoName, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.AddTeamRepository(teamID, orgName, repoName) + if err != nil { + return to.ErrorResult(fmt.Errorf("add repo %v/%v to team %v err: %v", orgName, repoName, teamID, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Repo %s/%s added to team %d", orgName, repoName, teamID)), nil +} + +func removeTeamRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called removeTeamRepoFn") + args := req.GetArguments() + teamID, err := params.GetIndex(args, "team_id") + if err != nil { + return to.ErrorResult(err) + } + orgName, err := params.GetString(args, "org") + if err != nil { + return to.ErrorResult(err) + } + repoName, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.RemoveTeamRepository(teamID, orgName, repoName) + if err != nil { + return to.ErrorResult(fmt.Errorf("remove repo %v/%v from team %v err: %v", orgName, repoName, teamID, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Repo %s/%s removed from team %d", orgName, repoName, teamID)), nil +} diff --git a/operation/org/slim.go b/operation/org/slim.go new file mode 100644 index 0000000..9ddfee9 --- /dev/null +++ b/operation/org/slim.go @@ -0,0 +1,85 @@ +package org + +import ( + gitea_sdk "code.gitea.io/sdk/gitea" +) + +func slimOrg(o *gitea_sdk.Organization) map[string]any { + if o == nil { + return nil + } + return map[string]any{ + "id": o.ID, + "username": o.UserName, + "full_name": o.FullName, + "description": o.Description, + "website": o.Website, + "location": o.Location, + "visibility": o.Visibility, + "avatar_url": o.AvatarURL, + } +} + +func slimTeam(t *gitea_sdk.Team) map[string]any { + if t == nil { + return nil + } + return map[string]any{ + "id": t.ID, + "name": t.Name, + "description": t.Description, + "permission": t.Permission, + "units": t.Units, + "includes_all_repositories": t.IncludesAllRepositories, + "can_create_org_repo": t.CanCreateOrgRepo, + } +} + +func slimTeams(teams []*gitea_sdk.Team) []map[string]any { + out := make([]map[string]any, 0, len(teams)) + for _, t := range teams { + out = append(out, slimTeam(t)) + } + return out +} + +func slimUser(u *gitea_sdk.User) map[string]any { + if u == nil { + return nil + } + return map[string]any{ + "id": u.ID, + "login": u.UserName, + "full_name": u.FullName, + "email": u.Email, + "avatar_url": u.AvatarURL, + } +} + +func slimUsers(users []*gitea_sdk.User) []map[string]any { + out := make([]map[string]any, 0, len(users)) + for _, u := range users { + out = append(out, slimUser(u)) + } + return out +} + +func slimRepos(repos []*gitea_sdk.Repository) []map[string]any { + out := make([]map[string]any, 0, len(repos)) + for _, r := range repos { + if r == nil { + continue + } + m := map[string]any{ + "id": r.ID, + "full_name": r.FullName, + "html_url": r.HTMLURL, + "private": r.Private, + } + if r.Owner != nil { + m["owner"] = r.Owner.UserName + } + out = append(out, m) + } + return out +} diff --git a/operation/pull/pull.go b/operation/pull/pull.go index f8d573b..2a12b36 100644 --- a/operation/pull/pull.go +++ b/operation/pull/pull.go @@ -40,8 +40,8 @@ var ( PullRequestReadTool = mcp.NewTool( PullRequestReadToolName, - mcp.WithDescription("Get pull request information. Use method 'get' for PR details, 'get_diff' for diff, 'get_reviews'/'get_review'/'get_review_comments' for review data."), - mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments")), + mcp.WithDescription("Get pull request information. Use method 'get' for PR details, 'get_diff' for diff, 'get_reviews'/'get_review'/'get_review_comments' for review data, 'list_files' for changed files, 'is_merged' to check merge status."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments", "list_files", "is_merged")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")), @@ -140,11 +140,85 @@ func pullRequestReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT return getPullRequestReviewFn(ctx, req) case "get_review_comments": return listPullRequestReviewCommentsFn(ctx, req) + case "list_files": + return listPullRequestFilesFn(ctx, req) + case "is_merged": + return isPullRequestMergedFn(ctx, req) default: return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) } } +func listPullRequestFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called listPullRequestFilesFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repoName, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "index") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + files, _, err := client.ListPullRequestFiles(owner, repoName, index, gitea_sdk.ListPullRequestFilesOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list PR files %v/%v#%v err: %v", owner, repoName, index, err)) + } + + out := make([]map[string]any, 0, len(files)) + for _, f := range files { + out = append(out, map[string]any{ + "filename": f.Filename, + "status": f.Status, + "additions": f.Additions, + "deletions": f.Deletions, + "changes": f.Changes, + }) + } + return to.TextResult(out) +} + +func isPullRequestMergedFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called isPullRequestMergedFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repoName, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + index, err := params.GetIndex(args, "index") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + merged, _, err := client.IsPullRequestMerged(owner, repoName, index) + if err != nil { + return to.ErrorResult(fmt.Errorf("check PR merged %v/%v#%v err: %v", owner, repoName, index, err)) + } + return to.TextResult(map[string]any{"merged": merged}) +} + func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { method, err := params.GetString(req.GetArguments(), "method") if err != nil { diff --git a/operation/repo/branch_protection.go b/operation/repo/branch_protection.go new file mode 100644 index 0000000..e4d5048 --- /dev/null +++ b/operation/repo/branch_protection.go @@ -0,0 +1,309 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "code.gitea.io/sdk/gitea" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + BranchProtectionReadToolName = "branch_protection_read" + BranchProtectionWriteToolName = "branch_protection_write" +) + +var ( + BranchProtectionReadTool = mcp.NewTool( + BranchProtectionReadToolName, + mcp.WithDescription("Read branch protection rules. Use method 'list' to list all rules, 'get' to get a specific rule."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("name", mcp.Description("branch protection rule name (required for 'get')")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), + mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), + ) + + BranchProtectionWriteTool = mcp.NewTool( + BranchProtectionWriteToolName, + mcp.WithDescription("Create, edit, or delete branch protection rules."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("branch_name", mcp.Description("branch name or pattern (required for 'create')")), + mcp.WithString("rule_name", mcp.Description("rule name (required for 'edit', 'delete')")), + mcp.WithBoolean("enable_push", mcp.Description("enable push to protected branch")), + mcp.WithArray("push_whitelist_usernames", mcp.Description("usernames allowed to push"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithArray("push_whitelist_teams", mcp.Description("team names allowed to push"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithBoolean("enable_merge_whitelist", mcp.Description("enable merge whitelist")), + mcp.WithArray("merge_whitelist_usernames", mcp.Description("usernames allowed to merge"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithArray("merge_whitelist_teams", mcp.Description("team names allowed to merge"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithNumber("required_approvals", mcp.Description("number of required approvals")), + mcp.WithBoolean("block_on_rejected_reviews", mcp.Description("block merge on rejected reviews")), + mcp.WithBoolean("block_on_outdated_branch", mcp.Description("block merge on outdated branch")), + mcp.WithBoolean("dismiss_stale_approvals", mcp.Description("dismiss stale approvals on new commits")), + mcp.WithBoolean("require_signed_commits", mcp.Description("require signed commits")), + mcp.WithString("protected_file_patterns", mcp.Description("protected file patterns (semicolon separated)")), + mcp.WithString("unprotected_file_patterns", mcp.Description("unprotected file patterns (semicolon separated)")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: BranchProtectionReadTool, + Handler: branchProtectionReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: BranchProtectionWriteTool, + Handler: branchProtectionWriteFn, + }) +} + +func branchProtectionReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list": + return listBranchProtectionsFn(ctx, req) + case "get": + return getBranchProtectionFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func listBranchProtectionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called listBranchProtectionsFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + bps, _, err := client.ListBranchProtections(owner, repo, gitea_sdk.ListBranchProtectionsOptions{}) + if err != nil { + return to.ErrorResult(fmt.Errorf("list branch protections %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimBranchProtections(bps)) +} + +func getBranchProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called getBranchProtectionFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(args, "name") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + bp, _, err := client.GetBranchProtection(owner, repo, name) + if err != nil { + return to.ErrorResult(fmt.Errorf("get branch protection %v in %v/%v err: %v", name, owner, repo, err)) + } + return to.TextResult(slimBranchProtection(bp)) +} + +func branchProtectionWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create": + return createBranchProtectionFn(ctx, req) + case "edit": + return editBranchProtectionFn(ctx, req) + case "delete": + return deleteBranchProtectionFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func createBranchProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called createBranchProtectionFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + branchName, err := params.GetString(args, "branch_name") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + opt := gitea_sdk.CreateBranchProtectionOption{ + BranchName: branchName, + } + if v, ok := args["enable_push"].(bool); ok { + opt.EnablePush = v + } + opt.PushWhitelistUsernames = params.GetStringSlice(args, "push_whitelist_usernames") + opt.PushWhitelistTeams = params.GetStringSlice(args, "push_whitelist_teams") + if v, ok := args["enable_merge_whitelist"].(bool); ok { + opt.EnableMergeWhitelist = v + } + opt.MergeWhitelistUsernames = params.GetStringSlice(args, "merge_whitelist_usernames") + opt.MergeWhitelistTeams = params.GetStringSlice(args, "merge_whitelist_teams") + if val, exists := args["required_approvals"]; exists { + if i, ok := params.ToInt64(val); ok { + opt.RequiredApprovals = i + } + } + if v, ok := args["block_on_rejected_reviews"].(bool); ok { + opt.BlockOnRejectedReviews = v + } + if v, ok := args["block_on_outdated_branch"].(bool); ok { + opt.BlockOnOutdatedBranch = v + } + if v, ok := args["dismiss_stale_approvals"].(bool); ok { + opt.DismissStaleApprovals = v + } + if v, ok := args["require_signed_commits"].(bool); ok { + opt.RequireSignedCommits = v + } + if v, ok := args["protected_file_patterns"].(string); ok { + opt.ProtectedFilePatterns = v + } + if v, ok := args["unprotected_file_patterns"].(string); ok { + opt.UnprotectedFilePatterns = v + } + + bp, _, err := client.CreateBranchProtection(owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create branch protection in %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimBranchProtection(bp)) +} + +func editBranchProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called editBranchProtectionFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + ruleName, err := params.GetString(args, "rule_name") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + opt := gitea_sdk.EditBranchProtectionOption{} + if v, ok := args["enable_push"].(bool); ok { + opt.EnablePush = &v + } + if v := params.GetStringSlice(args, "push_whitelist_usernames"); v != nil { + opt.PushWhitelistUsernames = v + } + if v := params.GetStringSlice(args, "push_whitelist_teams"); v != nil { + opt.PushWhitelistTeams = v + } + if v, ok := args["enable_merge_whitelist"].(bool); ok { + opt.EnableMergeWhitelist = &v + } + if v := params.GetStringSlice(args, "merge_whitelist_usernames"); v != nil { + opt.MergeWhitelistUsernames = v + } + if v := params.GetStringSlice(args, "merge_whitelist_teams"); v != nil { + opt.MergeWhitelistTeams = v + } + if val, exists := args["required_approvals"]; exists { + if i, ok := params.ToInt64(val); ok { + opt.RequiredApprovals = &i + } + } + if v, ok := args["block_on_rejected_reviews"].(bool); ok { + opt.BlockOnRejectedReviews = &v + } + if v, ok := args["block_on_outdated_branch"].(bool); ok { + opt.BlockOnOutdatedBranch = &v + } + if v, ok := args["dismiss_stale_approvals"].(bool); ok { + opt.DismissStaleApprovals = &v + } + if v, ok := args["require_signed_commits"].(bool); ok { + opt.RequireSignedCommits = &v + } + if v, ok := args["protected_file_patterns"].(string); ok { + opt.ProtectedFilePatterns = &v + } + if v, ok := args["unprotected_file_patterns"].(string); ok { + opt.UnprotectedFilePatterns = &v + } + + bp, _, err := client.EditBranchProtection(owner, repo, ruleName, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("edit branch protection %v in %v/%v err: %v", ruleName, owner, repo, err)) + } + return to.TextResult(slimBranchProtection(bp)) +} + +func deleteBranchProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called deleteBranchProtectionFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + ruleName, err := params.GetString(args, "rule_name") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.DeleteBranchProtection(owner, repo, ruleName) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete branch protection %v from %v/%v err: %v", ruleName, owner, repo, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Branch protection rule '%s' deleted from %s/%s", ruleName, owner, repo)), nil +} diff --git a/operation/repo/collaborator.go b/operation/repo/collaborator.go new file mode 100644 index 0000000..7eb73f2 --- /dev/null +++ b/operation/repo/collaborator.go @@ -0,0 +1,168 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "code.gitea.io/sdk/gitea" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + CollaboratorReadToolName = "collaborator_read" + CollaboratorWriteToolName = "collaborator_write" +) + +var ( + CollaboratorReadTool = mcp.NewTool( + CollaboratorReadToolName, + mcp.WithDescription("List repository collaborators."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list")), + 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)), + ) + + CollaboratorWriteTool = mcp.NewTool( + CollaboratorWriteToolName, + mcp.WithDescription("Add or remove repository collaborators. Use method 'add' to grant access, 'delete' to revoke."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("add", "delete")), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("username", mcp.Required(), mcp.Description("collaborator username")), + mcp.WithString("permission", mcp.Description("permission level (for 'add')"), mcp.Enum("read", "write", "admin")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: CollaboratorReadTool, + Handler: collaboratorReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: CollaboratorWriteTool, + Handler: collaboratorWriteFn, + }) +} + +func collaboratorReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list": + return listCollaboratorsFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func listCollaboratorsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called listCollaboratorsFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + users, _, err := client.ListCollaborators(owner, repo, gitea_sdk.ListCollaboratorsOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list collaborators %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimUsers(users)) +} + +func collaboratorWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "add": + return addCollaboratorFn(ctx, req) + case "delete": + return deleteCollaboratorFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func addCollaboratorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called addCollaboratorFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + username, err := params.GetString(args, "username") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + opt := gitea_sdk.AddCollaboratorOption{} + if perm, ok := args["permission"].(string); ok { + am := gitea_sdk.AccessMode(perm) + opt.Permission = &am + } + _, err = client.AddCollaborator(owner, repo, username, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("add collaborator %v to %v/%v err: %v", username, owner, repo, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Collaborator %s added to %s/%s", username, owner, repo)), nil +} + +func deleteCollaboratorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called deleteCollaboratorFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + username, err := params.GetString(args, "username") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.DeleteCollaborator(owner, repo, username) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete collaborator %v from %v/%v err: %v", username, owner, repo, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Collaborator %s removed from %s/%s", username, owner, repo)), nil +} diff --git a/operation/repo/compare.go b/operation/repo/compare.go new file mode 100644 index 0000000..a170fae --- /dev/null +++ b/operation/repo/compare.go @@ -0,0 +1,59 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + GetCompareToolName = "get_compare" +) + +var ( + GetCompareTool = mcp.NewTool( + GetCompareToolName, + mcp.WithDescription("Compare two branches, tags, or commits. Returns commits and diff stats between the base and head."), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("basehead", mcp.Required(), mcp.Description("comparison in format 'base...head' (e.g. 'main...feature-branch')")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: GetCompareTool, + Handler: getCompareFn, + }) +} + +func getCompareFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called getCompareFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + basehead, err := params.GetString(args, "basehead") + if err != nil { + return to.ErrorResult(err) + } + + var result map[string]any + _, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/compare/%s", owner, repo, basehead), nil, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("compare %v in %v/%v err: %v", basehead, owner, repo, err)) + } + return to.TextResult(result) +} diff --git a/operation/repo/deploy_key.go b/operation/repo/deploy_key.go new file mode 100644 index 0000000..6f1451c --- /dev/null +++ b/operation/repo/deploy_key.go @@ -0,0 +1,206 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "code.gitea.io/sdk/gitea" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + DeployKeyReadToolName = "deploy_key_read" + DeployKeyWriteToolName = "deploy_key_write" +) + +var ( + DeployKeyReadTool = mcp.NewTool( + DeployKeyReadToolName, + mcp.WithDescription("Read deploy keys. Use method 'list' to list all, 'get' to get a specific key."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("id", mcp.Description("deploy key ID (required for 'get')")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), + mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), + ) + + DeployKeyWriteTool = mcp.NewTool( + DeployKeyWriteToolName, + mcp.WithDescription("Create or delete deploy keys. Use method 'create' to add a new key, 'delete' to remove."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "delete")), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("id", mcp.Description("deploy key ID (required for 'delete')")), + mcp.WithString("title", mcp.Description("key title (required for 'create')")), + mcp.WithString("key", mcp.Description("SSH public key content (required for 'create')")), + mcp.WithBoolean("read_only", mcp.Description("whether the key has read-only access (default true)")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: DeployKeyReadTool, + Handler: deployKeyReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: DeployKeyWriteTool, + Handler: deployKeyWriteFn, + }) +} + +func deployKeyReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list": + return listDeployKeysFn(ctx, req) + case "get": + return getDeployKeyFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func listDeployKeysFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called listDeployKeysFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + keys, _, err := client.ListDeployKeys(owner, repo, gitea_sdk.ListDeployKeysOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list deploy keys %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimDeployKeys(keys)) +} + +func getDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called getDeployKeyFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(args, "id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + key, _, err := client.GetDeployKey(owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("get deploy key %v in %v/%v err: %v", id, owner, repo, err)) + } + return to.TextResult(slimDeployKey(key)) +} + +func deployKeyWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create": + return createDeployKeyFn(ctx, req) + case "delete": + return deleteDeployKeyFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func createDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called createDeployKeyFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + title, err := params.GetString(args, "title") + if err != nil { + return to.ErrorResult(err) + } + key, err := params.GetString(args, "key") + if err != nil { + return to.ErrorResult(err) + } + readOnly := true + if v, ok := args["read_only"].(bool); ok { + readOnly = v + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + dk, _, err := client.CreateDeployKey(owner, repo, gitea_sdk.CreateKeyOption{ + Title: title, + Key: key, + ReadOnly: readOnly, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("create deploy key in %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimDeployKey(dk)) +} + +func deleteDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called deleteDeployKeyFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(args, "id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.DeleteDeployKey(owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete deploy key %v from %v/%v err: %v", id, owner, repo, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Deploy key %d deleted from %s/%s", id, owner, repo)), nil +} diff --git a/operation/repo/repo.go b/operation/repo/repo.go index 9140803..76acde8 100644 --- a/operation/repo/repo.go +++ b/operation/repo/repo.go @@ -23,6 +23,9 @@ const ( ForkRepoToolName = "fork_repo" ListMyReposToolName = "list_my_repos" ListOrgReposToolName = "list_org_repos" + GetRepoToolName = "get_repo" + RepoWriteToolName = "repo_write" + ListForksToolName = "list_forks" ) var ( @@ -67,6 +70,40 @@ var ( mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)), ) + + GetRepoTool = mcp.NewTool( + GetRepoToolName, + mcp.WithDescription("Get repository details including settings, permissions, and metadata"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + ) + + RepoWriteTool = mcp.NewTool( + RepoWriteToolName, + mcp.WithDescription("Edit, delete, or transfer a repository. Use method 'edit' to change settings, 'delete' to remove, 'transfer' to change ownership."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("edit", "delete", "transfer")), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("description", mcp.Description("repository description (for 'edit')")), + mcp.WithString("website", mcp.Description("repository website URL (for 'edit')")), + mcp.WithBoolean("private", mcp.Description("whether the repository is private (for 'edit')")), + mcp.WithBoolean("has_issues", mcp.Description("enable issues (for 'edit')")), + mcp.WithBoolean("has_wiki", mcp.Description("enable wiki (for 'edit')")), + mcp.WithBoolean("has_pull_requests", mcp.Description("enable pull requests (for 'edit')")), + mcp.WithString("default_branch", mcp.Description("default branch name (for 'edit')")), + mcp.WithBoolean("archived", mcp.Description("archive/unarchive the repository (for 'edit')")), + mcp.WithString("new_owner", mcp.Description("new owner username or org (required for 'transfer')")), + mcp.WithArray("team_ids", mcp.Description("team IDs to grant access to (for 'transfer')"), mcp.Items(map[string]any{"type": "number"})), + ) + + ListForksTool = mcp.NewTool( + ListForksToolName, + mcp.WithDescription("List forks of a repository"), + 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.Min(1)), + mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)), + ) ) func init() { @@ -86,6 +123,18 @@ func init() { Tool: ListOrgReposTool, Handler: ListOrgReposFn, }) + Tool.RegisterRead(server.ServerTool{ + Tool: GetRepoTool, + Handler: GetRepoFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: RepoWriteTool, + Handler: repoWriteFn, + }) + Tool.RegisterRead(server.ServerTool{ + Tool: ListForksTool, + Handler: ListForksFn, + }) } func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -229,3 +278,174 @@ func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool } return to.TextResult(repos) } + +func GetRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetRepoFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + r, _, err := client.GetRepo(owner, repo) + if err != nil { + return to.ErrorResult(fmt.Errorf("get repo %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimRepoDetail(r)) +} + +func repoWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "edit": + return editRepoFn(ctx, req) + case "delete": + return deleteRepoFn(ctx, req) + case "transfer": + return transferRepoFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func editRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called editRepoFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + opt := gitea_sdk.EditRepoOption{} + if v, ok := args["description"].(string); ok { + opt.Description = &v + } + if v, ok := args["website"].(string); ok { + opt.Website = &v + } + if v, ok := args["private"].(bool); ok { + opt.Private = &v + } + if v, ok := args["has_issues"].(bool); ok { + opt.HasIssues = &v + } + if v, ok := args["has_wiki"].(bool); ok { + opt.HasWiki = &v + } + if v, ok := args["has_pull_requests"].(bool); ok { + opt.HasPullRequests = &v + } + if v, ok := args["default_branch"].(string); ok { + opt.DefaultBranch = &v + } + if v, ok := args["archived"].(bool); ok { + opt.Archived = &v + } + + r, _, err := client.EditRepo(owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("edit repo %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimRepo(r)) +} + +func deleteRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called deleteRepoFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.DeleteRepo(owner, repo) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete repo %v/%v err: %v", owner, repo, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Repository %s/%s deleted successfully", owner, repo)), nil +} + +func transferRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called transferRepoFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + newOwner, err := params.GetString(args, "new_owner") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + opt := gitea_sdk.TransferRepoOption{ + NewOwner: newOwner, + } + if teamIDs, err := params.GetInt64Slice(args, "team_ids"); err == nil { + opt.TeamIDs = &teamIDs + } + r, _, err := client.TransferRepo(owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("transfer repo %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimRepo(r)) +} + +func ListForksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListForksFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + forks, _, err := client.ListForks(owner, repo, gitea_sdk.ListForksOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list forks %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimRepos(forks)) +} diff --git a/operation/repo/slim.go b/operation/repo/slim.go index 10482ee..ac9852f 100644 --- a/operation/repo/slim.go +++ b/operation/repo/slim.go @@ -206,6 +206,146 @@ func slimTree(t *gitea_sdk.GitTreeResponse) map[string]any { } } +func slimRepoDetail(r *gitea_sdk.Repository) map[string]any { + if r == nil { + return nil + } + m := slimRepo(r) + m["website"] = r.Website + m["has_issues"] = r.HasIssues + m["has_wiki"] = r.HasWiki + m["has_pull_requests"] = r.HasPullRequests + m["has_projects"] = r.HasProjects + m["mirror"] = r.Mirror + m["internal"] = r.Internal + m["empty"] = r.Empty + m["size"] = r.Size + if r.Permissions != nil { + m["permissions"] = map[string]any{ + "admin": r.Permissions.Admin, + "push": r.Permissions.Push, + "pull": r.Permissions.Pull, + } + } + if r.Parent != nil { + m["parent"] = map[string]any{ + "full_name": r.Parent.FullName, + "html_url": r.Parent.HTMLURL, + } + } + return m +} + +func slimUser(u *gitea_sdk.User) map[string]any { + if u == nil { + return nil + } + return map[string]any{ + "id": u.ID, + "login": u.UserName, + "full_name": u.FullName, + "email": u.Email, + "avatar_url": u.AvatarURL, + "is_admin": u.IsAdmin, + } +} + +func slimUsers(users []*gitea_sdk.User) []map[string]any { + out := make([]map[string]any, 0, len(users)) + for _, u := range users { + out = append(out, slimUser(u)) + } + return out +} + +func slimDeployKey(k *gitea_sdk.DeployKey) map[string]any { + if k == nil { + return nil + } + return map[string]any{ + "id": k.ID, + "title": k.Title, + "fingerprint": k.Fingerprint, + "key": k.Key, + "url": k.URL, + "read_only": k.ReadOnly, + "created_at": k.Created, + } +} + +func slimDeployKeys(keys []*gitea_sdk.DeployKey) []map[string]any { + out := make([]map[string]any, 0, len(keys)) + for _, k := range keys { + out = append(out, slimDeployKey(k)) + } + return out +} + +func slimBranchProtection(bp *gitea_sdk.BranchProtection) map[string]any { + if bp == nil { + return nil + } + return map[string]any{ + "branch_name": bp.BranchName, + "rule_name": bp.RuleName, + "enable_push": bp.EnablePush, + "push_whitelist_usernames": bp.PushWhitelistUsernames, + "push_whitelist_teams": bp.PushWhitelistTeams, + "enable_merge_whitelist": bp.EnableMergeWhitelist, + "merge_whitelist_usernames": bp.MergeWhitelistUsernames, + "merge_whitelist_teams": bp.MergeWhitelistTeams, + "required_approvals": bp.RequiredApprovals, + "enable_approvals_whitelist": bp.EnableApprovalsWhitelist, + "block_on_rejected_reviews": bp.BlockOnRejectedReviews, + "block_on_outdated_branch": bp.BlockOnOutdatedBranch, + "dismiss_stale_approvals": bp.DismissStaleApprovals, + "require_signed_commits": bp.RequireSignedCommits, + "protected_file_patterns": bp.ProtectedFilePatterns, + "unprotected_file_patterns": bp.UnprotectedFilePatterns, + "created_at": bp.Created, + "updated_at": bp.Updated, + } +} + +func slimBranchProtections(bps []*gitea_sdk.BranchProtection) []map[string]any { + out := make([]map[string]any, 0, len(bps)) + for _, bp := range bps { + out = append(out, slimBranchProtection(bp)) + } + return out +} + +func slimHook(h *gitea_sdk.Hook) map[string]any { + if h == nil { + return nil + } + m := map[string]any{ + "id": h.ID, + "type": h.Type, + "active": h.Active, + "events": h.Events, + "created_at": h.Created, + "updated_at": h.Updated, + } + if h.Config != nil { + if url, ok := h.Config["url"]; ok { + m["url"] = url + } + if ct, ok := h.Config["content_type"]; ok { + m["content_type"] = ct + } + } + return m +} + +func slimHooks(hooks []*gitea_sdk.Hook) []map[string]any { + out := make([]map[string]any, 0, len(hooks)) + for _, h := range hooks { + out = append(out, slimHook(h)) + } + return out +} + func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any { out := make([]map[string]any, 0, len(entries)) for _, c := range entries { diff --git a/operation/repo/topic.go b/operation/repo/topic.go new file mode 100644 index 0000000..10b5b62 --- /dev/null +++ b/operation/repo/topic.go @@ -0,0 +1,169 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "code.gitea.io/sdk/gitea" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + TopicReadToolName = "topic_read" + TopicWriteToolName = "topic_write" +) + +var ( + TopicReadTool = mcp.NewTool( + TopicReadToolName, + mcp.WithDescription("List repository topics."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list")), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + ) + + TopicWriteTool = mcp.NewTool( + TopicWriteToolName, + mcp.WithDescription("Manage repository topics. Use 'add' to add one, 'delete' to remove one, 'set' to replace all topics."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("add", "delete", "set")), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("topic", mcp.Description("single topic name (required for 'add', 'delete')")), + mcp.WithArray("topics", mcp.Description("list of topic names (required for 'set')"), mcp.Items(map[string]any{"type": "string"})), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: TopicReadTool, + Handler: topicReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: TopicWriteTool, + Handler: topicWriteFn, + }) +} + +func topicReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called topicReadFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + topics, _, err := client.ListRepoTopics(owner, repo, gitea_sdk.ListRepoTopicsOptions{}) + if err != nil { + return to.ErrorResult(fmt.Errorf("list topics %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(topics) +} + +func topicWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "add": + return addTopicFn(ctx, req) + case "delete": + return deleteTopicFn(ctx, req) + case "set": + return setTopicsFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func addTopicFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called addTopicFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + topic, err := params.GetString(args, "topic") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.AddRepoTopic(owner, repo, topic) + if err != nil { + return to.ErrorResult(fmt.Errorf("add topic %v to %v/%v err: %v", topic, owner, repo, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Topic '%s' added to %s/%s", topic, owner, repo)), nil +} + +func deleteTopicFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called deleteTopicFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + topic, err := params.GetString(args, "topic") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.DeleteRepoTopic(owner, repo, topic) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete topic %v from %v/%v err: %v", topic, owner, repo, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Topic '%s' removed from %s/%s", topic, owner, repo)), nil +} + +func setTopicsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called setTopicsFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + topics := params.GetStringSlice(args, "topics") + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.SetRepoTopics(owner, repo, topics) + if err != nil { + return to.ErrorResult(fmt.Errorf("set topics for %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(map[string]any{ + "message": fmt.Sprintf("Topics set for %s/%s", owner, repo), + "topics": topics, + }) +} diff --git a/operation/repo/webhook.go b/operation/repo/webhook.go new file mode 100644 index 0000000..da6c2e4 --- /dev/null +++ b/operation/repo/webhook.go @@ -0,0 +1,304 @@ +package repo + +import ( + "context" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "code.gitea.io/sdk/gitea" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + WebhookReadToolName = "webhook_read" + WebhookWriteToolName = "webhook_write" +) + +var ( + WebhookReadTool = mcp.NewTool( + WebhookReadToolName, + mcp.WithDescription("Read repository webhooks. Use method 'list' to list all, 'get' to get a specific webhook."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("id", mcp.Description("webhook ID (required for 'get')")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), + mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), + ) + + WebhookWriteTool = mcp.NewTool( + WebhookWriteToolName, + mcp.WithDescription("Create, edit, delete, or test repository webhooks."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete", "test")), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("id", mcp.Description("webhook ID (required for 'edit', 'delete', 'test')")), + mcp.WithString("type", mcp.Description("webhook type (for 'create')"), mcp.Enum("gitea", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "wechatwork", "packagist")), + mcp.WithString("url", mcp.Description("target URL (required for 'create', optional for 'edit')")), + mcp.WithString("content_type", mcp.Description("content type"), mcp.Enum("json", "form")), + mcp.WithString("secret", mcp.Description("webhook secret")), + mcp.WithArray("events", mcp.Description("event types to trigger on (e.g. push, pull_request, issues)"), mcp.Items(map[string]any{"type": "string"})), + mcp.WithBoolean("active", mcp.Description("whether the webhook is active")), + mcp.WithString("branch_filter", mcp.Description("branch filter pattern")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: WebhookReadTool, + Handler: webhookReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: WebhookWriteTool, + Handler: webhookWriteFn, + }) +} + +func webhookReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list": + return listWebhooksFn(ctx, req) + case "get": + return getWebhookFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func listWebhooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called listWebhooksFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + page, pageSize := params.GetPagination(args, 30) + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + hooks, _, err := client.ListRepoHooks(owner, repo, gitea_sdk.ListHooksOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list webhooks %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimHooks(hooks)) +} + +func getWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called getWebhookFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(args, "id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + hook, _, err := client.GetRepoHook(owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("get webhook %v in %v/%v err: %v", id, owner, repo, err)) + } + return to.TextResult(slimHook(hook)) +} + +func webhookWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create": + return createWebhookFn(ctx, req) + case "edit": + return editWebhookFn(ctx, req) + case "delete": + return deleteWebhookFn(ctx, req) + case "test": + return testWebhookFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func createWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called createWebhookFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + hookType := params.GetOptionalString(args, "type", "gitea") + url, err := params.GetString(args, "url") + if err != nil { + return to.ErrorResult(err) + } + contentType := params.GetOptionalString(args, "content_type", "json") + secret, _ := args["secret"].(string) + events := params.GetStringSlice(args, "events") + active := true + if v, ok := args["active"].(bool); ok { + active = v + } + branchFilter, _ := args["branch_filter"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + opt := gitea_sdk.CreateHookOption{ + Type: gitea_sdk.HookType(hookType), + Config: map[string]string{ + "url": url, + "content_type": contentType, + "secret": secret, + }, + Events: events, + Active: active, + BranchFilter: branchFilter, + } + + hook, _, err := client.CreateRepoHook(owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create webhook in %v/%v err: %v", owner, repo, err)) + } + return to.TextResult(slimHook(hook)) +} + +func editWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called editWebhookFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(args, "id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + opt := gitea_sdk.EditHookOption{} + config := map[string]string{} + if v, ok := args["url"].(string); ok { + config["url"] = v + } + if v, ok := args["content_type"].(string); ok { + config["content_type"] = v + } + if v, ok := args["secret"].(string); ok { + config["secret"] = v + } + if len(config) > 0 { + opt.Config = config + } + if events := params.GetStringSlice(args, "events"); events != nil { + opt.Events = events + } + if v, ok := args["active"].(bool); ok { + opt.Active = &v + } + if v, ok := args["branch_filter"].(string); ok { + opt.BranchFilter = v + } + + _, err = client.EditRepoHook(owner, repo, id, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("edit webhook %v in %v/%v err: %v", id, owner, repo, err)) + } + // EditRepoHook doesn't return the hook, fetch it + hook, _, err := client.GetRepoHook(owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("get webhook %v in %v/%v after edit err: %v", id, owner, repo, err)) + } + return to.TextResult(slimHook(hook)) +} + +func deleteWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called deleteWebhookFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(args, "id") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.DeleteRepoHook(owner, repo, id) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete webhook %v from %v/%v err: %v", id, owner, repo, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Webhook %d deleted from %s/%s", id, owner, repo)), nil +} + +func testWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called testWebhookFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + id, err := params.GetIndex(args, "id") + if err != nil { + return to.ErrorResult(err) + } + // TestRepoHook is not in the SDK - use raw REST + _, err = gitea.DoJSON(ctx, "POST", fmt.Sprintf("repos/%s/%s/hooks/%d/tests", owner, repo, id), nil, nil, nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("test webhook %v in %v/%v err: %v", id, owner, repo, err)) + } + return mcp.NewToolResultText(fmt.Sprintf("Webhook %d test triggered for %s/%s", id, owner, repo)), nil +}