diff --git a/README.md b/README.md index 91311de..7f3828c 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,8 @@ Once configured, try `list all my repositories` in the chat box. | timetracking_write | timetracking | Write | Write time tracking: stopwatches and entries | | package_read | packages | Read | Read package registry: list packages, list versions, or get a version | | package_write | packages | Write | Delete a package version (irreversible) | +| project_read | project | Read | Read projects: list projects, get a project, list columns, get a column, or list issues in a column | +| project_write | project | Write | Write projects: create, update, or delete projects and columns; reorder columns; add, remove, or move issues | | list_issues | issue | Read | List repository issues | | attachment_read | issue | Read | Read issue/comment attachments: list metadata, get metadata, or download content | | issue_read | issue | Read | Read issue: details, comments, or labels | diff --git a/README.zh-cn.md b/README.zh-cn.md index deb926a..22b9504 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -156,6 +156,8 @@ Cursor 等客户端可使用 stdio 命令: | timetracking_write | timetracking | 写入 | 写入时间跟踪:计时器和记录条目 | | package_read | packages | 读取 | 读取软件包注册表:列出软件包、列出版本或获取某个版本 | | package_write | packages | 写入 | 删除软件包版本(不可恢复) | +| project_read | project | 读取 | 读取项目:列出项目、获取项目、列出列、获取列或列出列中的议题 | +| project_write | project | 写入 | 写入项目:创建、更新或删除项目与列;重新排序列;添加、移除或移动议题 | | list_issues | issue | 读取 | 列出仓库问题 | | attachment_read | issue | 读取 | 读取问题/评论附件:列出元数据、获取元数据或下载内容 | | issue_read | issue | 读取 | 读取问题:详情、评论或标签 | diff --git a/README.zh-tw.md b/README.zh-tw.md index 9dc812e..4705feb 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -156,6 +156,8 @@ Cursor 等客戶端可使用 stdio 命令: | timetracking_write | timetracking | 寫入 | 寫入時間追蹤:計時器和記錄項目 | | package_read | packages | 讀取 | 讀取軟體套件註冊表:列出套件、列出版本或取得某個版本 | | package_write | packages | 寫入 | 刪除軟體套件版本(不可復原) | +| project_read | project | 讀取 | 讀取專案:列出專案、取得專案、列出欄、取得欄或列出欄中的議題 | +| project_write | project | 寫入 | 寫入專案:建立、更新或刪除專案與欄;重新排序欄;新增、移除或移動議題 | | list_issues | issue | 讀取 | 列出倉庫問題 | | attachment_read | issue | 讀取 | 讀取問題/評論附件:列出中繼資料、取得中繼資料或下載內容 | | issue_read | issue | 讀取 | 讀取問題:詳情、評論或標籤 | diff --git a/go.mod b/go.mod index 58238da..2326679 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 toolchain go1.26.6 require ( - gitea.dev/sdk v1.2.0 + gitea.dev/sdk v1.2.1-0.20260822201514-76e7eafcee4b github.com/modelcontextprotocol/go-sdk v1.7.0 go.uber.org/zap v1.28.0 go.uber.org/zap/exp v0.3.0 diff --git a/go.sum b/go.sum index 648c0a0..51f2df7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo= -gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE= +gitea.dev/sdk v1.2.1-0.20260822201514-76e7eafcee4b h1:Ub2tRd59s7zBxalMyaNyuwl2JA9/ZV4svGVbmV7itcU= +gitea.dev/sdk v1.2.1-0.20260822201514-76e7eafcee4b/go.mod h1:McaA1Vd+POnguz48vHfgD9jWmeInt10eH/bKaCIVGDA= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/operation/issue/slim.go b/operation/issue/slim.go index 84105e4..abed1a3 100644 --- a/operation/issue/slim.go +++ b/operation/issue/slim.go @@ -11,6 +11,7 @@ func slimIssue(i *gitea_sdk.Issue) map[string]any { return nil } m := map[string]any{ + "id": i.ID, "number": i.Index, "title": i.Title, "body": i.Body, @@ -51,6 +52,7 @@ func slimIssues(issues []*gitea_sdk.Issue) []map[string]any { continue } m := map[string]any{ + "id": i.ID, "number": i.Index, "title": i.Title, "state": i.State, diff --git a/operation/issue/slim_test.go b/operation/issue/slim_test.go index c61105b..79de0bc 100644 --- a/operation/issue/slim_test.go +++ b/operation/issue/slim_test.go @@ -8,6 +8,7 @@ import ( func TestSlimIssue(t *testing.T) { i := &gitea_sdk.Issue{ + ID: 99, Index: 42, Title: "Bug report", Body: "Something is broken", @@ -24,6 +25,9 @@ func TestSlimIssue(t *testing.T) { m := slimIssue(i) + if m["id"] != int64(99) { + t.Errorf("expected id 99, got %v", m["id"]) + } if m["number"] != int64(42) { t.Errorf("expected number 42, got %v", m["number"]) } diff --git a/operation/operation.go b/operation/operation.go index 610d081..10fde1d 100644 --- a/operation/operation.go +++ b/operation/operation.go @@ -19,6 +19,7 @@ import ( "gitea.com/gitea/gitea-mcp/operation/milestone" "gitea.com/gitea/gitea-mcp/operation/notification" "gitea.com/gitea/gitea-mcp/operation/packages" + "gitea.com/gitea/gitea-mcp/operation/project" "gitea.com/gitea/gitea-mcp/operation/pull" "gitea.com/gitea/gitea-mcp/operation/repo" "gitea.com/gitea/gitea-mcp/operation/search" @@ -47,7 +48,7 @@ var ( domainTools = []*tool.Tool{ user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool, label.Tool, milestone.Tool, packages.Tool, pull.Tool, search.Tool, - version.Tool, wiki.Tool, timetracking.Tool, + project.Tool, version.Tool, wiki.Tool, timetracking.Tool, repo.FileTool, repo.BranchTool, repo.TagTool, repo.CommitTool, repo.ReleaseTool, } ) diff --git a/operation/project/project.go b/operation/project/project.go new file mode 100644 index 0000000..8f8943a --- /dev/null +++ b/operation/project/project.go @@ -0,0 +1,608 @@ +package project + +import ( + "context" + "errors" + "fmt" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + gitea_sdk "gitea.dev/sdk" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var Tool = tool.New("project") + +const ( + ProjectReadToolName = "project_read" + ProjectWriteToolName = "project_write" +) + +var ( + ProjectReadTool = tool.NewDefinition( + ProjectReadToolName, + "Read projects: list projects, get a project, list columns, get a column, or list issues in a column.", + annotation.ReadOnly("Read projects"), + tool.String("method", tool.Required(), tool.Enum("list", "get", "list_columns", "get_column", "list_column_issues")), + tool.String("scope", tool.Required(), tool.Enum("repo", "org", "user", "current_user")), + tool.String("owner", tool.Description("repo owner for 'repo' scope, org name for 'org' scope, username for 'user' scope")), + tool.String("repo", tool.Description("repo name for 'repo' scope")), + tool.String("state", tool.Default("all"), tool.Enum("open", "closed", "all")), + tool.Number("project_id", tool.Description("project ID for 'get' and column methods")), + tool.Number("column_id", tool.Description("column ID for 'get_column' and 'list_column_issues'")), + tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)), + tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30), tool.Minimum(1)), + ) + + ProjectWriteTool = tool.NewDefinition( + ProjectWriteToolName, + "Write projects: create, update, or delete projects and columns; reorder columns; add, remove, or move issues.", + annotation.Destructive("Create, update, or delete projects and project columns"), + tool.String("method", tool.Required(), tool.Enum("create", "update", "delete", "create_column", "update_column", "delete_column", "set_default_column", "move_columns", "add_issue", "remove_issue", "move_issue")), + tool.String("scope", tool.Required(), tool.Enum("repo", "org", "current_user")), + tool.String("owner", tool.Description("repo owner for 'repo' scope, org name for 'org' scope")), + tool.String("repo", tool.Description("repo name for 'repo' scope")), + tool.Number("project_id", tool.Description("project ID (required except for 'create')")), + tool.Number("column_id", tool.Description("column ID (required for column and issue methods)")), + tool.Number("issue_id", tool.Description("global issue ID (required for 'add_issue', 'remove_issue', and 'move_issue')")), + tool.String("title", tool.Description("for 'create', 'update', 'create_column', and 'update_column'")), + tool.String("description", tool.Description("for 'create' and 'update'")), + tool.String("template_type", tool.Enum("none", "basic_kanban", "bug_triage")), + tool.String("card_type", tool.Enum("text_only", "images_and_text")), + tool.String("state", tool.Enum("open", "closed")), + tool.String("color", tool.Description("6-digit hex column color, e.g. #FF0000")), + tool.Number("sorting", tool.Description("position for 'update_column' and 'move_issue'")), + tool.Array("column_ids", tool.Description("ordered column IDs for 'move_columns'"), tool.Items(map[string]any{"type": "number"})), + ) +) + +func init() { + Tool.RegisterRead(tool.ServerTool{ + Tool: ProjectReadTool, + Handler: projectReadFn, + }) + Tool.RegisterWrite(tool.ServerTool{ + Tool: ProjectWriteTool, + Handler: projectWriteFn, + }) +} + +type projectScopeInfo struct { + scope gitea_sdk.ProjectScope + kind string +} + +func getProjectScope(args map[string]any, writable bool) (projectScopeInfo, error) { + scopeName := params.GetOptionalString(args, "scope", "") + switch scopeName { + case "repo": + owner, err := params.GetString(args, "owner") + if err != nil { + return projectScopeInfo{}, err + } + repo, err := params.GetString(args, "repo") + if err != nil { + return projectScopeInfo{}, err + } + return projectScopeInfo{scope: gitea_sdk.RepoProjectScope(owner, repo), kind: "repo"}, nil + case "org": + owner, err := params.GetString(args, "owner") + if err != nil { + return projectScopeInfo{}, err + } + return projectScopeInfo{scope: gitea_sdk.OrgProjectScope(owner), kind: "org"}, nil + case "user": + owner, err := params.GetString(args, "owner") + if err != nil { + return projectScopeInfo{}, err + } + if writable { + return projectScopeInfo{}, errors.New("scope 'user' only supports listing projects") + } + return projectScopeInfo{scope: gitea_sdk.UserProjectScope(owner), kind: "user"}, nil + case "current_user": + return projectScopeInfo{scope: gitea_sdk.CurrentUserProjectScope(), kind: "current_user"}, nil + default: + if scopeName == "" { + return projectScopeInfo{}, errors.New("scope is required") + } + return projectScopeInfo{}, fmt.Errorf("unknown scope: %s", scopeName) + } +} + +func projectReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list": + return listProjectsFn(ctx, args) + case "get": + return getProjectFn(ctx, args) + case "list_columns": + return listProjectColumnsFn(ctx, args) + case "get_column": + return getProjectColumnFn(ctx, args) + case "list_column_issues": + return listProjectColumnIssuesFn(ctx, args) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func projectWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "create": + return createProjectFn(ctx, args) + case "update": + return updateProjectFn(ctx, args) + case "delete": + return deleteProjectFn(ctx, args) + case "create_column": + return createProjectColumnFn(ctx, args) + case "update_column": + return updateProjectColumnFn(ctx, args) + case "delete_column": + return deleteProjectColumnFn(ctx, args) + case "set_default_column": + return setDefaultProjectColumnFn(ctx, args) + case "move_columns": + return moveProjectColumnsFn(ctx, args) + case "add_issue": + return addIssueToProjectColumnFn(ctx, args) + case "remove_issue": + return removeIssueFromProjectColumnFn(ctx, args) + case "move_issue": + return moveProjectIssueFn(ctx, args) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func listProjectsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, false) + if err != nil { + return to.ErrorResult(err) + } + state := params.GetOptionalString(args, "state", "all") + page, pageSize := params.GetPagination(args, 30) + opt := gitea_sdk.ListProjectsOptions{ + State: gitea_sdk.StateType(state), + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + projects, _, err := client.Projects.ListProjects(ctx, info.scope, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("list projects err: %v", err)) + } + return to.TextResult(slimProjects(projects)) +} + +func getProjectFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, false) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_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)) + } + project, _, err := client.Projects.GetProject(ctx, info.scope, projectID) + if err != nil { + return to.ErrorResult(fmt.Errorf("get project/%d err: %v", projectID, err)) + } + return to.TextResult(slimProject(project)) +} + +func listProjectColumnsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, false) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_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)) + } + columns, _, err := client.Projects.ListProjectColumns(ctx, info.scope, projectID, gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list project/%d/columns err: %v", projectID, err)) + } + return to.TextResult(slimProjectColumns(columns)) +} + +func getProjectColumnFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, false) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + columnID, err := params.GetIndex(args, "column_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)) + } + column, _, err := client.Projects.GetProjectColumn(ctx, info.scope, projectID, columnID) + if err != nil { + return to.ErrorResult(fmt.Errorf("get project/%d/columns/%d err: %v", projectID, columnID, err)) + } + return to.TextResult(slimProjectColumn(column)) +} + +func listProjectColumnIssuesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, false) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + columnID, err := params.GetIndex(args, "column_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)) + } + issues, _, err := client.Projects.ListProjectColumnIssues(ctx, info.scope, projectID, columnID, gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list project/%d/columns/%d/issues err: %v", projectID, columnID, err)) + } + return to.TextResult(slimProjectIssues(issues)) +} + +func createProjectFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + title, err := params.GetString(args, "title") + if err != nil { + return to.ErrorResult(err) + } + opt := gitea_sdk.CreateProjectOption{Title: title} + if description, ok := args["description"].(string); ok { + opt.Description = description + } + if templateType, ok := args["template_type"].(string); ok { + opt.TemplateType = gitea_sdk.ProjectTemplateType(templateType) + } + if cardType, ok := args["card_type"].(string); ok { + opt.CardType = gitea_sdk.ProjectCardType(cardType) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + project, _, err := client.Projects.CreateProject(ctx, info.scope, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create project err: %v", err)) + } + return to.TextResult(slimProject(project)) +} + +func updateProjectFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + opt := gitea_sdk.EditProjectOption{ + Title: params.GetPresentStringPtr(args, "title"), + Description: params.GetPresentStringPtr(args, "description"), + } + if cardType, ok := args["card_type"].(string); ok { + value := gitea_sdk.ProjectCardType(cardType) + opt.CardType = &value + } + if state, ok := args["state"].(string); ok { + value := gitea_sdk.StateType(state) + opt.State = &value + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + project, _, err := client.Projects.EditProject(ctx, info.scope, projectID, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("update project/%d err: %v", projectID, err)) + } + return to.TextResult(slimProject(project)) +} + +func deleteProjectFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_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.Projects.DeleteProject(ctx, info.scope, projectID) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete project/%d err: %v", projectID, err)) + } + return to.TextResult("Project deleted successfully") +} + +func createProjectColumnFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + title, err := params.GetString(args, "title") + if err != nil { + return to.ErrorResult(err) + } + opt := gitea_sdk.CreateProjectColumnOption{Title: title} + if color, ok := args["color"].(string); ok { + opt.Color = color + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + column, _, err := client.Projects.CreateProjectColumn(ctx, info.scope, projectID, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("create project/%d/column err: %v", projectID, err)) + } + return to.TextResult(slimProjectColumn(column)) +} + +func updateProjectColumnFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + columnID, err := params.GetIndex(args, "column_id") + if err != nil { + return to.ErrorResult(err) + } + opt := gitea_sdk.EditProjectColumnOption{ + Title: params.GetPresentStringPtr(args, "title"), + Color: params.GetPresentStringPtr(args, "color"), + } + if sorting, ok := optionalIntPtr(args, "sorting"); ok { + opt.Sorting = sorting + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + column, _, err := client.Projects.EditProjectColumn(ctx, info.scope, projectID, columnID, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("update project/%d/columns/%d err: %v", projectID, columnID, err)) + } + return to.TextResult(slimProjectColumn(column)) +} + +func deleteProjectColumnFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + columnID, err := params.GetIndex(args, "column_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.Projects.DeleteProjectColumn(ctx, info.scope, projectID, columnID) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete project/%d/columns/%d err: %v", projectID, columnID, err)) + } + return to.TextResult("Project column deleted successfully") +} + +func setDefaultProjectColumnFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + columnID, err := params.GetIndex(args, "column_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.Projects.SetDefaultProjectColumn(ctx, info.scope, projectID, columnID) + if err != nil { + return to.ErrorResult(fmt.Errorf("set project/%d default column/%d err: %v", projectID, columnID, err)) + } + return to.TextResult("Project default column set successfully") +} + +func moveProjectColumnsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + columnIDs, err := params.GetInt64Slice(args, "column_ids") + 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.Projects.MoveProjectColumns(ctx, info.scope, projectID, gitea_sdk.MoveProjectColumnsOption{ColumnIDs: columnIDs}) + if err != nil { + return to.ErrorResult(fmt.Errorf("move project/%d columns err: %v", projectID, err)) + } + return to.TextResult("Project columns moved successfully") +} + +func addIssueToProjectColumnFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + columnID, err := params.GetIndex(args, "column_id") + if err != nil { + return to.ErrorResult(err) + } + issueID, err := params.GetIndex(args, "issue_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.Projects.AddIssueToProjectColumn(ctx, info.scope, projectID, columnID, issueID) + if err != nil { + return to.ErrorResult(fmt.Errorf("add issue/%d to project/%d/columns/%d err: %v", issueID, projectID, columnID, err)) + } + return to.TextResult("Issue added to project column successfully") +} + +func removeIssueFromProjectColumnFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + columnID, err := params.GetIndex(args, "column_id") + if err != nil { + return to.ErrorResult(err) + } + issueID, err := params.GetIndex(args, "issue_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.Projects.RemoveIssueFromProjectColumn(ctx, info.scope, projectID, columnID, issueID) + if err != nil { + return to.ErrorResult(fmt.Errorf("remove issue/%d from project/%d/columns/%d err: %v", issueID, projectID, columnID, err)) + } + return to.TextResult("Issue removed from project column successfully") +} + +func moveProjectIssueFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + info, err := getProjectScope(args, true) + if err != nil { + return to.ErrorResult(err) + } + projectID, err := params.GetIndex(args, "project_id") + if err != nil { + return to.ErrorResult(err) + } + issueID, err := params.GetIndex(args, "issue_id") + if err != nil { + return to.ErrorResult(err) + } + columnID, err := params.GetIndex(args, "column_id") + if err != nil { + return to.ErrorResult(err) + } + opt := gitea_sdk.MoveProjectIssueOption{ColumnID: columnID} + if sorting, ok := optionalInt64Ptr(args, "sorting"); ok { + opt.Sorting = sorting + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.Projects.MoveProjectIssue(ctx, info.scope, projectID, issueID, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("move issue/%d in project/%d err: %v", issueID, projectID, err)) + } + return to.TextResult("Project issue moved successfully") +} + +func optionalIntPtr(args map[string]any, key string) (*int, bool) { + value, ok := params.ToInt64(args[key]) + if !ok { + return nil, false + } + converted := int(value) + return &converted, true +} + +func optionalInt64Ptr(args map[string]any, key string) (*int64, bool) { + value, ok := params.ToInt64(args[key]) + if !ok { + return nil, false + } + return &value, true +} diff --git a/operation/project/project_test.go b/operation/project/project_test.go new file mode 100644 index 0000000..d5e801e --- /dev/null +++ b/operation/project/project_test.go @@ -0,0 +1,517 @@ +package project + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestGetProjectScope(t *testing.T) { + tests := []struct { + name string + args map[string]any + writable bool + wantKind string + wantErr bool + }{ + { + name: "repo", + args: map[string]any{"scope": "repo", "owner": "octo", "repo": "demo"}, + wantKind: "repo", + }, + { + name: "org", + args: map[string]any{"scope": "org", "owner": "acme"}, + wantKind: "org", + }, + { + name: "user read", + args: map[string]any{"scope": "user", "owner": "octo"}, + wantKind: "user", + }, + { + name: "user write", + args: map[string]any{"scope": "user", "owner": "octo"}, + writable: true, + wantErr: true, + }, + { + name: "current user", + args: map[string]any{"scope": "current_user"}, + wantKind: "current_user", + }, + { + name: "missing scope", + args: map[string]any{}, + wantErr: true, + }, + { + name: "repo missing owner", + args: map[string]any{"scope": "repo", "repo": "demo"}, + wantErr: true, + }, + { + name: "repo missing repo", + args: map[string]any{"scope": "repo", "owner": "octo"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info, err := getProjectScope(tt.args, tt.writable) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error, got nil") + } + return + } + if err != nil { + t.Fatalf("getProjectScope() error = %v", err) + } + if info.kind != tt.wantKind { + t.Fatalf("scope kind = %q, want %q", info.kind, tt.wantKind) + } + }) + } +} + +func TestProjectReadFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + projectID = 10 + columnID = 20 + ) + + var ( + mu sync.Mutex + gotReq = struct { + method string + path string + query string + }{} + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + mu.Lock() + gotReq.method = r.Method + gotReq.path = r.URL.Path + gotReq.query = r.URL.RawQuery + mu.Unlock() + + switch r.URL.Path { + case "/api/v1/version": + _, _ = w.Write([]byte(`{"version":"1.28.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner, repo): + _, _ = w.Write(fmt.Appendf(nil, `[{"id":%d,"title":"Board","state":"open"}]`, projectID)) + case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID): + _, _ = w.Write(fmt.Appendf(nil, `{"id":%d,"title":"Board","state":"open"}`, projectID)) + case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner, repo, projectID): + _, _ = w.Write(fmt.Appendf(nil, `[{"id":%d,"title":"Todo","project_id":%d}]`, columnID, projectID)) + case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner, repo, projectID, columnID): + _, _ = w.Write([]byte(`[{"id":5,"number":1,"title":"An issue","state":"open","html_url":"https://example.com/1","user":{"login":"octo"}}]`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID): + _, _ = w.Write(fmt.Appendf(nil, `{"id":%d,"title":"Todo","project_id":%d}`, columnID, projectID)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version + flag.Host, flag.Token, flag.Version = server.URL, "", "test" + defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "project-read-token") + + tests := []struct { + name string + args map[string]any + wantPath string + wantID float64 + }{ + { + name: "list", + args: map[string]any{ + "method": "list", + "scope": "repo", + "owner": owner, + "repo": repo, + "state": "open", + }, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner, repo), + wantID: projectID, + }, + { + name: "get", + args: map[string]any{ + "method": "get", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + }, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID), + wantID: projectID, + }, + { + name: "list columns", + args: map[string]any{ + "method": "list_columns", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + }, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner, repo, projectID), + wantID: columnID, + }, + { + name: "get column", + args: map[string]any{ + "method": "get_column", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "column_id": float64(columnID), + }, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID), + wantID: columnID, + }, + { + name: "list column issues", + args: map[string]any{ + "method": "list_column_issues", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "column_id": float64(columnID), + }, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner, repo, projectID, columnID), + wantID: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := projectReadFn(ctx, tt.args) + if err != nil { + t.Fatalf("projectReadFn() error = %v", err) + } + if result.IsError { + t.Fatalf("projectReadFn() returned error result: %v", result) + } + + mu.Lock() + path := gotReq.path + query := gotReq.query + mu.Unlock() + if path != tt.wantPath { + t.Fatalf("request path = %q, want %q", path, tt.wantPath) + } + if tt.name == "list" && query == "" { + t.Fatal("expected list query parameters, got none") + } + + if len(result.Content) == 0 { + t.Fatal("expected result content") + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("result content type = %T, want text content", result.Content[0]) + } + assertFirstID(t, []byte(text.Text), tt.wantID) + }) + } +} + +func TestProjectWriteFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + projectID = 10 + columnID = 20 + issueID = 5 + ) + + type request struct { + method string + path string + body map[string]any + } + + var ( + mu sync.Mutex + requests []request + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/version" { + _, _ = w.Write([]byte(`{"version":"1.28.0"}`)) + return + } + + var body map[string]any + if r.Body != nil && r.ContentLength > 0 { + _ = json.NewDecoder(r.Body).Decode(&body) + } + mu.Lock() + requests = append(requests, request{method: r.Method, path: r.URL.Path, body: body}) + mu.Unlock() + + switch r.Method { + case http.MethodGet: + _, _ = w.Write([]byte(`[]`)) + case http.MethodPost, http.MethodPatch: + _, _ = w.Write([]byte(`{}`)) + default: + w.WriteHeader(http.StatusNoContent) + } + })) + defer server.Close() + + origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version + flag.Host, flag.Token, flag.Version = server.URL, "", "test" + defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "project-write-token") + + tests := []struct { + name string + args map[string]any + wantMethod string + wantPath string + wantBody map[string]any + }{ + { + name: "create", + args: map[string]any{ + "method": "create", + "scope": "repo", + "owner": owner, + "repo": repo, + "title": "Board", + "description": "Project board", + "template_type": "basic_kanban", + "card_type": "images_and_text", + }, + wantMethod: http.MethodPost, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner, repo), + wantBody: map[string]any{"title": "Board", "description": "Project board", "template_type": "basic_kanban", "card_type": "images_and_text"}, + }, + { + name: "update", + args: map[string]any{ + "method": "update", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "title": "Updated Board", + "state": "closed", + }, + wantMethod: http.MethodPatch, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID), + wantBody: map[string]any{"title": "Updated Board", "state": "closed"}, + }, + { + name: "delete", + args: map[string]any{ + "method": "delete", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + }, + wantMethod: http.MethodDelete, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID), + }, + { + name: "create column", + args: map[string]any{ + "method": "create_column", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "title": "Todo", + "color": "#FF0000", + }, + wantMethod: http.MethodPost, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner, repo, projectID), + wantBody: map[string]any{"title": "Todo", "color": "#FF0000"}, + }, + { + name: "update column", + args: map[string]any{ + "method": "update_column", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "column_id": float64(columnID), + "title": "Doing", + "sorting": float64(2), + }, + wantMethod: http.MethodPatch, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID), + wantBody: map[string]any{"title": "Doing", "sorting": float64(2)}, + }, + { + name: "delete column", + args: map[string]any{ + "method": "delete_column", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "column_id": float64(columnID), + }, + wantMethod: http.MethodDelete, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID), + }, + { + name: "set default column", + args: map[string]any{ + "method": "set_default_column", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "column_id": float64(columnID), + }, + wantMethod: http.MethodPost, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/default", owner, repo, projectID, columnID), + }, + { + name: "move columns", + args: map[string]any{ + "method": "move_columns", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "column_ids": []any{float64(20), float64(21)}, + }, + wantMethod: http.MethodPost, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/move", owner, repo, projectID), + wantBody: map[string]any{"column_ids": []any{float64(20), float64(21)}}, + }, + { + name: "add issue", + args: map[string]any{ + "method": "add_issue", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "column_id": float64(columnID), + "issue_id": float64(issueID), + }, + wantMethod: http.MethodPost, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner, repo, projectID, columnID, issueID), + }, + { + name: "remove issue", + args: map[string]any{ + "method": "remove_issue", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "column_id": float64(columnID), + "issue_id": float64(issueID), + }, + wantMethod: http.MethodDelete, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner, repo, projectID, columnID, issueID), + }, + { + name: "move issue", + args: map[string]any{ + "method": "move_issue", + "scope": "repo", + "owner": owner, + "repo": repo, + "project_id": float64(projectID), + "issue_id": float64(issueID), + "column_id": float64(columnID), + "sorting": float64(3), + }, + wantMethod: http.MethodPost, + wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner, repo, projectID, issueID), + wantBody: map[string]any{"column_id": float64(columnID), "sorting": float64(3)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := projectWriteFn(ctx, tt.args) + if err != nil { + t.Fatalf("projectWriteFn() error = %v", err) + } + if result.IsError { + t.Fatalf("projectWriteFn() returned error result: %v", result) + } + + mu.Lock() + request := requests[len(requests)-1] + mu.Unlock() + if request.method != tt.wantMethod { + t.Fatalf("request method = %q, want %q", request.method, tt.wantMethod) + } + if request.path != tt.wantPath { + t.Fatalf("request path = %q, want %q", request.path, tt.wantPath) + } + if tt.wantBody != nil { + if len(request.body) != len(tt.wantBody) { + t.Fatalf("request body = %v, want %v", request.body, tt.wantBody) + } + for key, want := range tt.wantBody { + if got := request.body[key]; fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("request body[%q] = %v, want %v", key, got, want) + } + } + } + }) + } +} + +func assertFirstID(t *testing.T, data []byte, want float64) { + t.Helper() + + if len(data) > 0 && data[0] == '[' { + var items []map[string]any + if err := json.Unmarshal(data, &items); err != nil { + t.Fatalf("unmarshal result text: %v", err) + } + if len(items) == 0 { + t.Fatal("expected at least one result item") + } + if items[0]["id"] != want { + t.Fatalf("first result id = %v, want %v", items[0]["id"], want) + } + return + } + + var item map[string]any + if err := json.Unmarshal(data, &item); err != nil { + t.Fatalf("unmarshal result text: %v", err) + } + if item["id"] != want { + t.Fatalf("result id = %v, want %v", item["id"], want) + } +} diff --git a/operation/project/slim.go b/operation/project/slim.go new file mode 100644 index 0000000..796fa85 --- /dev/null +++ b/operation/project/slim.go @@ -0,0 +1,96 @@ +package project + +import ( + "gitea.com/gitea/gitea-mcp/pkg/slim" + + gitea_sdk "gitea.dev/sdk" +) + +func slimProject(p *gitea_sdk.Project) map[string]any { + if p == nil { + return nil + } + return map[string]any{ + "id": p.ID, + "title": p.Title, + "description": p.Description, + "state": string(p.State), + "type": string(p.Type), + "template_type": string(p.TemplateType), + "card_type": string(p.CardType), + "owner_id": p.OwnerID, + "repo_id": p.RepoID, + "creator": slim.UserLogin(p.Creator), + "num_open_issues": p.NumOpenIssues, + "num_closed_issues": p.NumClosedIssues, + "num_issues": p.NumIssues, + "html_url": p.HTMLURL, + "created_at": p.Created, + "updated_at": p.Updated, + "closed_at": p.Closed, + } +} + +func slimProjects(projects []*gitea_sdk.Project) []map[string]any { + out := make([]map[string]any, 0, len(projects)) + for _, p := range projects { + if p == nil { + continue + } + out = append(out, slimProject(p)) + } + return out +} + +func slimProjectColumn(c *gitea_sdk.ProjectColumn) map[string]any { + if c == nil { + return nil + } + return map[string]any{ + "id": c.ID, + "title": c.Title, + "default": c.Default, + "sorting": c.Sorting, + "color": c.Color, + "project_id": c.ProjectID, + "creator": slim.UserLogin(c.Creator), + "created_at": c.Created, + "updated_at": c.Updated, + } +} + +func slimProjectColumns(columns []*gitea_sdk.ProjectColumn) []map[string]any { + out := make([]map[string]any, 0, len(columns)) + for _, c := range columns { + if c == nil { + continue + } + out = append(out, slimProjectColumn(c)) + } + return out +} + +func slimProjectIssue(i *gitea_sdk.Issue) map[string]any { + if i == nil { + return nil + } + return map[string]any{ + "id": i.ID, + "number": i.Index, + "title": i.Title, + "state": string(i.State), + "html_url": i.HTMLURL, + "user": slim.UserLogin(i.Poster), + } +} + +func slimProjectIssues(issues []*gitea_sdk.Issue) []map[string]any { + out := make([]map[string]any, 0, len(issues)) + for _, i := range issues { + if i == nil { + continue + } + out = append(out, slimProjectIssue(i)) + } + return out +}