Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c45b42cb5 | |||
| 7759c7f327 | |||
| 5867f2f472 | |||
| 26f826d25c | |||
| baf792b061 | |||
| 08128b9471 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Gitea MCP DevContainer",
|
"name": "Gitea MCP DevContainer",
|
||||||
"image": "mcr.microsoft.com/devcontainers/go:1.24-bookworm",
|
"image": "mcr.microsoft.com/devcontainers/go:1.26-bookworm",
|
||||||
"features": {},
|
"features": {},
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
|
|||||||
+8
-1
@@ -102,9 +102,16 @@ issues:
|
|||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- gofmt
|
- gci
|
||||||
- gofumpt
|
- gofumpt
|
||||||
settings:
|
settings:
|
||||||
|
gci:
|
||||||
|
custom-order: true
|
||||||
|
sections:
|
||||||
|
- standard
|
||||||
|
- prefix(gitea.com/gitea/gitea-mcp)
|
||||||
|
- blank
|
||||||
|
- default
|
||||||
gofumpt:
|
gofumpt:
|
||||||
extra-rules: true
|
extra-rules: true
|
||||||
exclusions:
|
exclusions:
|
||||||
|
|||||||
@@ -1,71 +1,8 @@
|
|||||||
# AGENTS.md
|
- Use `make help` to find available development targets
|
||||||
|
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them
|
||||||
This file provides guidance to AI coding agents when working with code in this repository.
|
- Run `make tidy` after any `go.mod` changes
|
||||||
|
- Ensure no trailing whitespace in edited files
|
||||||
## Development Commands
|
- Use Conventional Commits format for commit messages and PR titles (e.g. `type(scope): subject`)
|
||||||
|
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
||||||
**Build**: `make build` - Build the gitea-mcp binary
|
- Include authorship attribution in issue and pull request comments
|
||||||
**Install**: `make install` - Build and install to GOPATH/bin
|
- Add `Co-Authored-By` lines to all commits, indicating name and model used
|
||||||
**Clean**: `make clean` - Remove build artifacts
|
|
||||||
**Test**: `go test ./...` - Run all tests
|
|
||||||
**Hot reload**: `make dev` - Start development server with hot reload (requires air)
|
|
||||||
**Dependencies**: `make vendor` - Tidy and verify module dependencies
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
This is a **Gitea MCP (Model Context Protocol) Server** written in Go that provides MCP tools for interacting with Gitea repositories, issues, pull requests, users, and more.
|
|
||||||
|
|
||||||
**Core Components**:
|
|
||||||
|
|
||||||
- `main.go` + `cmd/cmd.go`: CLI entry point and flag parsing
|
|
||||||
- `operation/operation.go`: Main server setup and tool registration
|
|
||||||
- `pkg/tool/tool.go`: Tool registry with read/write categorization
|
|
||||||
- `operation/*/`: Individual tool modules (user, repo, issue, pull, search, wiki, etc.)
|
|
||||||
|
|
||||||
**Transport Modes**:
|
|
||||||
|
|
||||||
- **stdio** (default): Standard input/output for MCP clients
|
|
||||||
- **http**: HTTP server mode on configurable port (default 8080)
|
|
||||||
|
|
||||||
**Authentication**:
|
|
||||||
|
|
||||||
- Global token via `--token` flag or `GITEA_ACCESS_TOKEN` env var
|
|
||||||
- HTTP mode supports per-request Bearer token override in Authorization header
|
|
||||||
- Token precedence: HTTP Authorization header > CLI flag > environment variable
|
|
||||||
|
|
||||||
**Tool Organization**:
|
|
||||||
|
|
||||||
- Tools are categorized as read-only or write operations
|
|
||||||
- `--read-only` flag exposes only read tools
|
|
||||||
- Tool modules register via `Tool.RegisterRead()` and `Tool.RegisterWrite()`
|
|
||||||
|
|
||||||
**Key Configuration**:
|
|
||||||
|
|
||||||
- Default Gitea host: `https://gitea.com` (override with `--host` or `GITEA_HOST`)
|
|
||||||
- Environment variables can override CLI flags: `MCP_MODE`, `GITEA_READONLY`, `GITEA_DEBUG`, `GITEA_INSECURE`
|
|
||||||
- Logs are written to `~/.gitea-mcp/gitea-mcp.log` with rotation
|
|
||||||
|
|
||||||
## Available Tools
|
|
||||||
|
|
||||||
The server provides 40+ MCP tools covering:
|
|
||||||
|
|
||||||
- **User**: get_my_user_info, get_user_orgs, search_users
|
|
||||||
- **Repository**: create_repo, fork_repo, list_my_repos, search_repos
|
|
||||||
- **Branches/Tags**: create_branch, delete_branch, list_branches, create_tag, list_tags
|
|
||||||
- **Files**: get_file_content, create_file, update_file, delete_file, get_dir_content
|
|
||||||
- **Issues**: create_issue, list_repo_issues, create_issue_comment, edit_issue
|
|
||||||
- **Pull Requests**: create_pull_request, list_repo_pull_requests, get_pull_request_by_index
|
|
||||||
- **Releases**: create_release, list_releases, get_latest_release
|
|
||||||
- **Wiki**: create_wiki_page, update_wiki_page, list_wiki_pages
|
|
||||||
- **Search**: search_repos, search_users, search_org_teams
|
|
||||||
- **Version**: get_gitea_mcp_server_version
|
|
||||||
|
|
||||||
## Common Development Patterns
|
|
||||||
|
|
||||||
**Testing**: Use `go test ./operation -run TestFunctionName` for specific tests
|
|
||||||
|
|
||||||
**Token Context**: HTTP requests use `pkg/context.TokenContextKey` for request-scoped token access
|
|
||||||
|
|
||||||
**Flag Access**: All packages access configuration via global variables in `pkg/flag/flag.go`
|
|
||||||
|
|
||||||
**Graceful Shutdown**: HTTP mode implements graceful shutdown with 10-second timeout on SIGTERM/SIGINT
|
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ EXECUTABLE := gitea-mcp
|
|||||||
VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
|
VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
|
||||||
LDFLAGS := -X "main.Version=$(VERSION)"
|
LDFLAGS := -X "main.Version=$(VERSION)"
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
|
||||||
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
|
|
||||||
|
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
help: ## print this help message
|
help: ## print this help message
|
||||||
@@ -51,7 +50,16 @@ dev: air ## run the application with hot reload
|
|||||||
|
|
||||||
.PHONY: fmt
|
.PHONY: fmt
|
||||||
fmt: ## format the Go code
|
fmt: ## format the Go code
|
||||||
$(GO) run $(GOFUMPT_PACKAGE) -w .
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt
|
||||||
|
|
||||||
|
.PHONY: fmt-check
|
||||||
|
fmt-check: fmt ## check that Go code is formatted
|
||||||
|
@diff=$$(git diff --color=always); \
|
||||||
|
if [ -n "$$diff" ]; then \
|
||||||
|
echo "Please run 'make fmt' and commit the result:"; \
|
||||||
|
printf "%s" "$${diff}"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go ## lint everything
|
lint: lint-go ## lint everything
|
||||||
|
|||||||
+12
@@ -5,6 +5,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/operation"
|
"gitea.com/gitea/gitea-mcp/operation"
|
||||||
@@ -53,6 +54,7 @@ func init() {
|
|||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
fmt.Fprintln(w, "Environment variables:")
|
fmt.Fprintln(w, "Environment variables:")
|
||||||
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n")
|
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n")
|
||||||
|
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN_FILE\tPath to a file containing the access token (e.g. a Docker secret)\n")
|
||||||
fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n")
|
fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n")
|
||||||
fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n")
|
fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n")
|
||||||
fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n")
|
fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n")
|
||||||
@@ -74,6 +76,16 @@ func init() {
|
|||||||
if flagPkg.Token == "" {
|
if flagPkg.Token == "" {
|
||||||
flagPkg.Token = os.Getenv("GITEA_ACCESS_TOKEN")
|
flagPkg.Token = os.Getenv("GITEA_ACCESS_TOKEN")
|
||||||
}
|
}
|
||||||
|
if flagPkg.Token == "" {
|
||||||
|
if tokenFile := os.Getenv("GITEA_ACCESS_TOKEN_FILE"); tokenFile != "" {
|
||||||
|
data, err := os.ReadFile(tokenFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error reading GITEA_ACCESS_TOKEN_FILE: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
flagPkg.Token = strings.TrimRight(string(data), "\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if os.Getenv("MCP_MODE") != "" {
|
if os.Getenv("MCP_MODE") != "" {
|
||||||
flagPkg.Mode = os.Getenv("MCP_MODE")
|
flagPkg.Mode = os.Getenv("MCP_MODE")
|
||||||
|
|||||||
+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) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,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,
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Tool = tool.New()
|
||||||
|
|
||||||
|
const (
|
||||||
|
NotificationReadToolName = "notification_read"
|
||||||
|
NotificationWriteToolName = "notification_write"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
NotificationReadTool = mcp.NewTool(
|
||||||
|
NotificationReadToolName,
|
||||||
|
mcp.WithDescription("Get notifications. Use method 'list' to list notifications (optionally scoped to a repo), 'get' to get a single notification thread by ID."),
|
||||||
|
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")),
|
||||||
|
mcp.WithString("owner", mcp.Description("repository owner (for 'list' to scope to a repo)")),
|
||||||
|
mcp.WithString("repo", mcp.Description("repository name (for 'list' to scope to a repo)")),
|
||||||
|
mcp.WithNumber("id", mcp.Description("notification thread ID (required for 'get')")),
|
||||||
|
mcp.WithString("status", mcp.Description("filter by status (for 'list')"), mcp.Enum("unread", "read", "pinned")),
|
||||||
|
mcp.WithString("subject_type", mcp.Description("filter by subject type (for 'list')"), mcp.Enum("Issue", "Pull", "Commit", "Repository")),
|
||||||
|
mcp.WithString("since", mcp.Description("filter notifications updated after this ISO 8601 timestamp (for 'list')")),
|
||||||
|
mcp.WithString("before", mcp.Description("filter notifications updated before this ISO 8601 timestamp (for 'list')")),
|
||||||
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
||||||
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationWriteTool = mcp.NewTool(
|
||||||
|
NotificationWriteToolName,
|
||||||
|
mcp.WithDescription("Manage notifications. Use method 'mark_read' to mark a single notification as read, 'mark_all_read' to mark all notifications as read (optionally scoped to a repo)."),
|
||||||
|
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("mark_read", "mark_all_read")),
|
||||||
|
mcp.WithNumber("id", mcp.Description("notification thread ID (required for 'mark_read')")),
|
||||||
|
mcp.WithString("owner", mcp.Description("repository owner (for 'mark_all_read' to scope to a repo)")),
|
||||||
|
mcp.WithString("repo", mcp.Description("repository name (for 'mark_all_read' to scope to a repo)")),
|
||||||
|
mcp.WithString("last_read_at", mcp.Description("ISO 8601 timestamp, marks notifications before this time as read (for 'mark_all_read', defaults to now)")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Tool.RegisterRead(server.ServerTool{
|
||||||
|
Tool: NotificationReadTool,
|
||||||
|
Handler: notificationReadFn,
|
||||||
|
})
|
||||||
|
Tool.RegisterWrite(server.ServerTool{
|
||||||
|
Tool: NotificationWriteTool,
|
||||||
|
Handler: notificationWriteFn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationReadFn(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 "list":
|
||||||
|
return listNotificationsFn(ctx, req)
|
||||||
|
case "get":
|
||||||
|
return getNotificationFn(ctx, req)
|
||||||
|
default:
|
||||||
|
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationWriteFn(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 "mark_read":
|
||||||
|
return markNotificationReadFn(ctx, req)
|
||||||
|
case "mark_all_read":
|
||||||
|
return markAllNotificationsReadFn(ctx, req)
|
||||||
|
default:
|
||||||
|
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called listNotificationsFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
page, pageSize := params.GetPagination(args, 30)
|
||||||
|
opt := gitea_sdk.ListNotificationOptions{
|
||||||
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if status, ok := args["status"].(string); ok {
|
||||||
|
opt.Status = []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatus(status)}
|
||||||
|
}
|
||||||
|
if subjectType, ok := args["subject_type"].(string); ok {
|
||||||
|
opt.SubjectTypes = []gitea_sdk.NotifySubjectType{gitea_sdk.NotifySubjectType(subjectType)}
|
||||||
|
}
|
||||||
|
if t := params.GetOptionalTime(args, "since"); t != nil {
|
||||||
|
opt.Since = *t
|
||||||
|
}
|
||||||
|
if t := params.GetOptionalTime(args, "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))
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := params.GetOptionalString(args, "owner", "")
|
||||||
|
repo := params.GetOptionalString(args, "repo", "")
|
||||||
|
if owner != "" && repo != "" {
|
||||||
|
threads, _, err := client.ListRepoNotifications(owner, repo, opt)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("list %v/%v/notifications err: %v", owner, repo, err))
|
||||||
|
}
|
||||||
|
return to.TextResult(slimThreads(threads))
|
||||||
|
}
|
||||||
|
|
||||||
|
threads, _, err := client.ListNotifications(opt)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("list notifications err: %v", err))
|
||||||
|
}
|
||||||
|
return to.TextResult(slimThreads(threads))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNotificationFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called getNotificationFn")
|
||||||
|
id, err := params.GetIndex(req.GetArguments(), "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))
|
||||||
|
}
|
||||||
|
thread, _, err := client.GetNotification(id)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get notification/%v err: %v", id, err))
|
||||||
|
}
|
||||||
|
return to.TextResult(slimThread(thread))
|
||||||
|
}
|
||||||
|
|
||||||
|
func markNotificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called markNotificationReadFn")
|
||||||
|
id, err := params.GetIndex(req.GetArguments(), "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))
|
||||||
|
}
|
||||||
|
thread, _, err := client.ReadNotification(id)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("mark notification/%v read err: %v", id, err))
|
||||||
|
}
|
||||||
|
if thread != nil {
|
||||||
|
return to.TextResult(slimThread(thread))
|
||||||
|
}
|
||||||
|
return to.TextResult("Notification marked as read")
|
||||||
|
}
|
||||||
|
|
||||||
|
func markAllNotificationsReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called markAllNotificationsReadFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
lastReadAt := time.Now()
|
||||||
|
if t := params.GetOptionalTime(args, "last_read_at"); t != nil {
|
||||||
|
lastReadAt = *t
|
||||||
|
}
|
||||||
|
opt := gitea_sdk.MarkNotificationOptions{
|
||||||
|
LastReadAt: lastReadAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := params.GetOptionalString(args, "owner", "")
|
||||||
|
repo := params.GetOptionalString(args, "repo", "")
|
||||||
|
if owner != "" && repo != "" {
|
||||||
|
threads, _, err := client.ReadRepoNotifications(owner, repo, opt)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("mark %v/%v/notifications read err: %v", owner, repo, err))
|
||||||
|
}
|
||||||
|
if threads != nil {
|
||||||
|
return to.TextResult(slimThreads(threads))
|
||||||
|
}
|
||||||
|
return to.TextResult("All repository notifications marked as read")
|
||||||
|
}
|
||||||
|
|
||||||
|
threads, _, err := client.ReadNotifications(opt)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("mark all notifications read err: %v", err))
|
||||||
|
}
|
||||||
|
if threads != nil {
|
||||||
|
return to.TextResult(slimThreads(threads))
|
||||||
|
}
|
||||||
|
return to.TextResult("All notifications marked as read")
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func slimThread(t *gitea_sdk.NotificationThread) map[string]any {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := map[string]any{
|
||||||
|
"id": t.ID,
|
||||||
|
"unread": t.Unread,
|
||||||
|
"updated_at": t.UpdatedAt,
|
||||||
|
}
|
||||||
|
if t.Pinned {
|
||||||
|
m["pinned"] = true
|
||||||
|
}
|
||||||
|
if t.Repository != nil {
|
||||||
|
m["repository"] = t.Repository.FullName
|
||||||
|
}
|
||||||
|
if t.Subject != nil {
|
||||||
|
subject := map[string]any{
|
||||||
|
"title": t.Subject.Title,
|
||||||
|
"type": t.Subject.Type,
|
||||||
|
"state": t.Subject.State,
|
||||||
|
}
|
||||||
|
if t.Subject.HTMLURL != "" {
|
||||||
|
subject["html_url"] = t.Subject.HTMLURL
|
||||||
|
}
|
||||||
|
if t.Subject.LatestCommentHTMLURL != "" {
|
||||||
|
subject["latest_comment_html_url"] = t.Subject.LatestCommentHTMLURL
|
||||||
|
}
|
||||||
|
m["subject"] = subject
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func slimThreads(threads []*gitea_sdk.NotificationThread) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(threads))
|
||||||
|
for _, t := range threads {
|
||||||
|
if t == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := map[string]any{
|
||||||
|
"id": t.ID,
|
||||||
|
"unread": t.Unread,
|
||||||
|
"updated_at": t.UpdatedAt,
|
||||||
|
}
|
||||||
|
if t.Pinned {
|
||||||
|
m["pinned"] = true
|
||||||
|
}
|
||||||
|
if t.Repository != nil {
|
||||||
|
m["repository"] = t.Repository.FullName
|
||||||
|
}
|
||||||
|
if t.Subject != nil {
|
||||||
|
m["subject"] = map[string]any{
|
||||||
|
"title": t.Subject.Title,
|
||||||
|
"type": t.Subject.Type,
|
||||||
|
"state": t.Subject.State,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"gitea.com/gitea/gitea-mcp/operation/issue"
|
"gitea.com/gitea/gitea-mcp/operation/issue"
|
||||||
"gitea.com/gitea/gitea-mcp/operation/label"
|
"gitea.com/gitea/gitea-mcp/operation/label"
|
||||||
"gitea.com/gitea/gitea-mcp/operation/milestone"
|
"gitea.com/gitea/gitea-mcp/operation/milestone"
|
||||||
|
"gitea.com/gitea/gitea-mcp/operation/notification"
|
||||||
"gitea.com/gitea/gitea-mcp/operation/pull"
|
"gitea.com/gitea/gitea-mcp/operation/pull"
|
||||||
"gitea.com/gitea/gitea-mcp/operation/repo"
|
"gitea.com/gitea/gitea-mcp/operation/repo"
|
||||||
"gitea.com/gitea/gitea-mcp/operation/search"
|
"gitea.com/gitea/gitea-mcp/operation/search"
|
||||||
@@ -41,6 +42,9 @@ func RegisterTool(s *server.MCPServer) {
|
|||||||
// Repo Tool
|
// Repo Tool
|
||||||
s.AddTools(repo.Tool.Tools()...)
|
s.AddTools(repo.Tool.Tools()...)
|
||||||
|
|
||||||
|
// Notification Tool
|
||||||
|
s.AddTools(notification.Tool.Tools()...)
|
||||||
|
|
||||||
// Issue Tool
|
// Issue Tool
|
||||||
s.AddTools(issue.Tool.Tools()...)
|
s.AddTools(issue.Tool.Tools()...)
|
||||||
|
|
||||||
|
|||||||
+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,10 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -769,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 ""
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
|
||||||
"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"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/params"
|
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/tool"
|
"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/mcp"
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-1
@@ -7,9 +7,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewClient(token string) (*gitea.Client, error) {
|
func NewClient(token string) (*gitea.Client, error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
"gopkg.in/natefinch/lumberjack.v2"
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package tool
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user