diff --git a/operation/issue/issue.go b/operation/issue/issue.go index e3703bc..f5a68f7 100644 --- a/operation/issue/issue.go +++ b/operation/issue/issue.go @@ -55,9 +55,9 @@ var ( IssueReadTool = tool.NewDefinition( IssueReadToolName, - "Read issue: details, comments, or labels.", + "Read issue: details, comments, labels, or the full discussion as Markdown.", annotation.ReadOnly("Read issue details"), - tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels")), + tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels", "get_discussion_markdown")), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.Number("issue_number", tool.Required()), @@ -112,6 +112,8 @@ func issueReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, return getIssueCommentsByIndexFn(ctx, args) case "get_labels": return getIssueLabelsFn(ctx, args) + case "get_discussion_markdown": + return getIssueDiscussionMarkdownFn(ctx, args) default: return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) } @@ -398,6 +400,54 @@ func getIssueCommentsByIndexFn(ctx context.Context, args map[string]any) (*mcp.C return to.TextResult(out) } +func getIssueDiscussionMarkdownFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + 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) + } + index, err := params.GetIndex(args, "issue_number") + if err != nil { + return to.ErrorResult(err) + } + + var issue issueWithAssets + issuePath := fmt.Sprintf("repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), index) + if _, err := gitea.DoJSON(ctx, "GET", issuePath, nil, nil, &issue); err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err)) + } + + var comments []commentWithAssets + commentsPath := fmt.Sprintf("repos/%s/%s/issues/%d/comments", url.PathEscape(owner), url.PathEscape(repo), index) + if _, err := gitea.DoJSON(ctx, "GET", commentsPath, nil, nil, &comments); err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err)) + } + + discComments := make([]discussionComment, 0, len(comments)) + for i := range comments { + discComments = append(discComments, discussionComment{ + Author: slim.UserLogin(comments[i].Poster), + CreatedAt: comments[i].Created, + Body: slim.BodyWithAttachments(comments[i].Body, comments[i].Assets), + }) + } + + md := formatDiscussionMarkdown(discussionIssue{ + Number: issue.Index, + Title: issue.Title, + Author: slim.UserLogin(issue.Poster), + State: string(issue.State), + Labels: slim.LabelNames(issue.Labels), + Body: slim.BodyWithAttachments(issue.Body, issue.Assets), + CreatedAt: issue.Created, + }, discComments) + + return to.RawTextResult(md) +} + func getIssueLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { owner, err := params.GetString(args, "owner") if err != nil { diff --git a/operation/issue/issue_test.go b/operation/issue/issue_test.go index f65f0a9..dfce9c4 100644 --- a/operation/issue/issue_test.go +++ b/operation/issue/issue_test.go @@ -266,6 +266,119 @@ func Test_getIssueByIndexFn_includesAttachments(t *testing.T) { } } +func Test_getIssueDiscussionMarkdownFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/42", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "number": 42, + "title": "bug with screenshot", + "body": "see attached", + "state": "open", + "user": {"login": "octocat"}, + "labels": [{"name": "bug"}], + "created_at": "2026-01-02T15:04:05Z", + "assets": [ + {"id": 1, "name": "shot.png", "size": 1024, "browser_download_url": "https://example/shot.png"} + ] + }`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/42/comments", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id": 1, "body": "see this", "user": {"login": "reviewer"}, "created_at": "2026-01-03T09:00:00Z", "assets": [ + {"id": 9, "name": "log.txt", "size": 200, "browser_download_url": "https://example/log.txt"} + ]} + ]`)) + default: + http.NotFound(w, r) + } + }) + server := httptest.NewServer(handler) + 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 }() + + args := map[string]any{ + "owner": owner, "repo": repo, "issue_number": float64(42), + } + res, err := getIssueDiscussionMarkdownFn(context.Background(), args) + if err != nil { + t.Fatalf("getIssueDiscussionMarkdownFn() error = %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %v", res.Content) + } + body := res.Content[0].(*mcp.TextContent).Text + for _, want := range []string{ + "# bug with screenshot (#42)", + "**Author:** octocat", + "**State:** open", + "**Labels:** bug", + "[shot.png](https://example/shot.png)", + "### reviewer on 2026-01-03T09:00:00Z", + "[log.txt](https://example/log.txt)", + } { + if !strings.Contains(body, want) { + t.Fatalf("expected markdown to contain %q, got:\n%s", want, body) + } + } +} + +func Test_getIssueDiscussionMarkdownFn_noComments(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.12.0"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/7", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number": 7, "title": "quiet issue", "state": "open", "user": {"login": "octocat"}}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/7/comments", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + default: + http.NotFound(w, r) + } + }) + server := httptest.NewServer(handler) + 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 }() + + args := map[string]any{ + "owner": owner, "repo": repo, "issue_number": float64(7), + } + res, err := getIssueDiscussionMarkdownFn(context.Background(), args) + if err != nil { + t.Fatalf("getIssueDiscussionMarkdownFn() error = %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %v", res.Content) + } + body := res.Content[0].(*mcp.TextContent).Text + if !strings.Contains(body, "_No comments yet._") { + t.Fatalf("expected placeholder for no comments, got:\n%s", body) + } +} + func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) { const ( owner = "octo" diff --git a/operation/issue/markdown.go b/operation/issue/markdown.go new file mode 100644 index 0000000..66d55ab --- /dev/null +++ b/operation/issue/markdown.go @@ -0,0 +1,63 @@ +package issue + +import ( + "fmt" + "strings" + "time" +) + +type discussionIssue struct { + Number int64 + Title string + Author string + State string + Labels []string + Body string + CreatedAt time.Time +} + +type discussionComment struct { + Author string + CreatedAt time.Time + Body string +} + +// formatDiscussionMarkdown renders an issue and its comments as a single +// Markdown document suitable for display without further parsing. +func formatDiscussionMarkdown(issue discussionIssue, comments []discussionComment) string { + var b strings.Builder + + fmt.Fprintf(&b, "# %s (#%d)\n\n", issue.Title, issue.Number) + fmt.Fprintf(&b, "**Author:** %s\n", issue.Author) + fmt.Fprintf(&b, "**State:** %s\n", issue.State) + if len(issue.Labels) > 0 { + fmt.Fprintf(&b, "**Labels:** %s\n", strings.Join(issue.Labels, ", ")) + } + if !issue.CreatedAt.IsZero() { + fmt.Fprintf(&b, "**Created:** %s\n", issue.CreatedAt.UTC().Format(time.RFC3339)) + } + b.WriteString("\n") + b.WriteString(strings.TrimSpace(issue.Body)) + b.WriteString("\n\n---\n\n## Comments\n\n") + + if len(comments) == 0 { + b.WriteString("_No comments yet._\n") + return b.String() + } + + for i, c := range comments { + fmt.Fprintf(&b, "### %s", c.Author) + if !c.CreatedAt.IsZero() { + fmt.Fprintf(&b, " on %s", c.CreatedAt.UTC().Format(time.RFC3339)) + } + b.WriteString("\n\n") + b.WriteString(strings.TrimSpace(c.Body)) + if i < len(comments)-1 { + b.WriteString("\n\n---\n\n") + } else { + b.WriteString("\n") + } + } + + return b.String() +} diff --git a/operation/issue/markdown_test.go b/operation/issue/markdown_test.go new file mode 100644 index 0000000..e8190c4 --- /dev/null +++ b/operation/issue/markdown_test.go @@ -0,0 +1,85 @@ +package issue + +import ( + "strings" + "testing" + "time" +) + +func Test_formatDiscussionMarkdown_includesIssueAndComments(t *testing.T) { + created := time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC) + commentTime := time.Date(2026, 1, 3, 9, 0, 0, 0, time.UTC) + md := formatDiscussionMarkdown( + discussionIssue{ + Number: 42, + Title: "bug with screenshot", + Author: "octocat", + State: "open", + Labels: []string{"bug", "help wanted"}, + Body: "see attached", + CreatedAt: created, + }, + []discussionComment{ + {Author: "reviewer", CreatedAt: commentTime, Body: "thanks for reporting"}, + }, + ) + + wantSubstrings := []string{ + "# bug with screenshot (#42)", + "**Author:** octocat", + "**State:** open", + "**Labels:** bug, help wanted", + "**Created:** 2026-01-02T15:04:05Z", + "see attached", + "## Comments", + "### reviewer", + "2026-01-03T09:00:00Z", + "thanks for reporting", + } + for _, want := range wantSubstrings { + if !strings.Contains(md, want) { + t.Fatalf("expected markdown to contain %q, got:\n%s", want, md) + } + } +} + +func Test_formatDiscussionMarkdown_noComments(t *testing.T) { + md := formatDiscussionMarkdown( + discussionIssue{ + Number: 1, + Title: "no comments yet", + Author: "octocat", + State: "open", + }, + nil, + ) + + if !strings.Contains(md, "## Comments") { + t.Fatalf("expected a Comments section, got:\n%s", md) + } + if !strings.Contains(md, "_No comments yet._") { + t.Fatalf("expected placeholder for no comments, got:\n%s", md) + } +} + +func Test_formatDiscussionMarkdown_attachmentBodyIsInlined(t *testing.T) { + md := formatDiscussionMarkdown( + discussionIssue{ + Number: 7, + Title: "with attachment", + Author: "octocat", + State: "open", + Body: "see attached\n\n[shot.png](https://example/shot.png)", + }, + []discussionComment{ + {Author: "reviewer", Body: "log attached\n\n[log.txt](https://example/log.txt)"}, + }, + ) + + if !strings.Contains(md, "[shot.png](https://example/shot.png)") { + t.Fatalf("expected issue attachment link in markdown, got:\n%s", md) + } + if !strings.Contains(md, "[log.txt](https://example/log.txt)") { + t.Fatalf("expected comment attachment link in markdown, got:\n%s", md) + } +} diff --git a/pkg/to/to.go b/pkg/to/to.go index edb1b78..604cab2 100644 --- a/pkg/to/to.go +++ b/pkg/to/to.go @@ -23,6 +23,17 @@ func TextResult(v any) (*mcp.CallToolResult, error) { }, nil } +// RawTextResult returns text as-is, without JSON-encoding it. Use it for +// content that is already meant to be read directly, such as Markdown. +func RawTextResult(text string) (*mcp.CallToolResult, error) { + if flag.Debug { + log.Debugf("Text Result: %s", text) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, nil +} + func ErrorResult(err error) (*mcp.CallToolResult, error) { log.Errorf("%s", err.Error()) var result mcp.CallToolResult