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 }