Inline issue/comment attachments in body (#183)
The Gitea API returns an `assets` array on issue and comment responses, but the SDK structs drop it — so attachments are invisible to MCP agents.
Append each attachment as a `[name](url)` markdown link at the end of the body, mirroring how GitHub embeds attachments inline (which `github-mcp-server` preserves as-is).
**Coverage:**
- `issue_read get` — issue body attachments
- `issue_read get_comments` — issue and PR conversation comment attachments (same endpoint)
- `pull_request_read get` — PR description attachments (Gitea's `/pulls/` endpoint omits `assets`, so a follow-up best-effort call to `/issues/{n}/assets` surfaces them; PRs are issues internally)
PR review summaries and line-comment reviews don't support attachments per the Gitea API spec, so nothing to do there.
Closes https://gitea.com/gitea/gitea-mcp/issues/182
---
This PR was written with the help of Claude Opus 4.7
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/183
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
This commit is contained in:
+29
-17
@@ -3,6 +3,7 @@ package issue
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||||
@@ -15,6 +16,18 @@ import (
|
|||||||
"github.com/mark3labs/mcp-go/server"
|
"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()
|
var Tool = tool.New()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -142,16 +155,14 @@ func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
var issue issueWithAssets
|
||||||
if err != nil {
|
path := fmt.Sprintf("repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), index)
|
||||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &issue); err != nil {
|
||||||
}
|
|
||||||
issue, _, err := client.GetIssue(owner, repo, index)
|
|
||||||
if err != nil {
|
|
||||||
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
|
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
|
||||||
}
|
}
|
||||||
|
m := slimIssue(&issue.Issue)
|
||||||
return to.TextResult(slimIssue(issue))
|
m["body"] = bodyWithAttachments(issue.Body, issue.Assets)
|
||||||
|
return to.TextResult(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
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 {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
opt := gitea_sdk.ListIssueCommentOptions{}
|
var comments []commentWithAssets
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
path := fmt.Sprintf("repos/%s/%s/issues/%d/comments", url.PathEscape(owner), url.PathEscape(repo), index)
|
||||||
if err != nil {
|
if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &comments); err != nil {
|
||||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
|
||||||
}
|
|
||||||
issue, _, err := client.ListIssueComments(owner, repo, index, opt)
|
|
||||||
if err != nil {
|
|
||||||
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err))
|
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))
|
||||||
return to.TextResult(slimComments(issue))
|
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) {
|
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
|||||||
@@ -166,3 +166,104 @@ func Test_createIssueFn_labels(t *testing.T) {
|
|||||||
t.Fatalf("expected due_date to be set")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+21
-8
@@ -1,6 +1,9 @@
|
|||||||
package issue
|
package issue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +40,24 @@ func labelNames(labels []*gitea_sdk.Label) []string {
|
|||||||
return out
|
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 {
|
func slimIssue(i *gitea_sdk.Issue) map[string]any {
|
||||||
if i == nil {
|
if i == nil {
|
||||||
return 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 {
|
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
|
||||||
out := make([]map[string]any, 0, len(labels))
|
out := make([]map[string]any, 0, len(labels))
|
||||||
for _, l := range labels {
|
for _, l := range labels {
|
||||||
|
|||||||
@@ -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) {
|
func TestSlimIssues_ListIsSlimmer(t *testing.T) {
|
||||||
i := &gitea_sdk.Issue{
|
i := &gitea_sdk.Issue{
|
||||||
Index: 1,
|
Index: 1,
|
||||||
|
|||||||
+12
-1
@@ -3,6 +3,7 @@ package pull
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
"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.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) {
|
func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
package pull
|
package pull
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
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 {
|
func userLogin(u *gitea_sdk.User) string {
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
Reference in New Issue
Block a user