Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c45b42cb5 | |||
| 7759c7f327 | |||
| 5867f2f472 | |||
| 26f826d25c | |||
| baf792b061 | |||
| 08128b9471 | |||
| 133fe487cd | |||
| 05682e2afa | |||
| a5dd03c7f0 | |||
| 9056a5ef27 | |||
| c8004e9198 |
@@ -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,12 +3,11 @@ 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
|
||||||
@echo "Usage: make [target]"
|
@echo "Usage: make [target]"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
@@ -16,7 +15,7 @@ help: ## Print this help message.
|
|||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: build ## Install the application.
|
install: build ## install the application
|
||||||
@echo "Installing $(EXECUTABLE)..."
|
@echo "Installing $(EXECUTABLE)..."
|
||||||
@mkdir -p $(GOPATH)/bin
|
@mkdir -p $(GOPATH)/bin
|
||||||
@cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE)
|
@cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE)
|
||||||
@@ -24,23 +23,23 @@ install: build ## Install the application.
|
|||||||
@echo "Please add $(GOPATH)/bin to your PATH if it is not already there."
|
@echo "Please add $(GOPATH)/bin to your PATH if it is not already there."
|
||||||
|
|
||||||
.PHONY: uninstall
|
.PHONY: uninstall
|
||||||
uninstall: ## Uninstall the application.
|
uninstall: ## uninstall the application
|
||||||
@echo "Uninstalling $(EXECUTABLE)..."
|
@echo "Uninstalling $(EXECUTABLE)..."
|
||||||
@rm -f $(GOPATH)/bin/$(EXECUTABLE)
|
@rm -f $(GOPATH)/bin/$(EXECUTABLE)
|
||||||
@echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)"
|
@echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)"
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean: ## Clean the build artifacts.
|
clean: ## delete build artifacts
|
||||||
@echo "Cleaning up build artifacts..."
|
@echo "Cleaning up build artifacts..."
|
||||||
@rm -f $(EXECUTABLE)
|
@rm -f $(EXECUTABLE)
|
||||||
@echo "Cleaned up $(EXECUTABLE)"
|
@echo "Cleaned up $(EXECUTABLE)"
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: ## Build the application.
|
build: ## build the application
|
||||||
$(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE)
|
$(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE)
|
||||||
|
|
||||||
.PHONY: air
|
.PHONY: air
|
||||||
air: ## Install air for hot reload.
|
air: ## install air for hot reload
|
||||||
@hash air > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash air > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GO) install github.com/air-verse/air@latest; \
|
$(GO) install github.com/air-verse/air@latest; \
|
||||||
fi
|
fi
|
||||||
@@ -49,6 +48,19 @@ air: ## Install air for hot reload.
|
|||||||
dev: air ## run the application with hot reload
|
dev: air ## run the application with hot reload
|
||||||
air --build.cmd "make build" --build.bin ./gitea-mcp
|
air --build.cmd "make build" --build.bin ./gitea-mcp
|
||||||
|
|
||||||
|
.PHONY: fmt
|
||||||
|
fmt: ## format the Go code
|
||||||
|
$(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
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ To configure the MCP server for Gitea, add the following to your MCP configurati
|
|||||||
> You can provide your Gitea host and access token either as command-line arguments or environment variables.
|
> You can provide your Gitea host and access token either as command-line arguments or environment variables.
|
||||||
> Command-line arguments have the highest priority
|
> Command-line arguments have the highest priority
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Many tools support `page` and `perPage` parameters for pagination. The maximum effective page size is determined by the Gitea server's `[api].MAX_RESPONSE_ITEMS` setting (default: **50**). Requesting a `perPage` value higher than this limit will be silently capped by the server.
|
||||||
|
|
||||||
Once everything is set up, try typing the following in your MCP-compatible chatbox:
|
Once everything is set up, try typing the following in your MCP-compatible chatbox:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ cp gitea-mcp /usr/local/bin/
|
|||||||
> 可通过命令行参数或环境变量提供 Gitea 主机和访问令牌。
|
> 可通过命令行参数或环境变量提供 Gitea 主机和访问令牌。
|
||||||
> 命令行参数优先。
|
> 命令行参数优先。
|
||||||
|
|
||||||
|
> [!注意]
|
||||||
|
> 许多工具支持 `page` 和 `perPage` 分页参数。最大有效页面大小由 Gitea 服务器的 `[api].MAX_RESPONSE_ITEMS` 设置决定(默认值:**50**)。请求超过此限制的 `perPage` 值将被服务器静默截断。
|
||||||
|
|
||||||
一切设置完成后,可在 MCP 聊天框输入:
|
一切设置完成后,可在 MCP 聊天框输入:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ cp gitea-mcp /usr/local/bin/
|
|||||||
> 可用命令列參數或環境變數提供 Gitea 主機與存取令牌。
|
> 可用命令列參數或環境變數提供 Gitea 主機與存取令牌。
|
||||||
> 命令列參數優先。
|
> 命令列參數優先。
|
||||||
|
|
||||||
|
> [!注意]
|
||||||
|
> 許多工具支援 `page` 和 `perPage` 分頁參數。最大有效頁面大小由 Gitea 伺服器的 `[api].MAX_RESPONSE_ITEMS` 設定決定(預設值:**50**)。請求超過此限制的 `perPage` 值將被伺服器靜默截斷。
|
||||||
|
|
||||||
一切設定完成後,可在 MCP 聊天框輸入:
|
一切設定完成後,可在 MCP 聊天框輸入:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+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")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ go 1.26.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
code.gitea.io/sdk/gitea v0.23.2
|
code.gitea.io/sdk/gitea v0.23.2
|
||||||
github.com/mark3labs/mcp-go v0.44.0
|
github.com/mark3labs/mcp-go v0.45.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||||
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
|
github.com/mark3labs/mcp-go v0.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc=
|
||||||
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
github.com/mark3labs/mcp-go v0.45.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ var (
|
|||||||
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
|
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
|
||||||
mcp.WithString("name", mcp.Description("variable name (required for get methods)")),
|
mcp.WithString("name", mcp.Description("variable name (required for get methods)")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(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), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
|
|
||||||
ActionsConfigWriteTool = mcp.NewTool(
|
ActionsConfigWriteTool = mcp.NewTool(
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ var (
|
|||||||
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return (for 'get_job_log_preview')"), mcp.DefaultNumber(65536), mcp.Min(1024)),
|
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return (for 'get_job_log_preview')"), mcp.DefaultNumber(65536), mcp.Min(1024)),
|
||||||
mcp.WithString("output_path", mcp.Description("output file path (for 'download_job_log')")),
|
mcp.WithString("output_path", mcp.Description("output file path (for 'download_job_log')")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(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), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
|
|
||||||
ActionsRunWriteTool = mcp.NewTool(
|
ActionsRunWriteTool = mcp.NewTool(
|
||||||
|
|||||||
+59
-19
@@ -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 (
|
||||||
@@ -30,8 +43,11 @@ var (
|
|||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||||
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
|
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
|
||||||
|
mcp.WithArray("labels", mcp.Description("filter by label names"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
|
mcp.WithString("since", mcp.Description("filter issues updated after this ISO 8601 timestamp")),
|
||||||
|
mcp.WithString("before", mcp.Description("filter issues updated before this ISO 8601 timestamp")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
IssueReadTool = mcp.NewTool(
|
IssueReadTool = mcp.NewTool(
|
||||||
@@ -56,8 +72,11 @@ var (
|
|||||||
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")),
|
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")),
|
||||||
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")),
|
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")),
|
||||||
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")),
|
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")),
|
||||||
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
|
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
|
||||||
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
|
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
|
||||||
|
mcp.WithString("ref", mcp.Description("branch name to associate with the issue (for 'create', 'update')")),
|
||||||
|
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
|
||||||
|
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,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) {
|
||||||
@@ -162,14 +179,22 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
|
|||||||
if !ok {
|
if !ok {
|
||||||
state = "all"
|
state = "all"
|
||||||
}
|
}
|
||||||
|
labels := params.GetStringSlice(req.GetArguments(), "labels")
|
||||||
page, pageSize := params.GetPagination(req.GetArguments(), 30)
|
page, pageSize := params.GetPagination(req.GetArguments(), 30)
|
||||||
opt := gitea_sdk.ListIssueOption{
|
opt := gitea_sdk.ListIssueOption{
|
||||||
State: gitea_sdk.StateType(state),
|
State: gitea_sdk.StateType(state),
|
||||||
|
Labels: labels,
|
||||||
ListOptions: gitea_sdk.ListOptions{
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil {
|
||||||
|
opt.Since = *t
|
||||||
|
}
|
||||||
|
if t := params.GetOptionalTime(req.GetArguments(), "before"); t != nil {
|
||||||
|
opt.Before = *t
|
||||||
|
}
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
@@ -213,6 +238,13 @@ func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
|
|||||||
opt.Milestone = milestone
|
opt.Milestone = milestone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if labelIDs, err := params.GetInt64Slice(req.GetArguments(), "labels"); err == nil {
|
||||||
|
opt.Labels = labelIDs
|
||||||
|
}
|
||||||
|
if ref, ok := req.GetArguments()["ref"].(string); ok {
|
||||||
|
opt.Ref = ref
|
||||||
|
}
|
||||||
|
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline")
|
||||||
issue, _, err := client.CreateIssue(owner, repo, opt)
|
issue, _, err := client.CreateIssue(owner, repo, opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
|
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
|
||||||
@@ -289,6 +321,13 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
|
|||||||
if ok {
|
if ok {
|
||||||
opt.State = new(gitea_sdk.StateType(state))
|
opt.State = new(gitea_sdk.StateType(state))
|
||||||
}
|
}
|
||||||
|
if ref, ok := req.GetArguments()["ref"].(string); ok {
|
||||||
|
opt.Ref = &ref
|
||||||
|
}
|
||||||
|
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline")
|
||||||
|
if removeDeadline, ok := req.GetArguments()["remove_deadline"].(bool); ok {
|
||||||
|
opt.RemoveDeadline = &removeDeadline
|
||||||
|
}
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,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) {
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package issue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_listRepoIssuesFn_filters(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
gotQuery string
|
||||||
|
)
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/v1/version":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
|
||||||
|
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"private":false}`))
|
||||||
|
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo):
|
||||||
|
mu.Lock()
|
||||||
|
gotQuery = r.URL.RawQuery
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origHost := flag.Host
|
||||||
|
origToken := flag.Token
|
||||||
|
origVersion := flag.Version
|
||||||
|
flag.Host = server.URL
|
||||||
|
flag.Token = ""
|
||||||
|
flag.Version = "test"
|
||||||
|
defer func() {
|
||||||
|
flag.Host = origHost
|
||||||
|
flag.Token = origToken
|
||||||
|
flag.Version = origVersion
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{
|
||||||
|
Params: mcp.CallToolParams{
|
||||||
|
Arguments: map[string]any{
|
||||||
|
"owner": owner,
|
||||||
|
"repo": repo,
|
||||||
|
"labels": []any{"bug", "enhancement"},
|
||||||
|
"since": "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := listRepoIssuesFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listRepoIssuesFn() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if !strings.Contains(gotQuery, "labels=bug%2Cenhancement") {
|
||||||
|
t.Fatalf("expected labels query param, got %s", gotQuery)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotQuery, "since=2026-01-01") {
|
||||||
|
t.Fatalf("expected since query param, got %s", gotQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_createIssueFn_labels(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
gotBody map[string]any
|
||||||
|
)
|
||||||
|
|
||||||
|
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/issues", owner, repo):
|
||||||
|
mu.Lock()
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
gotBody = body
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origHost := flag.Host
|
||||||
|
origToken := flag.Token
|
||||||
|
origVersion := flag.Version
|
||||||
|
flag.Host = server.URL
|
||||||
|
flag.Token = ""
|
||||||
|
flag.Version = "test"
|
||||||
|
defer func() {
|
||||||
|
flag.Host = origHost
|
||||||
|
flag.Token = origToken
|
||||||
|
flag.Version = origVersion
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{
|
||||||
|
Params: mcp.CallToolParams{
|
||||||
|
Arguments: map[string]any{
|
||||||
|
"owner": owner,
|
||||||
|
"repo": repo,
|
||||||
|
"title": "test issue",
|
||||||
|
"body": "body",
|
||||||
|
"labels": []any{float64(10), float64(20)},
|
||||||
|
"deadline": "2026-06-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := createIssueFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createIssueFn() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
labels, ok := gotBody["labels"].([]any)
|
||||||
|
if !ok || len(labels) != 2 {
|
||||||
|
t.Fatalf("expected 2 labels, got %v", gotBody["labels"])
|
||||||
|
}
|
||||||
|
if labels[0] != float64(10) || labels[1] != float64(20) {
|
||||||
|
t.Fatalf("expected labels [10,20], got %v", labels)
|
||||||
|
}
|
||||||
|
if gotBody["due_date"] == nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-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
|
||||||
@@ -63,6 +84,12 @@ func slimIssue(i *gitea_sdk.Issue) map[string]any {
|
|||||||
"title": i.Milestone.Title,
|
"title": i.Milestone.Title,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if i.Ref != "" {
|
||||||
|
m["ref"] = i.Ref
|
||||||
|
}
|
||||||
|
if i.Deadline != nil {
|
||||||
|
m["deadline"] = i.Deadline
|
||||||
|
}
|
||||||
if i.PullRequest != nil {
|
if i.PullRequest != nil {
|
||||||
m["is_pull"] = true
|
m["is_pull"] = true
|
||||||
}
|
}
|
||||||
@@ -88,6 +115,12 @@ func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
|
|||||||
if len(i.Labels) > 0 {
|
if len(i.Labels) > 0 {
|
||||||
m["labels"] = labelNames(i.Labels)
|
m["labels"] = labelNames(i.Labels)
|
||||||
}
|
}
|
||||||
|
if i.Ref != "" {
|
||||||
|
m["ref"] = i.Ref
|
||||||
|
}
|
||||||
|
if i.Deadline != nil {
|
||||||
|
m["deadline"] = i.Deadline
|
||||||
|
}
|
||||||
out = append(out, m)
|
out = append(out, m)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -107,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,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ var (
|
|||||||
mcp.WithString("org", mcp.Description("organization name (required for 'list_org')")),
|
mcp.WithString("org", mcp.Description("organization name (required for 'list_org')")),
|
||||||
mcp.WithNumber("id", mcp.Description("label ID (required for 'get_repo')")),
|
mcp.WithNumber("id", mcp.Description("label ID (required for 'get_repo')")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
LabelWriteTool = mcp.NewTool(
|
LabelWriteTool = mcp.NewTool(
|
||||||
@@ -47,6 +47,7 @@ var (
|
|||||||
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
|
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
|
||||||
mcp.WithString("description", mcp.Description("label description")),
|
mcp.WithString("description", mcp.Description("label description")),
|
||||||
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
|
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
|
||||||
|
mcp.WithBoolean("is_archived", mcp.Description("whether the label is archived (for create/edit repo label methods)")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,10 +179,13 @@ func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
}
|
}
|
||||||
description, _ := req.GetArguments()["description"].(string) // Optional
|
description, _ := req.GetArguments()["description"].(string) // Optional
|
||||||
|
|
||||||
|
isArchived, _ := req.GetArguments()["is_archived"].(bool)
|
||||||
|
|
||||||
opt := gitea_sdk.CreateLabelOption{
|
opt := gitea_sdk.CreateLabelOption{
|
||||||
Name: name,
|
Name: name,
|
||||||
Color: color,
|
Color: color,
|
||||||
Description: description,
|
Description: description,
|
||||||
|
IsArchived: isArchived,
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
@@ -220,6 +224,9 @@ func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
|
|||||||
if description, ok := req.GetArguments()["description"].(string); ok {
|
if description, ok := req.GetArguments()["description"].(string); ok {
|
||||||
opt.Description = new(description)
|
opt.Description = new(description)
|
||||||
}
|
}
|
||||||
|
if isArchived, ok := req.GetArguments()["is_archived"].(bool); ok {
|
||||||
|
opt.IsArchived = &isArchived
|
||||||
|
}
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ var (
|
|||||||
mcp.WithString("state", mcp.Description("milestone state (for 'list')"), mcp.DefaultString("all")),
|
mcp.WithString("state", mcp.Description("milestone state (for 'list')"), mcp.DefaultString("all")),
|
||||||
mcp.WithString("name", mcp.Description("milestone name filter (for 'list')")),
|
mcp.WithString("name", mcp.Description("milestone name filter (for 'list')")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
MilestoneWriteTool = mcp.NewTool(
|
MilestoneWriteTool = mcp.NewTool(
|
||||||
|
|||||||
@@ -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()...)
|
||||||
|
|
||||||
|
|||||||
+41
-5
@@ -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"
|
||||||
@@ -35,7 +36,7 @@ var (
|
|||||||
mcp.WithString("sort", mcp.Description("sort"), mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")),
|
mcp.WithString("sort", mcp.Description("sort"), mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")),
|
||||||
mcp.WithNumber("milestone", mcp.Description("milestone")),
|
mcp.WithNumber("milestone", mcp.Description("milestone")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
PullRequestReadTool = mcp.NewTool(
|
PullRequestReadTool = mcp.NewTool(
|
||||||
@@ -48,7 +49,7 @@ var (
|
|||||||
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'get_review', 'get_review_comments')")),
|
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'get_review', 'get_review_comments')")),
|
||||||
mcp.WithBoolean("binary", mcp.Description("whether to include binary file changes (for 'get_diff')")),
|
mcp.WithBoolean("binary", mcp.Description("whether to include binary file changes (for 'get_diff')")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
PullRequestWriteTool = mcp.NewTool(
|
PullRequestWriteTool = mcp.NewTool(
|
||||||
@@ -67,9 +68,15 @@ var (
|
|||||||
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
|
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
|
||||||
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")),
|
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")),
|
||||||
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")),
|
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")),
|
||||||
|
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'update')"), mcp.Items(map[string]any{"type": "number"})),
|
||||||
|
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
|
||||||
|
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
|
||||||
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
|
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
|
||||||
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")),
|
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")),
|
||||||
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
|
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
|
||||||
|
mcp.WithBoolean("force_merge", mcp.Description("force merge even if checks are not passing (for 'merge')")),
|
||||||
|
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("auto-merge when checks succeed (for 'merge')")),
|
||||||
|
mcp.WithString("head_commit_id", mcp.Description("expected head commit SHA for merge conflict detection (for 'merge')")),
|
||||||
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
|
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
|
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
mcp.WithBoolean("draft", mcp.Description("mark PR as draft (for 'create', 'update'). Gitea uses a 'WIP: ' title prefix for drafts.")),
|
mcp.WithBoolean("draft", mcp.Description("mark PR as draft (for 'create', 'update'). Gitea uses a 'WIP: ' title prefix for drafts.")),
|
||||||
@@ -203,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) {
|
||||||
@@ -331,12 +348,17 @@ func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
}
|
}
|
||||||
pr, _, err := client.CreatePullRequest(owner, repo, gitea_sdk.CreatePullRequestOption{
|
opt := gitea_sdk.CreatePullRequestOption{
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: body,
|
Body: body,
|
||||||
Head: head,
|
Head: head,
|
||||||
Base: base,
|
Base: base,
|
||||||
})
|
}
|
||||||
|
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
|
||||||
|
opt.Labels = labelIDs
|
||||||
|
}
|
||||||
|
opt.Deadline = params.GetOptionalTime(args, "deadline")
|
||||||
|
pr, _, err := client.CreatePullRequest(owner, repo, opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
|
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
|
||||||
}
|
}
|
||||||
@@ -751,11 +773,18 @@ func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
|
|||||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
forceMerge, _ := args["force_merge"].(bool)
|
||||||
|
mergeWhenChecksSucceed, _ := args["merge_when_checks_succeed"].(bool)
|
||||||
|
headCommitID, _ := args["head_commit_id"].(string)
|
||||||
|
|
||||||
opt := gitea_sdk.MergePullRequestOption{
|
opt := gitea_sdk.MergePullRequestOption{
|
||||||
Style: gitea_sdk.MergeStyle(mergeStyle),
|
Style: gitea_sdk.MergeStyle(mergeStyle),
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
DeleteBranchAfterMerge: deleteBranch,
|
DeleteBranchAfterMerge: deleteBranch,
|
||||||
|
ForceMerge: forceMerge,
|
||||||
|
MergeWhenChecksSucceed: mergeWhenChecksSucceed,
|
||||||
|
HeadCommitId: headCommitID,
|
||||||
}
|
}
|
||||||
|
|
||||||
merged, resp, err := client.MergePullRequest(owner, repo, index, opt)
|
merged, resp, err := client.MergePullRequest(owner, repo, index, opt)
|
||||||
@@ -842,6 +871,13 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok {
|
if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok {
|
||||||
opt.AllowMaintainerEdit = new(allowMaintainerEdit)
|
opt.AllowMaintainerEdit = new(allowMaintainerEdit)
|
||||||
}
|
}
|
||||||
|
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
|
||||||
|
opt.Labels = labelIDs
|
||||||
|
}
|
||||||
|
opt.Deadline = params.GetOptionalTime(args, "deadline")
|
||||||
|
if removeDeadline, ok := args["remove_deadline"].(bool); ok {
|
||||||
|
opt.RemoveDeadline = &removeDeadline
|
||||||
|
}
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -254,6 +256,168 @@ func Test_mergePullRequestFn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_mergePullRequestFn_newParams(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
index = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
gotBody map[string]any
|
||||||
|
)
|
||||||
|
|
||||||
|
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/merge", owner, repo, index):
|
||||||
|
mu.Lock()
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
gotBody = body
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origHost := flag.Host
|
||||||
|
origToken := flag.Token
|
||||||
|
origVersion := flag.Version
|
||||||
|
flag.Host = server.URL
|
||||||
|
flag.Token = ""
|
||||||
|
flag.Version = "test"
|
||||||
|
defer func() {
|
||||||
|
flag.Host = origHost
|
||||||
|
flag.Token = origToken
|
||||||
|
flag.Version = origVersion
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{
|
||||||
|
Params: mcp.CallToolParams{
|
||||||
|
Arguments: map[string]any{
|
||||||
|
"owner": owner,
|
||||||
|
"repo": repo,
|
||||||
|
"index": float64(index),
|
||||||
|
"merge_style": "merge",
|
||||||
|
"force_merge": true,
|
||||||
|
"merge_when_checks_succeed": true,
|
||||||
|
"head_commit_id": "abc123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := mergePullRequestFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mergePullRequestFn() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if gotBody["force_merge"] != true {
|
||||||
|
t.Fatalf("expected force_merge true, got %v", gotBody["force_merge"])
|
||||||
|
}
|
||||||
|
if gotBody["merge_when_checks_succeed"] != true {
|
||||||
|
t.Fatalf("expected merge_when_checks_succeed true, got %v", gotBody["merge_when_checks_succeed"])
|
||||||
|
}
|
||||||
|
if gotBody["head_commit_id"] != "abc123" {
|
||||||
|
t.Fatalf("expected head_commit_id 'abc123', got %v", gotBody["head_commit_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_createPullRequestFn_labels(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
gotBody map[string]any
|
||||||
|
)
|
||||||
|
|
||||||
|
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", owner, repo):
|
||||||
|
mu.Lock()
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
gotBody = body
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origHost := flag.Host
|
||||||
|
origToken := flag.Token
|
||||||
|
origVersion := flag.Version
|
||||||
|
flag.Host = server.URL
|
||||||
|
flag.Token = ""
|
||||||
|
flag.Version = "test"
|
||||||
|
defer func() {
|
||||||
|
flag.Host = origHost
|
||||||
|
flag.Token = origToken
|
||||||
|
flag.Version = origVersion
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{
|
||||||
|
Params: mcp.CallToolParams{
|
||||||
|
Arguments: map[string]any{
|
||||||
|
"owner": owner,
|
||||||
|
"repo": repo,
|
||||||
|
"title": "test",
|
||||||
|
"body": "body",
|
||||||
|
"head": "feature",
|
||||||
|
"base": "main",
|
||||||
|
"labels": []any{float64(1), float64(2)},
|
||||||
|
"deadline": "2026-06-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := createPullRequestFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createPullRequestFn() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
labels, ok := gotBody["labels"].([]any)
|
||||||
|
if !ok || len(labels) != 2 {
|
||||||
|
t.Fatalf("expected 2 labels, got %v", gotBody["labels"])
|
||||||
|
}
|
||||||
|
if labels[0] != float64(1) || labels[1] != float64(2) {
|
||||||
|
t.Fatalf("expected labels [1,2], got %v", labels)
|
||||||
|
}
|
||||||
|
if gotBody["due_date"] == nil {
|
||||||
|
t.Fatalf("expected due_date to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_applyDraftPrefix(t *testing.T) {
|
func Test_applyDraftPrefix(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -607,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 ""
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ var (
|
|||||||
mcp.WithDescription("List branches"),
|
mcp.WithDescription("List branches"),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||||
|
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)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,10 +133,11 @@ func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
|
page, pageSize := params.GetPagination(args, 30)
|
||||||
opt := gitea_sdk.ListRepoBranchesOptions{
|
opt := gitea_sdk.ListRepoBranchesOptions{
|
||||||
ListOptions: gitea_sdk.ListOptions{
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
Page: 1,
|
Page: page,
|
||||||
PageSize: 30,
|
PageSize: pageSize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
ListRepoCommitsToolName = "list_commits"
|
ListRepoCommitsToolName = "list_commits"
|
||||||
|
GetCommitToolName = "get_commit"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ListRepoCommitsTool = mcp.NewTool(
|
var (
|
||||||
|
ListRepoCommitsTool = mcp.NewTool(
|
||||||
ListRepoCommitsToolName,
|
ListRepoCommitsToolName,
|
||||||
mcp.WithDescription("List repository commits"),
|
mcp.WithDescription("List repository commits"),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||||
@@ -26,7 +28,16 @@ var ListRepoCommitsTool = mcp.NewTool(
|
|||||||
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")),
|
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")),
|
||||||
mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")),
|
mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")),
|
||||||
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
|
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
GetCommitTool = mcp.NewTool(
|
||||||
|
GetCommitToolName,
|
||||||
|
mcp.WithDescription("Get details of a specific commit"),
|
||||||
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||||
|
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||||
|
mcp.WithString("sha", mcp.Required(), mcp.Description("commit SHA")),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -34,6 +45,10 @@ func init() {
|
|||||||
Tool: ListRepoCommitsTool,
|
Tool: ListRepoCommitsTool,
|
||||||
Handler: ListRepoCommitsFn,
|
Handler: ListRepoCommitsFn,
|
||||||
})
|
})
|
||||||
|
Tool.RegisterRead(server.ServerTool{
|
||||||
|
Tool: GetCommitTool,
|
||||||
|
Handler: GetCommitFn,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
@@ -75,3 +90,29 @@ func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
}
|
}
|
||||||
return to.TextResult(slimCommits(commits))
|
return to.TextResult(slimCommits(commits))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetCommitFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called GetCommitFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
sha, err := params.GetString(args, "sha")
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
commit, _, err := client.GetSingleCommit(owner, repo, sha)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get commit %v err: %v", sha, err))
|
||||||
|
}
|
||||||
|
return to.TextResult(slimCommit(commit))
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ var (
|
|||||||
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
|
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
|
||||||
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
|
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(20), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ var (
|
|||||||
mcp.WithString("license", mcp.Description("License to use")),
|
mcp.WithString("license", mcp.Description("License to use")),
|
||||||
mcp.WithString("readme", mcp.Description("Readme of the repository to create")),
|
mcp.WithString("readme", mcp.Description("Readme of the repository to create")),
|
||||||
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")),
|
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")),
|
||||||
|
mcp.WithString("trust_model", mcp.Description("Trust model for verifying GPG signatures"), mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")),
|
||||||
|
mcp.WithString("object_format_name", mcp.Description("Object format: sha1 or sha256"), mcp.Enum("sha1", "sha256")),
|
||||||
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
|
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ var (
|
|||||||
ListMyReposToolName,
|
ListMyReposToolName,
|
||||||
mcp.WithDescription("List my repositories"),
|
mcp.WithDescription("List my repositories"),
|
||||||
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
|
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
|
|
||||||
ListOrgReposTool = mcp.NewTool(
|
ListOrgReposTool = mcp.NewTool(
|
||||||
@@ -102,6 +104,8 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
|
|||||||
license, _ := args["license"].(string)
|
license, _ := args["license"].(string)
|
||||||
readme, _ := args["readme"].(string)
|
readme, _ := args["readme"].(string)
|
||||||
defaultBranch, _ := args["default_branch"].(string)
|
defaultBranch, _ := args["default_branch"].(string)
|
||||||
|
trustModel, _ := args["trust_model"].(string)
|
||||||
|
objectFormatName, _ := args["object_format_name"].(string)
|
||||||
organization, _ := args["organization"].(string)
|
organization, _ := args["organization"].(string)
|
||||||
|
|
||||||
opt := gitea_sdk.CreateRepoOption{
|
opt := gitea_sdk.CreateRepoOption{
|
||||||
@@ -115,6 +119,8 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
|
|||||||
License: license,
|
License: license,
|
||||||
Readme: readme,
|
Readme: readme,
|
||||||
DefaultBranch: defaultBranch,
|
DefaultBranch: defaultBranch,
|
||||||
|
TrustModel: gitea_sdk.TrustModel(trustModel),
|
||||||
|
ObjectFormatName: objectFormatName,
|
||||||
}
|
}
|
||||||
|
|
||||||
var repo *gitea_sdk.Repository
|
var repo *gitea_sdk.Repository
|
||||||
|
|||||||
@@ -184,6 +184,28 @@ func slimContents(c *gitea_sdk.ContentsResponse) map[string]any {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func slimTree(t *gitea_sdk.GitTreeResponse) map[string]any {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries := make([]map[string]any, 0, len(t.Entries))
|
||||||
|
for _, e := range t.Entries {
|
||||||
|
entries = append(entries, map[string]any{
|
||||||
|
"path": e.Path,
|
||||||
|
"mode": e.Mode,
|
||||||
|
"type": e.Type,
|
||||||
|
"size": e.Size,
|
||||||
|
"sha": e.SHA,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"sha": t.SHA,
|
||||||
|
"truncated": t.Truncated,
|
||||||
|
"total_count": t.TotalCount,
|
||||||
|
"tree": entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any {
|
func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any {
|
||||||
out := make([]map[string]any, 0, len(entries))
|
out := make([]map[string]any, 0, len(entries))
|
||||||
for _, c := range entries {
|
for _, c := range entries {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ var (
|
|||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(20), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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_sdk "code.gitea.io/sdk/gitea"
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
GetRepoTreeToolName = "get_repository_tree"
|
||||||
|
)
|
||||||
|
|
||||||
|
var GetRepoTreeTool = mcp.NewTool(
|
||||||
|
GetRepoTreeToolName,
|
||||||
|
mcp.WithDescription("Get the file tree of a repository"),
|
||||||
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||||
|
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||||
|
mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch name, or tag name")),
|
||||||
|
mcp.WithBoolean("recursive", mcp.Description("whether to get the tree recursively")),
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Tool.RegisterRead(server.ServerTool{
|
||||||
|
Tool: GetRepoTreeTool,
|
||||||
|
Handler: GetRepoTreeFn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRepoTreeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called GetRepoTreeFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
treeSHA, err := params.GetString(args, "tree_sha")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
recursive, _ := args["recursive"].(bool)
|
||||||
|
page, pageSize := params.GetPagination(args, 30)
|
||||||
|
|
||||||
|
opt := gitea_sdk.ListTreeOptions{
|
||||||
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
},
|
||||||
|
Ref: treeSHA,
|
||||||
|
Recursive: recursive,
|
||||||
|
}
|
||||||
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
|
}
|
||||||
|
tree, _, err := client.GetTrees(owner, repo, opt)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get repository tree err: %v", err))
|
||||||
|
}
|
||||||
|
return to.TextResult(slimTree(tree))
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSlimTree(t *testing.T) {
|
||||||
|
tree := &gitea_sdk.GitTreeResponse{
|
||||||
|
SHA: "abc123",
|
||||||
|
TotalCount: 2,
|
||||||
|
Truncated: false,
|
||||||
|
Entries: []gitea_sdk.GitEntry{
|
||||||
|
{Path: "src", Mode: "040000", Type: "tree", Size: 0, SHA: "def456"},
|
||||||
|
{Path: "main.go", Mode: "100644", Type: "blob", Size: 42, SHA: "789abc"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := slimTree(tree)
|
||||||
|
if m["sha"] != "abc123" {
|
||||||
|
t.Errorf("expected sha abc123, got %v", m["sha"])
|
||||||
|
}
|
||||||
|
if m["total_count"] != 2 {
|
||||||
|
t.Errorf("expected total_count 2, got %v", m["total_count"])
|
||||||
|
}
|
||||||
|
entries := m["tree"].([]map[string]any)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0]["path"] != "src" {
|
||||||
|
t.Errorf("expected first entry path src, got %v", entries[0]["path"])
|
||||||
|
}
|
||||||
|
if entries[1]["type"] != "blob" {
|
||||||
|
t.Errorf("expected second entry type blob, got %v", entries[1]["type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlimTreeNil(t *testing.T) {
|
||||||
|
if m := slimTree(nil); m != nil {
|
||||||
|
t.Errorf("expected nil, got %v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRepoTreeToolRequired(t *testing.T) {
|
||||||
|
for _, field := range []string{"owner", "repo", "tree_sha"} {
|
||||||
|
if !slices.Contains(GetRepoTreeTool.InputSchema.Required, field) {
|
||||||
|
t.Errorf("expected %q to be required", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package search
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -21,6 +22,7 @@ const (
|
|||||||
SearchUsersToolName = "search_users"
|
SearchUsersToolName = "search_users"
|
||||||
SearchOrgTeamsToolName = "search_org_teams"
|
SearchOrgTeamsToolName = "search_org_teams"
|
||||||
SearchReposToolName = "search_repos"
|
SearchReposToolName = "search_repos"
|
||||||
|
SearchIssuesToolName = "search_issues"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -29,7 +31,7 @@ var (
|
|||||||
mcp.WithDescription("search users"),
|
mcp.WithDescription("search users"),
|
||||||
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
|
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
|
||||||
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
SearOrgTeamsTool = mcp.NewTool(
|
SearOrgTeamsTool = mcp.NewTool(
|
||||||
@@ -39,7 +41,7 @@ var (
|
|||||||
mcp.WithString("query", mcp.Required(), mcp.Description("search organization teams")),
|
mcp.WithString("query", mcp.Required(), mcp.Description("search organization teams")),
|
||||||
mcp.WithBoolean("includeDescription", mcp.Description("include description?")),
|
mcp.WithBoolean("includeDescription", mcp.Description("include description?")),
|
||||||
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
SearchReposTool = mcp.NewTool(
|
SearchReposTool = mcp.NewTool(
|
||||||
@@ -54,7 +56,19 @@ var (
|
|||||||
mcp.WithString("sort", mcp.Description("Sort")),
|
mcp.WithString("sort", mcp.Description("Sort")),
|
||||||
mcp.WithString("order", mcp.Description("Order")),
|
mcp.WithString("order", mcp.Description("Order")),
|
||||||
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
||||||
|
)
|
||||||
|
|
||||||
|
SearchIssuesTool = mcp.NewTool(
|
||||||
|
SearchIssuesToolName,
|
||||||
|
mcp.WithDescription("Search for issues and pull requests across all accessible repositories"),
|
||||||
|
mcp.WithString("query", mcp.Required(), mcp.Description("search keyword")),
|
||||||
|
mcp.WithString("state", mcp.Description("filter by state: open, closed, all"), mcp.Enum("open", "closed", "all")),
|
||||||
|
mcp.WithString("type", mcp.Description("filter by type: issues, pulls"), mcp.Enum("issues", "pulls")),
|
||||||
|
mcp.WithString("labels", mcp.Description("comma-separated list of label names")),
|
||||||
|
mcp.WithString("owner", mcp.Description("filter by repository owner")),
|
||||||
|
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)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +85,10 @@ func init() {
|
|||||||
Tool: SearchReposTool,
|
Tool: SearchReposTool,
|
||||||
Handler: ReposFn,
|
Handler: ReposFn,
|
||||||
})
|
})
|
||||||
|
Tool.RegisterRead(server.ServerTool{
|
||||||
|
Tool: SearchIssuesTool,
|
||||||
|
Handler: IssuesFn,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
@@ -175,3 +193,42 @@ func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
|
|||||||
}
|
}
|
||||||
return to.TextResult(slimRepos(repos))
|
return to.TextResult(slimRepos(repos))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called IssuesFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
query, err := params.GetString(args, "query")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
page, pageSize := params.GetPagination(args, 30)
|
||||||
|
|
||||||
|
opt := gitea_sdk.ListIssueOption{
|
||||||
|
KeyWord: query,
|
||||||
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if state, ok := args["state"].(string); ok {
|
||||||
|
opt.State = gitea_sdk.StateType(state)
|
||||||
|
}
|
||||||
|
if issueType, ok := args["type"].(string); ok {
|
||||||
|
opt.Type = gitea_sdk.IssueType(issueType)
|
||||||
|
}
|
||||||
|
if labels, ok := args["labels"].(string); ok && labels != "" {
|
||||||
|
opt.Labels = strings.Split(labels, ",")
|
||||||
|
}
|
||||||
|
if owner, ok := args["owner"].(string); ok {
|
||||||
|
opt.Owner = owner
|
||||||
|
}
|
||||||
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
|
}
|
||||||
|
issues, _, err := client.ListIssues(opt)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("search issues err: %v", err))
|
||||||
|
}
|
||||||
|
return to.TextResult(slimIssues(issues))
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,3 +86,59 @@ func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func userLogin(u *gitea_sdk.User) string {
|
||||||
|
if u == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.UserName
|
||||||
|
}
|
||||||
|
|
||||||
|
func labelNames(labels []*gitea_sdk.Label) []string {
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(labels))
|
||||||
|
for _, l := range labels {
|
||||||
|
if l != nil {
|
||||||
|
out = append(out, l.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(issues))
|
||||||
|
for _, i := range issues {
|
||||||
|
if i == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := map[string]any{
|
||||||
|
"number": i.Index,
|
||||||
|
"title": i.Title,
|
||||||
|
"state": i.State,
|
||||||
|
"html_url": i.HTMLURL,
|
||||||
|
"user": userLogin(i.Poster),
|
||||||
|
"comments": i.Comments,
|
||||||
|
"created_at": i.Created,
|
||||||
|
"updated_at": i.Updated,
|
||||||
|
}
|
||||||
|
if len(i.Labels) > 0 {
|
||||||
|
m["labels"] = labelNames(i.Labels)
|
||||||
|
}
|
||||||
|
if i.Repository != nil {
|
||||||
|
m["repository"] = i.Repository.FullName
|
||||||
|
}
|
||||||
|
if i.Ref != "" {
|
||||||
|
m["ref"] = i.Ref
|
||||||
|
}
|
||||||
|
if i.Deadline != nil {
|
||||||
|
m["deadline"] = i.Deadline
|
||||||
|
}
|
||||||
|
if i.PullRequest != nil {
|
||||||
|
m["is_pull"] = true
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSlimIssues(t *testing.T) {
|
||||||
|
issues := []*gitea_sdk.Issue{
|
||||||
|
{
|
||||||
|
Index: 1,
|
||||||
|
Title: "Bug report",
|
||||||
|
State: gitea_sdk.StateOpen,
|
||||||
|
HTMLURL: "https://gitea.com/org/repo/issues/1",
|
||||||
|
Poster: &gitea_sdk.User{UserName: "alice"},
|
||||||
|
Labels: []*gitea_sdk.Label{{Name: "bug"}},
|
||||||
|
Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"},
|
||||||
|
PullRequest: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Index: 2,
|
||||||
|
Title: "Add feature",
|
||||||
|
State: gitea_sdk.StateOpen,
|
||||||
|
Poster: &gitea_sdk.User{UserName: "bob"},
|
||||||
|
Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"},
|
||||||
|
PullRequest: &gitea_sdk.PullRequestMeta{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := slimIssues(issues)
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("expected 2 issues, got %d", len(result))
|
||||||
|
}
|
||||||
|
if result[0]["repository"] != "org/repo" {
|
||||||
|
t.Errorf("expected repository org/repo, got %v", result[0]["repository"])
|
||||||
|
}
|
||||||
|
if result[0]["labels"].([]string)[0] != "bug" {
|
||||||
|
t.Errorf("expected label bug, got %v", result[0]["labels"])
|
||||||
|
}
|
||||||
|
if _, ok := result[0]["is_pull"]; ok {
|
||||||
|
t.Error("issue should not have is_pull")
|
||||||
|
}
|
||||||
|
if result[1]["is_pull"] != true {
|
||||||
|
t.Error("PR should have is_pull=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchIssuesToolRequired(t *testing.T) {
|
||||||
|
if !slices.Contains(SearchIssuesTool.InputSchema.Required, "query") {
|
||||||
|
t.Error("search_issues should require query")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -32,7 +32,7 @@ var (
|
|||||||
mcp.WithString("repo", mcp.Description("repository name (required for 'list_issue_times', 'list_repo_times')")),
|
mcp.WithString("repo", mcp.Description("repository name (required for 'list_issue_times', 'list_repo_times')")),
|
||||||
mcp.WithNumber("index", mcp.Description("issue index (required for 'list_issue_times')")),
|
mcp.WithNumber("index", mcp.Description("issue index (required for 'list_issue_times')")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
TimetrackingWriteTool = mcp.NewTool(
|
TimetrackingWriteTool = mcp.NewTool(
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ var (
|
|||||||
GetUserOrgsToolName,
|
GetUserOrgsToolName,
|
||||||
mcp.WithDescription("Get organizations associated with the authenticated user"),
|
mcp.WithDescription("Get organizations associated with the authenticated user"),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)),
|
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(defaultPageSize)),
|
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(defaultPageSize)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+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"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package params
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetString extracts a required string parameter from MCP tool arguments.
|
// GetString extracts a required string parameter from MCP tool arguments.
|
||||||
@@ -101,6 +102,18 @@ func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOptionalTime extracts an optional RFC3339 timestamp parameter, returning nil if missing or unparseable.
|
||||||
|
func GetOptionalTime(args map[string]any, key string) *time.Time {
|
||||||
|
val, ok := args[key].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, val); err == nil {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetOptionalInt extracts an optional integer parameter from MCP tool arguments.
|
// GetOptionalInt extracts an optional integer parameter from MCP tool arguments.
|
||||||
// Returns defaultVal if the key is missing or the value cannot be parsed.
|
// Returns defaultVal if the key is missing or the value cannot be parsed.
|
||||||
// Accepts both float64 (JSON number) and string representations.
|
// Accepts both float64 (JSON number) and string representations.
|
||||||
|
|||||||
@@ -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