package issue import ( "context" "fmt" "net/url" "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/slim" "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" ) // issueWithAssets / commentWithAssets wrap the SDK types to capture the // `assets` field that the SDK currently drops on these endpoints. type issueWithAssets struct { gitea_sdk.Issue Assets []*gitea_sdk.Attachment `json:"assets"` } type commentWithAssets struct { gitea_sdk.Comment Assets []*gitea_sdk.Attachment `json:"assets"` } var Tool = tool.New() const ( ListRepoIssuesToolName = "list_issues" IssueReadToolName = "issue_read" IssueWriteToolName = "issue_write" ) var ( ListRepoIssuesTool = mcp.NewTool( ListRepoIssuesToolName, mcp.WithToolAnnotation(annotation.ReadOnly("List repository issues")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), mcp.WithString("state", mcp.DefaultString("all")), mcp.WithArray("labels", mcp.Description("label name filter"), mcp.Items(map[string]any{"type": "string"})), mcp.WithString("since", mcp.Description("updated after ISO 8601")), mcp.WithString("before", mcp.Description("updated before ISO 8601")), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), ) IssueReadTool = mcp.NewTool( IssueReadToolName, mcp.WithDescription("Read issue: details, comments, or labels."), mcp.WithToolAnnotation(annotation.ReadOnly("Read issue details")), mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_comments", "get_labels")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), mcp.WithNumber("issue_number", mcp.Required()), ) IssueWriteTool = mcp.NewTool( IssueWriteToolName, mcp.WithDescription("Write issues: create, update, manage comments and labels."), mcp.WithToolAnnotation(annotation.Write("Create or update issues, comments, and labels")), mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), mcp.WithNumber("issue_number", mcp.Description("required except for 'create'")), mcp.WithString("title", mcp.Description("required for 'create'")), mcp.WithString("body", mcp.Description("required for 'create'/'add_comment'/'edit_comment'")), mcp.WithArray("assignees", mcp.Items(map[string]any{"type": "string"})), mcp.WithNumber("milestone"), mcp.WithString("state", mcp.Enum("open", "closed", "all")), mcp.WithNumber("commentID", mcp.Description("for 'edit_comment'")), mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})), mcp.WithNumber("label_id", mcp.Description("for 'remove_label'")), mcp.WithString("ref", mcp.Description("branch to associate")), mcp.WithString("deadline", mcp.Description("ISO 8601")), mcp.WithBoolean("remove_deadline"), ) ) func init() { Tool.RegisterRead(server.ServerTool{ Tool: ListRepoIssuesTool, Handler: listRepoIssuesFn, }) Tool.RegisterRead(server.ServerTool{ Tool: IssueReadTool, Handler: issueReadFn, }) Tool.RegisterWrite(server.ServerTool{ Tool: IssueWriteTool, Handler: issueWriteFn, }) } func issueReadFn(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 getIssueByIndexFn(ctx, req) case "get_comments": return getIssueCommentsByIndexFn(ctx, req) case "get_labels": return getIssueLabelsFn(ctx, req) default: return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) } } func issueWriteFn(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 createIssueFn(ctx, req) case "update": return editIssueFn(ctx, req) case "add_comment": return createIssueCommentFn(ctx, req) case "edit_comment": return editIssueCommentFn(ctx, req) case "add_labels": return addIssueLabelsFn(ctx, req) case "remove_label": return removeIssueLabelFn(ctx, req) case "replace_labels": return replaceIssueLabelsFn(ctx, req) case "clear_labels": return clearIssueLabelsFn(ctx, req) default: return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) } } func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } index, err := params.GetIndex(req.GetArguments(), "issue_number") if err != nil { return to.ErrorResult(err) } var issue issueWithAssets path := fmt.Sprintf("repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), index) if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &issue); err != nil { return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err)) } m := slimIssue(&issue.Issue) m["body"] = slim.BodyWithAttachments(issue.Body, issue.Assets) return to.TextResult(m) } func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } state, ok := req.GetArguments()["state"].(string) if !ok { state = "all" } labels := params.GetStringSlice(req.GetArguments(), "labels") page, pageSize := params.GetPagination(req.GetArguments(), 30) opt := gitea_sdk.ListIssueOption{ State: gitea_sdk.StateType(state), Labels: labels, ListOptions: gitea_sdk.ListOptions{ Page: page, PageSize: pageSize, }, } if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil { opt.Since = *t } if t := params.GetOptionalTime(req.GetArguments(), "before"); t != nil { opt.Before = *t } client, err := gitea.ClientFromContext(ctx) if err != nil { return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) } issues, _, err := client.ListRepoIssues(owner, repo, opt) if err != nil { return to.ErrorResult(fmt.Errorf("get %v/%v/issues err: %v", owner, repo, err)) } return to.TextResult(slimIssues(issues)) } func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } title, err := params.GetString(req.GetArguments(), "title") if err != nil { return to.ErrorResult(err) } body, err := params.GetString(req.GetArguments(), "body") 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.CreateIssueOption{ Title: title, Body: body, } opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees") if val, exists := req.GetArguments()["milestone"]; exists { if milestone, ok := params.ToInt64(val); ok { opt.Milestone = milestone } } if labelIDs, err := params.GetInt64Slice(req.GetArguments(), "labels"); err == nil { opt.Labels = labelIDs } if ref, ok := req.GetArguments()["ref"].(string); ok { opt.Ref = ref } opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline") issue, _, err := client.CreateIssue(owner, repo, opt) if err != nil { return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err)) } return to.TextResult(slimIssue(issue)) } func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } index, err := params.GetIndex(req.GetArguments(), "issue_number") if err != nil { return to.ErrorResult(err) } body, err := params.GetString(req.GetArguments(), "body") if err != nil { return to.ErrorResult(err) } opt := gitea_sdk.CreateIssueCommentOption{ Body: body, } client, err := gitea.ClientFromContext(ctx) if err != nil { return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) } issueComment, _, err := client.CreateIssueComment(owner, repo, index, opt) if err != nil { return to.ErrorResult(fmt.Errorf("create %v/%v/issue/%v/comment err: %v", owner, repo, index, err)) } return to.TextResult(slimComment(issueComment)) } func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } index, err := params.GetIndex(req.GetArguments(), "issue_number") if err != nil { return to.ErrorResult(err) } args := req.GetArguments() opt := gitea_sdk.EditIssueOption{ Body: params.GetPresentStringPtr(args, "body"), Ref: params.GetPresentStringPtr(args, "ref"), Assignees: params.GetStringSlice(args, "assignees"), Deadline: params.GetOptionalTime(args, "deadline"), RemoveDeadline: params.GetOptionalBoolPtr(args, "remove_deadline"), } if title, ok := args["title"].(string); ok { opt.Title = title } if val, exists := args["milestone"]; exists { if milestone, ok := params.ToInt64(val); ok { opt.Milestone = &milestone } } if state, ok := args["state"].(string); ok { s := gitea_sdk.StateType(state) opt.State = &s } client, err := gitea.ClientFromContext(ctx) if err != nil { return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) } issue, _, err := client.EditIssue(owner, repo, index, opt) if err != nil { return to.ErrorResult(fmt.Errorf("edit %v/%v/issue/%v err: %v", owner, repo, index, err)) } return to.TextResult(slimIssue(issue)) } func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } commentID, err := params.GetIndex(req.GetArguments(), "commentID") if err != nil { return to.ErrorResult(err) } body, err := params.GetString(req.GetArguments(), "body") if err != nil { return to.ErrorResult(err) } opt := gitea_sdk.EditIssueCommentOption{ Body: body, } client, err := gitea.ClientFromContext(ctx) if err != nil { return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) } issueComment, _, err := client.EditIssueComment(owner, repo, commentID, opt) if err != nil { return to.ErrorResult(fmt.Errorf("edit %v/%v/issues/comments/%v err: %v", owner, repo, commentID, err)) } return to.TextResult(slimComment(issueComment)) } func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } index, err := params.GetIndex(req.GetArguments(), "issue_number") if err != nil { return to.ErrorResult(err) } var comments []commentWithAssets path := fmt.Sprintf("repos/%s/%s/issues/%d/comments", url.PathEscape(owner), url.PathEscape(repo), index) if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &comments); err != nil { return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err)) } out := make([]map[string]any, 0, len(comments)) for i := range comments { m := slimComment(&comments[i].Comment) m["body"] = slim.BodyWithAttachments(comments[i].Body, comments[i].Assets) out = append(out, m) } return to.TextResult(out) } func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } index, err := params.GetIndex(req.GetArguments(), "issue_number") 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)) } labels, _, err := client.GetIssueLabels(owner, repo, index, gitea_sdk.ListLabelsOptions{}) if err != nil { return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/labels err: %v", owner, repo, index, err)) } return to.TextResult(slim.Labels(labels)) } func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } index, err := params.GetIndex(req.GetArguments(), "issue_number") if err != nil { return to.ErrorResult(err) } labels, err := params.GetInt64Slice(req.GetArguments(), "labels") 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)) } issueLabels, _, err := client.AddIssueLabels(owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels}) if err != nil { return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, index, err)) } return to.TextResult(slim.Labels(issueLabels)) } func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } index, err := params.GetIndex(req.GetArguments(), "issue_number") if err != nil { return to.ErrorResult(err) } labels, err := params.GetInt64Slice(req.GetArguments(), "labels") 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)) } issueLabels, _, err := client.ReplaceIssueLabels(owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels}) if err != nil { return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, index, err)) } return to.TextResult(slim.Labels(issueLabels)) } func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } index, err := params.GetIndex(req.GetArguments(), "issue_number") 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.ClearIssueLabels(owner, repo, index) if err != nil { return to.ErrorResult(fmt.Errorf("clear labels on %v/%v/issue/%v err: %v", owner, repo, index, err)) } return to.TextResult("Labels cleared successfully") } func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := params.GetString(req.GetArguments(), "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil { return to.ErrorResult(err) } index, err := params.GetIndex(req.GetArguments(), "issue_number") if err != nil { return to.ErrorResult(err) } labelID, err := params.GetIndex(req.GetArguments(), "label_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.DeleteIssueLabel(owner, repo, index, labelID) if err != nil { return to.ErrorResult(fmt.Errorf("remove label %v from %v/%v/issue/%v err: %v", labelID, owner, repo, index, err)) } return to.TextResult("Label removed successfully") }