diff --git a/operation/issue/issue.go b/operation/issue/issue.go index 3c824d8..0920808 100644 --- a/operation/issue/issue.go +++ b/operation/issue/issue.go @@ -3,6 +3,7 @@ package issue import ( "context" "fmt" + "net/url" "gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/log" @@ -15,6 +16,18 @@ import ( "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 ( @@ -142,16 +155,14 @@ func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT 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)) - } - issue, _, err := client.GetIssue(owner, repo, index) - if err != nil { + 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)) } - - return to.TextResult(slimIssue(issue)) + m := slimIssue(&issue.Issue) + m["body"] = bodyWithAttachments(issue.Body, issue.Assets) + return to.TextResult(m) } func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -377,17 +388,18 @@ func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*m if err != nil { return to.ErrorResult(err) } - opt := gitea_sdk.ListIssueCommentOptions{} - client, err := gitea.ClientFromContext(ctx) - if err != nil { - return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) - } - issue, _, err := client.ListIssueComments(owner, repo, index, opt) - if err != nil { + 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)) } - - return to.TextResult(slimComments(issue)) + out := make([]map[string]any, 0, len(comments)) + for i := range comments { + m := slimComment(&comments[i].Comment) + m["body"] = 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) { diff --git a/operation/issue/issue_test.go b/operation/issue/issue_test.go index 647feb5..1f59441 100644 --- a/operation/issue/issue_test.go +++ b/operation/issue/issue_test.go @@ -166,3 +166,104 @@ func Test_createIssueFn_labels(t *testing.T) { t.Fatalf("expected due_date to be set") } } + +func Test_getIssueByIndexFn_includesAttachments(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", + "assets": [ + {"id": 1, "name": "shot.png", "size": 1024, "browser_download_url": "https://example/shot.png"} + ] + }`)) + 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 }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, "index": float64(42), + }}} + res, err := getIssueByIndexFn(context.Background(), req) + if err != nil { + t.Fatalf("getIssueByIndexFn() 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, `[shot.png](https://example/shot.png)`) { + t.Fatalf("expected attachment markdown inlined in body, got: %s", body) + } + if strings.Contains(body, `"attachments"`) { + t.Fatalf("attachments should be inlined into body, not a separate field: %s", body) + } +} + +func Test_getIssueCommentsByIndexFn_includesAttachments(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/comments", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id": 1, "body": "see this", "assets": [ + {"id": 9, "name": "log.txt", "size": 200, "browser_download_url": "https://example/log.txt"} + ]}, + {"id": 2, "body": "no attachment", "assets": []} + ]`)) + 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 }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, "index": float64(7), + }}} + res, err := getIssueCommentsByIndexFn(context.Background(), req) + if err != nil { + t.Fatalf("getIssueCommentsByIndexFn() 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, `[log.txt](https://example/log.txt)`) { + t.Fatalf("expected attachment markdown inlined in body, got: %s", body) + } + if strings.Contains(body, `"attachments"`) { + t.Fatalf("attachments should be inlined into body, not a separate field: %s", body) + } +} diff --git a/operation/issue/slim.go b/operation/issue/slim.go index 79bde93..0d440d7 100644 --- a/operation/issue/slim.go +++ b/operation/issue/slim.go @@ -1,6 +1,9 @@ package issue import ( + "fmt" + "strings" + gitea_sdk "code.gitea.io/sdk/gitea" ) @@ -37,6 +40,24 @@ func labelNames(labels []*gitea_sdk.Label) []string { return out } +func bodyWithAttachments(body string, atts []*gitea_sdk.Attachment) string { + links := make([]string, 0, len(atts)) + for _, a := range atts { + if a == nil || a.DownloadURL == "" { + continue + } + links = append(links, fmt.Sprintf("[%s](%s)", a.Name, a.DownloadURL)) + } + if len(links) == 0 { + return body + } + joined := strings.Join(links, "\n") + if body == "" { + return joined + } + return body + "\n\n" + joined +} + func slimIssue(i *gitea_sdk.Issue) map[string]any { if i == nil { return nil @@ -119,14 +140,6 @@ func slimComment(c *gitea_sdk.Comment) map[string]any { } } -func slimComments(comments []*gitea_sdk.Comment) []map[string]any { - out := make([]map[string]any, 0, len(comments)) - for _, c := range comments { - out = append(out, slimComment(c)) - } - return out -} - func slimLabels(labels []*gitea_sdk.Label) []map[string]any { out := make([]map[string]any, 0, len(labels)) for _, l := range labels { diff --git a/operation/issue/slim_test.go b/operation/issue/slim_test.go index 12a421e..2beacd1 100644 --- a/operation/issue/slim_test.go +++ b/operation/issue/slim_test.go @@ -40,6 +40,29 @@ func TestSlimIssue(t *testing.T) { } } +func TestBodyWithAttachments(t *testing.T) { + atts := []*gitea_sdk.Attachment{ + {Name: "shot.png", DownloadURL: "https://example/shot.png"}, + {Name: "log.txt", DownloadURL: "https://example/log.txt"}, + } + got := bodyWithAttachments("see attached", atts) + want := "see attached\n\n[shot.png](https://example/shot.png)\n[log.txt](https://example/log.txt)" + if got != want { + t.Errorf("got %q, want %q", got, want) + } + + if got := bodyWithAttachments("only body", nil); got != "only body" { + t.Errorf("nil attachments should return body unchanged, got %q", got) + } + if got := bodyWithAttachments("", atts); got != "[shot.png](https://example/shot.png)\n[log.txt](https://example/log.txt)" { + t.Errorf("empty body should drop separator, got %q", got) + } + skipped := []*gitea_sdk.Attachment{nil, {Name: "noop", DownloadURL: ""}} + if got := bodyWithAttachments("body", skipped); got != "body" { + t.Errorf("nil/empty-URL attachments should be skipped, got %q", got) + } +} + func TestSlimIssues_ListIsSlimmer(t *testing.T) { i := &gitea_sdk.Issue{ Index: 1, diff --git a/operation/pull/pull.go b/operation/pull/pull.go index f8d573b..50e58ca 100644 --- a/operation/pull/pull.go +++ b/operation/pull/pull.go @@ -3,6 +3,7 @@ package pull import ( "context" "fmt" + "net/url" "strings" "gitea.com/gitea/gitea-mcp/pkg/gitea" @@ -209,7 +210,17 @@ func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err)) } - return to.TextResult(slimPullRequest(pr)) + // /pulls/{n} omits `assets`; PRs are issues internally, so the issue + // assets endpoint surfaces description attachments. + var assets []*gitea_sdk.Attachment + assetsPath := fmt.Sprintf("repos/%s/%s/issues/%d/assets", url.PathEscape(owner), url.PathEscape(repo), index) + if _, err := gitea.DoJSON(ctx, "GET", assetsPath, nil, nil, &assets); err != nil { + log.Debugf("fetch %v/%v/issues/%v/assets err: %v", owner, repo, index, err) + } + + m := slimPullRequest(pr) + m["body"] = bodyWithAttachments(pr.Body, assets) + return to.TextResult(m) } func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { diff --git a/operation/pull/pull_test.go b/operation/pull/pull_test.go index 6a8b56a..4195de0 100644 --- a/operation/pull/pull_test.go +++ b/operation/pull/pull_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "sync" "testing" @@ -770,3 +771,143 @@ func Test_getPullRequestDiffFn(t *testing.T) { }) } } + +func Test_getPullRequestByIndexFn_includesAttachments(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 9 + ) + + 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", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"see screenshot","state":"open"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":1,"name":"shot.png","browser_download_url":"https://example/shot.png"}]`)) + 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 }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, "index": float64(index), + }}} + res, err := getPullRequestByIndexFn(context.Background(), req) + if err != nil { + t.Fatalf("getPullRequestByIndexFn() 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, `[shot.png](https://example/shot.png)`) { + t.Fatalf("expected attachment markdown inlined in body, got: %s", body) + } +} + +func Test_getPullRequestByIndexFn_emptyAssetsLeavesBody(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 9 + ) + + 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", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"plain body","state":"open"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index): + 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 }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, "index": float64(index), + }}} + res, err := getPullRequestByIndexFn(context.Background(), req) + if err != nil { + t.Fatalf("getPullRequestByIndexFn() error = %v", err) + } + body := res.Content[0].(mcp.TextContent).Text + if !strings.Contains(body, `"body":"plain body"`) { + t.Fatalf("expected body unchanged when assets are empty, got: %s", body) + } +} + +func Test_getPullRequestByIndexFn_assetsFailureNonFatal(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 9 + ) + + 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", owner, repo): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"private":false}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"plain body","state":"open"}`)) + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index): + http.Error(w, "boom", http.StatusInternalServerError) + 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 }() + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ + "owner": owner, "repo": repo, "index": float64(index), + }}} + res, err := getPullRequestByIndexFn(context.Background(), req) + if err != nil { + t.Fatalf("getPullRequestByIndexFn() error = %v", err) + } + if res.IsError { + t.Fatalf("assets fetch failure should not fail the PR fetch: %v", res.Content) + } + body := res.Content[0].(mcp.TextContent).Text + if !strings.Contains(body, `"plain body"`) { + t.Fatalf("expected PR body preserved when assets fail, got: %s", body) + } +} diff --git a/operation/pull/slim.go b/operation/pull/slim.go index 0bf8416..3c311c2 100644 --- a/operation/pull/slim.go +++ b/operation/pull/slim.go @@ -1,9 +1,30 @@ package pull import ( + "fmt" + "strings" + gitea_sdk "code.gitea.io/sdk/gitea" ) +func bodyWithAttachments(body string, atts []*gitea_sdk.Attachment) string { + links := make([]string, 0, len(atts)) + for _, a := range atts { + if a == nil || a.DownloadURL == "" { + continue + } + links = append(links, fmt.Sprintf("[%s](%s)", a.Name, a.DownloadURL)) + } + if len(links) == 0 { + return body + } + joined := strings.Join(links, "\n") + if body == "" { + return joined + } + return body + "\n\n" + joined +} + func userLogin(u *gitea_sdk.User) string { if u == nil { return ""