Initial commit: Gitea MCP Server

This commit is contained in:
2026-04-10 21:57:19 -07:00
commit 1f27844dce
119 changed files with 25001 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
package accesstoken
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.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"
)
const (
CreateAccessTokenToolName = "create_access_token"
DeleteAccessTokenToolName = "delete_access_token"
)
var Tool = tool.New()
var (
CreateAccessTokenTool = mcp.NewTool(
CreateAccessTokenToolName,
mcp.WithDescription("Create a new personal access token"),
mcp.WithString("name", mcp.Required(), mcp.Description("Token name/description")),
mcp.WithArray("scopes", mcp.Description("Array of permission scopes"), mcp.Items(map[string]any{"type": "string"})),
)
DeleteAccessTokenTool = mcp.NewTool(
DeleteAccessTokenToolName,
mcp.WithDescription("Delete a personal access token"),
mcp.WithString("token", mcp.Required(), mcp.Description("The token value or name to delete")),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateAccessTokenTool,
Handler: createAccessTokenFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteAccessTokenTool,
Handler: deleteAccessTokenFn,
})
}
func createAccessTokenFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[AccessToken] Called createAccessTokenFn")
args := req.GetArguments()
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
scopesRaw := params.GetStringSlice(args, "scopes")
var scopes []gitea_sdk.AccessTokenScope
if scopesRaw == nil {
scopes = []gitea_sdk.AccessTokenScope{"repo", "user"}
} else {
scopes = make([]gitea_sdk.AccessTokenScope, len(scopesRaw))
for i, s := range scopesRaw {
scopes[i] = gitea_sdk.AccessTokenScope(s)
}
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.GetMyUserInfo()
if err != nil {
return to.ErrorResult(fmt.Errorf("get current user err: %v", err))
}
createOpt := gitea_sdk.CreateAccessTokenOption{
Name: name,
Scopes: scopes,
}
token, _, err := client.CreateAccessToken(createOpt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create access token err: %v", err))
}
return to.TextResult(map[string]interface{}{
"name": token.Name,
"token": token.Token,
"id": token.ID,
"scopes": token.Scopes,
"created": token.Created,
"token_last_eight": token.TokenLastEight,
})
}
func deleteAccessTokenFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[AccessToken] Called deleteAccessTokenFn")
args := req.GetArguments()
token, err := params.GetString(args, "token")
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))
}
_, err = client.DeleteAccessToken(token)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete access token err: %v", err))
}
return to.TextResult("Access token deleted successfully")
}
+8
View File
@@ -0,0 +1,8 @@
package actions
import (
"gitea.com/gitea/gitea-mcp/pkg/tool"
)
// Tool is the registry for all Actions-related MCP tools.
var Tool = tool.New()
+345
View File
@@ -0,0 +1,345 @@
package actions
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
DefaultMaxArtifactSize = 100 * 1024 * 1024
ActionsArtifactToolName = "list_action_artifacts"
)
var (
ActionsArtifactTool = mcp.NewTool(
ActionsArtifactToolName,
mcp.WithDescription("List and download artifacts from workflow runs. Use method 'list' to list artifacts, 'get' to get a specific artifact, 'download' to download artifact content."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "download")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Description("run ID to filter artifacts (optional for list, required for get/download)")),
mcp.WithString("artifact_name", mcp.Description("artifact name to filter (optional)")),
mcp.WithNumber("artifact_id", mcp.Description("artifact ID (required for 'get' and 'download' methods)")),
mcp.WithString("output_path", mcp.Description("output file path (for 'download' method). If not specified, saves to ~/.gitea-mcp/artifacts/")),
mcp.WithNumber("max_size", mcp.Description("maximum artifact size in bytes to download (default 100MB)"), mcp.DefaultNumber(DefaultMaxArtifactSize), mcp.Min(1024)),
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)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsArtifactTool, Handler: artifactHandler})
}
func artifactHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionArtifacts",
"param": "method",
}))
}
switch method {
case "list":
return listActionArtifactsFn(ctx, req)
case "get":
return getActionArtifactFn(ctx, req)
case "download":
return downloadActionArtifactFn(ctx, req)
default:
return to.ErrorResult(errors.NewEnhancedError(
fmt.Errorf("unknown method: %s", method),
"Invalid method. Use 'list', 'get', or 'download'",
errors.CategoryActions,
).WithOperation("ListActionArtifacts"))
}
}
func listActionArtifactsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listActionArtifactsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"owner is required",
errors.CategoryActions,
).WithOperation("ListActionArtifacts"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"repo is required",
errors.CategoryActions,
).WithOperation("ListActionArtifacts"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
artifactName, _ := req.GetArguments()["artifact_name"].(string)
var runID int64
if runIDVal, exists := req.GetArguments()["run_id"]; exists {
if runIDFloat, ok := runIDVal.(float64); ok {
runID = int64(runIDFloat)
}
}
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if artifactName != "" {
query.Set("name", artifactName)
}
if runID > 0 {
query.Set("run_id", strconv.FormatInt(runID, 10))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
if errors.IsActionsAPIUnavailable(err) {
return to.TextResult(map[string]any{
"artifacts": []any{},
"total_count": 0,
"message": "Actions API not available on this Gitea version",
})
}
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionArtifacts",
"owner": owner,
"repo": repo,
}))
}
return to.TextResult(slimActionArtifacts(result))
}
func getActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getActionArtifactFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"owner is required",
errors.CategoryActions,
).WithOperation("GetActionArtifact"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"repo is required",
errors.CategoryActions,
).WithOperation("GetActionArtifact"))
}
artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id")
if err != nil || artifactID <= 0 {
return to.ErrorResult(errors.NewEnhancedError(
err,
"artifact_id is required",
errors.CategoryActions,
).WithOperation("GetActionArtifact"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d", url.PathEscape(owner), url.PathEscape(repo), artifactID),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "GetActionArtifact",
"owner": owner,
"repo": repo,
"artifact_id": strconv.FormatInt(artifactID, 10),
}))
}
return to.TextResult(slimActionArtifact(result))
}
func downloadActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called downloadActionArtifactFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"owner is required",
errors.CategoryActions,
).WithOperation("DownloadActionArtifact"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"repo is required",
errors.CategoryActions,
).WithOperation("DownloadActionArtifact"))
}
artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id")
if err != nil || artifactID <= 0 {
return to.ErrorResult(errors.NewEnhancedError(
err,
"artifact_id is required",
errors.CategoryActions,
).WithOperation("DownloadActionArtifact"))
}
maxSize := int64(params.GetOptionalInt(req.GetArguments(), "max_size", DefaultMaxArtifactSize))
outputPath, _ := req.GetArguments()["output_path"].(string)
var artifactInfo any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d", url.PathEscape(owner), url.PathEscape(repo), artifactID),
},
nil, nil, &artifactInfo,
)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"owner": owner,
"repo": repo,
"artifact_id": strconv.FormatInt(artifactID, 10),
}))
}
var artifactSize int64
if info, ok := artifactInfo.(map[string]any); ok {
if size, ok := info["size_in_bytes"].(float64); ok {
artifactSize = int64(size)
}
}
if artifactSize > maxSize {
return to.ErrorResult(errors.NewEnhancedError(
fmt.Errorf("artifact size %d exceeds maximum allowed size %d", artifactSize, maxSize),
fmt.Sprintf("Artifact size (%s) exceeds maximum allowed size (%s). Use max_size parameter to increase limit.",
formatBytes(artifactSize), formatBytes(maxSize)),
errors.CategoryActions,
).WithOperation("DownloadActionArtifact").
WithParam("owner", owner).
WithParam("repo", repo).
WithParam("artifact_id", strconv.FormatInt(artifactID, 10)))
}
artifactBytes, _, err := gitea.DoBytes(ctx, "GET",
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d/download", url.PathEscape(owner), url.PathEscape(repo), artifactID),
nil, nil, "application/zip",
)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"owner": owner,
"repo": repo,
"artifact_id": strconv.FormatInt(artifactID, 10),
}))
}
if outputPath == "" {
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
var artifactName string
if info, ok := artifactInfo.(map[string]any); ok {
if name, ok := info["name"].(string); ok {
artifactName = name
}
}
if artifactName == "" {
artifactName = fmt.Sprintf("artifact-%d", artifactID)
}
outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", owner, repo, fmt.Sprintf("%s.zip", artifactName))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"action": "create_output_dir",
}))
}
if err := os.WriteFile(outputPath, artifactBytes, 0o600); err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"action": "write_file",
}))
}
var artifactName string
if info, ok := artifactInfo.(map[string]any); ok {
if name, ok := info["name"].(string); ok {
artifactName = name
}
}
return to.TextResult(map[string]any{
"artifact_id": artifactID,
"name": artifactName,
"path": outputPath,
"size_in_bytes": len(artifactBytes),
"message": "artifact downloaded successfully",
})
}
func slimActionArtifact(raw any) any {
if m, ok := raw.(map[string]any); ok {
return pick(m, "id", "name", "size_in_bytes", "download_url", "run_id", "created_at", "expires_at")
}
return raw
}
func slimActionArtifacts(raw any) any {
return slimPaginated(raw, func(m map[string]any) map[string]any {
return pick(m, "id", "name", "size_in_bytes", "download_url", "run_id", "created_at", "expires_at")
})
}
func formatBytes(bytes int64) string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/GB)
case bytes >= MB:
return fmt.Sprintf("%.2f MB", float64(bytes)/MB)
case bytes >= KB:
return fmt.Sprintf("%.2f KB", float64(bytes)/KB)
default:
return fmt.Sprintf("%d B", bytes)
}
}
+415
View File
@@ -0,0 +1,415 @@
package actions
import (
"testing"
)
func TestSlimActionArtifact(t *testing.T) {
tests := []struct {
name string
input map[string]any
expected map[string]any
}{
{
name: "complete artifact",
input: map[string]any{
"id": float64(123),
"name": "build-output",
"size_in_bytes": float64(1024000),
"download_url": "https://gitea.example.com/api/v1/repos/owner/repo/actions/artifacts/123/download",
"run_id": float64(456),
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-02-15T10:30:00Z",
"extra_field": "should be removed",
},
expected: map[string]any{
"id": float64(123),
"name": "build-output",
"size_in_bytes": float64(1024000),
"download_url": "https://gitea.example.com/api/v1/repos/owner/repo/actions/artifacts/123/download",
"run_id": float64(456),
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-02-15T10:30:00Z",
},
},
{
name: "minimal artifact",
input: map[string]any{
"id": float64(789),
"name": "test-results",
},
expected: map[string]any{
"id": float64(789),
"name": "test-results",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifact(tt.input)
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("slimActionArtifact() did not return a map")
}
for key, expectedValue := range tt.expected {
if resultMap[key] != expectedValue {
t.Fatalf("slimActionArtifact()[%q] = %v, want %v", key, resultMap[key], expectedValue)
}
}
if _, exists := resultMap["extra_field"]; exists {
t.Fatalf("slimActionArtifact() should not include 'extra_field'")
}
})
}
}
func TestSlimActionArtifactNonMap(t *testing.T) {
tests := []struct {
name string
input any
}{
{
name: "nil input",
input: nil,
},
{
name: "string input",
input: "not a map",
},
{
name: "int input",
input: 123,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifact(tt.input)
if result != tt.input {
t.Fatalf("slimActionArtifact() = %v, want %v", result, tt.input)
}
})
}
}
func TestSlimActionArtifacts(t *testing.T) {
tests := []struct {
name string
input map[string]any
expected map[string]any
}{
{
name: "artifacts with total count",
input: map[string]any{
"total_count": float64(2),
"artifacts": []any{
map[string]any{
"id": float64(1),
"name": "artifact-1",
"size_in_bytes": float64(1000),
"download_url": "url1",
},
map[string]any{
"id": float64(2),
"name": "artifact-2",
"size_in_bytes": float64(2000),
"download_url": "url2",
},
},
},
expected: map[string]any{
"total_count": float64(2),
},
},
{
name: "empty artifacts list",
input: map[string]any{
"total_count": float64(0),
"artifacts": []any{},
},
expected: map[string]any{
"total_count": float64(0),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifacts(tt.input)
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("slimActionArtifacts() did not return a map")
}
if resultMap["total_count"] != tt.expected["total_count"] {
t.Fatalf("total_count = %v, want %v", resultMap["total_count"], tt.expected["total_count"])
}
if artifacts, ok := resultMap["artifacts"].([]any); ok {
for _, item := range artifacts {
if artifact, ok := item.(map[string]any); ok {
if _, exists := artifact["id"]; !exists {
t.Fatalf("slimmed artifact should have 'id' field")
}
}
}
}
})
}
}
func TestFormatBytes(t *testing.T) {
tests := []struct {
bytes int64
expected string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1.00 KB"},
{1536, "1.50 KB"},
{1024 * 1024, "1.00 MB"},
{1536 * 1024, "1.50 MB"},
{100 * 1024 * 1024, "100.00 MB"},
{1024 * 1024 * 1024, "1.00 GB"},
{1536 * 1024 * 1024, "1.50 GB"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := formatBytes(tt.bytes)
if result != tt.expected {
t.Fatalf("formatBytes(%d) = %q, want %q", tt.bytes, result, tt.expected)
}
})
}
}
func TestFormatBytes_EdgeCases(t *testing.T) {
tests := []struct {
bytes int64
expected string
}{
{-1, "-1 B"},
{0, "0 B"},
{1, "1 B"},
{512, "512 B"},
{1023, "1023 B"},
{1024, "1.00 KB"},
{1025, "1.00 KB"},
{1536, "1.50 KB"},
{1024 * 1024, "1.00 MB"},
{1024*1024 + 1, "1.00 MB"},
{1536 * 1024 * 1024, "1.50 GB"},
{1024 * 1024 * 1024, "1.00 GB"},
{1024 * 1024 * 1024 * 1024, "1024.00 GB"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := formatBytes(tt.bytes)
if result != tt.expected {
t.Fatalf("formatBytes(%d) = %q, want %q", tt.bytes, result, tt.expected)
}
})
}
}
func TestSlimActionArtifact_EdgeCases(t *testing.T) {
tests := []struct {
name string
input any
check func(t *testing.T, result any)
}{
{
name: "nil input",
input: nil,
check: func(t *testing.T, result any) {
if result != nil {
t.Fatalf("expected nil, got %v", result)
}
},
},
{
name: "string input",
input: "not a map",
check: func(t *testing.T, result any) {
if result != "not a map" {
t.Fatalf("expected 'not a map', got %v", result)
}
},
},
{
name: "int input",
input: 123,
check: func(t *testing.T, result any) {
if result != 123 {
t.Fatalf("expected 123, got %v", result)
}
},
},
{
name: "empty map",
input: map[string]any{},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if len(resultMap) != 0 {
t.Fatalf("expected empty map, got %d fields", len(resultMap))
}
},
},
{
name: "map with only extra fields",
input: map[string]any{
"extra1": "value1",
"extra2": "value2",
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if len(resultMap) != 0 {
t.Fatalf("expected empty map after filtering, got %d fields", len(resultMap))
}
},
},
{
name: "map with null values",
input: map[string]any{
"id": float64(123),
"name": nil,
"size_in_bytes": float64(1000),
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if resultMap["name"] != nil {
t.Fatalf("expected nil name, got %v", resultMap["name"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifact(tt.input)
tt.check(t, result)
})
}
}
func TestSlimActionArtifacts_EdgeCases(t *testing.T) {
tests := []struct {
name string
input any
check func(t *testing.T, result any)
}{
{
name: "nil input",
input: nil,
check: func(t *testing.T, result any) {
if result != nil {
t.Fatalf("expected nil, got %v", result)
}
},
},
{
name: "string input",
input: "not a map",
check: func(t *testing.T, result any) {
if result != "not a map" {
t.Fatalf("expected 'not a map', got %v", result)
}
},
},
{
name: "map without artifacts key",
input: map[string]any{
"total_count": float64(0),
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if resultMap["total_count"] != float64(0) {
t.Fatalf("expected total_count 0, got %v", resultMap["total_count"])
}
},
},
{
name: "map with nil artifacts",
input: map[string]any{
"total_count": float64(0),
"artifacts": nil,
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if resultMap["total_count"] != float64(0) {
t.Fatalf("expected total_count 0, got %v", resultMap["total_count"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifacts(tt.input)
tt.check(t, result)
})
}
}
func TestDefaultMaxArtifactSize_Constant(t *testing.T) {
expectedSize := int64(100 * 1024 * 1024)
if DefaultMaxArtifactSize != expectedSize {
t.Fatalf("DefaultMaxArtifactSize = %d, want %d", DefaultMaxArtifactSize, expectedSize)
}
}
func TestActionsArtifactToolName_Constant(t *testing.T) {
expectedName := "list_action_artifacts"
if ActionsArtifactToolName != expectedName {
t.Fatalf("ActionsArtifactToolName = %q, want %q", ActionsArtifactToolName, expectedName)
}
}
func TestSlimActionArtifactsNonMap(t *testing.T) {
tests := []struct {
name string
input any
}{
{
name: "nil input",
input: nil,
},
{
name: "string input",
input: "not a map",
},
{
name: "int input",
input: 123,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifacts(tt.input)
if result != tt.input {
t.Fatalf("slimActionArtifacts() = %v, want %v", result, tt.input)
}
})
}
}
+555
View File
@@ -0,0 +1,555 @@
package actions
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"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_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ActionsConfigReadToolName = "actions_config_read"
ActionsConfigWriteToolName = "actions_config_write"
)
type secretMeta struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at,omitzero"`
}
func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta {
metas := make([]secretMeta, 0, len(secrets))
for _, s := range secrets {
if s == nil {
continue
}
metas = append(metas, secretMeta{
Name: s.Name,
Description: s.Description,
CreatedAt: s.Created,
})
}
return metas
}
var (
ActionsConfigReadTool = mcp.NewTool(
ActionsConfigReadToolName,
mcp.WithDescription("Read Actions secrets and variables configuration."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org 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("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsConfigWriteTool = mcp.NewTool(
ActionsConfigWriteToolName,
mcp.WithDescription("Manage Actions secrets and variables: create, update, or delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("secret or variable name (required for most methods)")),
mcp.WithString("data", mcp.Description("secret value (required for upsert secret methods)")),
mcp.WithString("value", mcp.Description("variable value (required for create/update variable methods)")),
mcp.WithString("description", mcp.Description("description for secret or variable")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsConfigReadTool, Handler: configReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsConfigWriteTool, Handler: configWriteFn})
}
func configReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_repo_secrets":
return listRepoActionSecretsFn(ctx, req)
case "list_org_secrets":
return listOrgActionSecretsFn(ctx, req)
case "list_repo_variables":
return listRepoActionVariablesFn(ctx, req)
case "get_repo_variable":
return getRepoActionVariableFn(ctx, req)
case "list_org_variables":
return listOrgActionVariablesFn(ctx, req)
case "get_org_variable":
return getOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func configWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "upsert_repo_secret":
return upsertRepoActionSecretFn(ctx, req)
case "delete_repo_secret":
return deleteRepoActionSecretFn(ctx, req)
case "upsert_org_secret":
return upsertOrgActionSecretFn(ctx, req)
case "delete_org_secret":
return deleteOrgActionSecretFn(ctx, req)
case "create_repo_variable":
return createRepoActionVariableFn(ctx, req)
case "update_repo_variable":
return updateRepoActionVariableFn(ctx, req)
case "delete_repo_variable":
return deleteRepoActionVariableFn(ctx, req)
case "create_org_variable":
return createOrgActionVariableFn(ctx, req)
case "update_org_variable":
return updateOrgActionVariableFn(ctx, req)
case "delete_org_variable":
return deleteOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
// Secret functions
func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionSecretsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListRepoActionSecret(owner, repo, gitea_sdk.ListRepoActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionSecret(owner, repo, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionSecret(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode})
}
func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionSecretsFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListOrgActionSecret(org, gitea_sdk.ListOrgActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionSecret(org, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
escapedOrg := url.PathEscape(org)
escapedSecret := url.PathEscape(name)
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/secrets/%s", escapedOrg, escapedSecret), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted"})
}
// Variable functions
func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionVariablesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/variables", url.PathEscape(owner), url.PathEscape(repo)), query, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action variables err: %v", err))
}
return to.TextResult(result)
}
func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repo action variable err: %v", err))
}
return to.TextResult(variable)
}
func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("create repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("update repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode})
}
func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionVariablesFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variables, _, err := client.ListOrgActionVariable(org, gitea_sdk.ListOrgActionVariableOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action variables err: %v", err))
}
return to.TextResult(variables)
}
func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetOrgActionVariable(org, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get org action variable err: %v", err))
}
return to.TextResult(variable)
}
func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionVariable(org, gitea_sdk.CreateOrgActionVariableOption{
Name: name,
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateOrgActionVariable(org, name, gitea_sdk.UpdateOrgActionVariableOption{
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("update org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted"})
}
+22
View File
@@ -0,0 +1,22 @@
package actions
import "testing"
func TestTailByLines(t *testing.T) {
in := []byte("a\nb\nc\nd\n")
got := string(tailByLines(in, 2))
if got != "c\nd\n" {
t.Fatalf("tailByLines(...,2) = %q", got)
}
}
func TestLimitBytesKeepsTail(t *testing.T) {
in := []byte("0123456789")
out, truncated := limitBytes(in, 4)
if !truncated {
t.Fatalf("expected truncated=true")
}
if string(out) != "6789" {
t.Fatalf("limitBytes tail = %q, want %q", string(out), "6789")
}
}
+598
View File
@@ -0,0 +1,598 @@
package actions
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
MonitorWorkflowDispatchToolName = "monitor_workflow_dispatch"
DefaultPollInterval = 10 * time.Second
DefaultTimeout = 5 * time.Minute
)
var (
MonitorWorkflowDispatchTool = mcp.NewTool(
MonitorWorkflowDispatchToolName,
mcp.WithDescription("Dispatch a workflow and monitor its execution until completion. Returns full execution summary including run ID, status, conclusion, duration, and logs."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("workflow ID or filename")),
mcp.WithString("ref", mcp.Required(), mcp.Description("git ref (branch/tag) to run workflow on")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object")),
mcp.WithNumber("timeout_seconds", mcp.Description("polling timeout in seconds (default: 300 = 5 minutes)"), mcp.DefaultNumber(300), mcp.Min(10)),
mcp.WithNumber("poll_interval_seconds", mcp.Description("poll interval in seconds (default: 10)"), mcp.DefaultNumber(10), mcp.Min(5)),
)
)
type MonitorResult struct {
RunID int64 `json:"run_id"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
WorkflowID string `json:"workflow_id"`
WorkflowName string `json:"workflow_name,omitempty"`
Branch string `json:"branch"`
CommitSHA string `json:"commit_sha"`
Duration string `json:"duration"`
DurationSec float64 `json:"duration_seconds"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Jobs []JobSummary `json:"jobs"`
Logs map[string]JobLogs `json:"logs,omitempty"`
Error string `json:"error,omitempty"`
TimedOut bool `json:"timed_out"`
}
type JobSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Steps []StepInfo `json:"steps,omitempty"`
}
type StepInfo struct {
Name string `json:"name"`
Number int `json:"number"`
Status string `json:"status"`
Conclusion string `json:"conclusion,omitempty"`
}
type JobLogs struct {
JobID int64 `json:"job_id"`
JobName string `json:"job_name"`
Log string `json:"log,omitempty"`
Bytes int `json:"bytes"`
Truncated bool `json:"truncated"`
}
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: MonitorWorkflowDispatchTool,
Handler: monitorWorkflowDispatchFn,
})
}
func monitorWorkflowDispatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called monitorWorkflowDispatchFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("owner is required"),
map[string]string{"operation": "MonitorWorkflowDispatch"},
))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("repo is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner},
))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("workflow_id is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner, "repo": repo},
))
}
ref, err := params.GetString(req.GetArguments(), "ref")
if err != nil || ref == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("ref is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner, "repo": repo, "workflow_id": workflowID},
))
}
timeoutSec := int(params.GetOptionalInt(req.GetArguments(), "timeout_seconds", 300))
pollIntervalSec := int(params.GetOptionalInt(req.GetArguments(), "poll_interval_seconds", 10))
var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists {
if m, ok := raw.(map[string]any); ok {
inputs = m
}
}
if err := checkActionsAPIAvailable(ctx); err != nil {
return to.ErrorResult(err)
}
log.Infof("Dispatching workflow %s for %s/%s on ref %s", workflowID, owner, repo, ref)
_, err = dispatchWorkflow(ctx, owner, repo, workflowID, ref, inputs)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DispatchWorkflow",
"owner": owner,
"repo": repo,
"workflow": workflowID,
}))
}
timeout := time.Duration(timeoutSec) * time.Second
pollInterval := time.Duration(pollIntervalSec) * time.Second
log.Infof("Waiting for workflow run to start (timeout: %v)...", timeout)
runID, err := waitForRunToStart(ctx, owner, repo, workflowID, ref, pollInterval, timeout)
if err != nil {
result := MonitorResult{
WorkflowID: workflowID,
Branch: ref,
Error: fmt.Sprintf("Workflow dispatched but run never started: %v", err),
}
return toErrorResultWithJSON(result)
}
log.Infof("Run %d started, monitoring until completion...", runID)
monitorResult, err := monitorRunUntilComplete(ctx, owner, repo, runID, pollInterval, timeout)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "MonitorRun",
"owner": owner,
"repo": repo,
"run_id": fmt.Sprintf("%d", runID),
}))
}
log.Infof("Retrieving logs for run %d...", runID)
logs, err := retrieveJobLogs(ctx, owner, repo, monitorResult.Jobs)
if err != nil {
log.Warnf("Failed to retrieve some job logs: %v", err)
}
monitorResult.Logs = logs
return toTextResultWithJSON(monitorResult)
}
func checkActionsAPIAvailable(ctx context.Context) error {
var versionResp struct {
Version string `json:"version"`
}
status, err := gitea.DoJSON(ctx, "GET", "version", nil, nil, &versionResp)
if err != nil {
return errors.TranslateError(
fmt.Errorf("failed to check Gitea version: status=%d, err=%v", status, err),
map[string]string{"operation": "CheckGiteaVersion"},
)
}
major, minor, patch, err := parseVersionForCheck(versionResp.Version)
if err != nil {
return errors.TranslateError(
fmt.Errorf("failed to parse version '%s': %v", versionResp.Version, err),
map[string]string{"operation": "ParseVersion"},
)
}
if major < 1 || (major == 1 && minor < 23) {
return errors.NewEnhancedError(
stderrors.New("Actions API not available"),
fmt.Sprintf("Actions API requires Gitea 1.23+, found %d.%d.%d", major, minor, patch),
errors.CategoryActions,
).WithOperation("CheckActionsAPIAvailable")
}
return nil
}
func parseVersionForCheck(version string) (int, int, int, error) {
version = trimVersionPrefix(version)
parts := splitVersion(version)
if len(parts) < 2 {
return 0, 0, 0, fmt.Errorf("invalid version format: %s", version)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid major version: %s", parts[0])
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid minor version: %s", parts[1])
}
patch := 0
if len(parts) >= 3 {
patch, err = strconv.Atoi(parts[2])
if err != nil {
patch = 0
}
}
return major, minor, patch, nil
}
func trimVersionPrefix(v string) string {
v = trimOnePrefix(v, "v")
v = trimOnePrefix(v, "V")
return v
}
func trimOnePrefix(s, prefix string) string {
if len(s) > 0 && s[0] == prefix[0] {
return s[1:]
}
return s
}
func splitVersion(v string) []string {
var parts []string
start := 0
for i := 0; i < len(v); i++ {
if v[i] == '.' {
if start < i {
parts = append(parts, v[start:i])
}
start = i + 1
}
}
if start < len(v) {
parts = append(parts, v[start:])
}
return parts
}
func dispatchWorkflow(ctx context.Context, owner, repo, workflowID, ref string, inputs map[string]any) (map[string]any, error) {
body := map[string]any{
"ref": ref,
}
if inputs != nil {
body["inputs"] = inputs
}
err := doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, body, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if stderrors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return nil, errors.NewEnhancedError(
err,
fmt.Sprintf("workflow dispatch not supported on this Gitea version (endpoint returned %d)", httpErr.StatusCode),
errors.CategoryActions,
).WithOperation("DispatchWorkflow")
}
return nil, err
}
return map[string]any{"message": "workflow dispatched"}, nil
}
func waitForRunToStart(ctx context.Context, owner, repo, workflowID, ref string, pollInterval, timeout time.Duration) (int64, error) {
startTime := time.Now()
seenRunIDs := make(map[int64]bool)
for time.Since(startTime) < timeout {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
runs, err := listRecentRuns(ctx, owner, repo, workflowID, ref, 10)
if err != nil {
log.Warnf("Failed to list runs: %v", err)
time.Sleep(pollInterval)
continue
}
for _, run := range runs {
runID := int64(run["id"].(float64))
if seenRunIDs[runID] {
continue
}
seenRunIDs[runID] = true
status := getString(run, "status")
if status == "queued" || status == "in_progress" || status == "waiting" {
return runID, nil
}
createdAt := getString(run, "created_at")
if createdAt != "" {
runTime, err := time.Parse(time.RFC3339, createdAt)
if err == nil && time.Since(runTime) < 2*time.Minute {
return runID, nil
}
}
}
time.Sleep(pollInterval)
}
return 0, fmt.Errorf("timeout waiting for run to start after %v", timeout)
}
func listRecentRuns(ctx context.Context, owner, repo, workflowID, ref string, limit int) ([]map[string]any, error) {
query := url.Values{}
query.Set("limit", strconv.Itoa(limit))
var result struct {
WorkflowRuns []map[string]any `json:"workflow_runs"`
}
err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return nil, err
}
var matchingRuns []map[string]any
for _, run := range result.WorkflowRuns {
if workflowID != "" {
runWorkflowID := getString(run, "workflow_id")
runPath := getString(run, "path")
if runWorkflowID != workflowID && runPath != workflowID &&
runWorkflowID != "" && !containsPath(runPath, workflowID) {
continue
}
}
if ref != "" {
headBranch := getString(run, "head_branch")
if headBranch != "" && headBranch != ref {
continue
}
}
matchingRuns = append(matchingRuns, run)
}
return matchingRuns, nil
}
func containsPath(path, substring string) bool {
return len(path) > 0 && len(substring) > 0 &&
(path == substring ||
(len(path) > len(substring) && (path[len(path)-len(substring):] == substring ||
path[:len(substring)] == substring)))
}
func getString(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if v, ok := m[key].(float64); ok {
return strconv.FormatInt(int64(v), 10)
}
return ""
}
func monitorRunUntilComplete(ctx context.Context, owner, repo string, runID int64, pollInterval, timeout time.Duration) (*MonitorResult, error) {
startTime := time.Now()
var firstSeen time.Time
for time.Since(startTime) < timeout {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
run, err := getRun(ctx, owner, repo, runID)
if err != nil {
return nil, fmt.Errorf("failed to get run %d: %v", runID, err)
}
status := getString(run, "status")
conclusion := getString(run, "conclusion")
if firstSeen.IsZero() {
firstSeen = time.Now()
}
if status == "completed" {
return buildMonitorResult(run, runID, owner, repo, firstSeen, time.Now())
}
log.Debugf("Run %d status: %s (conclusion: %s), waiting...", runID, status, conclusion)
time.Sleep(pollInterval)
}
run, err := getRun(ctx, owner, repo, runID)
if err != nil {
return nil, fmt.Errorf("timeout after %v and failed to get final status: %v", timeout, err)
}
result, _ := buildMonitorResult(run, runID, owner, repo, firstSeen, time.Now())
result.TimedOut = true
result.Error = fmt.Sprintf("Polling timed out after %v", timeout)
return result, nil
}
func getRun(ctx context.Context, owner, repo string, runID int64) (map[string]any, error) {
var result map[string]any
err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, &result,
)
return result, err
}
func buildMonitorResult(run map[string]any, runID int64, owner, repo string, started, ended time.Time) (*MonitorResult, error) {
result := &MonitorResult{
RunID: runID,
Status: getString(run, "status"),
Conclusion: getString(run, "conclusion"),
WorkflowID: getString(run, "workflow_id"),
WorkflowName: getString(run, "name"),
Branch: getString(run, "head_branch"),
CommitSHA: getString(run, "head_sha"),
StartedAt: getString(run, "created_at"),
CompletedAt: getString(run, "updated_at"),
}
if createdAt := getString(run, "run_started_at"); createdAt != "" {
result.StartedAt = createdAt
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
started = t
}
}
if updatedAt := getString(run, "updated_at"); updatedAt != "" {
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
ended = t
}
}
duration := ended.Sub(started)
if duration < 0 {
duration = 0
}
result.Duration = duration.String()
result.DurationSec = duration.Seconds()
jobs, err := listRunJobs(runID, owner, repo, run)
if err == nil {
result.Jobs = jobs
}
return result, nil
}
func listRunJobs(runID int64, owner, repo string, run map[string]any) ([]JobSummary, error) {
if jobsData, ok := run["jobs"].([]any); ok && len(jobsData) > 0 {
return parseJobSummaries(jobsData), nil
}
return nil, nil
}
func parseJobSummaries(jobsData []any) []JobSummary {
var summaries []JobSummary
for _, j := range jobsData {
job, ok := j.(map[string]any)
if !ok {
continue
}
summary := JobSummary{
ID: int64(job["id"].(float64)),
Name: getString(job, "name"),
Status: getString(job, "status"),
Conclusion: getString(job, "conclusion"),
StartedAt: getString(job, "started_at"),
CompletedAt: getString(job, "completed_at"),
}
if stepsData, ok := job["steps"].([]any); ok {
summary.Steps = parseSteps(stepsData)
}
summaries = append(summaries, summary)
}
return summaries
}
func parseSteps(stepsData []any) []StepInfo {
var steps []StepInfo
for _, s := range stepsData {
step, ok := s.(map[string]any)
if !ok {
continue
}
steps = append(steps, StepInfo{
Name: getString(step, "name"),
Number: int(step["number"].(float64)),
Status: getString(step, "status"),
Conclusion: getString(step, "conclusion"),
})
}
return steps
}
func retrieveJobLogs(ctx context.Context, owner, repo string, jobs []JobSummary) (map[string]JobLogs, error) {
logs := make(map[string]JobLogs)
for _, job := range jobs {
if job.ID == 0 {
continue
}
logData, _, err := fetchJobLogBytes(ctx, owner, repo, job.ID)
if err != nil {
log.Warnf("Failed to fetch logs for job %d: %v", job.ID, err)
continue
}
maxLogBytes := 100 * 1024
truncated := false
if len(logData) > maxLogBytes {
logData = logData[len(logData)-maxLogBytes:]
truncated = true
}
logs[job.Name] = JobLogs{
JobID: job.ID,
JobName: job.Name,
Log: string(logData),
Bytes: len(logData),
Truncated: truncated,
}
}
return logs, nil
}
func toTextResultWithJSON(result *MonitorResult) (*mcp.CallToolResult, error) {
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("failed to marshal result: %v", err))
}
return to.TextResult(string(jsonBytes))
}
func toErrorResultWithJSON(result MonitorResult) (*mcp.CallToolResult, error) {
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("%s (marshal error: %v)", result.Error, err))
}
return to.TextResult(fmt.Sprintf("Error: %s\n\nPartial Result:\n%s", result.Error, string(jsonBytes)))
}
+607
View File
@@ -0,0 +1,607 @@
package actions
import (
"testing"
)
func TestTrimVersionPrefix(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"v1.22.5", "1.22.5"},
{"V1.22.5", "1.22.5"},
{"1.22.5", "1.22.5"},
{"v1.23.0", "1.23.0"},
{"2.0.0", "2.0.0"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := trimVersionPrefix(tt.input)
if result != tt.expected {
t.Errorf("trimVersionPrefix(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestSplitVersion(t *testing.T) {
tests := []struct {
input string
expected []string
}{
{"1.22.5", []string{"1", "22", "5"}},
{"1.23", []string{"1", "23"}},
{"2.0.0", []string{"2", "0", "0"}},
{"1", []string{"1"}},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := splitVersion(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("splitVersion(%q) = %v, want %v", tt.input, result, tt.expected)
return
}
for i := range result {
if result[i] != tt.expected[i] {
t.Errorf("splitVersion(%q)[%d] = %q, want %q", tt.input, i, result[i], tt.expected[i])
}
}
})
}
}
func TestParseVersionForCheck(t *testing.T) {
tests := []struct {
version string
wantMajor int
wantMinor int
wantPatch int
wantErr bool
}{
{"1.22.5", 1, 22, 5, false},
{"1.23.0", 1, 23, 0, false},
{"v1.22.5", 1, 22, 5, false},
{"V1.23.0", 1, 23, 0, false},
{"2.0.0", 2, 0, 0, false},
{"1.22", 1, 22, 0, false},
{"invalid", 0, 0, 0, true},
{"", 0, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
major, minor, patch, err := parseVersionForCheck(tt.version)
if (err != nil) != tt.wantErr {
t.Errorf("parseVersionForCheck(%q) error = %v, wantErr %v", tt.version, err, tt.wantErr)
return
}
if !tt.wantErr {
if major != tt.wantMajor || minor != tt.wantMinor || patch != tt.wantPatch {
t.Errorf("parseVersionForCheck(%q) = (%d, %d, %d), want (%d, %d, %d)",
tt.version, major, minor, patch, tt.wantMajor, tt.wantMinor, tt.wantPatch)
}
}
})
}
}
func TestContainsPath(t *testing.T) {
tests := []struct {
path string
substr string
expected bool
}{
{".gitea/workflows/build.yml", "build.yml", true},
{".github/workflows/test.yml", "test.yml", true},
{".gitea/workflows/build.yml", "deploy.yml", false},
{"build.yml", "build.yml", true},
{"", "test", false},
{"test", "", false},
}
for _, tt := range tests {
t.Run(tt.path+"_"+tt.substr, func(t *testing.T) {
result := containsPath(tt.path, tt.substr)
if result != tt.expected {
t.Errorf("containsPath(%q, %q) = %v, want %v", tt.path, tt.substr, result, tt.expected)
}
})
}
}
func TestGetString(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
expected string
}{
{
name: "string value",
m: map[string]any{"name": "test"},
key: "name",
expected: "test",
},
{
name: "float64 value",
m: map[string]any{"id": float64(123)},
key: "id",
expected: "123",
},
{
name: "missing key",
m: map[string]any{"other": "value"},
key: "name",
expected: "",
},
{
name: "nil map",
m: nil,
key: "name",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getString(tt.m, tt.key)
if result != tt.expected {
t.Errorf("getString(%v, %q) = %q, want %q", tt.m, tt.key, result, tt.expected)
}
})
}
}
func TestParseJobSummaries(t *testing.T) {
jobsData := []any{
map[string]any{
"id": float64(1),
"name": "build",
"status": "completed",
"conclusion": "success",
"started_at": "2024-01-15T10:00:00Z",
"completed_at": "2024-01-15T10:05:00Z",
"steps": []any{
map[string]any{
"name": "Checkout",
"number": float64(1),
"status": "completed",
"conclusion": "success",
},
},
},
}
summaries := parseJobSummaries(jobsData)
if len(summaries) != 1 {
t.Fatalf("expected 1 summary, got %d", len(summaries))
}
if summaries[0].ID != 1 {
t.Errorf("expected ID 1, got %d", summaries[0].ID)
}
if summaries[0].Name != "build" {
t.Errorf("expected name 'build', got %s", summaries[0].Name)
}
if len(summaries[0].Steps) != 1 {
t.Errorf("expected 1 step, got %d", len(summaries[0].Steps))
}
}
func TestParseSteps(t *testing.T) {
stepsData := []any{
map[string]any{
"name": "Checkout",
"number": float64(1),
"status": "completed",
"conclusion": "success",
},
map[string]any{
"name": "Build",
"number": float64(2),
"status": "completed",
"conclusion": "success",
},
}
steps := parseSteps(stepsData)
if len(steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(steps))
}
if steps[0].Name != "Checkout" || steps[0].Number != 1 {
t.Errorf("first step mismatch: %+v", steps[0])
}
if steps[1].Name != "Build" || steps[1].Number != 2 {
t.Errorf("second step mismatch: %+v", steps[1])
}
}
func TestMonitorResultTypes(t *testing.T) {
result := &MonitorResult{
RunID: 123,
Status: "completed",
Conclusion: "success",
WorkflowID: "build.yml",
Branch: "main",
CommitSHA: "abc123",
Duration: "5m0s",
DurationSec: 300,
Jobs: []JobSummary{
{
ID: 1,
Name: "build",
Status: "completed",
Conclusion: "success",
Steps: []StepInfo{
{Name: "Checkout", Number: 1, Status: "completed", Conclusion: "success"},
},
},
},
Logs: map[string]JobLogs{
"build": {
JobID: 1,
JobName: "build",
Log: "Building...",
Bytes: 100,
},
},
}
if result.RunID != 123 {
t.Errorf("RunID mismatch")
}
if result.Status != "completed" {
t.Errorf("Status mismatch")
}
if len(result.Jobs) != 1 {
t.Errorf("Jobs length mismatch")
}
if len(result.Logs) != 1 {
t.Errorf("Logs length mismatch")
}
}
func TestTrimOnePrefix(t *testing.T) {
tests := []struct {
s string
prefix string
expected string
}{
{"v1.22.5", "v", "1.22.5"},
{"V1.22.5", "V", "1.22.5"},
{"1.22.5", "v", "1.22.5"},
{"", "v", ""},
{"v", "v", ""},
{"test", "x", "test"},
}
for _, tt := range tests {
t.Run(tt.s+"_"+tt.prefix, func(t *testing.T) {
result := trimOnePrefix(tt.s, tt.prefix)
if result != tt.expected {
t.Errorf("trimOnePrefix(%q, %q) = %q, want %q", tt.s, tt.prefix, result, tt.expected)
}
})
}
}
func TestParseVersionForCheck_EdgeCases(t *testing.T) {
tests := []struct {
version string
wantMajor int
wantMinor int
wantPatch int
wantErr bool
}{
{"1.0.0", 1, 0, 0, false},
{"0.0.1", 0, 0, 1, false},
{"1.23.0+build", 1, 23, 0, false},
{"1.23.0-rc1", 1, 23, 0, false},
{"1.23", 1, 23, 0, false},
{"1", 1, 0, 0, false},
{"", 0, 0, 0, true},
{"abc", 0, 0, 0, true},
{"1.abc.5", 0, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
major, minor, patch, err := parseVersionForCheck(tt.version)
if (err != nil) != tt.wantErr {
t.Errorf("parseVersionForCheck(%q) error = %v, wantErr %v", tt.version, err, tt.wantErr)
return
}
if !tt.wantErr {
if major != tt.wantMajor || minor != tt.wantMinor || patch != tt.wantPatch {
t.Errorf("parseVersionForCheck(%q) = (%d, %d, %d), want (%d, %d, %d)",
tt.version, major, minor, patch, tt.wantMajor, tt.wantMinor, tt.wantPatch)
}
}
})
}
}
func TestContainsPath_EdgeCases(t *testing.T) {
tests := []struct {
path string
substr string
expected bool
}{
{"", "", false},
{"build.yml", "", false},
{"", "build", false},
{"a/b/c/d.yml", "c/d.yml", true},
{"a/b/c/d.yml", "b/c", false},
{"build.yml", "build.yml", true},
{"/absolute/path", "path", true},
}
for _, tt := range tests {
t.Run(tt.path+"_"+tt.substr, func(t *testing.T) {
result := containsPath(tt.path, tt.substr)
if result != tt.expected {
t.Errorf("containsPath(%q, %q) = %v, want %v", tt.path, tt.substr, result, tt.expected)
}
})
}
}
func TestGetString_EdgeCases(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
expected string
}{
{
name: "int value",
m: map[string]any{"count": int(42)},
key: "count",
expected: "",
},
{
name: "bool value",
m: map[string]any{"active": true},
key: "active",
expected: "",
},
{
name: "empty string value",
m: map[string]any{"name": ""},
key: "name",
expected: "",
},
{
name: "large float64",
m: map[string]any{"id": float64(9223372036854775807)},
key: "id",
expected: "9223372036854775807",
},
{
name: "zero float64",
m: map[string]any{"count": float64(0)},
key: "count",
expected: "0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getString(tt.m, tt.key)
if result != tt.expected {
t.Errorf("getString(%v, %q) = %q, want %q", tt.m, tt.key, result, tt.expected)
}
})
}
}
func TestParseJobSummaries_EdgeCases(t *testing.T) {
tests := []struct {
name string
jobsData []any
wantLen int
}{
{
name: "empty jobs",
jobsData: []any{},
wantLen: 0,
},
{
name: "nil jobs",
jobsData: nil,
wantLen: 0,
},
{
name: "job without steps",
jobsData: []any{
map[string]any{
"id": float64(1),
"name": "build",
"status": "completed",
"conclusion": "success",
},
},
wantLen: 1,
},
{
name: "job with empty steps",
jobsData: []any{
map[string]any{
"id": float64(1),
"name": "build",
"status": "completed",
"conclusion": "success",
"steps": []any{},
},
},
wantLen: 1,
},
{
name: "invalid job entry",
jobsData: []any{
"not a map",
map[string]any{
"id": float64(2),
"name": "test",
"status": "completed",
"conclusion": "success",
},
},
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summaries := parseJobSummaries(tt.jobsData)
if len(summaries) != tt.wantLen {
t.Errorf("parseJobSummaries() returned %d summaries, want %d", len(summaries), tt.wantLen)
}
})
}
}
func TestParseSteps_EdgeCases(t *testing.T) {
tests := []struct {
name string
stepsData []any
wantLen int
}{
{
name: "empty steps",
stepsData: []any{},
wantLen: 0,
},
{
name: "nil steps",
stepsData: nil,
wantLen: 0,
},
{
name: "invalid step entry",
stepsData: []any{
"not a map",
map[string]any{
"name": "Checkout",
"number": float64(1),
"status": "completed",
"conclusion": "success",
},
},
wantLen: 1,
},
{
name: "step without conclusion",
stepsData: []any{
map[string]any{
"name": "Setup",
"number": float64(1),
"status": "in_progress",
},
},
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
steps := parseSteps(tt.stepsData)
if len(steps) != tt.wantLen {
t.Errorf("parseSteps() returned %d steps, want %d", len(steps), tt.wantLen)
}
})
}
}
func TestMonitorResultStruct(t *testing.T) {
result := &MonitorResult{
RunID: 123,
Status: "completed",
Conclusion: "success",
WorkflowID: "build.yml",
WorkflowName: "Build",
Branch: "main",
CommitSHA: "abc123",
Duration: "5m0s",
DurationSec: 300,
StartedAt: "2024-01-15T10:00:00Z",
CompletedAt: "2024-01-15T10:05:00Z",
Jobs: []JobSummary{},
Logs: map[string]JobLogs{},
Error: "",
TimedOut: false,
}
if result.RunID != 123 {
t.Errorf("RunID = %d, want 123", result.RunID)
}
if result.Status != "completed" {
t.Errorf("Status = %s, want completed", result.Status)
}
if result.Conclusion != "success" {
t.Errorf("Conclusion = %s, want success", result.Conclusion)
}
if result.TimedOut {
t.Error("TimedOut should be false")
}
}
func TestMonitorResultErrorState(t *testing.T) {
result := &MonitorResult{
RunID: 456,
Status: "completed",
Conclusion: "failure",
WorkflowID: "test.yml",
Branch: "develop",
CommitSHA: "def456",
Duration: "2m30s",
DurationSec: 150,
Error: "Test failed",
TimedOut: false,
Jobs: []JobSummary{
{
ID: 1,
Name: "test",
Status: "completed",
Conclusion: "failure",
},
},
}
if result.Conclusion != "failure" {
t.Errorf("Conclusion = %s, want failure", result.Conclusion)
}
if result.Error != "Test failed" {
t.Errorf("Error = %s, want 'Test failed'", result.Error)
}
if len(result.Jobs) != 1 {
t.Errorf("Jobs count = %d, want 1", len(result.Jobs))
}
}
func TestMonitorResultTimeoutState(t *testing.T) {
result := &MonitorResult{
RunID: 789,
Status: "in_progress",
Conclusion: "",
WorkflowID: "deploy.yml",
Branch: "main",
CommitSHA: "ghi789",
Duration: "10m0s",
DurationSec: 600,
Error: "Polling timed out after 10m0s",
TimedOut: true,
Jobs: []JobSummary{},
}
if !result.TimedOut {
t.Error("TimedOut should be true")
}
if result.Error == "" {
t.Error("Error should not be empty for timeout")
}
}
+193
View File
@@ -0,0 +1,193 @@
package actions
import (
"context"
"fmt"
"net/http"
"net/url"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListActionRunnersToolName = "list_action_runners"
)
// ActionRunner represents a self-hosted action runner
// This is a local type since Gitea SDK v0.23.2 doesn't include it
type ActionRunner struct {
ID int64 `json:"id"`
Name string `json:"name"`
UUID string `json:"uuid"`
Status string `json:"status"`
Online bool `json:"online"`
Busy bool `json:"busy"`
Version string `json:"version,omitempty"`
Labels []string `json:"labels,omitempty"`
LastOnline string `json:"last_online,omitempty"`
}
// ActionRunnersResponse represents the API response for listing runners
type ActionRunnersResponse struct {
TotalCount int `json:"total_count"`
Runners []*ActionRunner `json:"runners"`
}
var (
ListActionRunnersTool = mcp.NewTool(
ListActionRunnersToolName,
mcp.WithDescription("List self-hosted action runners for a repository. Shows runner status, labels, and availability. Filter by status (online/offline). Note: Requires Gitea 1.23+; Gitea 1.22.5 does not support Actions API."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("status", mcp.Description("optional status filter (online, offline, busy, idle)"), mcp.Enum("online", "offline", "busy", "idle")),
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)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListActionRunnersTool,
Handler: listActionRunnersFn,
})
}
func listActionRunnersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listActionRunnersFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionRunners",
"param": "owner",
}))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionRunners",
"param": "repo",
}))
}
statusFilter, _ := req.GetArguments()["status"].(string)
page, pageSize := params.GetPagination(req.GetArguments(), 30)
// Use REST API to get runners
apiPath := fmt.Sprintf("/repos/%s/%s/actions/runners", owner, repo)
query := url.Values{
"page": []string{fmt.Sprintf("%d", page)},
"per_page": []string{fmt.Sprintf("%d", pageSize)},
}
var runnersResp ActionRunnersResponse
statusCode, err := gitea.DoJSON(ctx, http.MethodGet, apiPath, query, nil, &runnersResp)
if err != nil {
if statusCode == http.StatusNotFound {
// Gitea 1.22.5 doesn't have Actions API - return empty list with message
return to.TextResult(map[string]interface{}{
"total_count": 0,
"runners": []interface{}{},
"note": "Actions API not available in Gitea 1.22.5. Requires Gitea 1.23+.",
})
}
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionRunners",
"owner": owner,
"repo": repo,
}))
}
// Filter by status if requested
filteredRunners := make([]*ActionRunner, 0, len(runnersResp.Runners))
if statusFilter != "" {
for _, runner := range runnersResp.Runners {
switch statusFilter {
case "online":
if runner.Online {
filteredRunners = append(filteredRunners, runner)
}
case "offline":
if !runner.Online {
filteredRunners = append(filteredRunners, runner)
}
case "busy":
if runner.Busy {
filteredRunners = append(filteredRunners, runner)
}
case "idle":
if runner.Online && !runner.Busy {
filteredRunners = append(filteredRunners, runner)
}
default:
if runner.Status == statusFilter {
filteredRunners = append(filteredRunners, runner)
}
}
}
} else {
filteredRunners = runnersResp.Runners
}
result := slimActionRunners(filteredRunners)
return to.TextResult(result)
}
func slimActionRunners(runners []*ActionRunner) map[string]interface{} {
if len(runners) == 0 {
return map[string]interface{}{
"total_count": 0,
"runners": []interface{}{},
}
}
slimmed := make([]map[string]interface{}, 0, len(runners))
for _, runner := range runners {
slimmed = append(slimmed, slimActionRunner(runner))
}
return map[string]interface{}{
"total_count": len(runners),
"runners": slimmed,
}
}
func slimActionRunner(runner *ActionRunner) map[string]interface{} {
if runner == nil {
return nil
}
result := map[string]interface{}{
"id": runner.ID,
"name": runner.Name,
"uuid": runner.UUID,
"status": runner.Status,
"online": runner.Online,
"busy": runner.Busy,
}
if runner.Version != "" {
result["version"] = runner.Version
}
if len(runner.Labels) > 0 {
result["labels"] = runner.Labels
} else {
result["labels"] = []string{}
}
if runner.LastOnline != "" {
result["last_online"] = runner.LastOnline
}
return result
}
+354
View File
@@ -0,0 +1,354 @@
package actions
import (
"testing"
)
func TestSlimActionRunner(t *testing.T) {
tests := []struct {
name string
runner *ActionRunner
expected map[string]interface{}
}{
{
name: "complete runner",
runner: &ActionRunner{
ID: 1,
Name: "metal",
UUID: "uuid-123",
Status: "online",
Online: true,
Busy: false,
Version: "1.22.5",
Labels: []string{"metal", "self-hosted"},
LastOnline: "2024-01-01T00:00:00Z",
},
expected: map[string]interface{}{
"id": int64(1),
"name": "metal",
"uuid": "uuid-123",
"status": "online",
"online": true,
"busy": false,
"version": "1.22.5",
"labels": []string{"metal", "self-hosted"},
},
},
{
name: "runner without labels",
runner: &ActionRunner{
ID: 2,
Name: "cloud-1",
UUID: "uuid-456",
Status: "offline",
Online: false,
Busy: false,
Version: "1.22.5",
},
expected: map[string]interface{}{
"id": int64(2),
"name": "cloud-1",
"uuid": "uuid-456",
"status": "offline",
"online": false,
"busy": false,
"version": "1.22.5",
"labels": []string{},
},
},
{
name: "nil runner",
runner: nil,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunner(tt.runner)
if !runnerMapsEqual(result, tt.expected) {
t.Fatalf("slimActionRunner() = %v, want %v", result, tt.expected)
}
})
}
}
func TestSlimActionRunners(t *testing.T) {
tests := []struct {
name string
runners []*ActionRunner
expected map[string]interface{}
}{
{
name: "multiple runners",
runners: []*ActionRunner{
{
ID: 1,
Name: "metal",
Status: "online",
Online: true,
Labels: []string{"metal"},
},
{
ID: 2,
Name: "cloud-1",
Status: "offline",
Online: false,
Labels: []string{"cloud-1"},
},
},
expected: map[string]interface{}{
"total_count": 2,
},
},
{
name: "empty list",
runners: []*ActionRunner{},
expected: map[string]interface{}{
"total_count": 0,
"runners": []interface{}{},
},
},
{
name: "nil runners",
runners: nil,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunners(tt.runners)
if result["total_count"] != tt.expected["total_count"] {
t.Fatalf("total_count = %v, want %v", result["total_count"], tt.expected["total_count"])
}
})
}
}
func TestSlimActionRunner_EdgeCases(t *testing.T) {
tests := []struct {
name string
runner *ActionRunner
check func(t *testing.T, result map[string]interface{})
}{
{
name: "runner with zero ID",
runner: &ActionRunner{
ID: 0,
Name: "zero-runner",
Status: "offline",
},
check: func(t *testing.T, result map[string]interface{}) {
if result["id"] != int64(0) {
t.Errorf("expected ID 0, got %v", result["id"])
}
},
},
{
name: "runner with empty name",
runner: &ActionRunner{
ID: 1,
Name: "",
Status: "online",
},
check: func(t *testing.T, result map[string]interface{}) {
if result["name"] != "" {
t.Errorf("expected empty name, got %v", result["name"])
}
},
},
{
name: "runner with empty labels",
runner: &ActionRunner{
ID: 2,
Name: "no-labels",
Status: "online",
Labels: []string{},
},
check: func(t *testing.T, result map[string]interface{}) {
labels, ok := result["labels"].([]string)
if !ok || len(labels) != 0 {
t.Errorf("expected empty labels slice, got %v", result["labels"])
}
},
},
{
name: "runner with empty LastOnline",
runner: &ActionRunner{
ID: 3,
Name: "never-online",
Status: "offline",
Online: false,
LastOnline: "",
},
check: func(t *testing.T, result map[string]interface{}) {
if _, exists := result["last_online"]; exists {
t.Error("should not have last_online when empty")
}
},
},
{
name: "runner with many labels",
runner: &ActionRunner{
ID: 4,
Name: "many-labels",
Status: "online",
Labels: []string{"label1", "label2", "label3", "label4", "label5"},
},
check: func(t *testing.T, result map[string]interface{}) {
labels, ok := result["labels"].([]string)
if !ok || len(labels) != 5 {
t.Errorf("expected 5 labels, got %v", result["labels"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunner(tt.runner)
if result == nil {
t.Fatal("slimActionRunner returned nil")
}
tt.check(t, result)
})
}
}
func TestSlimActionRunners_EdgeCases(t *testing.T) {
tests := []struct {
name string
runners []*ActionRunner
check func(t *testing.T, result map[string]interface{})
}{
{
name: "nil runners slice",
runners: nil,
check: func(t *testing.T, result map[string]interface{}) {
if result["total_count"] != 0 {
t.Errorf("expected total_count 0, got %v", result["total_count"])
}
runners, ok := result["runners"].([]interface{})
if !ok || len(runners) != 0 {
t.Errorf("expected empty runners slice, got %v", result["runners"])
}
},
},
{
name: "single runner",
runners: []*ActionRunner{{ID: 1, Name: "single"}},
check: func(t *testing.T, result map[string]interface{}) {
if result["total_count"] != 1 {
t.Errorf("expected total_count 1, got %v", result["total_count"])
}
runners, ok := result["runners"].([]map[string]interface{})
if !ok || len(runners) != 1 {
t.Errorf("expected 1 runner, got %v", result["runners"])
}
},
},
{
name: "many runners",
runners: []*ActionRunner{
{ID: 1, Name: "runner1"},
{ID: 2, Name: "runner2"},
{ID: 3, Name: "runner3"},
{ID: 4, Name: "runner4"},
{ID: 5, Name: "runner5"},
},
check: func(t *testing.T, result map[string]interface{}) {
if result["total_count"] != 5 {
t.Errorf("expected total_count 5, got %v", result["total_count"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunners(tt.runners)
tt.check(t, result)
})
}
}
func TestRunnerMapsEqual_EdgeCases(t *testing.T) {
tests := []struct {
name string
a map[string]interface{}
b map[string]interface{}
expected bool
}{
{
name: "both nil",
a: nil,
b: nil,
expected: true,
},
{
name: "one nil",
a: map[string]interface{}{"key": "value"},
b: nil,
expected: false,
},
{
name: "different lengths",
a: map[string]interface{}{"a": 1},
b: map[string]interface{}{"a": 1, "b": 2},
expected: false,
},
{
name: "same keys different values",
a: map[string]interface{}{"key": "value1"},
b: map[string]interface{}{"key": "value2"},
expected: false,
},
{
name: "empty maps",
a: map[string]interface{}{},
b: map[string]interface{}{},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := runnerMapsEqual(tt.a, tt.b)
if result != tt.expected {
t.Errorf("runnerMapsEqual() = %v, want %v", result, tt.expected)
}
})
}
}
// runnerMapsEqual compares two map[string]interface{} values for equality
func runnerMapsEqual(a, b map[string]interface{}) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
bv, ok := b[k]
if !ok {
return false
}
// Simple comparison for basic types
switch vv := v.(type) {
case []string:
bvv, ok := bv.([]string)
if !ok || len(vv) != len(bvv) {
return false
}
for i, sv := range vv {
if sv != bvv[i] {
return false
}
}
default:
if v != bv {
return false
}
}
}
return true
}
+549
View File
@@ -0,0 +1,549 @@
package actions
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"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"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ActionsRunReadToolName = "actions_run_read"
ActionsRunWriteToolName = "actions_run_write"
)
var (
ActionsRunReadTool = mcp.NewTool(
ActionsRunReadToolName,
mcp.WithDescription("Read Actions workflow, run, and job data. Use method 'list_workflows'/'get_workflow' for workflows, 'list_runs'/'get_run' for runs, 'list_jobs'/'list_run_jobs' for jobs, 'get_job_log_preview'/'download_job_log' for logs."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'get_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'get_run', 'list_run_jobs')")),
mcp.WithNumber("job_id", mcp.Description("job ID (required for 'get_job_log_preview', 'download_job_log')")),
mcp.WithString("status", mcp.Description("optional status filter (for 'list_runs', 'list_jobs')")),
mcp.WithNumber("tail_lines", mcp.Description("number of lines from end of log (for 'get_job_log_preview')"), mcp.DefaultNumber(200), mcp.Min(1)),
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.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)),
)
ActionsRunWriteTool = mcp.NewTool(
ActionsRunWriteToolName,
mcp.WithDescription("Trigger, cancel, or rerun Actions workflows."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'dispatch_workflow')")),
mcp.WithString("ref", mcp.Description("git ref branch or tag (required for 'dispatch_workflow')")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object (for 'dispatch_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'cancel_run', 'rerun_run')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsRunReadTool, Handler: runReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsRunWriteTool, Handler: runWriteFn})
}
func runReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_workflows":
return listRepoActionWorkflowsFn(ctx, req)
case "get_workflow":
return getRepoActionWorkflowFn(ctx, req)
case "list_runs":
return listRepoActionRunsFn(ctx, req)
case "get_run":
return getRepoActionRunFn(ctx, req)
case "list_jobs":
return listRepoActionJobsFn(ctx, req)
case "list_run_jobs":
return listRepoActionRunJobsFn(ctx, req)
case "get_job_log_preview":
return getRepoActionJobLogPreviewFn(ctx, req)
case "download_job_log":
return downloadRepoActionJobLogFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func runWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "dispatch_workflow":
return dispatchRepoActionWorkflowFn(ctx, req)
case "cancel_run":
return cancelRepoActionRunFn(ctx, req)
case "rerun_run":
return rerunRepoActionRunFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func doJSONWithFallback(ctx context.Context, method string, paths []string, query url.Values, body, respOut any) error {
var lastErr error
for _, p := range paths {
_, err := gitea.DoJSON(ctx, method, p, query, body, respOut)
if err == nil {
return nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
continue
}
return err
}
return lastErr
}
func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionWorkflowsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action workflows err: %v", err))
}
return to.TextResult(slimActionWorkflows(result))
}
func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.New("workflow_id is required"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action workflow err: %v", err))
}
return to.TextResult(slimActionWorkflow(result))
}
func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called dispatchRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.New("workflow_id is required"))
}
ref, err := params.GetString(req.GetArguments(), "ref")
if err != nil || ref == "" {
return to.ErrorResult(errors.New("ref is required"))
}
var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists {
if m, ok := raw.(map[string]any); ok {
inputs = m
}
}
body := map[string]any{
"ref": ref,
}
if inputs != nil {
body["inputs"] = inputs
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, body, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return to.ErrorResult(fmt.Errorf("workflow dispatch not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
}
return to.ErrorResult(fmt.Errorf("dispatch action workflow err: %v", err))
}
return to.TextResult(map[string]any{"message": "workflow dispatched"})
}
func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionRunsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action runs err: %v", err))
}
return to.TextResult(slimActionRuns(result))
}
func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action run err: %v", err))
}
return to.TextResult(slimActionRun(result))
}
func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called cancelRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/cancel", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, nil,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("cancel action run err: %v", err))
}
return to.TextResult(map[string]any{"message": "run cancellation requested"})
}
func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called rerunRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun", url.PathEscape(owner), url.PathEscape(repo), runID),
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun-failed-jobs", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return to.ErrorResult(fmt.Errorf("workflow rerun not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
}
return to.ErrorResult(fmt.Errorf("rerun action run err: %v", err))
}
return to.TextResult(map[string]any{"message": "run rerun requested"})
}
func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/jobs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action jobs err: %v", err))
}
return to.TextResult(slimActionJobs(result))
}
func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionRunJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/jobs", url.PathEscape(owner), url.PathEscape(repo), runID),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action run jobs err: %v", err))
}
return to.TextResult(slimActionJobs(result))
}
// Log functions (merged from logs.go)
func logPaths(owner, repo string, jobID int64) []string {
return []string{
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/tasks/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/task/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
}
}
func fetchJobLogBytes(ctx context.Context, owner, repo string, jobID int64) ([]byte, string, error) {
var lastErr error
for _, p := range logPaths(owner, repo, jobID) {
b, _, err := gitea.DoBytes(ctx, "GET", p, nil, nil, "text/plain")
if err == nil {
return b, p, nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
continue
}
return nil, p, err
}
return nil, "", lastErr
}
func tailByLines(data []byte, tailLines int) []byte {
if tailLines <= 0 || len(data) == 0 {
return data
}
lines := 0
i := len(data) - 1
for i >= 0 {
if data[i] == '\n' {
lines++
if lines > tailLines {
return data[i+1:]
}
}
i--
}
return data
}
func limitBytes(data []byte, maxBytes int) ([]byte, bool) {
if maxBytes <= 0 {
return data, false
}
if len(data) <= maxBytes {
return data, false
}
return data[len(data)-maxBytes:], true
}
func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionJobLogPreviewFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
jobID, err := params.GetIndex(req.GetArguments(), "job_id")
if err != nil {
return to.ErrorResult(err)
}
tailLines := int(params.GetOptionalInt(req.GetArguments(), "tail_lines", 200))
maxBytes := int(params.GetOptionalInt(req.GetArguments(), "max_bytes", 65536))
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("get job log err: %v", err))
}
tailed := tailByLines(raw, tailLines)
limited, truncated := limitBytes(tailed, maxBytes)
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"bytes": len(raw),
"tail_lines": tailLines,
"max_bytes": maxBytes,
"truncated": truncated,
"log": string(limited),
})
}
func downloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called downloadRepoActionJobLogFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
jobID, err := params.GetIndex(req.GetArguments(), "job_id")
if err != nil {
return to.ErrorResult(err)
}
outputPath, _ := req.GetArguments()["output_path"].(string)
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("download job log err: %v", err))
}
if outputPath == "" {
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", "actions-logs", owner, repo, fmt.Sprintf("%d.log", jobID))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return to.ErrorResult(fmt.Errorf("create output dir err: %v", err))
}
if err := os.WriteFile(outputPath, raw, 0o600); err != nil {
return to.ErrorResult(fmt.Errorf("write log file err: %v", err))
}
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"path": outputPath,
"bytes": len(raw),
})
}
+92
View File
@@ -0,0 +1,92 @@
package actions
func pick(m map[string]any, keys ...string) map[string]any {
out := make(map[string]any, len(keys))
for _, k := range keys {
if v, ok := m[k]; ok {
out[k] = v
}
}
return out
}
func slimPaginated(raw any, itemFn func(map[string]any) map[string]any) any {
m, ok := raw.(map[string]any)
if !ok {
return raw
}
result := make(map[string]any)
if tc, ok := m["total_count"]; ok {
result["total_count"] = tc
}
for key, val := range m {
if key == "total_count" {
continue
}
arr, ok := val.([]any)
if !ok {
continue
}
slimmed := make([]any, 0, len(arr))
for _, item := range arr {
if im, ok := item.(map[string]any); ok {
slimmed = append(slimmed, itemFn(im))
}
}
result[key] = slimmed
break
}
return result
}
func slimRun(m map[string]any) map[string]any {
return pick(m, "id", "name", "head_branch", "head_sha", "run_number",
"event", "status", "conclusion", "workflow_id",
"html_url", "created_at", "updated_at")
}
func slimJob(m map[string]any) map[string]any {
out := pick(m, "id", "run_id", "name", "workflow_name",
"status", "conclusion", "html_url",
"started_at", "completed_at")
if steps, ok := m["steps"].([]any); ok {
slim := make([]any, 0, len(steps))
for _, s := range steps {
if sm, ok := s.(map[string]any); ok {
slim = append(slim, pick(sm, "name", "number", "status", "conclusion"))
}
}
out["steps"] = slim
}
return out
}
func slimWorkflow(m map[string]any) map[string]any {
return pick(m, "id", "name", "path", "state", "html_url", "created_at", "updated_at")
}
func slimActionRun(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimRun(m)
}
return raw
}
func slimActionRuns(raw any) any {
return slimPaginated(raw, slimRun)
}
func slimActionJobs(raw any) any {
return slimPaginated(raw, slimJob)
}
func slimActionWorkflow(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimWorkflow(m)
}
return raw
}
func slimActionWorkflows(raw any) any {
return slimPaginated(raw, slimWorkflow)
}
+326
View File
@@ -0,0 +1,326 @@
package actions
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"path/filepath"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"gopkg.in/yaml.v3"
)
const (
GetWorkflowFileContentToolName = "get_workflow_file_content"
)
var (
GetWorkflowFileContentTool = mcp.NewTool(
GetWorkflowFileContentToolName,
mcp.WithDescription("Get workflow file content from .gitea/workflows/ or .github/workflows/ directories. Auto-discovers workflow files and returns parsed YAML as JSON."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Description("git ref (branch/tag/commit). Defaults to default branch if not specified")),
mcp.WithString("pattern", mcp.Description("file pattern to match (e.g., '*.yml', 'build-*.yml'). Defaults to all workflow files")),
mcp.WithString("filename", mcp.Description("specific workflow filename to retrieve. If provided, pattern is ignored")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetWorkflowFileContentTool,
Handler: getWorkflowFileContentFn,
})
}
type WorkflowFile struct {
Name string `json:"name"`
Path string `json:"path"`
SHA string `json:"sha"`
Size int64 `json:"size"`
Content interface{} `json:"content"`
RawContent string `json:"raw_content,omitempty"`
Encoding string `json:"encoding,omitempty"`
}
type WorkflowFilesResult struct {
Directory string `json:"directory"`
Files []WorkflowFile `json:"files"`
TotalCount int `json:"total_count"`
}
func getWorkflowFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getWorkflowFileContentFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "GetWorkflowFileContent",
"param": "owner",
}))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "GetWorkflowFileContent",
"param": "repo",
}))
}
ref, _ := req.GetArguments()["ref"].(string)
pattern, _ := req.GetArguments()["pattern"].(string)
filename, _ := req.GetArguments()["filename"].(string)
directories := []string{".gitea/workflows", ".github/workflows"}
var result WorkflowFilesResult
var lastErr error
for _, dir := range directories {
if filename != "" {
file, err := getWorkflowFile(ctx, owner, repo, ref, filepath.Join(dir, filename))
if err == nil {
result.Directory = dir
result.Files = []WorkflowFile{*file}
result.TotalCount = 1
return to.TextResult(result)
}
if lastErr == nil {
lastErr = err
}
continue
}
files, err := discoverWorkflowFiles(ctx, owner, repo, ref, dir, pattern)
if err == nil && len(files) > 0 {
result.Directory = dir
result.Files = files
result.TotalCount = len(files)
return to.TextResult(result)
}
if err != nil && lastErr == nil {
lastErr = err
}
}
return to.TextResult(WorkflowFilesResult{
Directory: "",
Files: []WorkflowFile{},
TotalCount: 0,
})
}
func discoverWorkflowFiles(ctx context.Context, owner, repo, ref, dir, pattern string) ([]WorkflowFile, error) {
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "DiscoverWorkflowFiles",
"owner": owner,
"repo": repo,
})
}
contents, _, err := client.ListContents(owner, repo, ref, dir)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListWorkflowDirectory",
"owner": owner,
"repo": repo,
"path": dir,
})
}
var files []WorkflowFile
for _, content := range contents {
if content.Type != "file" {
continue
}
ext := strings.ToLower(filepath.Ext(content.Name))
if ext != ".yml" && ext != ".yaml" {
continue
}
if pattern != "" && !matchPattern(content.Name, pattern) {
continue
}
filePath := filepath.Join(dir, content.Name)
file, err := getWorkflowFile(ctx, owner, repo, ref, filePath)
if err != nil {
log.Debugf("Failed to get workflow file %s: %v", filePath, err)
continue
}
files = append(files, *file)
}
return files, nil
}
func getWorkflowFile(ctx context.Context, owner, repo, ref, path string) (*WorkflowFile, error) {
escapedOwner := url.PathEscape(owner)
escapedRepo := url.PathEscape(repo)
escapedPath := url.PathEscape(path)
var result any
_, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/contents/%s", escapedOwner, escapedRepo, escapedPath), nil, nil, &result)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "GetWorkflowFile",
"owner": owner,
"repo": repo,
"path": path,
})
}
contentMap, ok := result.(map[string]any)
if !ok {
return nil, fmt.Errorf("unexpected response type for workflow file")
}
workflowFile := &WorkflowFile{
Path: path,
}
if name, ok := contentMap["name"].(string); ok {
workflowFile.Name = name
}
if sha, ok := contentMap["sha"].(string); ok {
workflowFile.SHA = sha
}
if size, ok := contentMap["size"].(float64); ok {
workflowFile.Size = int64(size)
}
if encoding, ok := contentMap["encoding"].(string); ok {
workflowFile.Encoding = encoding
}
if contentStr, ok := contentMap["content"].(string); ok && contentStr != "" {
var rawContent []byte
if workflowFile.Encoding == "base64" {
decoded, err := base64.StdEncoding.DecodeString(contentStr)
if err != nil {
return nil, errors.TranslateError(
errors.NewEnhancedError(err, "Failed to decode workflow file content", errors.CategoryActions),
map[string]string{
"operation": "DecodeWorkflowFile",
"path": path,
"encoding": workflowFile.Encoding,
},
)
}
rawContent = decoded
} else {
rawContent = []byte(contentStr)
}
workflowFile.RawContent = string(rawContent)
var yamlContent interface{}
if err := yaml.Unmarshal(rawContent, &yamlContent); err != nil {
log.Debugf("Failed to parse YAML for %s: %v", path, err)
yamlContent = nil
} else {
workflowFile.Content = convertYamlToInterface(yamlContent)
}
}
return workflowFile, nil
}
func matchPattern(filename, pattern string) bool {
pattern = strings.ToLower(pattern)
filename = strings.ToLower(filename)
if pattern == "*" || pattern == "*.*" {
return true
}
if strings.HasPrefix(pattern, "*") {
suffix := pattern[1:]
return strings.HasSuffix(filename, suffix)
}
if strings.HasSuffix(pattern, "*") {
prefix := pattern[:len(pattern)-1]
return strings.HasPrefix(filename, prefix)
}
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
mid := pattern[1 : len(pattern)-1]
return strings.Contains(filename, mid)
}
return filename == pattern || strings.HasPrefix(filename, pattern)
}
func convertYamlToInterface(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
result := make(map[string]interface{})
for k, v := range val {
result[k] = convertYamlToInterface(v)
}
return result
case map[interface{}]interface{}:
result := make(map[string]interface{})
for k, v := range val {
key := fmt.Sprintf("%v", k)
result[key] = convertYamlToInterface(v)
}
return result
case []interface{}:
result := make([]interface{}, len(val))
for i, v := range val {
result[i] = convertYamlToInterface(v)
}
return result
case []yaml.Node:
result := make([]interface{}, len(val))
for i, v := range val {
result[i] = convertYamlToInterface(&v)
}
return result
case *yaml.Node:
switch val.Kind {
case yaml.ScalarNode:
s := val.Value
var parsed interface{}
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
return parsed
}
return s
case yaml.SequenceNode:
result := make([]interface{}, len(val.Content))
for i, n := range val.Content {
result[i] = convertYamlToInterface(&n)
}
return result
case yaml.MappingNode:
result := make(map[string]interface{})
for i := 0; i < len(val.Content); i += 2 {
key := val.Content[i].Value
result[key] = convertYamlToInterface(&val.Content[i+1])
}
return result
default:
return val.Value
}
default:
return val
}
}
+398
View File
@@ -0,0 +1,398 @@
package actions
import (
"testing"
)
func TestMatchPattern(t *testing.T) {
tests := []struct {
filename string
pattern string
want bool
}{
{"build.yml", "*", true},
{"build.yml", "*.*", true},
{"build.yml", "*.yml", true},
{"build.yaml", "*.yml", true},
{"build.yaml", "*.yaml", true},
{"build-test.yml", "build-*.yml", true},
{"build-test.yml", "*-test.yml", true},
{"build.yml", "deploy*.yml", false},
{"build.yml", "*.yaml", false},
{"BUILD.YML", "*.yml", true},
{"build.yml", "build.yml", true},
{"deploy.yml", "build.yml", false},
}
for _, tt := range tests {
t.Run(tt.filename+"_"+tt.pattern, func(t *testing.T) {
got := matchPattern(tt.filename, tt.pattern)
if got != tt.want {
t.Fatalf("matchPattern(%q, %q) = %v, want %v", tt.filename, tt.pattern, got, tt.want)
}
})
}
}
func TestConvertYamlToInterface(t *testing.T) {
tests := []struct {
name string
input interface{}
expected interface{}
}{
{
name: "string value",
input: "hello",
expected: "hello",
},
{
name: "int value",
input: 42,
expected: 42,
},
{
name: "simple map",
input: map[string]interface{}{
"name": "test",
"val": 123,
},
expected: map[string]interface{}{
"name": "test",
"val": 123,
},
},
{
name: "nested map",
input: map[string]interface{}{
"level1": map[string]interface{}{
"level2": "value",
},
},
expected: map[string]interface{}{
"level1": map[string]interface{}{
"level2": "value",
},
},
},
{
name: "slice",
input: []interface{}{"a", "b", "c"},
expected: []interface{}{"a", "b", "c"},
},
{
name: "map with interface keys",
input: map[interface{}]interface{}{
"key": "value",
123: "numeric key",
},
expected: map[string]interface{}{
"key": "value",
"123": "numeric key",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertYamlToInterface(tt.input)
if !deepEqual(got, tt.expected) {
t.Fatalf("convertYamlToInterface() = %v, want %v", got, tt.expected)
}
})
}
}
func deepEqual(a, b interface{}) bool {
switch av := a.(type) {
case map[string]interface{}:
bv, ok := b.(map[string]interface{})
if !ok || len(av) != len(bv) {
return false
}
for k, v := range av {
if !deepEqual(v, bv[k]) {
return false
}
}
return true
case []interface{}:
bv, ok := b.([]interface{})
if !ok || len(av) != len(bv) {
return false
}
for i, v := range av {
if !deepEqual(v, bv[i]) {
return false
}
}
return true
default:
return a == b
}
}
func TestWorkflowFileStruct(t *testing.T) {
wf := WorkflowFile{
Name: "test.yml",
Path: ".gitea/workflows/test.yml",
SHA: "abc123",
Size: 1024,
Content: map[string]interface{}{"name": "Test Workflow"},
RawContent: "name: Test Workflow",
Encoding: "base64",
}
if wf.Name != "test.yml" {
t.Fatalf("Name = %q, want %q", wf.Name, "test.yml")
}
if wf.Path != ".gitea/workflows/test.yml" {
t.Fatalf("Path = %q, want %q", wf.Path, ".gitea/workflows/test.yml")
}
}
func TestMatchPattern_EdgeCases(t *testing.T) {
tests := []struct {
filename string
pattern string
want bool
}{
// Edge cases for empty strings
{"", "*", true},
{"file.yml", "", false},
{"", "", true},
// Edge cases for special characters
{"file-name.yml", "*-name.yml", true},
{"file_name.yml", "*.yml", true},
{"file.name.yml", "*.yml", true},
// Edge case: only wildcard
{"anything", "*", true},
{"", "*", true},
// Edge case: pattern equals filename
{"exact.yml", "exact.yml", true},
{"exact.yml", "exact.yaml", false},
// Edge case: case sensitivity
{"FILE.YML", "*.yml", true},
{"File.Yml", "*.yml", true},
// Edge case: middle wildcards
{"build-test-deploy.yml", "*test*.yml", true},
{"build-prod-deploy.yml", "*test*.yml", false},
// Edge case: multiple extensions
{"file.tar.gz", "*.gz", true},
{"file.tar.gz", "*.tar.gz", true},
// Edge case: dots in filename
{".github/workflows/build.yml", "*.yml", true},
// Edge case: numeric patterns
{"build-123.yml", "build-*.yml", true},
{"build-abc.yml", "build-*.yml", true},
}
for _, tt := range tests {
t.Run(tt.filename+"_"+tt.pattern, func(t *testing.T) {
got := matchPattern(tt.filename, tt.pattern)
if got != tt.want {
t.Fatalf("matchPattern(%q, %q) = %v, want %v", tt.filename, tt.pattern, got, tt.want)
}
})
}
}
func TestConvertYamlToInterface_EdgeCases(t *testing.T) {
tests := []struct {
name string
input interface{}
expected interface{}
}{
{
name: "nil value",
input: nil,
expected: nil,
},
{
name: "empty map",
input: map[string]interface{}{},
expected: map[string]interface{}{},
},
{
name: "empty slice",
input: []interface{}{},
expected: []interface{}{},
},
{
name: "nested empty structures",
input: map[string]interface{}{
"empty_map": map[string]interface{}{},
"empty_slice": []interface{}{},
},
expected: map[string]interface{}{
"empty_map": map[string]interface{}{},
"empty_slice": []interface{}{},
},
},
{
name: "boolean value",
input: true,
expected: true,
},
{
name: "float value",
input: 3.14,
expected: 3.14,
},
{
name: "deeply nested map",
input: map[string]interface{}{
"level1": map[string]interface{}{
"level2": map[string]interface{}{
"level3": map[string]interface{}{
"value": "deep",
},
},
},
},
expected: map[string]interface{}{
"level1": map[string]interface{}{
"level2": map[string]interface{}{
"level3": map[string]interface{}{
"value": "deep",
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertYamlToInterface(tt.input)
if !deepEqual(got, tt.expected) {
t.Fatalf("convertYamlToInterface() = %v, want %v", got, tt.expected)
}
})
}
}
func TestWorkflowFileStruct_EdgeCases(t *testing.T) {
tests := []struct {
name string
file WorkflowFile
want WorkflowFile
}{
{
name: "empty file",
file: WorkflowFile{},
want: WorkflowFile{},
},
{
name: "file with zero size",
file: WorkflowFile{
Name: "empty.yml",
Path: ".gitea/workflows/empty.yml",
SHA: "abc123",
Size: 0,
Content: nil,
RawContent: "",
Encoding: "",
},
want: WorkflowFile{
Name: "empty.yml",
Path: ".gitea/workflows/empty.yml",
SHA: "abc123",
Size: 0,
},
},
{
name: "file with large size",
file: WorkflowFile{
Name: "large.yml",
Path: ".gitea/workflows/large.yml",
SHA: "def456",
Size: 1024 * 1024 * 10, // 10MB
Content: map[string]interface{}{"name": "Large Workflow"},
RawContent: "name: Large Workflow\\n# ... lots of content ...",
Encoding: "base64",
},
want: WorkflowFile{
Name: "large.yml",
Path: ".gitea/workflows/large.yml",
SHA: "def456",
Size: 1024 * 1024 * 10,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.file.Name != tt.want.Name {
t.Errorf("Name = %q, want %q", tt.file.Name, tt.want.Name)
}
if tt.file.Path != tt.want.Path {
t.Errorf("Path = %q, want %q", tt.file.Path, tt.want.Path)
}
if tt.file.SHA != tt.want.SHA {
t.Errorf("SHA = %q, want %q", tt.file.SHA, tt.want.SHA)
}
if tt.file.Size != tt.want.Size {
t.Errorf("Size = %d, want %d", tt.file.Size, tt.want.Size)
}
})
}
}
func TestWorkflowFilesResultStruct_EdgeCases(t *testing.T) {
tests := []struct {
name string
result WorkflowFilesResult
}{
{
name: "empty result",
result: WorkflowFilesResult{
Directory: "",
Files: []WorkflowFile{},
TotalCount: 0,
},
},
{
name: "nil files",
result: WorkflowFilesResult{
Directory: ".gitea/workflows",
Files: nil,
TotalCount: 0,
},
},
{
name: "single file",
result: WorkflowFilesResult{
Directory: ".github/workflows",
Files: []WorkflowFile{
{Name: "ci.yml"},
},
TotalCount: 1,
},
},
{
name: "many files",
result: WorkflowFilesResult{
Directory: ".gitea/workflows",
Files: []WorkflowFile{
{Name: "build.yml"},
{Name: "test.yml"},
{Name: "deploy.yml"},
{Name: "lint.yml"},
{Name: "security.yml"},
},
TotalCount: 5,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if len(tt.result.Files) != tt.result.TotalCount {
t.Errorf("Files length (%d) != TotalCount (%d)", len(tt.result.Files), tt.result.TotalCount)
}
})
}
}
+191
View File
@@ -0,0 +1,191 @@
package activity
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.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"
)
const (
ListUserActivityToolName = "list_user_activity"
ListOrgActivityToolName = "list_org_activity"
ListTeamActivityToolName = "list_team_activity"
)
var Tool = tool.New()
var (
ListUserActivityTool = mcp.NewTool(
ListUserActivityToolName,
mcp.WithDescription("List activity/feeds for a user"),
mcp.WithString("username", mcp.Required(), mcp.Description("Username")),
mcp.WithString("date", mcp.Description("Filter by date (YYYY-MM-DD)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20)),
)
ListOrgActivityTool = mcp.NewTool(
ListOrgActivityToolName,
mcp.WithDescription("List activity/feeds for an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithString("date", mcp.Description("Filter by date (YYYY-MM-DD)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20)),
)
ListTeamActivityTool = mcp.NewTool(
ListTeamActivityToolName,
mcp.WithDescription("List activity/feeds for a team"),
mcp.WithNumber("team_id", mcp.Required(), mcp.Description("Team ID")),
mcp.WithString("date", mcp.Description("Filter by date (YYYY-MM-DD)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListUserActivityTool,
Handler: listUserActivityFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListOrgActivityTool,
Handler: listOrgActivityFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListTeamActivityTool,
Handler: listTeamActivityFn,
})
}
func listUserActivityFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Activity] Called listUserActivityFn")
args := req.GetArguments()
username, err := params.GetString(args, "username")
if err != nil {
return to.ErrorResult(err)
}
date := params.GetOptionalString(args, "date", "")
page, pageSize := params.GetPagination(args, 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListUserActivityFeedsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
Date: date,
}
activities, _, err := client.ListUserActivityFeeds(username, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list user activity err: %v", err))
}
return to.TextResult(slimActivities(activities))
}
func listOrgActivityFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Activity] Called listOrgActivityFn")
args := req.GetArguments()
org, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
date := params.GetOptionalString(args, "date", "")
page, pageSize := params.GetPagination(args, 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListOrgActivityFeedsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
Date: date,
}
activities, _, err := client.ListOrgActivityFeeds(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list org activity err: %v", err))
}
return to.TextResult(slimActivities(activities))
}
func listTeamActivityFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Activity] Called listTeamActivityFn")
args := req.GetArguments()
teamID, err := params.GetIndex(args, "team_id")
if err != nil {
return to.ErrorResult(err)
}
date := params.GetOptionalString(args, "date", "")
page, pageSize := params.GetPagination(args, 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListTeamActivityFeedsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
Date: date,
}
activities, _, err := client.ListTeamActivityFeeds(teamID, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list team activity err: %v", err))
}
return to.TextResult(slimActivities(activities))
}
func slimActivities(activities []*gitea_sdk.Activity) []map[string]interface{} {
result := make([]map[string]interface{}, len(activities))
for i, a := range activities {
result[i] = slimActivity(a)
}
return result
}
func slimActivity(a *gitea_sdk.Activity) map[string]interface{} {
result := map[string]interface{}{
"id": a.ID,
"op_type": a.OpType,
"content": a.Content,
"repo_id": a.RepoID,
"comment_id": a.CommentID,
"ref_name": a.RefName,
"is_private": a.IsPrivate,
"user_id": a.UserID,
"created": a.Created,
}
if a.Repo != nil {
result["repo_name"] = a.Repo.FullName
}
if a.ActUser != nil {
result["act_user_id"] = a.ActUserID
result["act_user_name"] = a.ActUser.UserName
}
return result
}
+224
View File
@@ -0,0 +1,224 @@
package attachment
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.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"
)
const (
ListReleaseAttachmentsToolName = "list_release_attachments"
GetReleaseAttachmentToolName = "get_release_attachment"
ListIssueCommentAttachmentsToolName = "list_issue_comment_attachments"
GetIssueCommentAttachmentToolName = "get_issue_comment_attachment"
)
var Tool = tool.New()
var (
ListReleaseAttachmentsTool = mcp.NewTool(
ListReleaseAttachmentsToolName,
mcp.WithDescription("List attachments for a release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("release", mcp.Required(), mcp.Description("Release ID")),
)
GetReleaseAttachmentTool = mcp.NewTool(
GetReleaseAttachmentToolName,
mcp.WithDescription("Get a specific release attachment"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("release", mcp.Required(), mcp.Description("Release ID")),
mcp.WithNumber("attachment", mcp.Required(), mcp.Description("Attachment ID")),
)
ListIssueCommentAttachmentsTool = mcp.NewTool(
ListIssueCommentAttachmentsToolName,
mcp.WithDescription("List attachments for an issue comment"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("comment", mcp.Required(), mcp.Description("Comment ID")),
)
GetIssueCommentAttachmentTool = mcp.NewTool(
GetIssueCommentAttachmentToolName,
mcp.WithDescription("Get a specific issue comment attachment"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("comment", mcp.Required(), mcp.Description("Comment ID")),
mcp.WithNumber("attachment", mcp.Required(), mcp.Description("Attachment ID")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListReleaseAttachmentsTool,
Handler: listReleaseAttachmentsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetReleaseAttachmentTool,
Handler: getReleaseAttachmentFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListIssueCommentAttachmentsTool,
Handler: listIssueCommentAttachmentsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetIssueCommentAttachmentTool,
Handler: getIssueCommentAttachmentFn,
})
}
func listReleaseAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Attachment] Called listReleaseAttachmentsFn")
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)
}
release, err := params.GetIndex(args, "release")
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))
}
attachments, _, err := client.ListReleaseAttachments(owner, repo, release, gitea_sdk.ListReleaseAttachmentsOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list release attachments err: %v", err))
}
return to.TextResult(slimAttachments(attachments))
}
func getReleaseAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Attachment] Called getReleaseAttachmentFn")
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)
}
release, err := params.GetIndex(args, "release")
if err != nil {
return to.ErrorResult(err)
}
attachment, err := params.GetIndex(args, "attachment")
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))
}
att, _, err := client.GetReleaseAttachment(owner, repo, release, attachment)
if err != nil {
return to.ErrorResult(fmt.Errorf("get release attachment err: %v", err))
}
return to.TextResult(slimAttachment(att))
}
func listIssueCommentAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Attachment] Called listIssueCommentAttachmentsFn")
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)
}
comment, err := params.GetIndex(args, "comment")
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))
}
attachments, _, err := client.ListIssueCommentAttachments(owner, repo, comment)
if err != nil {
return to.ErrorResult(fmt.Errorf("list issue comment attachments err: %v", err))
}
return to.TextResult(slimAttachments(attachments))
}
func getIssueCommentAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Attachment] Called getIssueCommentAttachmentFn")
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)
}
comment, err := params.GetIndex(args, "comment")
if err != nil {
return to.ErrorResult(err)
}
attachment, err := params.GetIndex(args, "attachment")
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))
}
att, _, err := client.GetIssueCommentAttachment(owner, repo, comment, attachment)
if err != nil {
return to.ErrorResult(fmt.Errorf("get issue comment attachment err: %v", err))
}
return to.TextResult(slimAttachment(att))
}
func slimAttachments(atts []*gitea_sdk.Attachment) []map[string]interface{} {
result := make([]map[string]interface{}, len(atts))
for i, a := range atts {
result[i] = slimAttachment(a)
}
return result
}
func slimAttachment(a *gitea_sdk.Attachment) map[string]interface{} {
return map[string]interface{}{
"id": a.ID,
"name": a.Name,
"size": a.Size,
"download_count": a.DownloadCount,
"download_url": a.DownloadURL,
"uuid": a.UUID,
"created": a.Created,
}
}
+259
View File
@@ -0,0 +1,259 @@
package collaborator
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.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"
)
const (
ListCollaboratorsToolName = "list_collaborators"
GetCollaboratorToolName = "get_collaborator"
AddCollaboratorToolName = "add_collaborator"
DeleteCollaboratorToolName = "delete_collaborator"
CollaboratorPermissionToolName = "collaborator_permission"
)
var Tool = tool.New()
var (
ListCollaboratorsTool = mcp.NewTool(
ListCollaboratorsToolName,
mcp.WithDescription("List collaborators for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("role", mcp.Description("Filter by role (admin, write, read, none)")),
)
GetCollaboratorTool = mcp.NewTool(
GetCollaboratorToolName,
mcp.WithDescription("Check if a user is a collaborator"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("collaborator", mcp.Required(), mcp.Description("username to check")),
)
AddCollaboratorTool = mcp.NewTool(
AddCollaboratorToolName,
mcp.WithDescription("Add a collaborator to a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("collaborator", mcp.Required(), mcp.Description("username to add")),
mcp.WithString("permission", mcp.Description("Permission level"), mcp.Enum("read", "write", "admin")),
)
DeleteCollaboratorTool = mcp.NewTool(
DeleteCollaboratorToolName,
mcp.WithDescription("Remove a collaborator from a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("collaborator", mcp.Required(), mcp.Description("username to remove")),
)
CollaboratorPermissionTool = mcp.NewTool(
CollaboratorPermissionToolName,
mcp.WithDescription("Get collaborator permission details"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("collaborator", mcp.Required(), mcp.Description("username")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListCollaboratorsTool,
Handler: listCollaboratorsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetCollaboratorTool,
Handler: getCollaboratorFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: CollaboratorPermissionTool,
Handler: collaboratorPermissionFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: AddCollaboratorTool,
Handler: addCollaboratorFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteCollaboratorTool,
Handler: deleteCollaboratorFn,
})
}
func listCollaboratorsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Collaborator] Called listCollaboratorsFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListCollaboratorsOptions{}
collabs, _, err := client.ListCollaborators(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list collaborators err: %v", err))
}
return to.TextResult(slimUsers(collabs))
}
func getCollaboratorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Collaborator] Called getCollaboratorFn")
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)
}
collaborator, err := params.GetString(args, "collaborator")
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))
}
isCollab, _, err := client.IsCollaborator(owner, repo, collaborator)
if err != nil {
return to.ErrorResult(fmt.Errorf("check collaborator err: %v", err))
}
return to.TextResult(map[string]interface{}{
"is_collaborator": isCollab,
})
}
func addCollaboratorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Collaborator] Called addCollaboratorFn")
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)
}
collaborator, err := params.GetString(args, "collaborator")
if err != nil {
return to.ErrorResult(err)
}
permission := params.GetOptionalString(args, "permission", "read")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
mode := gitea_sdk.AccessMode(permission)
opt := gitea_sdk.AddCollaboratorOption{
Permission: &mode,
}
_, err = client.AddCollaborator(owner, repo, collaborator, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("add collaborator err: %v", err))
}
return to.TextResult("Collaborator added successfully")
}
func deleteCollaboratorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Collaborator] Called deleteCollaboratorFn")
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)
}
collaborator, err := params.GetString(args, "collaborator")
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))
}
_, err = client.DeleteCollaborator(owner, repo, collaborator)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete collaborator err: %v", err))
}
return to.TextResult("Collaborator removed successfully")
}
func collaboratorPermissionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Collaborator] Called collaboratorPermissionFn")
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)
}
collaborator, err := params.GetString(args, "collaborator")
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))
}
result, _, err := client.CollaboratorPermission(owner, repo, collaborator)
if err != nil {
return to.ErrorResult(fmt.Errorf("get collaborator permission err: %v", err))
}
return to.TextResult(map[string]interface{}{
"permission": result.Permission,
"role": result.Role,
"user": result.User.UserName,
})
}
func slimUsers(users []*gitea_sdk.User) []map[string]interface{} {
result := make([]map[string]interface{}, len(users))
for i, u := range users {
result[i] = map[string]interface{}{
"id": u.ID,
"username": u.UserName,
"name": u.FullName,
"email": u.Email,
}
}
return result
}
+92
View File
@@ -0,0 +1,92 @@
package compare
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.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"
)
const (
CompareCommitsToolName = "compare_commits"
)
var Tool = tool.New()
var (
CompareCommitsTool = mcp.NewTool(
CompareCommitsToolName,
mcp.WithDescription("Compare commits between two branches/tags/commits"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
mcp.WithString("base", mcp.Required(), mcp.Description("Base branch/tag/commit")),
mcp.WithString("head", mcp.Required(), mcp.Description("Head branch/tag/commit")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: CompareCommitsTool,
Handler: compareCommitsFn,
})
}
func compareCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Compare] Called compareCommitsFn")
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)
}
base, err := params.GetString(args, "base")
if err != nil {
return to.ErrorResult(err)
}
head, err := params.GetString(args, "head")
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))
}
compare, _, err := client.CompareCommits(owner, repo, base, head)
if err != nil {
return to.ErrorResult(fmt.Errorf("compare commits err: %v", err))
}
return to.TextResult(slimCompare(compare))
}
func slimCompare(c *gitea_sdk.Compare) map[string]interface{} {
result := map[string]interface{}{
"total_commits": c.TotalCommits,
}
if c.Commits != nil {
commits := make([]map[string]interface{}, 0, len(c.Commits))
for _, cmt := range c.Commits {
commits = append(commits, map[string]interface{}{
"sha": cmt.SHA,
"url": cmt.URL,
})
}
result["commits"] = commits
}
return result
}
+208
View File
@@ -0,0 +1,208 @@
package deploykey
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.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"
)
const (
ListDeployKeysToolName = "list_deploy_keys"
GetDeployKeyToolName = "get_deploy_key"
CreateDeployKeyToolName = "create_deploy_key"
DeleteDeployKeyToolName = "delete_deploy_key"
)
var Tool = tool.New()
var (
ListDeployKeysTool = mcp.NewTool(
ListDeployKeysToolName,
mcp.WithDescription("List deploy keys for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
GetDeployKeyTool = mcp.NewTool(
GetDeployKeyToolName,
mcp.WithDescription("Get a specific deploy key by ID"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("Deploy key ID")),
)
CreateDeployKeyTool = mcp.NewTool(
CreateDeployKeyToolName,
mcp.WithDescription("Create a new deploy key for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("title", mcp.Required(), mcp.Description("Title/description for the deploy key")),
mcp.WithString("key", mcp.Required(), mcp.Description("The SSH public key content")),
mcp.WithBoolean("readOnly", mcp.Description("Whether the key is read-only"), mcp.DefaultBool(false)),
)
DeleteDeployKeyTool = mcp.NewTool(
DeleteDeployKeyToolName,
mcp.WithDescription("Delete a deploy key"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("Deploy key ID to delete")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListDeployKeysTool,
Handler: listDeployKeysFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetDeployKeyTool,
Handler: getDeployKeyFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateDeployKeyTool,
Handler: createDeployKeyFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteDeployKeyTool,
Handler: deleteDeployKeyFn,
})
}
func listDeployKeysFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DeployKey] Called listDeployKeysFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
keys, _, err := client.ListDeployKeys(owner, repo, gitea_sdk.ListDeployKeysOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list deploy keys err: %v", err))
}
return to.TextResult(slimDeployKeys(keys))
}
func getDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DeployKey] Called getDeployKeyFn")
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)
}
id, err := params.GetIndex(args, "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))
}
key, _, err := client.GetDeployKey(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get deploy key err: %v", err))
}
return to.TextResult(slimDeployKey(key))
}
func createDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DeployKey] Called createDeployKeyFn")
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)
}
title, err := params.GetString(args, "title")
if err != nil {
return to.ErrorResult(err)
}
key, err := params.GetString(args, "key")
if err != nil {
return to.ErrorResult(err)
}
readOnly, _ := args["readOnly"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
createOpt := gitea_sdk.CreateKeyOption{
Title: title,
Key: key,
ReadOnly: readOnly,
}
respKey, _, err := client.CreateDeployKey(owner, repo, createOpt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create deploy key err: %v", err))
}
return to.TextResult(slimDeployKey(respKey))
}
func deleteDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[DeployKey] Called deleteDeployKeyFn")
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)
}
id, err := params.GetIndex(args, "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))
}
_, err = client.DeleteDeployKey(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete deploy key err: %v", err))
}
return to.TextResult("Deploy key deleted successfully")
}
func slimDeployKeys(keys []*gitea_sdk.DeployKey) []map[string]interface{} {
result := make([]map[string]interface{}, len(keys))
for i, k := range keys {
result[i] = slimDeployKey(k)
}
return result
}
func slimDeployKey(k *gitea_sdk.DeployKey) map[string]interface{} {
return map[string]interface{}{
"id": k.ID,
"key": k.Key,
"title": k.Title,
"created": k.Created,
"fingerprint": k.Fingerprint,
"read_only": k.ReadOnly,
}
}
+582
View File
@@ -0,0 +1,582 @@
package operation
import (
"errors"
"testing"
gitea_errors "gitea.com/gitea/gitea-mcp/pkg/errors"
)
func TestErrorScenarios_NotFound(t *testing.T) {
tests := []struct {
name string
errMsg string
ctx map[string]string
isNotFound bool
}{
{
name: "404 file not found",
errMsg: "request failed with status 404: GetContents error",
ctx: map[string]string{"operation": "GetFile", "path": "README.md"},
isNotFound: true,
},
{
name: "404 repo not found",
errMsg: "request failed with status 404: GetRepo error",
ctx: map[string]string{"operation": "GetRepo"},
isNotFound: true,
},
{
name: "404 issue not found",
errMsg: "request failed with status 404: GetIssue error",
ctx: map[string]string{"operation": "GetIssue"},
isNotFound: true,
},
{
name: "404 pull request not found",
errMsg: "request failed with status 404: GetPullRequest error",
ctx: map[string]string{"operation": "GetPullRequest"},
isNotFound: true,
},
{
name: "401 auth error not found",
errMsg: "request failed with status 401: unauthorized",
ctx: map[string]string{"operation": "GetFile"},
isNotFound: false,
},
{
name: "500 server error not found",
errMsg: "request failed with status 500: internal server error",
ctx: map[string]string{"operation": "GetFile"},
isNotFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, tt.ctx)
if gitea_errors.IsNotFound(translated) != tt.isNotFound {
t.Errorf("IsNotFound() = %v, want %v", gitea_errors.IsNotFound(translated), tt.isNotFound)
}
})
}
}
func TestErrorScenarios_AuthErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isAuthErr bool
}{
{
name: "401 unauthorized",
errMsg: "request failed with status 401: unauthorized",
isAuthErr: true,
},
{
name: "403 forbidden",
errMsg: "request failed with status 403: forbidden",
isAuthErr: true,
},
{
name: "authentication failed message",
errMsg: "authentication failed",
isAuthErr: true,
},
{
name: "permission denied message",
errMsg: "permission denied",
isAuthErr: true,
},
{
name: "token error message",
errMsg: "check your access token",
isAuthErr: true,
},
{
name: "404 not auth error",
errMsg: "request failed with status 404: not found",
isAuthErr: false,
},
{
name: "500 not auth error",
errMsg: "request failed with status 500: server error",
isAuthErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, nil)
if gitea_errors.IsAuthError(translated) != tt.isAuthErr {
t.Errorf("IsAuthError() = %v, want %v", gitea_errors.IsAuthError(translated), tt.isAuthErr)
}
})
}
}
func TestErrorScenarios_NetworkErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isNetworkErr bool
isTimeout bool
}{
{
name: "connection refused",
errMsg: "connection refused",
isNetworkErr: true,
isTimeout: false,
},
{
name: "no such host",
errMsg: "no such host example.com",
isNetworkErr: true,
isTimeout: false,
},
{
name: "network unreachable",
errMsg: "network unreachable",
isNetworkErr: true,
isTimeout: false,
},
{
name: "timeout error",
errMsg: "request timeout",
isNetworkErr: true,
isTimeout: true,
},
{
name: "deadline exceeded",
errMsg: "context deadline exceeded",
isNetworkErr: true,
isTimeout: true,
},
{
name: "dial tcp",
errMsg: "dial tcp: connection refused",
isNetworkErr: true,
isTimeout: false,
},
{
name: "not network error",
errMsg: "file not found",
isNetworkErr: false,
isTimeout: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, nil)
if gitea_errors.IsNetworkError(translated) != tt.isNetworkErr {
t.Errorf("IsNetworkError() = %v, want %v", gitea_errors.IsNetworkError(translated), tt.isNetworkErr)
}
if gitea_errors.IsTimeout(translated) != tt.isTimeout {
t.Errorf("IsTimeout() = %v, want %v", gitea_errors.IsTimeout(translated), tt.isTimeout)
}
})
}
}
func TestErrorScenarios_ActionsAPIUnavailable(t *testing.T) {
tests := []struct {
name string
errMsg string
isActionsUnavailable bool
}{
{
name: "actions 404",
errMsg: "actions endpoint returned 404",
isActionsUnavailable: true,
},
{
name: "actions 405",
errMsg: "actions endpoint returned 405",
isActionsUnavailable: true,
},
{
name: "actions not found",
errMsg: "actions workflow not found",
isActionsUnavailable: true,
},
{
name: "actions method not allowed",
errMsg: "actions method not allowed",
isActionsUnavailable: true,
},
{
name: "actions enhanced error",
errMsg: "not supported on this Gitea version",
isActionsUnavailable: true,
},
{
name: "other actions error",
errMsg: "actions completed successfully",
isActionsUnavailable: false,
},
{
name: "file not found not actions",
errMsg: "file not found",
isActionsUnavailable: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
if gitea_errors.IsActionsAPIUnavailable(err) != tt.isActionsUnavailable {
t.Errorf("IsActionsAPIUnavailable() = %v, want %v", gitea_errors.IsActionsAPIUnavailable(err), tt.isActionsUnavailable)
}
})
}
}
func TestErrorScenarios_HTTPStatusCodes(t *testing.T) {
tests := []struct {
name string
statusCode int
isUnauthorized bool
isForbidden bool
isNotFound bool
isServerError bool
}{
{
name: "HTTP 401",
statusCode: 401,
isUnauthorized: true,
isForbidden: false,
isNotFound: false,
isServerError: false,
},
{
name: "HTTP 403",
statusCode: 403,
isUnauthorized: false,
isForbidden: true,
isNotFound: false,
isServerError: false,
},
{
name: "HTTP 404",
statusCode: 404,
isUnauthorized: false,
isForbidden: false,
isNotFound: true,
isServerError: false,
},
{
name: "HTTP 500",
statusCode: 500,
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: true,
},
{
name: "HTTP 502",
statusCode: 502,
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: true,
},
{
name: "HTTP 503",
statusCode: 503,
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: true,
},
{
name: "HTTP 200",
statusCode: 200,
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := &testHTTPError{status: tt.statusCode, message: "test error"}
if gitea_errors.IsUnauthorized(err) != tt.isUnauthorized {
t.Errorf("IsUnauthorized() = %v, want %v", gitea_errors.IsUnauthorized(err), tt.isUnauthorized)
}
if gitea_errors.IsForbidden(err) != tt.isForbidden {
t.Errorf("IsForbidden() = %v, want %v", gitea_errors.IsForbidden(err), tt.isForbidden)
}
if gitea_errors.IsNotFoundHTTP(err) != tt.isNotFound {
t.Errorf("IsNotFoundHTTP() = %v, want %v", gitea_errors.IsNotFoundHTTP(err), tt.isNotFound)
}
if gitea_errors.IsServerError(err) != tt.isServerError {
t.Errorf("IsServerError() = %v, want %v", gitea_errors.IsServerError(err), tt.isServerError)
}
})
}
}
func TestErrorScenarios_EnhancedErrorChaining(t *testing.T) {
original := errors.New("original error")
err := gitea_errors.TranslateError(original, map[string]string{
"operation": "TestOp",
"param1": "value1",
})
if !errors.Is(err, original) {
t.Error("enhanced error should wrap original")
}
var enhanced *gitea_errors.EnhancedError
if !errors.As(err, &enhanced) {
t.Fatal("should be able to extract EnhancedError")
}
if enhanced.Operation != "TestOp" {
t.Errorf("operation = %q, want %q", enhanced.Operation, "TestOp")
}
if enhanced.Context["param1"] != "value1" {
t.Errorf("context[param1] = %q, want %q", enhanced.Context["param1"], "value1")
}
formatted := enhanced.Format()
if formatted == "" {
t.Error("Format() should return non-empty string")
}
detailed := enhanced.FormatDetailed()
if detailed == "" {
t.Error("FormatDetailed() should return non-empty string")
}
}
func TestErrorScenarios_FluentAPI(t *testing.T) {
original := errors.New("test error")
enhanced := gitea_errors.TranslateError(original, nil).(*gitea_errors.EnhancedError)
result := enhanced.
WithOperation("GetFile").
WithParam("owner", "gitea").
WithParam("repo", "tea").
WithParam("path", "README.md")
if result != enhanced {
t.Error("fluent API should return same error for chaining")
}
if enhanced.Operation != "GetFile" {
t.Errorf("operation = %q, want %q", enhanced.Operation, "GetFile")
}
if enhanced.Context["owner"] != "gitea" {
t.Errorf("context[owner] = %q, want %q", enhanced.Context["owner"], "gitea")
}
if enhanced.Context["repo"] != "tea" {
t.Errorf("context[repo] = %q, want %q", enhanced.Context["repo"], "tea")
}
if enhanced.Context["path"] != "README.md" {
t.Errorf("context[path] = %q, want %q", enhanced.Context["path"], "README.md")
}
}
type testHTTPError struct {
status int
message string
}
func (e *testHTTPError) Error() string { return e.message }
func (e *testHTTPError) Status() int { return e.status }
func TestErrorScenarios_CrossToolErrorConsistency(t *testing.T) {
ctx := map[string]string{
"owner": "test-owner",
"repo": "test-repo",
"operation": "CrossToolTest",
}
testCases := []struct {
name string
errMsg string
category gitea_errors.ErrorCategory
}{
{
name: "file operation error",
errMsg: "GetContents failed",
category: gitea_errors.CategoryFile,
},
{
name: "auth operation error",
errMsg: "GetUser failed",
category: gitea_errors.CategoryAuth,
},
{
name: "repo operation error",
errMsg: "GetRepo failed",
category: gitea_errors.CategoryRepo,
},
{
name: "issue operation error",
errMsg: "GetIssue failed",
category: gitea_errors.CategoryIssue,
},
{
name: "pull request operation error",
errMsg: "GetPullRequest failed",
category: gitea_errors.CategoryPull,
},
{
name: "branch operation error",
errMsg: "GetBranch failed",
category: gitea_errors.CategoryBranch,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, ctx)
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected EnhancedError")
}
if enhanced.Category != tt.category {
t.Errorf("category = %q, want %q", enhanced.Category, tt.category)
}
if enhanced.Context["owner"] != "test-owner" {
t.Errorf("context[owner] not preserved")
}
if enhanced.Context["repo"] != "test-repo" {
t.Errorf("context[repo] not preserved")
}
})
}
}
func TestErrorScenarios_NilHandling(t *testing.T) {
if gitea_errors.TranslateError(nil, nil) != nil {
t.Error("TranslateError(nil) should return nil")
}
if gitea_errors.IsNotFound(nil) {
t.Error("IsNotFound(nil) should return false")
}
if gitea_errors.IsAuthError(nil) {
t.Error("IsAuthError(nil) should return false")
}
if gitea_errors.IsNetworkError(nil) {
t.Error("IsNetworkError(nil) should return false")
}
if gitea_errors.IsTimeout(nil) {
t.Error("IsTimeout(nil) should return false")
}
if gitea_errors.IsActionsAPIUnavailable(nil) {
t.Error("IsActionsAPIUnavailable(nil) should return false")
}
if gitea_errors.IsServerError(nil) {
t.Error("IsServerError(nil) should return false")
}
if gitea_errors.Wrap(nil, "operation") != nil {
t.Error("Wrap(nil) should return nil")
}
}
func TestErrorScenarios_ServerErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isServerErr bool
}{
{
name: "500 internal server error",
errMsg: "status 500",
isServerErr: true,
},
{
name: "502 bad gateway",
errMsg: "status 502",
isServerErr: true,
},
{
name: "503 service unavailable",
errMsg: "status 503",
isServerErr: true,
},
{
name: "504 gateway timeout",
errMsg: "status 504",
isServerErr: true,
},
{
name: "404 not server error",
errMsg: "status 404",
isServerErr: false,
},
{
name: "200 not server error",
errMsg: "status 200",
isServerErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
if gitea_errors.IsServerError(err) != tt.isServerErr {
t.Errorf("IsServerError() = %v, want %v", gitea_errors.IsServerError(err), tt.isServerErr)
}
})
}
}
func TestErrorScenarios_ErrorWrapping(t *testing.T) {
original := errors.New("original error")
wrapped := gitea_errors.Wrap(original, "GetFile")
if !errors.Is(wrapped, original) {
t.Error("wrapped error should contain original")
}
var enhanced *gitea_errors.EnhancedError
if !errors.As(wrapped, &enhanced) {
t.Fatal("wrapped should be EnhancedError")
}
if enhanced.Operation != "GetFile" {
t.Errorf("operation = %q, want %q", enhanced.Operation, "GetFile")
}
}
func TestErrorScenarios_ErrorUnwrap(t *testing.T) {
original := errors.New("original error")
enhanced := gitea_errors.NewEnhancedError(original, "translated", gitea_errors.CategoryFile)
unwrapped := enhanced.Unwrap()
if unwrapped != original {
t.Error("Unwrap() should return original error")
}
if !errors.Is(enhanced, original) {
t.Error("errors.Is should find original through unwrapping")
}
}
+507
View File
@@ -0,0 +1,507 @@
package issue
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.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 (
ListRepoIssuesToolName = "list_issues"
IssueReadToolName = "issue_read"
IssueWriteToolName = "issue_write"
)
var (
ListRepoIssuesTool = mcp.NewTool(
ListRepoIssuesToolName,
mcp.WithDescription("List repository issues"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
IssueReadTool = mcp.NewTool(
IssueReadToolName,
mcp.WithDescription("Get information about a specific issue. Use method 'get' for issue details, 'get_comments' for issue comments, 'get_labels' for issue labels."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_comments", "get_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
)
IssueWriteTool = mcp.NewTool(
IssueWriteToolName,
mcp.WithDescription("Create or update issues and comments, manage labels. Use method 'create' to create an issue, 'update' to edit, 'add_comment'/'edit_comment' for comments, 'add_labels'/'remove_label'/'replace_labels'/'clear_labels' for label management."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods except 'create')")),
mcp.WithString("title", mcp.Description("issue title (required for 'create')")),
mcp.WithString("body", mcp.Description("issue/comment body (required for 'create', 'add_comment', 'edit_comment')")),
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'create', 'update')"), mcp.Items(map[string]any{"type": "string"})),
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.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.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListRepoIssuesTool,
Handler: listRepoIssuesFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: IssueReadTool,
Handler: issueReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: IssueWriteTool,
Handler: issueWriteFn,
})
}
func issueReadFn(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 "get":
return getIssueByIndexFn(ctx, req)
case "get_comments":
return getIssueCommentsByIndexFn(ctx, req)
case "get_labels":
return getIssueLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func issueWriteFn(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 "create":
return createIssueFn(ctx, req)
case "update":
return editIssueFn(ctx, req)
case "add_comment":
return createIssueCommentFn(ctx, req)
case "edit_comment":
return editIssueCommentFn(ctx, req)
case "add_labels":
return addIssueLabelsFn(ctx, req)
case "remove_label":
return removeIssueLabelFn(ctx, req)
case "replace_labels":
return replaceIssueLabelsFn(ctx, req)
case "clear_labels":
return clearIssueLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueByIndexFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issue, _, err := client.GetIssue(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimIssue(issue))
}
func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListIssuesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
state, ok := req.GetArguments()["state"].(string)
if !ok {
state = "all"
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateType(state),
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issues, _, err := client.ListRepoIssues(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues err: %v", owner, repo, err))
}
return to.TextResult(slimIssues(issues))
}
func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createIssueFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
title, err := params.GetString(req.GetArguments(), "title")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(req.GetArguments(), "body")
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))
}
opt := gitea_sdk.CreateIssueOption{
Title: title,
Body: body,
}
opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees")
if val, exists := req.GetArguments()["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = milestone
}
}
issue, _, err := client.CreateIssue(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
}
return to.TextResult(slimIssue(issue))
}
func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createIssueCommentFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(req.GetArguments(), "body")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.CreateIssueCommentOption{
Body: body,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issueComment, _, err := client.CreateIssueComment(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue/%v/comment err: %v", owner, repo, index, err))
}
return to.TextResult(slimComment(issueComment))
}
func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editIssueFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditIssueOption{}
title, ok := req.GetArguments()["title"].(string)
if ok {
opt.Title = title
}
body, ok := req.GetArguments()["body"].(string)
if ok {
opt.Body = new(body)
}
opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees")
if val, exists := req.GetArguments()["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = new(milestone)
}
}
state, ok := req.GetArguments()["state"].(string)
if ok {
opt.State = new(gitea_sdk.StateType(state))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issue, _, err := client.EditIssue(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimIssue(issue))
}
func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editIssueCommentFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
commentID, err := params.GetIndex(req.GetArguments(), "commentID")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(req.GetArguments(), "body")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditIssueCommentOption{
Body: body,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issueComment, _, err := client.EditIssueComment(owner, repo, commentID, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/issues/comments/%v err: %v", owner, repo, commentID, err))
}
return to.TextResult(slimComment(issueComment))
}
func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueCommentsByIndexFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.ListIssueCommentOptions{}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issue, _, err := client.ListIssueComments(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err))
}
return to.TextResult(slimComments(issue))
}
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
}
labels, _, err := client.GetIssueLabels(owner, repo, index, gitea_sdk.ListLabelsOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/labels err: %v", owner, repo, index, err))
}
return to.TextResult(slimLabels(labels))
}
// Issue label operations (moved from label package)
func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labels, err := params.GetInt64Slice(req.GetArguments(), "labels")
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))
}
issueLabels, _, err := client.AddIssueLabels(owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels})
if err != nil {
return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimLabels(issueLabels))
}
func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called replaceIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labels, err := params.GetInt64Slice(req.GetArguments(), "labels")
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))
}
issueLabels, _, err := client.ReplaceIssueLabels(owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels})
if err != nil {
return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimLabels(issueLabels))
}
func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called clearIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
}
_, err = client.ClearIssueLabels(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("clear labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult("Labels cleared successfully")
}
func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called removeIssueLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labelID, err := params.GetIndex(req.GetArguments(), "label_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))
}
_, err = client.DeleteIssueLabel(owner, repo, index, labelID)
if err != nil {
return to.ErrorResult(fmt.Errorf("remove label %v from %v/%v/issue/%v err: %v", labelID, owner, repo, index, err))
}
return to.TextResult("Label removed successfully")
}
+133
View File
@@ -0,0 +1,133 @@
package issue
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func userLogins(users []*gitea_sdk.User) []string {
if len(users) == 0 {
return nil
}
out := make([]string, 0, len(users))
for _, u := range users {
if u != nil {
out = append(out, u.UserName)
}
}
return out
}
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 slimIssue(i *gitea_sdk.Issue) map[string]any {
if i == nil {
return nil
}
m := map[string]any{
"number": i.Index,
"title": i.Title,
"body": i.Body,
"state": i.State,
"html_url": i.HTMLURL,
"user": userLogin(i.Poster),
"labels": labelNames(i.Labels),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
"closed_at": i.Closed,
}
if len(i.Assignees) > 0 {
m["assignees"] = userLogins(i.Assignees)
}
if i.Milestone != nil {
m["milestone"] = map[string]any{
"id": i.Milestone.ID,
"title": i.Milestone.Title,
}
}
if i.PullRequest != nil {
m["is_pull"] = true
}
return m
}
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)
}
out = append(out, m)
}
return out
}
func slimComment(c *gitea_sdk.Comment) map[string]any {
if c == nil {
return nil
}
return map[string]any{
"id": c.ID,
"body": c.Body,
"user": userLogin(c.Poster),
"html_url": c.HTMLURL,
"created_at": c.Created,
"updated_at": c.Updated,
}
}
func slimComments(comments []*gitea_sdk.Comment) []map[string]any {
out := make([]map[string]any, 0, len(comments))
for _, c := range comments {
out = append(out, slimComment(c))
}
return out
}
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
if l == nil {
continue
}
out = append(out, map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
})
}
return out
}
+69
View File
@@ -0,0 +1,69 @@
package issue
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimIssue(t *testing.T) {
i := &gitea_sdk.Issue{
Index: 42,
Title: "Bug report",
Body: "Something is broken",
State: "open",
HTMLURL: "https://gitea.com/org/repo/issues/42",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "bug"}},
Milestone: &gitea_sdk.Milestone{
ID: 1,
Title: "v1.0",
},
PullRequest: &gitea_sdk.PullRequestMeta{HasMerged: false},
}
m := slimIssue(i)
if m["number"] != int64(42) {
t.Errorf("expected number 42, got %v", m["number"])
}
if m["body"] != "Something is broken" {
t.Errorf("expected body, got %v", m["body"])
}
if m["is_pull"] != true {
t.Error("expected is_pull true for issue with PullRequest")
}
ms := m["milestone"].(map[string]any)
if ms["title"] != "v1.0" {
t.Errorf("expected milestone title v1.0, got %v", ms["title"])
}
}
func TestSlimIssues_ListIsSlimmer(t *testing.T) {
i := &gitea_sdk.Issue{
Index: 1,
Title: "Issue",
State: "open",
Body: "Full body",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "enhancement"}},
}
single := slimIssue(i)
list := slimIssues([]*gitea_sdk.Issue{i})
// Single has body, list does not
if _, ok := single["body"]; !ok {
t.Error("single issue should have body")
}
if _, ok := list[0]["body"]; ok {
t.Error("list issue should not have body")
}
}
func TestSlimIssues_Nil(t *testing.T) {
if r := slimIssues(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}
+377
View File
@@ -0,0 +1,377 @@
package label
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.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 (
LabelReadToolName = "label_read"
LabelWriteToolName = "label_write"
)
var (
LabelReadTool = mcp.NewTool(
LabelReadToolName,
mcp.WithDescription("Read label information. Use method 'list_repo_labels' to list repository labels, 'get_repo_label' to get a specific repo label, 'list_org_labels' to list organization labels."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for 'list_org')")),
mcp.WithNumber("id", mcp.Description("label ID (required for 'get_repo')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
LabelWriteTool = mcp.NewTool(
LabelWriteToolName,
mcp.WithDescription("Create, edit, or delete labels for repositories or organizations."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithNumber("id", mcp.Description("label ID (required for edit/delete methods)")),
mcp.WithString("name", mcp.Description("label name (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.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: LabelReadTool,
Handler: labelReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: LabelWriteTool,
Handler: labelWriteFn,
})
}
func labelReadFn(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_repo_labels":
return listRepoLabelsFn(ctx, req)
case "get_repo_label":
return getRepoLabelFn(ctx, req)
case "list_org_labels":
return listOrgLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func labelWriteFn(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 "create_repo_label":
return createRepoLabelFn(ctx, req)
case "edit_repo_label":
return editRepoLabelFn(ctx, req)
case "delete_repo_label":
return deleteRepoLabelFn(ctx, req)
case "create_org_label":
return createOrgLabelFn(ctx, req)
case "edit_org_label":
return editOrgLabelFn(ctx, req)
case "delete_org_label":
return deleteOrgLabelFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListLabelsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
labels, _, err := client.ListRepoLabels(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/labels err: %v", owner, repo, err))
}
return to.TextResult(slimLabels(labels))
}
func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
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))
}
label, _, err := client.GetRepoLabel(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/label/%v err: %v", owner, repo, id, err))
}
return to.TextResult(slimLabel(label))
}
func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
color, err := params.GetString(req.GetArguments(), "color")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string) // Optional
opt := gitea_sdk.CreateLabelOption{
Name: name,
Color: color,
Description: description,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.CreateLabel(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/label err: %v", owner, repo, err))
}
return to.TextResult(slimLabel(label))
}
func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditLabelOption{}
if name, ok := req.GetArguments()["name"].(string); ok {
opt.Name = new(name)
}
if color, ok := req.GetArguments()["color"].(string); ok {
opt.Color = new(color)
}
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.EditLabel(owner, repo, id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/label/%v err: %v", owner, repo, id, err))
}
return to.TextResult(slimLabel(label))
}
func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
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))
}
_, err = client.DeleteLabel(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/%v/label/%v err: %v", owner, repo, id, err))
}
return to.TextResult("Label deleted successfully")
}
func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgLabelsFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListOrgLabelsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
labels, _, err := client.ListOrgLabels(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/labels err: %v", org, err))
}
return to.TextResult(slimLabels(labels))
}
func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgLabelFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
color, err := params.GetString(req.GetArguments(), "color")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string)
exclusive, _ := req.GetArguments()["exclusive"].(bool)
opt := gitea_sdk.CreateOrgLabelOption{
Name: name,
Color: color,
Description: description,
Exclusive: exclusive,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.CreateOrgLabel(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/labels err: %v", org, err))
}
return to.TextResult(slimLabel(label))
}
func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editOrgLabelFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditOrgLabelOption{}
if name, ok := req.GetArguments()["name"].(string); ok {
opt.Name = new(name)
}
if color, ok := req.GetArguments()["color"].(string); ok {
opt.Color = new(color)
}
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description)
}
if exclusive, ok := req.GetArguments()["exclusive"].(bool); ok {
opt.Exclusive = new(exclusive)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.EditOrgLabel(org, id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/labels/%v err: %v", org, id, err))
}
return to.TextResult(slimLabel(label))
}
func deleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgLabelFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
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))
}
_, err = client.DeleteOrgLabel(org, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/labels/%v err: %v", org, id, err))
}
return to.TextResult("Label deleted successfully")
}
+26
View File
@@ -0,0 +1,26 @@
package label
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimLabel(l *gitea_sdk.Label) map[string]any {
if l == nil {
return nil
}
return map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
}
}
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
out = append(out, slimLabel(l))
}
return out
}
+25
View File
@@ -0,0 +1,25 @@
package label
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimLabel(t *testing.T) {
l := &gitea_sdk.Label{
ID: 1,
Name: "bug",
Color: "#d73a4a",
Description: "Something isn't working",
Exclusive: false,
}
m := slimLabel(l)
if m["name"] != "bug" {
t.Errorf("expected name bug, got %v", m["name"])
}
if m["color"] != "#d73a4a" {
t.Errorf("expected color, got %v", m["color"])
}
}
+256
View File
@@ -0,0 +1,256 @@
package milestone
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.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 (
MilestoneReadToolName = "milestone_read"
MilestoneWriteToolName = "milestone_write"
)
var (
MilestoneReadTool = mcp.NewTool(
MilestoneReadToolName,
mcp.WithDescription("Read milestone information. Use method 'get' to get a specific milestone, 'list' to list milestones."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'get')")),
mcp.WithString("state", mcp.Description("milestone state (for 'list')"), mcp.DefaultString("all")),
mcp.WithString("name", mcp.Description("milestone name filter (for 'list')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
MilestoneWriteTool = mcp.NewTool(
MilestoneWriteToolName,
mcp.WithDescription("Create, edit, or delete milestones."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'edit', 'delete')")),
mcp.WithString("title", mcp.Description("milestone title (required for 'create')")),
mcp.WithString("description", mcp.Description("milestone description")),
mcp.WithString("due_on", mcp.Description("due date")),
mcp.WithString("state", mcp.Description("milestone state, one of open, closed (for 'edit')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: MilestoneReadTool,
Handler: milestoneReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: MilestoneWriteTool,
Handler: milestoneWriteFn,
})
}
func milestoneReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "get":
return getMilestoneFn(ctx, req)
case "list":
return listMilestonesFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func milestoneWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createMilestoneFn(ctx, req)
case "edit":
return editMilestoneFn(ctx, req)
case "delete":
return deleteMilestoneFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
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))
}
milestone, _, err := client.GetMilestone(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/milestone/%v err: %v", owner, repo, id, err))
}
return to.TextResult(slimMilestone(milestone))
}
func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listMilestonesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
state := params.GetOptionalString(req.GetArguments(), "state", "all")
name := params.GetOptionalString(req.GetArguments(), "name", "")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListMilestoneOption{
State: gitea_sdk.StateType(state),
Name: name,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestones, _, err := client.ListRepoMilestones(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/milestones err: %v", owner, repo, err))
}
return to.TextResult(slimMilestones(milestones))
}
func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
title, err := params.GetString(req.GetArguments(), "title")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.CreateMilestoneOption{
Title: title,
}
description, ok := req.GetArguments()["description"].(string)
if ok {
opt.Description = description
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.CreateMilestone(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/milestone err: %v", owner, repo, err))
}
return to.TextResult(slimMilestone(milestone))
}
func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditMilestoneOption{}
title, ok := req.GetArguments()["title"].(string)
if ok {
opt.Title = title
}
description, ok := req.GetArguments()["description"].(string)
if ok {
opt.Description = new(description)
}
state, ok := req.GetArguments()["state"].(string)
if ok {
opt.State = new(gitea_sdk.StateType(state))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.EditMilestone(owner, repo, id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/milestone/%v err: %v", owner, repo, id, err))
}
return to.TextResult(slimMilestone(milestone))
}
func deleteMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
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))
}
_, err = client.DeleteMilestone(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/%v/milestone/%v err: %v", owner, repo, id, err))
}
return to.TextResult("Milestone deleted successfully")
}
+28
View File
@@ -0,0 +1,28 @@
package milestone
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimMilestone(m *gitea_sdk.Milestone) map[string]any {
if m == nil {
return nil
}
return map[string]any{
"id": m.ID,
"title": m.Title,
"description": m.Description,
"state": m.State,
"open_issues": m.OpenIssues,
"closed_issues": m.ClosedIssues,
"due_on": m.Deadline,
}
}
func slimMilestones(milestones []*gitea_sdk.Milestone) []map[string]any {
out := make([]map[string]any, 0, len(milestones))
for _, m := range milestones {
out = append(out, slimMilestone(m))
}
return out
}
+256
View File
@@ -0,0 +1,256 @@
package mirror
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.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"
)
const (
ListPushMirrorsToolName = "list_push_mirrors"
GetPushMirrorToolName = "get_push_mirror"
CreatePushMirrorToolName = "create_push_mirror"
DeletePushMirrorToolName = "delete_push_mirror"
SyncMirrorToolName = "sync_mirror"
)
var Tool = tool.New()
var (
ListPushMirrorsTool = mcp.NewTool(
ListPushMirrorsToolName,
mcp.WithDescription("List push mirrors for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
GetPushMirrorTool = mcp.NewTool(
GetPushMirrorToolName,
mcp.WithDescription("Get a specific push mirror by remote name"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("remote_name", mcp.Required(), mcp.Description("Remote name")),
)
CreatePushMirrorTool = mcp.NewTool(
CreatePushMirrorToolName,
mcp.WithDescription("Create a push mirror for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("remote_name", mcp.Required(), mcp.Description("Remote name")),
mcp.WithString("remote_addr", mcp.Required(), mcp.Description("Remote address (git URL)")),
mcp.WithString("sync_interval", mcp.Description("Sync interval (e.g. 8h30m0s)")),
mcp.WithBoolean("sync_on_commit", mcp.Description("Sync on commit"), mcp.DefaultBool(false)),
)
DeletePushMirrorTool = mcp.NewTool(
DeletePushMirrorToolName,
mcp.WithDescription("Delete a push mirror"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("remote_name", mcp.Required(), mcp.Description("Remote name")),
)
SyncMirrorTool = mcp.NewTool(
SyncMirrorToolName,
mcp.WithDescription("Trigger immediate sync for a mirror repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListPushMirrorsTool,
Handler: listPushMirrorsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetPushMirrorTool,
Handler: getPushMirrorFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreatePushMirrorTool,
Handler: createPushMirrorFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeletePushMirrorTool,
Handler: deletePushMirrorFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: SyncMirrorTool,
Handler: syncMirrorFn,
})
}
func listPushMirrorsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Mirror] Called listPushMirrorsFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
mirrors, _, err := client.ListPushMirrors(owner, repo, gitea_sdk.ListOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list push mirrors err: %v", err))
}
return to.TextResult(slimMirrors(mirrors))
}
func getPushMirrorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Mirror] Called getPushMirrorFn")
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)
}
remoteName, err := params.GetString(args, "remote_name")
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))
}
mirror, _, err := client.GetPushMirrorByRemoteName(owner, repo, remoteName)
if err != nil {
return to.ErrorResult(fmt.Errorf("get push mirror err: %v", err))
}
return to.TextResult(slimMirror(mirror))
}
func createPushMirrorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Mirror] Called createPushMirrorFn")
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)
}
remoteAddr, err := params.GetString(args, "remote_addr")
if err != nil {
return to.ErrorResult(err)
}
syncInterval, _ := args["sync_interval"].(string)
syncOnCommit, _ := args["sync_on_commit"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.CreatePushMirrorOption{
RemoteAddress: remoteAddr,
Interval: syncInterval,
SyncONCommit: syncOnCommit,
}
mirror, _, err := client.PushMirrors(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create push mirror err: %v", err))
}
return to.TextResult(slimMirror(mirror))
}
func deletePushMirrorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Mirror] Called deletePushMirrorFn")
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)
}
remoteName, err := params.GetString(args, "remote_name")
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))
}
_, err = client.DeletePushMirror(owner, repo, remoteName)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete push mirror err: %v", err))
}
return to.TextResult("Push mirror deleted successfully")
}
func syncMirrorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Mirror] Called syncMirrorFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.MirrorSync(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("sync mirror err: %v", err))
}
return to.TextResult("Mirror sync triggered successfully")
}
func slimMirrors(mirrors []*gitea_sdk.PushMirrorResponse) []map[string]interface{} {
result := make([]map[string]interface{}, len(mirrors))
for i, m := range mirrors {
result[i] = slimMirror(m)
}
return result
}
func slimMirror(m *gitea_sdk.PushMirrorResponse) map[string]interface{} {
return map[string]interface{}{
"remote_name": m.RemoteName,
"remote_addr": m.RemoteAddress,
"interval": m.Interval,
"sync_on_commit": m.SyncONCommit,
"last_update": m.LastUpdate,
"last_error": m.LastError,
}
}
+218
View File
@@ -0,0 +1,218 @@
package notification
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.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("Read notifications. Use method 'list' to list all notifications, 'list_repo' for repo-specific notifications, 'check' to get unread count."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "list_repo", "check")),
mcp.WithString("owner", mcp.Description("repository owner (required for 'list_repo')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'list_repo')")),
mcp.WithString("status", mcp.Description("status filter"), mcp.Enum("unread", "read", "pinned")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
NotificationWriteTool = mcp.NewTool(
NotificationWriteToolName,
mcp.WithDescription("Mark notifications as read."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("read", "read_repo")),
mcp.WithString("owner", mcp.Description("repository owner (required for 'read_repo')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'read_repo')")),
mcp.WithNumber("id", mcp.Description("notification ID (optional, marks single if provided)")),
)
)
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) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listNotificationsFn(ctx, req)
case "list_repo":
return listRepoNotificationsFn(ctx, req)
case "check":
return checkNotificationsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func notificationWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "read":
return readNotificationsFn(ctx, req)
case "read_repo":
return readRepoNotificationsFn(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")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
status, _ := req.GetArguments()["status"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListNotificationOptions{
Status: []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatus(status)},
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
notifications, _, err := client.ListNotifications(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list notifications err: %v", err))
}
return to.TextResult(slimNotifications(notifications))
}
func listRepoNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoNotificationsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
status, _ := req.GetArguments()["status"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListNotificationOptions{
Status: []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatus(status)},
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
notifications, _, err := client.ListRepoNotifications(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo notifications err: %v", err))
}
return to.TextResult(slimNotifications(notifications))
}
func checkNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called checkNotificationsFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
count, _, err := client.CheckNotifications()
if err != nil {
return to.ErrorResult(fmt.Errorf("check notifications err: %v", err))
}
return to.TextResult(map[string]any{"unread_count": count})
}
func readNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called readNotificationsFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
id, hasID := req.GetArguments()["id"].(float64)
if hasID && id > 0 {
_, _, err := client.ReadNotification(int64(id), gitea_sdk.NotifyStatusRead)
if err != nil {
return to.ErrorResult(fmt.Errorf("read notification err: %v", err))
}
return to.TextResult("Notification marked as read")
}
opt := gitea_sdk.MarkNotificationOptions{Status: []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatusRead}}
_, _, err = client.ReadNotifications(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("read notifications err: %v", err))
}
return to.TextResult("All notifications marked as read")
}
func readRepoNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called readRepoNotificationsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
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))
}
opt := gitea_sdk.MarkNotificationOptions{Status: []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatusRead}}
_, _, err = client.ReadRepoNotifications(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("read repo notifications err: %v", err))
}
return to.TextResult("Repository notifications marked as read")
}
func slimNotifications(notifications []*gitea_sdk.NotificationThread) []map[string]any {
out := make([]map[string]any, 0, len(notifications))
for _, n := range notifications {
out = append(out, map[string]any{
"id": n.ID,
"unread": n.Unread,
"subject": n.Subject.Title,
"type": n.Subject.Type,
"url": n.Subject.URL,
"repository": n.Repository.FullName,
})
}
return out
}
+230
View File
@@ -0,0 +1,230 @@
package operation
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"gitea.com/gitea/gitea-mcp/operation/accesstoken"
"gitea.com/gitea/gitea-mcp/operation/actions"
"gitea.com/gitea/gitea-mcp/operation/activity"
"gitea.com/gitea/gitea-mcp/operation/attachment"
"gitea.com/gitea/gitea-mcp/operation/collaborator"
"gitea.com/gitea/gitea-mcp/operation/compare"
"gitea.com/gitea/gitea-mcp/operation/deploykey"
"gitea.com/gitea/gitea-mcp/operation/issue"
"gitea.com/gitea/gitea-mcp/operation/label"
"gitea.com/gitea/gitea-mcp/operation/milestone"
"gitea.com/gitea/gitea-mcp/operation/mirror"
"gitea.com/gitea/gitea-mcp/operation/notification"
"gitea.com/gitea/gitea-mcp/operation/org"
"gitea.com/gitea/gitea-mcp/operation/orgmember"
"gitea.com/gitea/gitea-mcp/operation/packages"
"gitea.com/gitea/gitea-mcp/operation/protection"
"gitea.com/gitea/gitea-mcp/operation/pull"
"gitea.com/gitea/gitea-mcp/operation/repo"
"gitea.com/gitea/gitea-mcp/operation/search"
"gitea.com/gitea/gitea-mcp/operation/settings"
"gitea.com/gitea/gitea-mcp/operation/sshkey"
"gitea.com/gitea/gitea-mcp/operation/stars"
"gitea.com/gitea/gitea-mcp/operation/team"
"gitea.com/gitea/gitea-mcp/operation/timetracking"
"gitea.com/gitea/gitea-mcp/operation/transfer"
"gitea.com/gitea/gitea-mcp/operation/user"
"gitea.com/gitea/gitea-mcp/operation/version"
"gitea.com/gitea/gitea-mcp/operation/webhook"
"gitea.com/gitea/gitea-mcp/operation/wiki"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"github.com/mark3labs/mcp-go/server"
)
var mcpServer *server.MCPServer
func RegisterTool(s *server.MCPServer) {
// User Tool
s.AddTools(user.Tool.Tools()...)
// Actions Tool
s.AddTools(actions.Tool.Tools()...)
// Repo Tool
s.AddTools(repo.Tool.Tools()...)
// Issue Tool
s.AddTools(issue.Tool.Tools()...)
// Label Tool
s.AddTools(label.Tool.Tools()...)
// Milestone Tool
s.AddTools(milestone.Tool.Tools()...)
// Pull Tool
s.AddTools(pull.Tool.Tools()...)
// Search Tool
s.AddTools(search.Tool.Tools()...)
// Version Tool
s.AddTools(version.Tool.Tools()...)
// Wiki Tool
s.AddTools(wiki.Tool.Tools()...)
// Time Tracking Tool
s.AddTools(timetracking.Tool.Tools()...)
// Org Tool
s.AddTools(org.Tool.Tools()...)
// Team Tool
s.AddTools(team.Tool.Tools()...)
// Notification Tool
s.AddTools(notification.Tool.Tools()...)
// Webhook Tool
s.AddTools(webhook.Tool.Tools()...)
// Branch Protection Tool
s.AddTools(protection.Tool.Tools()...)
// Stars Tool
s.AddTools(stars.Tool.Tools()...)
// SSH Key Tool
s.AddTools(sshkey.Tool.Tools()...)
// Deploy Key Tool
s.AddTools(deploykey.Tool.Tools()...)
// Access Token Tool
s.AddTools(accesstoken.Tool.Tools()...)
// Activity Tool
s.AddTools(activity.Tool.Tools()...)
// Settings Tool
s.AddTools(settings.Tool.Tools()...)
// Packages Tool
s.AddTools(packages.Tool.Tools()...)
// Collaborator Tool
s.AddTools(collaborator.Tool.Tools()...)
// Attachment Tool
s.AddTools(attachment.Tool.Tools()...)
// Mirror Tool
s.AddTools(mirror.Tool.Tools()...)
// Compare Tool
s.AddTools(compare.Tool.Tools()...)
// Transfer Tool
s.AddTools(transfer.Tool.Tools()...)
// OrgMember Tool
s.AddTools(orgmember.Tool.Tools()...)
s.DeleteTools("")
}
// parseAuthToken extracts the token from an Authorization header.
// Supports "Bearer <token>" (case-insensitive per RFC 7235) and
// Gitea-style "token <token>" formats.
// Returns the token and true if valid, empty string and false otherwise.
func parseAuthToken(authHeader string) (string, bool) {
if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") {
token := strings.TrimSpace(authHeader[7:])
if token != "" {
return token, true
}
}
if len(authHeader) > 6 && strings.EqualFold(authHeader[:6], "token ") {
token := strings.TrimSpace(authHeader[6:])
if token != "" {
return token, true
}
}
return "", false
}
func getContextWithToken(ctx context.Context, r *http.Request) context.Context {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return ctx
}
token, ok := parseAuthToken(authHeader)
if !ok {
return ctx
}
return context.WithValue(ctx, mcpContext.TokenContextKey, token)
}
func Run() error {
mcpServer = newMCPServer(flag.Version)
RegisterTool(mcpServer)
switch flag.Mode {
case "stdio":
if err := server.ServeStdio(
mcpServer,
); err != nil {
return err
}
case "http":
httpServer := server.NewStreamableHTTPServer(
mcpServer,
server.WithLogger(log.New()),
server.WithHeartbeatInterval(30*time.Second),
server.WithHTTPContextFunc(getContextWithToken),
)
log.Infof("Gitea MCP HTTP server listening on :%d", flag.Port)
// Graceful shutdown setup
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
shutdownDone := make(chan struct{})
go func() {
<-sigCh
log.Infof("Shutdown signal received, gracefully stopping HTTP server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Errorf("HTTP server shutdown error: %v", err)
}
close(shutdownDone)
}()
if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
<-shutdownDone // Wait for shutdown to finish
default:
return fmt.Errorf("invalid transport type: %s. Must be 'stdio' or 'http'", flag.Mode)
}
return nil
}
func newMCPServer(version string) *server.MCPServer {
return server.NewMCPServer(
"Gitea MCP Server",
version,
server.WithToolCapabilities(true),
server.WithLogging(),
server.WithRecovery(),
)
}
+105
View File
@@ -0,0 +1,105 @@
package operation
import (
"testing"
)
func TestParseAuthToken(t *testing.T) {
tests := []struct {
name string
header string
wantToken string
wantOK bool
}{
{
name: "valid Bearer token",
header: "Bearer validtoken",
wantToken: "validtoken",
wantOK: true,
},
{
name: "lowercase bearer",
header: "bearer lowercase",
wantToken: "lowercase",
wantOK: true,
},
{
name: "uppercase BEARER",
header: "BEARER uppercase",
wantToken: "uppercase",
wantOK: true,
},
{
name: "token with spaces trimmed",
header: "Bearer spacedToken ",
wantToken: "spacedToken",
wantOK: true,
},
{
name: "bearer with no token",
header: "Bearer ",
wantToken: "",
wantOK: false,
},
{
name: "bearer with only spaces",
header: "Bearer ",
wantToken: "",
wantOK: false,
},
{
name: "missing space after Bearer",
header: "Bearertoken",
wantToken: "",
wantOK: false,
},
{
name: "Gitea token format",
header: "token giteaapitoken",
wantToken: "giteaapitoken",
wantOK: true,
},
{
name: "Gitea Token format capitalized",
header: "Token giteaapitoken",
wantToken: "giteaapitoken",
wantOK: true,
},
{
name: "token with no value",
header: "token ",
wantToken: "",
wantOK: false,
},
{
name: "different auth type",
header: "Basic dXNlcjpwYXNz",
wantToken: "",
wantOK: false,
},
{
name: "empty header",
header: "",
wantToken: "",
wantOK: false,
},
{
name: "bearer token with internal spaces",
header: "Bearer token with spaces",
wantToken: "token with spaces",
wantOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotToken, gotOK := parseAuthToken(tt.header)
if gotToken != tt.wantToken {
t.Errorf("parseAuthToken() token = %q, want %q", gotToken, tt.wantToken)
}
if gotOK != tt.wantOK {
t.Errorf("parseAuthToken() ok = %v, want %v", gotOK, tt.wantOK)
}
})
}
}
+313
View File
@@ -0,0 +1,313 @@
package org
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.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 (
OrgReadToolName = "org_read"
OrgWriteToolName = "org_write"
)
var (
OrgReadTool = mcp.NewTool(
OrgReadToolName,
mcp.WithDescription("Read organization information. Use method 'get' to get org details, 'list' to list orgs, 'list_members' to list org members, 'list_teams' to list org teams."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list", "list_members", "list_teams")),
mcp.WithString("org", mcp.Description("organization name (required for 'get', 'list_members', 'list_teams')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
OrgWriteTool = mcp.NewTool(
OrgWriteToolName,
mcp.WithDescription("Create or update organizations."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "delete")),
mcp.WithString("org", mcp.Description("organization name (required for 'update', 'delete')")),
mcp.WithString("description", mcp.Description("organization description")),
mcp.WithString("full_name", mcp.Description("full name")),
mcp.WithString("location", mcp.Description("location")),
mcp.WithString("website", mcp.Description("website")),
mcp.WithString("visibility", mcp.Description("visibility"), mcp.Enum("public", "private", "limited")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: OrgReadTool,
Handler: orgReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: OrgWriteTool,
Handler: orgWriteFn,
})
}
func orgReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "get":
return getOrgFn(ctx, req)
case "list":
return listOrgsFn(ctx, req)
case "list_members":
return listOrgMembersFn(ctx, req)
case "list_teams":
return listOrgTeamsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func orgWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createOrgFn(ctx, req)
case "update":
return updateOrgFn(ctx, req)
case "delete":
return deleteOrgFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getOrgFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getOrgFn")
org, err := params.GetString(req.GetArguments(), "org")
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))
}
orgData, _, err := client.GetOrg(org)
if err != nil {
return to.ErrorResult(fmt.Errorf("get org err: %v", err))
}
return to.TextResult(slimOrg(orgData))
}
func listOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgsFn")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListOrgsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
orgs, _, err := client.ListMyOrgs(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list orgs err: %v", err))
}
return to.TextResult(slimOrgs(orgs))
}
func listOrgMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgMembersFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListOrgMembershipOption{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
members, _, err := client.ListOrgMembership(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list org members err: %v", err))
}
return to.TextResult(slimUsers(members))
}
func listOrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgTeamsFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListTeamsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
teams, _, err := client.ListOrgTeams(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list org teams err: %v", err))
}
return to.TextResult(slimTeams(teams))
}
func createOrgFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgFn")
name, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string)
fullName, _ := req.GetArguments()["full_name"].(string)
location, _ := req.GetArguments()["location"].(string)
website, _ := req.GetArguments()["website"].(string)
visibility, _ := req.GetArguments()["visibility"].(string)
opt := gitea_sdk.CreateOrgOption{
Name: name,
Description: description,
FullName: fullName,
Location: location,
Website: website,
Visibility: gitea_sdk.VisibleType(visibility),
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
org, _, err := client.CreateOrg(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create org err: %v", err))
}
return to.TextResult(slimOrg(org))
}
func updateOrgFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateOrgFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditOrgOption{}
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = description
}
if fullName, ok := req.GetArguments()["full_name"].(string); ok {
opt.FullName = fullName
}
if location, ok := req.GetArguments()["location"].(string); ok {
opt.Location = location
}
if website, ok := req.GetArguments()["website"].(string); ok {
opt.Website = website
}
if visibility, ok := req.GetArguments()["visibility"].(string); ok {
opt.Visibility = gitea_sdk.VisibleType(visibility)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.EditOrg(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update org err: %v", err))
}
return to.TextResult("Organization updated successfully")
}
func deleteOrgFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgFn")
org, err := params.GetString(req.GetArguments(), "org")
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))
}
_, err = client.DeleteOrg(org)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org err: %v", err))
}
return to.TextResult("Organization deleted successfully")
}
func slimOrg(o *gitea_sdk.Organization) map[string]any {
if o == nil {
return nil
}
return map[string]any{
"id": o.ID,
"name": o.Name,
"full_name": o.FullName,
"description": o.Description,
"avatar_url": o.AvatarURL,
"website": o.Website,
"location": o.Location,
"visibility": o.Visibility,
}
}
func slimOrgs(orgs []*gitea_sdk.Organization) []map[string]any {
out := make([]map[string]any, 0, len(orgs))
for _, o := range orgs {
out = append(out, slimOrg(o))
}
return out
}
func slimUsers(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
})
}
return out
}
func slimTeams(teams []*gitea_sdk.Team) []map[string]any {
out := make([]map[string]any, 0, len(teams))
for _, t := range teams {
out = append(out, map[string]any{
"id": t.ID,
"name": t.Name,
"description": t.Description,
"permission": t.Permission,
})
}
return out
}
+160
View File
@@ -0,0 +1,160 @@
package orgmember
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.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"
)
const (
CheckOrgMembershipToolName = "check_org_membership"
SetPublicOrgMembershipToolName = "set_public_org_membership"
ListUserOrgsToolName = "list_user_orgs_public"
)
var Tool = tool.New()
var (
CheckOrgMembershipTool = mcp.NewTool(
CheckOrgMembershipToolName,
mcp.WithDescription("Check if a user is a member of an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithString("user", mcp.Required(), mcp.Description("Username to check")),
)
SetPublicOrgMembershipTool = mcp.NewTool(
SetPublicOrgMembershipToolName,
mcp.WithDescription("Set public organization membership visibility"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithString("user", mcp.Required(), mcp.Description("Username")),
mcp.WithBoolean("visible", mcp.Required(), mcp.Description("Make membership public (true) or private (false)")),
)
ListUserOrgsTool = mcp.NewTool(
ListUserOrgsToolName,
mcp.WithDescription("List organizations a user belongs to"),
mcp.WithString("user", mcp.Required(), mcp.Description("Username")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: CheckOrgMembershipTool,
Handler: checkOrgMembershipFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListUserOrgsTool,
Handler: listUserOrgsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: SetPublicOrgMembershipTool,
Handler: setPublicOrgMembershipFn,
})
}
func checkOrgMembershipFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[OrgMember] Called checkOrgMembershipFn")
args := req.GetArguments()
org, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
user, err := params.GetString(args, "user")
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))
}
isMember, _, err := client.CheckOrgMembership(org, user)
if err != nil {
return to.ErrorResult(fmt.Errorf("check org membership err: %v", err))
}
return to.TextResult(map[string]interface{}{
"is_member": isMember,
})
}
func setPublicOrgMembershipFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[OrgMember] Called setPublicOrgMembershipFn")
args := req.GetArguments()
org, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
user, err := params.GetString(args, "user")
if err != nil {
return to.ErrorResult(err)
}
visible, _ := args["visible"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.SetPublicOrgMembership(org, user, visible)
if err != nil {
return to.ErrorResult(fmt.Errorf("set public org membership err: %v", err))
}
return to.TextResult(fmt.Sprintf("Public org membership %s for %s", map[bool]string{true: "enabled", false: "disabled"}[visible], user))
}
func listUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[OrgMember] Called listUserOrgsFn")
args := req.GetArguments()
user, err := params.GetString(args, "user")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListOrgsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
orgs, _, err := client.ListUserOrgs(user, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list user orgs err: %v", err))
}
return to.TextResult(slimOrgs(orgs))
}
func slimOrgs(orgs []*gitea_sdk.Organization) []map[string]interface{} {
result := make([]map[string]interface{}, len(orgs))
for i, o := range orgs {
result[i] = map[string]interface{}{
"id": o.ID,
"username": o.UserName,
"full_name": o.FullName,
"description": o.Description,
"website": o.Website,
}
}
return result
}
+284
View File
@@ -0,0 +1,284 @@
package packages
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.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"
)
const (
ListPackagesToolName = "list_packages"
GetPackageToolName = "get_package"
GetLatestPackageToolName = "get_latest_package"
DeletePackageToolName = "delete_package"
ListPackageFilesToolName = "list_package_files"
)
var Tool = tool.New()
var (
ListPackagesTool = mcp.NewTool(
ListPackagesToolName,
mcp.WithDescription("List packages for a user/org"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner (user or org) name")),
mcp.WithString("type", mcp.Description("Package type (container, npm, pypi, etc.)")),
mcp.WithString("name", mcp.Description("Filter by package name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20)),
)
GetPackageTool = mcp.NewTool(
GetPackageToolName,
mcp.WithDescription("Get a specific package version"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner name")),
mcp.WithString("type", mcp.Required(), mcp.Description("Package type")),
mcp.WithString("name", mcp.Required(), mcp.Description("Package name")),
mcp.WithString("version", mcp.Required(), mcp.Description("Package version")),
)
GetLatestPackageTool = mcp.NewTool(
GetLatestPackageToolName,
mcp.WithDescription("Get the latest version of a package"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner name")),
mcp.WithString("type", mcp.Required(), mcp.Description("Package type")),
mcp.WithString("name", mcp.Required(), mcp.Description("Package name")),
)
DeletePackageTool = mcp.NewTool(
DeletePackageToolName,
mcp.WithDescription("Delete a package or specific version"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner name")),
mcp.WithString("type", mcp.Required(), mcp.Description("Package type")),
mcp.WithString("name", mcp.Required(), mcp.Description("Package name")),
mcp.WithString("version", mcp.Description("Specific version to delete (omit to delete all)")),
)
ListPackageFilesTool = mcp.NewTool(
ListPackageFilesToolName,
mcp.WithDescription("List files in a package"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner name")),
mcp.WithString("type", mcp.Required(), mcp.Description("Package type")),
mcp.WithString("name", mcp.Required(), mcp.Description("Package name")),
mcp.WithString("version", mcp.Required(), mcp.Description("Package version")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListPackagesTool,
Handler: listPackagesFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetPackageTool,
Handler: getPackageFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetLatestPackageTool,
Handler: getLatestPackageFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListPackageFilesTool,
Handler: listPackageFilesFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeletePackageTool,
Handler: deletePackageFn,
})
}
func listPackagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Packages] Called listPackagesFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListPackagesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
pkgs, _, err := client.ListPackages(owner, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list packages err: %v", err))
}
return to.TextResult(slimPackages(pkgs))
}
func getPackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Packages] Called getPackageFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
packageType, err := params.GetString(args, "type")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
version, err := params.GetString(args, "version")
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))
}
pkg, _, err := client.GetPackage(owner, packageType, name, version)
if err != nil {
return to.ErrorResult(fmt.Errorf("get package err: %v", err))
}
return to.TextResult(slimPackage(pkg))
}
func getLatestPackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Packages] Called getLatestPackageFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
packageType, err := params.GetString(args, "type")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(args, "name")
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))
}
pkg, _, err := client.GetLatestPackage(owner, packageType, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get latest package err: %v", err))
}
return to.TextResult(slimPackage(pkg))
}
func deletePackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Packages] Called deletePackageFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
packageType, err := params.GetString(args, "type")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
version, _ := args["version"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeletePackage(owner, packageType, name, version)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete package err: %v", err))
}
return to.TextResult("Package deleted successfully")
}
func listPackageFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Packages] Called listPackageFilesFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
packageType, err := params.GetString(args, "type")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
version, err := params.GetString(args, "version")
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))
}
files, _, err := client.ListPackageFiles(owner, packageType, name, version)
if err != nil {
return to.ErrorResult(fmt.Errorf("list package files err: %v", err))
}
return to.TextResult(slimPackageFiles(files))
}
func slimPackages(pkgs []*gitea_sdk.Package) []map[string]interface{} {
result := make([]map[string]interface{}, len(pkgs))
for i, p := range pkgs {
result[i] = slimPackage(p)
}
return result
}
func slimPackage(p *gitea_sdk.Package) map[string]interface{} {
return map[string]interface{}{
"id": p.ID,
"name": p.Name,
"version": p.Version,
"package_type": p.Type,
"created_at": p.CreatedAt,
"owner": p.Owner.UserName,
}
}
func slimPackageFiles(files []*gitea_sdk.PackageFile) []map[string]interface{} {
result := make([]map[string]interface{}, len(files))
for i, f := range files {
result[i] = map[string]interface{}{
"id": f.ID,
"name": f.Name,
"size": f.Size,
"md5": f.MD5,
"sha256": f.SHA256,
"sha512": f.SHA512,
}
}
return result
}
+268
View File
@@ -0,0 +1,268 @@
package protection
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.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 (
ProtectionReadToolName = "protection_read"
ProtectionWriteToolName = "protection_write"
)
var (
ProtectionReadTool = mcp.NewTool(
ProtectionReadToolName,
mcp.WithDescription("Read branch protection. Use method 'list' to list all protections, 'get' to get specific branch protection."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch", mcp.Description("branch name (required for 'get')")),
)
ProtectionWriteTool = mcp.NewTool(
ProtectionWriteToolName,
mcp.WithDescription("Create, update, or delete branch protection rules."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch", mcp.Required(), mcp.Description("branch name")),
mcp.WithBoolean("require_signed_commits", mcp.Description("require signed commits")),
mcp.WithBoolean("enable_status_check", mcp.Description("enable status checks")),
mcp.WithNumber("required_approvals", mcp.Description("required approval count")),
mcp.WithBoolean("dismiss_stale_approvals", mcp.Description("dismiss stale approvals")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ProtectionReadTool,
Handler: protectionReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: ProtectionWriteTool,
Handler: protectionWriteFn,
})
}
func protectionReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listProtectionsFn(ctx, req)
case "get":
return getProtectionFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func protectionWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createProtectionFn(ctx, req)
case "edit":
return editProtectionFn(ctx, req)
case "delete":
return deleteProtectionFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listProtectionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listProtectionsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
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))
}
protections, _, err := client.ListBranchProtections(owner, repo, gitea_sdk.ListBranchProtectionsOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list branch protections err: %v", err))
}
return to.TextResult(slimProtections(protections))
}
func getProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getProtectionFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
branch, err := params.GetString(req.GetArguments(), "branch")
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))
}
protection, _, err := client.GetBranchProtection(owner, repo, branch)
if err != nil {
return to.ErrorResult(fmt.Errorf("get branch protection err: %v", err))
}
return to.TextResult(slimProtection(protection))
}
func createProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createProtectionFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
branch, err := params.GetString(req.GetArguments(), "branch")
if err != nil {
return to.ErrorResult(err)
}
args := req.GetArguments()
opt := gitea_sdk.CreateBranchProtectionOption{
BranchName: branch,
}
if v, ok := args["require_signed_commits"].(bool); ok {
opt.RequireSignedCommits = v
}
if v, ok := args["enable_status_check"].(bool); ok {
opt.EnableStatusCheck = v
}
if v, ok := args["required_approvals"].(float64); ok {
opt.RequiredApprovals = int64(v)
}
if v, ok := args["dismiss_stale_approvals"].(bool); ok {
opt.DismissStaleApprovals = v
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
protection, _, err := client.CreateBranchProtection(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create branch protection err: %v", err))
}
return to.TextResult(slimProtection(protection))
}
func editProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editProtectionFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
branch, err := params.GetString(req.GetArguments(), "branch")
if err != nil {
return to.ErrorResult(err)
}
args := req.GetArguments()
opt := gitea_sdk.EditBranchProtectionOption{}
if v, ok := args["require_signed_commits"].(bool); ok {
opt.RequireSignedCommits = &v
}
if v, ok := args["enable_status_check"].(bool); ok {
opt.EnableStatusCheck = &v
}
if v, ok := args["required_approvals"].(float64); ok {
vv := int64(v)
opt.RequiredApprovals = &vv
}
if v, ok := args["dismiss_stale_approvals"].(bool); ok {
opt.DismissStaleApprovals = &v
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
protection, _, err := client.EditBranchProtection(owner, repo, branch, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit branch protection err: %v", err))
}
return to.TextResult(slimProtection(protection))
}
func deleteProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteProtectionFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
branch, err := params.GetString(req.GetArguments(), "branch")
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))
}
_, err = client.DeleteBranchProtection(owner, repo, branch)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete branch protection err: %v", err))
}
return to.TextResult("Branch protection deleted successfully")
}
func slimProtections(bps []*gitea_sdk.BranchProtection) []map[string]any {
out := make([]map[string]any, 0, len(bps))
for _, bp := range bps {
out = append(out, slimProtection(bp))
}
return out
}
func slimProtection(bp *gitea_sdk.BranchProtection) map[string]any {
if bp == nil {
return nil
}
return map[string]any{
"branch_name": bp.BranchName,
"rule_name": bp.RuleName,
"require_signed_commits": bp.RequireSignedCommits,
"enable_status_check": bp.EnableStatusCheck,
"required_approvals": bp.RequiredApprovals,
"dismiss_stale_approvals": bp.DismissStaleApprovals,
}
}
+812
View File
@@ -0,0 +1,812 @@
package pull
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.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 (
ListRepoPullRequestsToolName = "list_pull_requests"
PullRequestReadToolName = "pull_request_read"
PullRequestWriteToolName = "pull_request_write"
PullRequestReviewWriteToolName = "pull_request_review_write"
)
var (
ListRepoPullRequestsTool = mcp.NewTool(
ListRepoPullRequestsToolName,
mcp.WithDescription("List repository pull requests"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("state"), mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")),
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("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
PullRequestReadTool = mcp.NewTool(
PullRequestReadToolName,
mcp.WithDescription("Get pull request information. Use method 'get' for PR details, 'get_diff' for diff, 'get_reviews'/'get_review'/'get_review_comments' for review data."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
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.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
PullRequestWriteTool = mcp.NewTool(
PullRequestWriteToolName,
mcp.WithDescription("Create, update, or merge pull requests, manage reviewers."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "merge", "add_reviewers", "remove_reviewers")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Description("pull request index (required for all methods except 'create')")),
mcp.WithString("title", mcp.Description("PR title (required for 'create', optional for 'update', 'merge')")),
mcp.WithString("body", mcp.Description("PR body (required for 'create', optional for 'update')")),
mcp.WithString("head", mcp.Description("PR head branch (required for 'create')")),
mcp.WithString("base", mcp.Description("PR base branch (required for 'create', optional for 'update')")),
mcp.WithString("assignee", mcp.Description("username to assign (for 'update')")),
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'update')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
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.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.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
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"})),
)
PullRequestReviewWriteTool = mcp.NewTool(
PullRequestReviewWriteToolName,
mcp.WithDescription("Manage pull request reviews: create, submit, delete, or dismiss."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "submit", "delete", "dismiss")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'submit', 'delete', 'dismiss')")),
mcp.WithString("state", mcp.Description("review state"), mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")),
mcp.WithString("body", mcp.Description("review body/comment")),
mcp.WithString("commit_id", mcp.Description("commit SHA to review (for 'create')")),
mcp.WithString("message", mcp.Description("dismissal reason (for 'dismiss')")),
mcp.WithArray("comments", mcp.Description("inline review comments (for 'create')"), mcp.Items(map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "file path to comment on"},
"body": map[string]any{"type": "string", "description": "comment body"},
"old_line_num": map[string]any{"type": "number", "description": "line number in the old file (for deletions/changes)"},
"new_line_num": map[string]any{"type": "number", "description": "line number in the new file (for additions/changes)"},
},
})),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListRepoPullRequestsTool,
Handler: listRepoPullRequestsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: PullRequestReadTool,
Handler: pullRequestReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: PullRequestWriteTool,
Handler: pullRequestWriteFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: PullRequestReviewWriteTool,
Handler: pullRequestReviewWriteFn,
})
}
func pullRequestReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "get":
return getPullRequestByIndexFn(ctx, req)
case "get_diff":
return getPullRequestDiffFn(ctx, req)
case "get_reviews":
return listPullRequestReviewsFn(ctx, req)
case "get_review":
return getPullRequestReviewFn(ctx, req)
case "get_review_comments":
return listPullRequestReviewCommentsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createPullRequestFn(ctx, req)
case "update":
return editPullRequestFn(ctx, req)
case "merge":
return mergePullRequestFn(ctx, req)
case "add_reviewers":
return createPullRequestReviewerFn(ctx, req)
case "remove_reviewers":
return deletePullRequestReviewerFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createPullRequestReviewFn(ctx, req)
case "submit":
return submitPullRequestReviewFn(ctx, req)
case "delete":
return deletePullRequestReviewFn(ctx, req)
case "dismiss":
return dismissPullRequestReviewFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getPullRequestByIndexFn")
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)
}
index, err := params.GetIndex(args, "index")
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))
}
pr, _, err := client.GetPullRequest(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}
func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getPullRequestDiffFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
binary, _ := args["binary"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
diffBytes, _, err := client.GetPullRequestDiff(owner, repo, index, gitea_sdk.PullRequestDiffOptions{
Binary: binary,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v diff err: %v", owner, repo, index, err))
}
return to.TextResult(string(diffBytes))
}
func listRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoPullRequests")
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)
}
state, _ := args["state"].(string)
sort := params.GetOptionalString(args, "sort", "recentupdate")
milestone := params.GetOptionalInt(args, "milestone", 0)
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListPullRequestsOptions{
State: gitea_sdk.StateType(state),
Sort: sort,
Milestone: milestone,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pullRequests, _, err := client.ListRepoPullRequests(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/pull_requests err: %v", owner, repo, err))
}
return to.TextResult(slimPullRequests(pullRequests))
}
func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestFn")
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)
}
title, err := params.GetString(args, "title")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(args, "body")
if err != nil {
return to.ErrorResult(err)
}
head, err := params.GetString(args, "head")
if err != nil {
return to.ErrorResult(err)
}
base, err := params.GetString(args, "base")
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))
}
pr, _, err := client.CreatePullRequest(owner, repo, gitea_sdk.CreatePullRequestOption{
Title: title,
Body: body,
Head: head,
Base: base,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
}
return to.TextResult(slimPullRequest(pr))
}
func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestReviewerFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewers := params.GetStringSlice(args, "reviewers")
teamReviewers := params.GetStringSlice(args, "team_reviewers")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.CreateReviewRequests(owner, repo, index, gitea_sdk.PullReviewRequestOptions{
Reviewers: reviewers,
TeamReviewers: teamReviewers,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create review requests for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully created review requests",
"reviewers": reviewers,
"team_reviewers": teamReviewers,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func deletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deletePullRequestReviewerFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewers := params.GetStringSlice(args, "reviewers")
teamReviewers := params.GetStringSlice(args, "team_reviewers")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteReviewRequests(owner, repo, index, gitea_sdk.PullReviewRequestOptions{
Reviewers: reviewers,
TeamReviewers: teamReviewers,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("delete review requests for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully deleted review requests",
"reviewers": reviewers,
"team_reviewers": teamReviewers,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listPullRequestReviewsFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
reviews, _, err := client.ListPullReviews(owner, repo, index, gitea_sdk.ListPullReviewsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list reviews for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimReviews(reviews))
}
func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getPullRequestReviewFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_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))
}
review, _, err := client.GetPullReview(owner, repo, index, reviewID)
if err != nil {
return to.ErrorResult(fmt.Errorf("get review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
return to.TextResult(slimReview(review))
}
func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listPullRequestReviewCommentsFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_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))
}
comments, _, err := client.ListPullReviewComments(owner, repo, index, reviewID)
if err != nil {
return to.ErrorResult(fmt.Errorf("list review comments for review %v on %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
return to.TextResult(slimReviewComments(comments))
}
func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestReviewFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.CreatePullReviewOptions{}
if state, ok := args["state"].(string); ok {
opt.State = gitea_sdk.ReviewStateType(state)
}
if body, ok := args["body"].(string); ok {
opt.Body = body
}
if commitID, ok := args["commit_id"].(string); ok {
opt.CommitID = commitID
}
// Parse inline comments
if commentsArg, exists := args["comments"]; exists {
if commentsSlice, ok := commentsArg.([]any); ok {
for _, comment := range commentsSlice {
if commentMap, ok := comment.(map[string]any); ok {
reviewComment := gitea_sdk.CreatePullReviewComment{}
if path, ok := commentMap["path"].(string); ok {
reviewComment.Path = path
}
if body, ok := commentMap["body"].(string); ok {
reviewComment.Body = body
}
if oldLineNum, ok := params.ToInt64(commentMap["old_line_num"]); ok {
reviewComment.OldLineNum = oldLineNum
}
if newLineNum, ok := params.ToInt64(commentMap["new_line_num"]); ok {
reviewComment.NewLineNum = newLineNum
}
opt.Comments = append(opt.Comments, reviewComment)
}
}
}
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
review, _, err := client.CreatePullReview(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create review for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimReview(review))
}
func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called submitPullRequestReviewFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_id")
if err != nil {
return to.ErrorResult(err)
}
state, err := params.GetString(args, "state")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.SubmitPullReviewOptions{
State: gitea_sdk.ReviewStateType(state),
}
if body, ok := args["body"].(string); ok {
opt.Body = body
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
review, _, err := client.SubmitPullReview(owner, repo, index, reviewID, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("submit review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
return to.TextResult(slimReview(review))
}
func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deletePullRequestReviewFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_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))
}
_, err = client.DeletePullReview(owner, repo, index, reviewID)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully deleted review",
"review_id": reviewID,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called dismissPullRequestReviewFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_id")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.DismissPullReviewOptions{}
if message, ok := args["message"].(string); ok {
opt.Message = message
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DismissPullReview(owner, repo, index, reviewID, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("dismiss review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully dismissed review",
"review_id": reviewID,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called mergePullRequestFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
mergeStyle := params.GetOptionalString(args, "merge_style", "merge")
title, _ := args["title"].(string)
message, _ := args["message"].(string)
deleteBranch, _ := args["delete_branch"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.MergePullRequestOption{
Style: gitea_sdk.MergeStyle(mergeStyle),
Title: title,
Message: message,
DeleteBranchAfterMerge: deleteBranch,
}
merged, resp, err := client.MergePullRequest(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v err: %v", owner, repo, index, err))
}
if !merged && resp != nil && resp.StatusCode >= 400 {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v failed: HTTP %d %s", owner, repo, index, resp.StatusCode, resp.Status))
}
if !merged {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v returned merged=false", owner, repo, index))
}
successMsg := map[string]any{
"merged": merged,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
"merge_style": mergeStyle,
"branch_deleted": deleteBranch,
}
return to.TextResult(successMsg)
}
func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editPullRequestFn")
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)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditPullRequestOption{}
if title, ok := args["title"].(string); ok {
opt.Title = title
}
if body, ok := args["body"].(string); ok {
opt.Body = new(body)
}
if base, ok := args["base"].(string); ok {
opt.Base = base
}
if assignee, ok := args["assignee"].(string); ok {
opt.Assignee = assignee
}
if assignees := params.GetStringSlice(args, "assignees"); assignees != nil {
opt.Assignees = assignees
}
if val, exists := args["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = milestone
}
}
if state, ok := args["state"].(string); ok {
opt.State = new(gitea_sdk.StateType(state))
}
if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok {
opt.AllowMaintainerEdit = new(allowMaintainerEdit)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pr, _, err := client.EditPullRequest(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}
+379
View File
@@ -0,0 +1,379 @@
package pull
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func Test_editPullRequestFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
indexInputs := []struct {
name string
val any
}{
{"float64", float64(index)},
{"string", "7"},
}
for _, ii := range indexInputs {
t.Run(ii.name, func(t *testing.T) {
var (
mu sync.Mutex
gotMethod string
gotPath string
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", owner, repo, index):
mu.Lock()
gotMethod = r.Method
gotPath = r.URL.Path
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"title":"%s","state":"open"}`, index, body["title"]))
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": ii.val,
"title": "WIP: my feature",
"state": "open",
},
},
}
result, err := editPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("editPullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotMethod != http.MethodPatch {
t.Fatalf("expected PATCH request, got %s", gotMethod)
}
if gotPath != fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index) {
t.Fatalf("unexpected path: %s", gotPath)
}
if gotBody["title"] != "WIP: my feature" {
t.Fatalf("expected title 'WIP: my feature', got %v", gotBody["title"])
}
if gotBody["state"] != "open" {
t.Fatalf("expected state 'open', got %v", gotBody["state"])
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed map[string]any
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if got := parsed["title"].(string); got != "WIP: my feature" {
t.Fatalf("result title = %q, want %q", got, "WIP: my feature")
}
})
}
}
func Test_mergePullRequestFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 5
)
indexInputs := []struct {
name string
val any
}{
{"float64", float64(index)},
{"string", "5"},
}
for _, ii := range indexInputs {
t.Run(ii.name, func(t *testing.T) {
var (
mu sync.Mutex
gotMethod string
gotPath string
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()
gotMethod = r.Method
gotPath = r.URL.Path
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": ii.val,
"merge_style": "squash",
"title": "feat: my squashed commit",
"message": "Squash merge of PR #5",
"delete_branch": true,
},
},
}
result, err := mergePullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("mergePullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotMethod != http.MethodPost {
t.Fatalf("expected POST request, got %s", gotMethod)
}
if gotPath != fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) {
t.Fatalf("unexpected path: %s", gotPath)
}
if gotBody["Do"] != "squash" {
t.Fatalf("expected Do 'squash', got %v", gotBody["Do"])
}
if gotBody["MergeTitleField"] != "feat: my squashed commit" {
t.Fatalf("expected MergeTitleField 'feat: my squashed commit', got %v", gotBody["MergeTitleField"])
}
if gotBody["MergeMessageField"] != "Squash merge of PR #5" {
t.Fatalf("expected MergeMessageField 'Squash merge of PR #5', got %v", gotBody["MergeMessageField"])
}
if gotBody["delete_branch_after_merge"] != true {
t.Fatalf("expected delete_branch_after_merge true, got %v", gotBody["delete_branch_after_merge"])
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed map[string]any
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if parsed["merged"] != true {
t.Fatalf("expected merged=true, got %v", parsed["merged"])
}
if parsed["merge_style"] != "squash" {
t.Fatalf("expected merge_style 'squash', got %v", parsed["merge_style"])
}
if parsed["branch_deleted"] != true {
t.Fatalf("expected branch_deleted=true, got %v", parsed["branch_deleted"])
}
})
}
}
func Test_getPullRequestDiffFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 12
diffRaw = "diff --git a/file.txt b/file.txt\n+line\n"
)
indexInputs := []struct {
name string
val any
}{
{"float64", float64(index)},
{"string", "12"},
}
for _, ii := range indexInputs {
t.Run(ii.name, func(t *testing.T) {
var (
mu sync.Mutex
diffRequested bool
binaryValue string
)
errCh := make(chan error, 1)
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("/%s/%s/pulls/%d.diff", owner, repo, index):
if r.Method != http.MethodGet {
select {
case errCh <- fmt.Errorf("unexpected method: %s", r.Method):
default:
}
}
mu.Lock()
diffRequested = true
binaryValue = r.URL.Query().Get("binary")
mu.Unlock()
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(diffRaw))
default:
select {
case errCh <- fmt.Errorf("unexpected request path: %s", r.URL.Path):
default:
}
}
})
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": ii.val,
"binary": true,
},
},
}
result, err := getPullRequestDiffFn(context.Background(), req)
if err != nil {
t.Fatalf("getPullRequestDiffFn() error = %v", err)
}
select {
case reqErr := <-errCh:
t.Fatalf("handler error: %v", reqErr)
default:
}
mu.Lock()
requested := diffRequested
gotBinary := binaryValue
mu.Unlock()
if !requested {
t.Fatalf("expected diff request to be made")
}
if gotBinary != "true" {
t.Fatalf("expected binary=true query param, got %q", gotBinary)
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
// The diff response is now a plain string
var parsed string
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if parsed != diffRaw {
t.Fatalf("diff = %q, want %q", parsed, diffRaw)
}
})
}
}
+191
View File
@@ -0,0 +1,191 @@
package pull
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func userLogins(users []*gitea_sdk.User) []string {
if len(users) == 0 {
return nil
}
out := make([]string, 0, len(users))
for _, u := range users {
if u != nil {
out = append(out, u.UserName)
}
}
return out
}
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 repoRef(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"full_name": r.FullName,
"description": r.Description,
}
}
func slimPullRequest(pr *gitea_sdk.PullRequest) map[string]any {
if pr == nil {
return nil
}
m := map[string]any{
"number": pr.Index,
"title": pr.Title,
"body": pr.Body,
"state": pr.State,
"draft": pr.Draft,
"merged": pr.HasMerged,
"mergeable": pr.Mergeable,
"html_url": pr.HTMLURL,
"user": userLogin(pr.Poster),
"labels": labelNames(pr.Labels),
"comments": pr.Comments,
"created_at": pr.Created,
"updated_at": pr.Updated,
"closed_at": pr.Closed,
}
if pr.HasMerged {
m["merged_at"] = pr.Merged
m["merge_commit_sha"] = pr.MergedCommitID
m["merged_by"] = userLogin(pr.MergedBy)
}
if pr.Head != nil {
head := map[string]any{"ref": pr.Head.Ref, "sha": pr.Head.Sha}
if pr.Head.Repository != nil {
head["repo"] = repoRef(pr.Head.Repository)
}
m["head"] = head
}
if pr.Base != nil {
base := map[string]any{"ref": pr.Base.Ref, "sha": pr.Base.Sha}
if pr.Base.Repository != nil {
base["repo"] = repoRef(pr.Base.Repository)
}
m["base"] = base
}
if pr.Additions != nil {
m["additions"] = *pr.Additions
}
if pr.Deletions != nil {
m["deletions"] = *pr.Deletions
}
if pr.ChangedFiles != nil {
m["changed_files"] = *pr.ChangedFiles
}
if len(pr.Assignees) > 0 {
m["assignees"] = userLogins(pr.Assignees)
}
if pr.Milestone != nil {
m["milestone"] = pr.Milestone.Title
}
if pr.ReviewComments > 0 {
m["review_comments"] = pr.ReviewComments
}
return m
}
func slimPullRequests(prs []*gitea_sdk.PullRequest) []map[string]any {
out := make([]map[string]any, 0, len(prs))
for _, pr := range prs {
if pr == nil {
continue
}
m := map[string]any{
"number": pr.Index,
"title": pr.Title,
"state": pr.State,
"draft": pr.Draft,
"merged": pr.HasMerged,
"html_url": pr.HTMLURL,
"user": userLogin(pr.Poster),
"created_at": pr.Created,
"updated_at": pr.Updated,
}
if pr.Head != nil {
m["head"] = pr.Head.Ref
}
if pr.Base != nil {
m["base"] = pr.Base.Ref
}
if len(pr.Labels) > 0 {
m["labels"] = labelNames(pr.Labels)
}
out = append(out, m)
}
return out
}
func slimReview(r *gitea_sdk.PullReview) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"id": r.ID,
"state": r.State,
"body": r.Body,
"user": userLogin(r.Reviewer),
"comments_count": r.CodeCommentsCount,
"submitted_at": r.Submitted,
"html_url": r.HTMLURL,
"stale": r.Stale,
"official": r.Official,
"dismissed": r.Dismissed,
}
}
func slimReviews(reviews []*gitea_sdk.PullReview) []map[string]any {
out := make([]map[string]any, 0, len(reviews))
for _, r := range reviews {
out = append(out, slimReview(r))
}
return out
}
func slimReviewComment(c *gitea_sdk.PullReviewComment) map[string]any {
if c == nil {
return nil
}
return map[string]any{
"id": c.ID,
"body": c.Body,
"path": c.Path,
"position": c.LineNum,
"old_position": c.OldLineNum,
"diff_hunk": c.DiffHunk,
"user": userLogin(c.Reviewer),
"html_url": c.HTMLURL,
"created_at": c.Created,
"updated_at": c.Updated,
}
}
func slimReviewComments(comments []*gitea_sdk.PullReviewComment) []map[string]any {
out := make([]map[string]any, 0, len(comments))
for _, c := range comments {
out = append(out, slimReviewComment(c))
}
return out
}
+124
View File
@@ -0,0 +1,124 @@
package pull
import (
"testing"
"time"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimPullRequest(t *testing.T) {
now := time.Now()
additions := 10
deletions := 5
changedFiles := 3
pr := &gitea_sdk.PullRequest{
Index: 1,
Title: "Fix bug",
Body: "Fixes #123",
State: "open",
Draft: false,
HasMerged: false,
Mergeable: true,
HTMLURL: "https://gitea.com/org/repo/pulls/1",
Poster: &gitea_sdk.User{UserName: "bob"},
Labels: []*gitea_sdk.Label{
{Name: "bug"},
{Name: "priority"},
},
Comments: 2,
Created: &now,
Updated: &now,
Additions: &additions,
Deletions: &deletions,
ChangedFiles: &changedFiles,
Head: &gitea_sdk.PRBranchInfo{
Ref: "fix-branch",
Sha: "abc123",
},
Base: &gitea_sdk.PRBranchInfo{
Ref: "main",
Sha: "def456",
},
Assignees: []*gitea_sdk.User{
{UserName: "alice"},
},
Milestone: &gitea_sdk.Milestone{Title: "v1.0"},
}
m := slimPullRequest(pr)
if m["number"] != int64(1) {
t.Errorf("expected number 1, got %v", m["number"])
}
if m["title"] != "Fix bug" {
t.Errorf("expected title Fix bug, got %v", m["title"])
}
if m["user"] != "bob" {
t.Errorf("expected user bob, got %v", m["user"])
}
if m["additions"] != 10 {
t.Errorf("expected additions 10, got %v", m["additions"])
}
if m["milestone"] != "v1.0" {
t.Errorf("expected milestone v1.0, got %v", m["milestone"])
}
labels := m["labels"].([]string)
if len(labels) != 2 || labels[0] != "bug" {
t.Errorf("expected labels [bug priority], got %v", labels)
}
head := m["head"].(map[string]any)
if head["ref"] != "fix-branch" {
t.Errorf("expected head ref fix-branch, got %v", head["ref"])
}
assignees := m["assignees"].([]string)
if len(assignees) != 1 || assignees[0] != "alice" {
t.Errorf("expected assignees [alice], got %v", assignees)
}
// merged fields should not be present for unmerged PR
if _, ok := m["merged_at"]; ok {
t.Error("merged_at should not be present for unmerged PR")
}
}
func TestSlimPullRequests_ListIsSlimmer(t *testing.T) {
pr := &gitea_sdk.PullRequest{
Index: 1,
Title: "PR title",
State: "open",
HTMLURL: "https://gitea.com/org/repo/pulls/1",
Poster: &gitea_sdk.User{UserName: "bob"},
Body: "Full body text here",
Head: &gitea_sdk.PRBranchInfo{Ref: "feature"},
Base: &gitea_sdk.PRBranchInfo{Ref: "main"},
}
single := slimPullRequest(pr)
list := slimPullRequests([]*gitea_sdk.PullRequest{pr})
// Single has body, list does not
if _, ok := single["body"]; !ok {
t.Error("single PR should have body")
}
if _, ok := list[0]["body"]; ok {
t.Error("list PR should not have body")
}
// List has head as string ref, single has head as map
if _, ok := single["head"].(map[string]any); !ok {
t.Error("single PR head should be a map")
}
if list[0]["head"] != "feature" {
t.Errorf("list PR head should be string ref, got %v", list[0]["head"])
}
}
func TestSlimPullRequests_Nil(t *testing.T) {
if r := slimPullRequests(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}
+150
View File
@@ -0,0 +1,150 @@
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 (
CreateBranchToolName = "create_branch"
DeleteBranchToolName = "delete_branch"
ListBranchesToolName = "list_branches"
)
var (
CreateBranchTool = mcp.NewTool(
CreateBranchToolName,
mcp.WithDescription("Create branch"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to create")),
mcp.WithString("old_branch", mcp.Required(), mcp.Description("Name of the old branch to create from")),
)
DeleteBranchTool = mcp.NewTool(
DeleteBranchToolName,
mcp.WithDescription("Delete branch"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to delete")),
)
ListBranchesTool = mcp.NewTool(
ListBranchesToolName,
mcp.WithDescription("List branches"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateBranchTool,
Handler: CreateBranchFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteBranchTool,
Handler: DeleteBranchFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListBranchesTool,
Handler: ListBranchesFn,
})
}
func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateBranchFn")
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)
}
branch, err := params.GetString(args, "branch")
if err != nil {
return to.ErrorResult(err)
}
oldBranch, _ := args["old_branch"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.CreateBranch(owner, repo, gitea_sdk.CreateBranchOption{
BranchName: branch,
OldBranchName: oldBranch,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create branch error: %v", err))
}
return mcp.NewToolResultText("Branch Created"), nil
}
func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteBranchFn")
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)
}
branch, err := params.GetString(args, "branch")
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))
}
_, _, err = client.DeleteRepoBranch(owner, repo, branch)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete branch error: %v", err))
}
return to.TextResult("Branch Deleted")
}
func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListBranchesFn")
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)
}
opt := gitea_sdk.ListRepoBranchesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 30,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
branches, _, err := client.ListRepoBranches(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list branches error: %v", err))
}
return to.TextResult(slimBranches(branches))
}
+77
View File
@@ -0,0 +1,77 @@
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 (
ListRepoCommitsToolName = "list_commits"
)
var ListRepoCommitsTool = mcp.NewTool(
ListRepoCommitsToolName,
mcp.WithDescription("List repository commits"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
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.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)),
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListRepoCommitsTool,
Handler: ListRepoCommitsFn,
})
}
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoCommitsFn")
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)
}
page, err := params.GetIndex(args, "page")
if err != nil {
return to.ErrorResult(err)
}
pageSize, err := params.GetIndex(args, "perPage")
if err != nil {
return to.ErrorResult(err)
}
sha, _ := args["sha"].(string)
path, _ := args["path"].(string)
opt := gitea_sdk.ListCommitOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
SHA: sha,
Path: path,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
commits, _, err := client.ListRepoCommits(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo commits err: %v", err))
}
return to.TextResult(slimCommits(commits))
}
+321
View File
@@ -0,0 +1,321 @@
package repo
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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 (
GetFileToolName = "get_file_contents"
GetDirToolName = "get_dir_contents"
CreateOrUpdateFileToolName = "create_or_update_file"
DeleteFileToolName = "delete_file"
)
var (
GetFileContentTool = mcp.NewTool(
GetFileToolName,
mcp.WithDescription("Get file Content and Metadata"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithBoolean("withLines", mcp.Description("whether to return file content with lines")),
)
GetDirContentTool = mcp.NewTool(
GetDirToolName,
mcp.WithDescription("Get a list of entries in a directory"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("directory path")),
)
CreateOrUpdateFileTool = mcp.NewTool(
CreateOrUpdateFileToolName,
mcp.WithDescription("Create or update a file. If sha is provided, updates the existing file; otherwise creates a new file."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("content", mcp.Required(), mcp.Description("file content")),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
mcp.WithString("sha", mcp.Description("SHA of the existing file (required for update, omit for create)")),
mcp.WithString("new_branch_name", mcp.Description("new branch name (for create only)")),
)
DeleteFileTool = mcp.NewTool(
DeleteFileToolName,
mcp.WithDescription("Delete file"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
mcp.WithString("sha", mcp.Required(), mcp.Description("sha")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetFileContentTool,
Handler: GetFileContentFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetDirContentTool,
Handler: GetDirContentFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateOrUpdateFileTool,
Handler: CreateOrUpdateFileFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteFileTool,
Handler: DeleteFileFn,
})
}
type ContentLine struct {
LineNumber int `json:"line"`
Content string `json:"content"`
}
func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetFileFn")
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)
}
ref, _ := args["ref"].(string)
filePath, err := params.GetString(args, "filePath")
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))
}
content, _, err := client.GetContents(owner, repo, ref, filePath)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "GetFile",
"owner": owner,
"repo": repo,
"path": filePath,
"ref": ref,
})
return to.ErrorResult(translatedErr)
}
withLines, _ := args["withLines"].(bool)
if withLines {
rawContent, err := base64.StdEncoding.DecodeString(*content.Content)
if err != nil {
return to.ErrorResult(fmt.Errorf("decode base64 content err: %v", err))
}
contentLines := make([]ContentLine, 0)
line := 0
scanner := bufio.NewScanner(bytes.NewReader(rawContent))
for scanner.Scan() {
line++
contentLines = append(contentLines, ContentLine{
LineNumber: line,
Content: scanner.Text(),
})
}
if err := scanner.Err(); err != nil {
return to.ErrorResult(fmt.Errorf("scan content err: %v", err))
}
// remove the last blank line if exists
// git does not consider the last line as a new line
if len(contentLines) > 0 && contentLines[len(contentLines)-1].Content == "" {
contentLines = contentLines[:len(contentLines)-1]
}
contentBytes, err := json.MarshalIndent(contentLines, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("marshal content lines err: %v", err))
}
contentStr := string(contentBytes)
content.Content = &contentStr
}
return to.TextResult(slimContents(content))
}
func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetDirContentFn")
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)
}
ref, _ := args["ref"].(string)
filePath, err := params.GetString(args, "filePath")
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))
}
content, _, err := client.ListContents(owner, repo, ref, filePath)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "GetDir",
"owner": owner,
"repo": repo,
"path": filePath,
"ref": ref,
})
return to.ErrorResult(translatedErr)
}
return to.TextResult(slimDirEntries(content))
}
func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateOrUpdateFileFn")
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)
}
filePath, err := params.GetString(args, "filePath")
if err != nil {
return to.ErrorResult(err)
}
content, _ := args["content"].(string)
message, _ := args["message"].(string)
branchName, _ := args["branch_name"].(string)
sha, _ := args["sha"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
if sha != "" {
// Update existing file
opt := gitea_sdk.UpdateFileOptions{
SHA: sha,
Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
},
}
_, _, err = client.UpdateFile(owner, repo, filePath, opt)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "UpdateFile",
"owner": owner,
"repo": repo,
"path": filePath,
"branch": branchName,
})
return to.ErrorResult(translatedErr)
}
return to.TextResult("Update file success")
}
// Create new file
opt := gitea_sdk.CreateFileOptions{
Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
},
}
if newBranch, ok := args["new_branch_name"].(string); ok && newBranch != "" {
opt.NewBranchName = newBranch
}
_, _, err = client.CreateFile(owner, repo, filePath, opt)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "CreateFile",
"owner": owner,
"repo": repo,
"path": filePath,
"branch": branchName,
})
return to.ErrorResult(translatedErr)
}
return to.TextResult("Create file success")
}
func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteFileFn")
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)
}
filePath, err := params.GetString(args, "filePath")
if err != nil {
return to.ErrorResult(err)
}
message, _ := args["message"].(string)
branchName, _ := args["branch_name"].(string)
sha, err := params.GetString(args, "sha")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.DeleteFileOptions{
FileOptions: gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
},
SHA: sha,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteFile(owner, repo, filePath, opt)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "DeleteFile",
"owner": owner,
"repo": repo,
"path": filePath,
"branch": branchName,
})
return to.ErrorResult(translatedErr)
}
return to.TextResult("Delete file success")
}
+365
View File
@@ -0,0 +1,365 @@
package repo
import (
"errors"
"testing"
gitea_errors "gitea.com/gitea/gitea-mcp/pkg/errors"
)
// mockClientError is a mock error that simulates SDK errors
type mockClientError struct {
message string
}
func (e *mockClientError) Error() string {
return e.message
}
func TestErrorTranslation_GetFile(t *testing.T) {
// Test that GetContentsOrList errors are translated properly
err := errors.New("GetContentsOrList: 404 Not Found")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetFile",
"owner": "karti-ai",
"repo": "docs",
"path": "README.md",
"ref": "main",
})
// Should return an EnhancedError
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
// Check operation
if enhanced.Operation != "GetFile" {
t.Errorf("expected operation GetFile, got %s", enhanced.Operation)
}
// Check context
if enhanced.Context["owner"] != "karti-ai" {
t.Errorf("expected owner karti-ai, got %s", enhanced.Context["owner"])
}
if enhanced.Context["path"] != "README.md" {
t.Errorf("expected path README.md, got %s", enhanced.Context["path"])
}
// Should be a file-related error
if enhanced.Category != gitea_errors.CategoryFile {
t.Errorf("expected CategoryFile, got %s", enhanced.Category)
}
// Should be identified as NotFound
if !gitea_errors.IsNotFound(translated) {
t.Error("expected error to be identified as NotFound")
}
t.Logf("Translated error message: %s", enhanced.Error())
}
func TestErrorTranslation_GetDir(t *testing.T) {
// Test that ListContents errors are translated properly
err := errors.New("ListContents: 404 Not Found")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetDir",
"owner": "karti-ai",
"repo": "public_website",
"path": ".gitea/workflows",
"ref": "main",
})
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Operation != "GetDir" {
t.Errorf("expected operation GetDir, got %s", enhanced.Operation)
}
if enhanced.Category != gitea_errors.CategoryFile {
t.Errorf("expected CategoryFile, got %s", enhanced.Category)
}
t.Logf("Translated error message: %s", enhanced.Error())
}
func TestErrorTranslation_CreateFile(t *testing.T) {
// Test that CreateFile errors are translated properly
err := errors.New("CreateFile: 422 Unprocessable Entity")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "CreateFile",
"owner": "karti-ai",
"repo": "docs",
"path": "newfile.md",
"branch": "main",
})
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Operation != "CreateFile" {
t.Errorf("expected operation CreateFile, got %s", enhanced.Operation)
}
if enhanced.Context["path"] != "newfile.md" {
t.Errorf("expected path newfile.md, got %s", enhanced.Context["path"])
}
t.Logf("Translated error message: %s", enhanced.Error())
}
func TestErrorTranslation_UpdateFile(t *testing.T) {
// Test that UpdateFile errors are translated properly
err := errors.New("UpdateFile: 409 Conflict")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "UpdateFile",
"owner": "karti-ai",
"repo": "docs",
"path": "README.md",
"branch": "main",
})
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Operation != "UpdateFile" {
t.Errorf("expected operation UpdateFile, got %s", enhanced.Operation)
}
t.Logf("Translated error message: %s", enhanced.Error())
}
func TestErrorTranslation_DeleteFile(t *testing.T) {
// Test that DeleteFile errors are translated properly
err := errors.New("DeleteFile: 404 Not Found")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "DeleteFile",
"owner": "karti-ai",
"repo": "docs",
"path": "oldfile.md",
"branch": "main",
})
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Operation != "DeleteFile" {
t.Errorf("expected operation DeleteFile, got %s", enhanced.Operation)
}
t.Logf("Translated error message: %s", enhanced.Error())
t.Logf("Error category: %s", enhanced.Category)
}
func TestErrorTranslation_Unwrap(t *testing.T) {
original := errors.New("GetContentsOrList: 404 Not Found")
translated := gitea_errors.TranslateError(original, map[string]string{
"operation": "GetFile",
"owner": "karti-ai",
"repo": "docs",
"path": "README.md",
})
// Should be able to unwrap to get original error
var enhanced *gitea_errors.EnhancedError
if errors.As(translated, &enhanced) {
unwrapped := enhanced.Unwrap()
if unwrapped == nil {
t.Error("expected to be able to unwrap error")
}
if unwrapped.Error() != original.Error() {
t.Errorf("expected unwrapped error to match original: got %s, want %s", unwrapped.Error(), original.Error())
}
} else {
t.Error("expected translated error to be EnhancedError")
}
}
func TestErrorTranslation_AuthErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isAuth bool
}{
{
name: "401 Unauthorized",
errMsg: "GetContentsOrList: 401 Unauthorized",
isAuth: true,
},
{
name: "403 Forbidden",
errMsg: "GetContentsOrList: 403 Forbidden",
isAuth: true,
},
{
name: "404 Not Found (not auth)",
errMsg: "GetContentsOrList: 404 Not Found",
isAuth: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetFile",
})
isAuth := gitea_errors.IsAuthError(translated)
if isAuth != tt.isAuth {
t.Errorf("IsAuthError() = %v, want %v", isAuth, tt.isAuth)
}
})
}
}
func TestErrorTranslation_PreservesExistingEnhancedError(t *testing.T) {
// If we translate an already-enhanced error, it should add context, not replace
original := errors.New("GetContentsOrList: 404 Not Found")
enhanced1 := gitea_errors.TranslateError(original, map[string]string{
"operation": "GetFile",
"owner": "karti-ai",
})
// Translate again with more context
enhanced2 := gitea_errors.TranslateError(enhanced1, map[string]string{
"repo": "docs",
"path": "README.md",
})
var e *gitea_errors.EnhancedError
if errors.As(enhanced2, &e) {
// Should have both sets of context
if e.Context["operation"] != "GetFile" {
t.Errorf("expected operation context to be preserved, got %s", e.Context["operation"])
}
if e.Context["owner"] != "karti-ai" {
t.Errorf("expected owner context to be preserved, got %s", e.Context["owner"])
}
if e.Context["repo"] != "docs" {
t.Errorf("expected repo context to be added, got %s", e.Context["repo"])
}
if e.Context["path"] != "README.md" {
t.Errorf("expected path context to be added, got %s", e.Context["path"])
}
} else {
t.Error("expected error to be EnhancedError")
}
}
func TestErrorTranslation_NetworkErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isNetwork bool
isTimeout bool
}{
{
name: "Connection refused",
errMsg: "GetContentsOrList: connection refused",
isNetwork: true,
isTimeout: false,
},
{
name: "Timeout",
errMsg: "GetContentsOrList: timeout",
isNetwork: true,
isTimeout: true,
},
{
name: "No such host",
errMsg: "GetContentsOrList: no such host",
isNetwork: true,
isTimeout: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetFile",
})
isNetwork := gitea_errors.IsNetworkError(translated)
isTimeout := gitea_errors.IsTimeout(translated)
t.Logf("Error: %s, IsNetwork: %v, IsTimeout: %v", tt.errMsg, isNetwork, isTimeout)
})
}
}
func TestErrorTranslation_ServerErrors(t *testing.T) {
tests := []struct {
name string
errMsg string
isServer bool
}{
{
name: "500 Internal Server Error",
errMsg: "GetContentsOrList: 500 Internal Server Error",
isServer: true,
},
{
name: "502 Bad Gateway",
errMsg: "GetContentsOrList: 502 Bad Gateway",
isServer: true,
},
{
name: "503 Service Unavailable",
errMsg: "GetContentsOrList: 503 Service Unavailable",
isServer: true,
},
{
name: "404 Not Found (not server)",
errMsg: "GetContentsOrList: 404 Not Found",
isServer: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetFile",
})
isServer := gitea_errors.IsServerError(translated)
t.Logf("Error: %s, IsServer: %v", tt.errMsg, isServer)
})
}
}
func TestErrorTranslation_Format(t *testing.T) {
err := errors.New("GetContentsOrList: 404 Not Found")
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "GetFile",
"owner": "karti-ai",
"repo": "docs",
"path": "README.md",
"ref": "main",
})
var enhanced *gitea_errors.EnhancedError
if errors.As(translated, &enhanced) {
formatted := enhanced.Format()
// Format should include operation
if formatted == "" {
t.Error("expected non-empty formatted error")
}
t.Logf("Formatted error: %s", formatted)
} else {
t.Error("expected translated error to be EnhancedError")
}
}
+557
View File
@@ -0,0 +1,557 @@
package repo
import (
"context"
"encoding/json"
"fmt"
"time"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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 (
RepoHealthCheckToolName = "repo_health_check"
)
var (
RepoHealthCheckTool = mcp.NewTool(
RepoHealthCheckToolName,
mcp.WithDescription("Check repository health by aggregating multiple status metrics including last commit date, open issues/PRs count, workflow status, and branch protection. Returns a comprehensive health score (0-100)."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithBoolean("include_workflows", mcp.Description("include workflow run status (may require additional API calls)"), mcp.DefaultBool(true)),
mcp.WithBoolean("include_protection", mcp.Description("include branch protection status"), mcp.DefaultBool(true)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: RepoHealthCheckTool,
Handler: repoHealthCheckFn,
})
}
// HealthResult represents the complete health check result
type HealthResult struct {
Repository string `json:"repository"`
HealthScore int `json:"health_score"`
HealthStatus string `json:"health_status"`
LastCommit *CommitInfo `json:"last_commit,omitempty"`
Issues *IssuesInfo `json:"issues,omitempty"`
PullRequests *PullRequestsInfo `json:"pull_requests,omitempty"`
WorkflowStatus *WorkflowStatusInfo `json:"workflow_status,omitempty"`
BranchProtection *BranchProtectionInfo `json:"branch_protection,omitempty"`
RepositoryInfo *RepositoryInfo `json:"repository_info,omitempty"`
Errors []HealthCheckError `json:"errors,omitempty"`
CheckedAt string `json:"checked_at"`
PartialResult bool `json:"partial_result"`
}
// CommitInfo contains last commit information
type CommitInfo struct {
SHA string `json:"sha"`
Message string `json:"message"`
Author string `json:"author"`
Date string `json:"date"`
DaysAgo int `json:"days_ago"`
Available bool `json:"available"`
}
// IssuesInfo contains issue metrics
type IssuesInfo struct {
OpenCount int `json:"open_count"`
TotalCount int `json:"total_count"`
Available bool `json:"available"`
}
// PullRequestsInfo contains PR metrics
type PullRequestsInfo struct {
OpenCount int `json:"open_count"`
TotalCount int `json:"total_count"`
Available bool `json:"available"`
}
// WorkflowStatusInfo contains workflow information
type WorkflowStatusInfo struct {
LastRunStatus string `json:"last_run_status,omitempty"`
LastRunConclusion string `json:"last_run_conclusion,omitempty"`
HasRecentRuns bool `json:"has_recent_runs"`
Available bool `json:"available"`
Error string `json:"error,omitempty"`
}
// BranchProtectionInfo contains protection metrics
type BranchProtectionInfo struct {
ProtectedBranchesCount int `json:"protected_branches_count"`
ProtectedBranches []string `json:"protected_branches,omitempty"`
Available bool `json:"available"`
}
// RepositoryInfo contains basic repo metrics
type RepositoryInfo struct {
Stars int `json:"stars"`
Forks int `json:"forks"`
Language string `json:"language,omitempty"`
IsPrivate bool `json:"is_private"`
IsArchived bool `json:"is_archived"`
Available bool `json:"available"`
}
// HealthCheckError represents an error from a specific check
type HealthCheckError struct {
Check string `json:"check"`
Error string `json:"error"`
}
func repoHealthCheckFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called repoHealthCheckFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "RepoHealthCheck",
"param": "owner",
}))
}
repoName, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "RepoHealthCheck",
"param": "repo",
}))
}
includeWorkflows := params.GetOptionalBool(req.GetArguments(), "include_workflows", true)
includeProtection := params.GetOptionalBool(req.GetArguments(), "include_protection", true)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "RepoHealthCheck",
"owner": owner,
"repo": repoName,
}))
}
result := &HealthResult{
Repository: fmt.Sprintf("%s/%s", owner, repoName),
CheckedAt: time.Now().UTC().Format(time.RFC3339),
PartialResult: false,
Errors: []HealthCheckError{},
}
// Check 1: Repository Info (always try first)
repoInfo, err := checkRepositoryInfo(ctx, client, owner, repoName)
if err != nil {
result.Errors = append(result.Errors, HealthCheckError{
Check: "repository_info",
Error: err.Error(),
})
result.PartialResult = true
} else {
result.RepositoryInfo = repoInfo
}
// Check 2: Last Commit
commitInfo, err := checkLastCommit(ctx, client, owner, repoName)
if err != nil {
result.Errors = append(result.Errors, HealthCheckError{
Check: "last_commit",
Error: err.Error(),
})
result.PartialResult = true
} else {
result.LastCommit = commitInfo
}
// Check 3: Issues
issuesInfo, err := checkIssues(ctx, client, owner, repoName)
if err != nil {
result.Errors = append(result.Errors, HealthCheckError{
Check: "issues",
Error: err.Error(),
})
result.PartialResult = true
} else {
result.Issues = issuesInfo
}
// Check 4: Pull Requests
prsInfo, err := checkPullRequests(ctx, client, owner, repoName)
if err != nil {
result.Errors = append(result.Errors, HealthCheckError{
Check: "pull_requests",
Error: err.Error(),
})
result.PartialResult = true
} else {
result.PullRequests = prsInfo
}
// Check 5: Workflow Status (optional, may fail on older Gitea versions)
if includeWorkflows {
workflowInfo, err := checkWorkflowStatus(ctx, owner, repoName)
if err != nil {
// Don't mark as partial for workflow errors on older Gitea versions
if !errors.IsActionsAPIUnavailable(err) {
result.Errors = append(result.Errors, HealthCheckError{
Check: "workflow_status",
Error: err.Error(),
})
}
result.WorkflowStatus = &WorkflowStatusInfo{
Available: false,
Error: err.Error(),
}
} else {
result.WorkflowStatus = workflowInfo
}
}
// Check 6: Branch Protection (optional)
if includeProtection {
protectionInfo, err := checkBranchProtection(ctx, client, owner, repoName)
if err != nil {
result.Errors = append(result.Errors, HealthCheckError{
Check: "branch_protection",
Error: err.Error(),
})
result.PartialResult = true
result.BranchProtection = &BranchProtectionInfo{
Available: false,
}
} else {
result.BranchProtection = protectionInfo
}
}
// Calculate health score
result.HealthScore = calculateHealthScore(result)
result.HealthStatus = getHealthStatus(result.HealthScore)
// Return result as JSON
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "RepoHealthCheck",
"step": "marshal_result",
}))
}
return to.TextResult(string(jsonBytes))
}
func checkRepositoryInfo(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*RepositoryInfo, error) {
r, _, err := client.GetRepo(owner, repo)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "GetRepo",
"owner": owner,
"repo": repo,
})
}
return &RepositoryInfo{
Stars: r.Stars,
Forks: r.Forks,
Language: r.Language,
IsPrivate: r.Private,
IsArchived: r.Archived,
Available: true,
}, nil
}
func checkLastCommit(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*CommitInfo, error) {
opt := gitea_sdk.ListCommitOptions{
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 1,
},
}
commits, _, err := client.ListRepoCommits(owner, repo, opt)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListRepoCommits",
"owner": owner,
"repo": repo,
})
}
if len(commits) == 0 {
return &CommitInfo{
Available: false,
}, nil
}
c := commits[0]
info := &CommitInfo{
SHA: c.SHA,
Available: true,
}
if c.RepoCommit != nil {
info.Message = c.RepoCommit.Message
if c.RepoCommit.Author != nil {
info.Author = c.RepoCommit.Author.Name
info.Date = c.RepoCommit.Author.Date
// Calculate days ago
if commitTime, err := time.Parse(time.RFC3339, c.RepoCommit.Author.Date); err == nil {
info.DaysAgo = int(time.Since(commitTime).Hours() / 24)
}
}
}
return info, nil
}
func checkIssues(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*IssuesInfo, error) {
// Get open issues count
openOpt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateOpen,
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 1,
},
}
openIssues, _, err := client.ListRepoIssues(owner, repo, openOpt)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListRepoIssues",
"owner": owner,
"repo": repo,
"state": "open",
})
}
// Get total issues count (we can use the repo info for this to save API calls)
// For simplicity, we'll just use what we can get from list
totalOpt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateAll,
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 1,
},
}
totalIssues, _, err := client.ListRepoIssues(owner, repo, totalOpt)
if err != nil {
// If we got open count, we can still return partial info
return &IssuesInfo{
OpenCount: len(openIssues),
Available: true,
}, nil
}
return &IssuesInfo{
OpenCount: len(openIssues),
TotalCount: len(totalIssues),
Available: true,
}, nil
}
func checkPullRequests(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*PullRequestsInfo, error) {
// Get open PRs
openOpt := gitea_sdk.ListPullRequestsOptions{
State: gitea_sdk.StateOpen,
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 1,
},
}
openPRs, _, err := client.ListRepoPullRequests(owner, repo, openOpt)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListRepoPullRequests",
"owner": owner,
"repo": repo,
"state": "open",
})
}
// Get total PRs
totalOpt := gitea_sdk.ListPullRequestsOptions{
State: gitea_sdk.StateAll,
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 1,
},
}
totalPRs, _, err := client.ListRepoPullRequests(owner, repo, totalOpt)
if err != nil {
return &PullRequestsInfo{
OpenCount: len(openPRs),
Available: true,
}, nil
}
return &PullRequestsInfo{
OpenCount: len(openPRs),
TotalCount: len(totalPRs),
Available: true,
}, nil
}
func checkWorkflowStatus(ctx context.Context, owner, repo string) (*WorkflowStatusInfo, error) {
// Use the REST API directly to get recent workflow runs
var result struct {
WorkflowRuns []map[string]any `json:"workflow_runs"`
}
status, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/runs", owner, repo), nil, nil, &result)
if err != nil {
// Check if this is an Actions API unavailability error
if status == 404 || status == 405 {
return nil, errors.NewEnhancedError(
err,
"Actions API not available on this Gitea version",
errors.CategoryActions,
).WithOperation("CheckWorkflowStatus")
}
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListWorkflowRuns",
"owner": owner,
"repo": repo,
})
}
info := &WorkflowStatusInfo{
Available: len(result.WorkflowRuns) > 0,
}
if len(result.WorkflowRuns) > 0 {
// Get the most recent run
run := result.WorkflowRuns[0]
info.LastRunStatus = getStringFromMap(run, "status")
info.LastRunConclusion = getStringFromMap(run, "conclusion")
info.HasRecentRuns = true
// Check if run is recent (within 7 days)
if createdAt := getStringFromMap(run, "created_at"); createdAt != "" {
if runTime, err := time.Parse(time.RFC3339, createdAt); err == nil {
info.HasRecentRuns = time.Since(runTime).Hours() < 24*7
}
}
}
return info, nil
}
func checkBranchProtection(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*BranchProtectionInfo, error) {
protections, _, err := client.ListBranchProtections(owner, repo, gitea_sdk.ListBranchProtectionsOptions{})
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListBranchProtections",
"owner": owner,
"repo": repo,
})
}
branches := make([]string, 0, len(protections))
for _, p := range protections {
branches = append(branches, p.BranchName)
}
return &BranchProtectionInfo{
ProtectedBranchesCount: len(protections),
ProtectedBranches: branches,
Available: true,
}, nil
}
func calculateHealthScore(result *HealthResult) int {
score := 100
// Deduct for stale commits (more than 30 days)
if result.LastCommit != nil && result.LastCommit.Available {
if result.LastCommit.DaysAgo > 90 {
score -= 30
} else if result.LastCommit.DaysAgo > 30 {
score -= 15
}
}
// Deduct for too many open issues (relative scoring)
if result.Issues != nil && result.Issues.Available {
if result.Issues.OpenCount > 50 {
score -= 10
} else if result.Issues.OpenCount > 20 {
score -= 5
}
}
// Deduct for old/stale PRs
if result.PullRequests != nil && result.PullRequests.Available {
if result.PullRequests.OpenCount > 10 {
score -= 5
}
}
// Deduct for workflow failures
if result.WorkflowStatus != nil && result.WorkflowStatus.Available {
if result.WorkflowStatus.LastRunConclusion == "failure" {
score -= 15
} else if result.WorkflowStatus.LastRunConclusion == "cancelled" {
score -= 5
}
if !result.WorkflowStatus.HasRecentRuns {
score -= 5
}
}
// Bonus for good practices
if result.BranchProtection != nil && result.BranchProtection.Available {
if result.BranchProtection.ProtectedBranchesCount > 0 {
score += 5 // Bonus for having protected branches
}
}
// Penalty for archived repos
if result.RepositoryInfo != nil && result.RepositoryInfo.IsArchived {
score -= 40
}
// Ensure score is within bounds
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
return score
}
func getHealthStatus(score int) string {
switch {
case score >= 90:
return "excellent"
case score >= 70:
return "good"
case score >= 50:
return "fair"
case score >= 30:
return "poor"
default:
return "critical"
}
}
func getStringFromMap(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
+697
View File
@@ -0,0 +1,697 @@
package repo
import (
"encoding/json"
"testing"
)
func TestCalculateHealthScore(t *testing.T) {
tests := []struct {
name string
result *HealthResult
expected int
}{
{
name: "perfect health - active repo",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 5,
},
Issues: &IssuesInfo{
Available: true,
OpenCount: 5,
},
PullRequests: &PullRequestsInfo{
Available: true,
OpenCount: 2,
},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "success",
HasRecentRuns: true,
},
BranchProtection: &BranchProtectionInfo{
Available: true,
ProtectedBranchesCount: 1,
},
RepositoryInfo: &RepositoryInfo{
IsArchived: false,
},
},
expected: 100,
},
{
name: "stale commits - 35 days",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 35,
},
Issues: &IssuesInfo{
Available: true,
OpenCount: 5,
},
},
expected: 85,
},
{
name: "very stale commits - 100 days",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 100,
},
Issues: &IssuesInfo{
Available: true,
OpenCount: 5,
},
},
expected: 70,
},
{
name: "too many open issues",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 5,
},
Issues: &IssuesInfo{
Available: true,
OpenCount: 60,
},
},
expected: 90,
},
{
name: "workflow failure",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 5,
},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "failure",
HasRecentRuns: true,
},
},
expected: 85,
},
{
name: "archived repository",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 5,
},
RepositoryInfo: &RepositoryInfo{
IsArchived: true,
},
},
expected: 60,
},
{
name: "empty result - no data",
result: &HealthResult{},
expected: 100,
},
{
name: "boundary - minimum score",
result: &HealthResult{
LastCommit: &CommitInfo{
Available: true,
DaysAgo: 1000,
},
RepositoryInfo: &RepositoryInfo{
IsArchived: true,
},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "failure",
HasRecentRuns: false,
},
Issues: &IssuesInfo{
Available: true,
OpenCount: 100,
},
},
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := calculateHealthScore(tt.result)
if score != tt.expected {
t.Errorf("calculateHealthScore() = %d, want %d", score, tt.expected)
}
})
}
}
func TestGetHealthStatus(t *testing.T) {
tests := []struct {
score int
expected string
}{
{95, "excellent"},
{90, "excellent"},
{85, "good"},
{70, "good"},
{60, "fair"},
{50, "fair"},
{40, "poor"},
{30, "poor"},
{20, "critical"},
{0, "critical"},
{100, "excellent"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
status := getHealthStatus(tt.score)
if status != tt.expected {
t.Errorf("getHealthStatus(%d) = %s, want %s", tt.score, status, tt.expected)
}
})
}
}
func TestGetStringFromMap(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
expected string
}{
{
name: "string value",
m: map[string]any{"status": "success"},
key: "status",
expected: "success",
},
{
name: "missing key",
m: map[string]any{"other": "value"},
key: "status",
expected: "",
},
{
name: "non-string value",
m: map[string]any{"count": 42},
key: "count",
expected: "",
},
{
name: "empty map",
m: map[string]any{},
key: "status",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getStringFromMap(tt.m, tt.key)
if result != tt.expected {
t.Errorf("getStringFromMap() = %q, want %q", result, tt.expected)
}
})
}
}
func TestHealthResultJSONMarshaling(t *testing.T) {
result := &HealthResult{
Repository: "owner/repo",
HealthScore: 85,
HealthStatus: "good",
LastCommit: &CommitInfo{
SHA: "abc123",
Message: "Initial commit",
Author: "user",
Date: "2024-01-15T10:30:00Z",
DaysAgo: 5,
Available: true,
},
Issues: &IssuesInfo{
OpenCount: 10,
TotalCount: 50,
Available: true,
},
PullRequests: &PullRequestsInfo{
OpenCount: 3,
TotalCount: 15,
Available: true,
},
WorkflowStatus: &WorkflowStatusInfo{
LastRunStatus: "completed",
LastRunConclusion: "success",
HasRecentRuns: true,
Available: true,
},
BranchProtection: &BranchProtectionInfo{
ProtectedBranchesCount: 2,
ProtectedBranches: []string{"main", "develop"},
Available: true,
},
RepositoryInfo: &RepositoryInfo{
Stars: 100,
Forks: 20,
Language: "Go",
IsPrivate: false,
IsArchived: false,
Available: true,
},
CheckedAt: "2024-01-20T10:00:00Z",
PartialResult: false,
}
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
t.Fatalf("Failed to marshal HealthResult: %v", err)
}
if len(jsonBytes) == 0 {
t.Error("Expected non-empty JSON output")
}
var unmarshaled HealthResult
if err := json.Unmarshal(jsonBytes, &unmarshaled); err != nil {
t.Fatalf("Failed to unmarshal HealthResult: %v", err)
}
if unmarshaled.HealthScore != result.HealthScore {
t.Errorf("HealthScore mismatch: got %d, want %d", unmarshaled.HealthScore, result.HealthScore)
}
if unmarshaled.HealthStatus != result.HealthStatus {
t.Errorf("HealthStatus mismatch: got %s, want %s", unmarshaled.HealthStatus, result.HealthStatus)
}
if unmarshaled.LastCommit == nil || unmarshaled.LastCommit.SHA != result.LastCommit.SHA {
t.Error("LastCommit mismatch")
}
}
func TestCalculateHealthScore_EdgeCases(t *testing.T) {
tests := []struct {
name string
result *HealthResult
expected int
}{
{
name: "all nil fields",
result: &HealthResult{
LastCommit: nil,
Issues: nil,
PullRequests: nil,
WorkflowStatus: nil,
BranchProtection: nil,
RepositoryInfo: nil,
},
expected: 100,
},
{
name: "unavailable fields",
result: &HealthResult{
LastCommit: &CommitInfo{Available: false},
Issues: &IssuesInfo{Available: false},
},
expected: 100,
},
{
name: "stale commits boundary - exactly 30 days",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 30},
},
expected: 100,
},
{
name: "stale commits boundary - exactly 31 days",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 31},
},
expected: 85,
},
{
name: "stale commits boundary - exactly 90 days",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 90},
},
expected: 85,
},
{
name: "stale commits boundary - exactly 91 days",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 91},
},
expected: 70,
},
{
name: "issues boundary - exactly 20",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
Issues: &IssuesInfo{Available: true, OpenCount: 20},
},
expected: 100,
},
{
name: "issues boundary - exactly 21",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
Issues: &IssuesInfo{Available: true, OpenCount: 21},
},
expected: 95,
},
{
name: "issues boundary - exactly 50",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
Issues: &IssuesInfo{Available: true, OpenCount: 50},
},
expected: 95,
},
{
name: "issues boundary - exactly 51",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
Issues: &IssuesInfo{Available: true, OpenCount: 51},
},
expected: 90,
},
{
name: "PRs boundary - exactly 10",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
PullRequests: &PullRequestsInfo{Available: true, OpenCount: 10},
},
expected: 100,
},
{
name: "PRs boundary - exactly 11",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
PullRequests: &PullRequestsInfo{Available: true, OpenCount: 11},
},
expected: 95,
},
{
name: "workflow cancelled",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "cancelled",
HasRecentRuns: true,
},
},
expected: 95,
},
{
name: "workflow no recent runs",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "success",
HasRecentRuns: false,
},
},
expected: 95,
},
{
name: "archived with negative score",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 1000},
RepositoryInfo: &RepositoryInfo{
IsArchived: true,
},
WorkflowStatus: &WorkflowStatusInfo{
Available: true,
LastRunConclusion: "failure",
HasRecentRuns: false,
},
},
expected: 0,
},
{
name: "maximum score cap",
result: &HealthResult{
LastCommit: &CommitInfo{Available: true, DaysAgo: 0},
Issues: &IssuesInfo{Available: true, OpenCount: 0},
BranchProtection: &BranchProtectionInfo{
Available: true,
ProtectedBranchesCount: 10,
},
},
expected: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := calculateHealthScore(tt.result)
if score != tt.expected {
t.Errorf("calculateHealthScore() = %d, want %d", score, tt.expected)
}
})
}
}
func TestGetHealthStatus_Boundaries(t *testing.T) {
tests := []struct {
score int
expected string
}{
{100, "excellent"},
{91, "excellent"},
{89, "good"},
{71, "good"},
{69, "fair"},
{51, "fair"},
{49, "poor"},
{31, "poor"},
{29, "critical"},
{1, "critical"},
{-10, "critical"},
{110, "excellent"},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("score_%d", tt.score), func(t *testing.T) {
status := getHealthStatus(tt.score)
if status != tt.expected {
t.Errorf("getHealthStatus(%d) = %s, want %s", tt.score, status, tt.expected)
}
})
}
}
func TestGetStringFromMap_EdgeCases(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
expected string
}{
{
name: "nil map",
m: nil,
key: "status",
expected: "",
},
{
name: "empty map",
m: map[string]any{},
key: "status",
expected: "",
},
{
name: "int value",
m: map[string]any{"count": int(42)},
key: "count",
expected: "",
},
{
name: "float64 value",
m: map[string]any{"count": float64(42)},
key: "count",
expected: "",
},
{
name: "bool value",
m: map[string]any{"active": true},
key: "active",
expected: "",
},
{
name: "nested map value",
m: map[string]any{"data": map[string]any{"key": "value"}},
key: "data",
expected: "",
},
{
name: "slice value",
m: map[string]any{"items": []string{"a", "b"}},
key: "items",
expected: "",
},
{
name: "empty string value",
m: map[string]any{"name": ""},
key: "name",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getStringFromMap(tt.m, tt.key)
if result != tt.expected {
t.Errorf("getStringFromMap() = %q, want %q", result, tt.expected)
}
})
}
}
func TestHealthCheckStructs(t *testing.T) {
t.Run("CommitInfo", func(t *testing.T) {
commit := &CommitInfo{
SHA: "abc123",
Message: "Initial commit",
Author: "user@example.com",
Date: "2024-01-15T10:30:00Z",
DaysAgo: 5,
Available: true,
}
if commit.SHA != "abc123" {
t.Error("SHA mismatch")
}
if commit.DaysAgo != 5 {
t.Error("DaysAgo mismatch")
}
})
t.Run("IssuesInfo", func(t *testing.T) {
issues := &IssuesInfo{
OpenCount: 10,
TotalCount: 50,
Available: true,
}
if issues.OpenCount != 10 {
t.Error("OpenCount mismatch")
}
if !issues.Available {
t.Error("Available should be true")
}
})
t.Run("PullRequestsInfo", func(t *testing.T) {
prs := &PullRequestsInfo{
OpenCount: 3,
TotalCount: 15,
Available: true,
}
if prs.OpenCount != 3 {
t.Error("OpenCount mismatch")
}
})
t.Run("WorkflowStatusInfo", func(t *testing.T) {
wf := &WorkflowStatusInfo{
LastRunStatus: "completed",
LastRunConclusion: "success",
HasRecentRuns: true,
Available: true,
Error: "",
}
if wf.LastRunConclusion != "success" {
t.Error("LastRunConclusion mismatch")
}
})
t.Run("BranchProtectionInfo", func(t *testing.T) {
bp := &BranchProtectionInfo{
ProtectedBranchesCount: 2,
ProtectedBranches: []string{"main", "develop"},
Available: true,
}
if bp.ProtectedBranchesCount != 2 {
t.Error("ProtectedBranchesCount mismatch")
}
if len(bp.ProtectedBranches) != 2 {
t.Error("ProtectedBranches length mismatch")
}
})
t.Run("RepositoryInfo", func(t *testing.T) {
repo := &RepositoryInfo{
Stars: 100,
Forks: 20,
Language: "Go",
IsPrivate: false,
IsArchived: false,
Available: true,
}
if repo.Stars != 100 {
t.Error("Stars mismatch")
}
if repo.IsArchived {
t.Error("IsArchived should be false")
}
})
t.Run("HealthCheckError", func(t *testing.T) {
err := HealthCheckError{
Check: "workflow_status",
Error: "API not available",
}
if err.Check != "workflow_status" {
t.Error("Check mismatch")
}
})
}
func TestHealthResultErrors(t *testing.T) {
result := &HealthResult{
Repository: "owner/repo",
HealthScore: 75,
HealthStatus: "good",
Errors: []HealthCheckError{
{Check: "workflow_status", Error: "API not available"},
{Check: "branch_protection", Error: "No permissions"},
},
PartialResult: true,
CheckedAt: "2024-01-20T10:00:00Z",
}
if !result.PartialResult {
t.Error("PartialResult should be true when errors exist")
}
if len(result.Errors) != 2 {
t.Errorf("Errors count = %d, want 2", len(result.Errors))
}
}
func TestHealthResultWithNilFields(t *testing.T) {
result := &HealthResult{
Repository: "owner/repo",
HealthScore: 100,
HealthStatus: "excellent",
LastCommit: nil,
Issues: nil,
PullRequests: nil,
WorkflowStatus: nil,
BranchProtection: nil,
RepositoryInfo: nil,
Errors: []HealthCheckError{},
CheckedAt: "2024-01-20T10:00:00Z",
PartialResult: false,
}
score := calculateHealthScore(result)
if score != 100 {
t.Errorf("calculateHealthScore() with nil fields = %d, want 100", score)
}
}
+264
View File
@@ -0,0 +1,264 @@
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 (
CreateReleaseToolName = "create_release"
DeleteReleaseToolName = "delete_release"
GetReleaseToolName = "get_release"
GetLatestReleaseToolName = "get_latest_release"
ListReleasesToolName = "list_releases"
)
var (
CreateReleaseTool = mcp.NewTool(
CreateReleaseToolName,
mcp.WithDescription("Create release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
mcp.WithString("target", mcp.Required(), mcp.Description("target commitish")),
mcp.WithString("title", mcp.Required(), mcp.Description("release title")),
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.WithString("body", mcp.Description("release body")),
)
DeleteReleaseTool = mcp.NewTool(
DeleteReleaseToolName,
mcp.WithDescription("Delete release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")),
)
GetReleaseTool = mcp.NewTool(
GetReleaseToolName,
mcp.WithDescription("Get release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")),
)
GetLatestReleaseTool = mcp.NewTool(
GetLatestReleaseToolName,
mcp.WithDescription("Get latest release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
ListReleasesTool = mcp.NewTool(
ListReleasesToolName,
mcp.WithDescription("List releases"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
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.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)),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateReleaseTool,
Handler: CreateReleaseFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteReleaseTool,
Handler: DeleteReleaseFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetReleaseTool,
Handler: GetReleaseFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetLatestReleaseTool,
Handler: GetLatestReleaseFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListReleasesTool,
Handler: ListReleasesFn,
})
}
func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateReleasesFn")
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)
}
tagName, err := params.GetString(args, "tag_name")
if err != nil {
return to.ErrorResult(err)
}
target, err := params.GetString(args, "target")
if err != nil {
return to.ErrorResult(err)
}
title, err := params.GetString(args, "title")
if err != nil {
return to.ErrorResult(err)
}
isDraft, _ := args["is_draft"].(bool)
isPreRelease, _ := args["is_pre_release"].(bool)
body, _ := args["body"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.CreateRelease(owner, repo, gitea_sdk.CreateReleaseOption{
TagName: tagName,
Target: target,
Title: title,
Note: body,
IsDraft: isDraft,
IsPrerelease: isPreRelease,
})
if err != nil {
return nil, fmt.Errorf("create release error: %v", err)
}
return mcp.NewToolResultText("Release Created"), nil
}
func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteReleaseFn")
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)
}
id, err := params.GetIndex(args, "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))
}
_, err = client.DeleteRelease(owner, repo, id)
if err != nil {
return nil, fmt.Errorf("delete release error: %v", err)
}
return to.TextResult("Release deleted successfully")
}
func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetReleaseFn")
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)
}
id, err := params.GetIndex(args, "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))
}
release, _, err := client.GetRelease(owner, repo, id)
if err != nil {
return nil, fmt.Errorf("get release error: %v", err)
}
return to.TextResult(slimRelease(release))
}
func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetLatestReleaseFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
release, _, err := client.GetLatestRelease(owner, repo)
if err != nil {
return nil, fmt.Errorf("get latest release error: %v", err)
}
return to.TextResult(slimRelease(release))
}
func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListReleasesFn")
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)
}
var pIsDraft *bool
isDraft, ok := args["is_draft"].(bool)
if ok {
pIsDraft = new(isDraft)
}
var pIsPreRelease *bool
isPreRelease, ok := args["is_pre_release"].(bool)
if ok {
pIsPreRelease = new(isPreRelease)
}
page := params.GetOptionalInt(args, "page", 1)
pageSize := params.GetOptionalInt(args, "perPage", 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
releases, _, err := client.ListReleases(owner, repo, gitea_sdk.ListReleasesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
IsDraft: pIsDraft,
IsPreRelease: pIsPreRelease,
})
if err != nil {
return nil, fmt.Errorf("list releases error: %v", err)
}
return to.TextResult(slimReleases(releases))
}
+225
View File
@@ -0,0 +1,225 @@
package repo
import (
"context"
"errors"
"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.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 (
CreateRepoToolName = "create_repo"
ForkRepoToolName = "fork_repo"
ListMyReposToolName = "list_my_repos"
ListOrgReposToolName = "list_org_repos"
)
var (
CreateRepoTool = mcp.NewTool(
CreateRepoToolName,
mcp.WithDescription("Create repository in personal account or organization"),
mcp.WithString("name", mcp.Required(), mcp.Description("Name of the repository to create")),
mcp.WithString("description", mcp.Description("Description of the repository to create")),
mcp.WithBoolean("private", mcp.Description("Whether the repository is private")),
mcp.WithString("issue_labels", mcp.Description("Issue Label set to use")),
mcp.WithBoolean("auto_init", mcp.Description("Whether the repository should be auto-intialized?")),
mcp.WithBoolean("template", mcp.Description("Whether the repository is template")),
mcp.WithString("gitignores", mcp.Description("Gitignores to use")),
mcp.WithString("license", mcp.Description("License to use")),
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("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
)
ForkRepoTool = mcp.NewTool(
ForkRepoToolName,
mcp.WithDescription("Fork repository"),
mcp.WithString("user", mcp.Required(), mcp.Description("User name of the repository to fork")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name to fork")),
mcp.WithString("organization", mcp.Description("Organization name to fork")),
mcp.WithString("name", mcp.Description("Name of the forked repository")),
)
ListMyReposTool = mcp.NewTool(
ListMyReposToolName,
mcp.WithDescription("List my repositories"),
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)),
)
ListOrgReposTool = mcp.NewTool(
ListOrgReposToolName,
mcp.WithDescription("List repositories of an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateRepoTool,
Handler: CreateRepoFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: ForkRepoTool,
Handler: ForkRepoFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListMyReposTool,
Handler: ListMyReposFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListOrgReposTool,
Handler: ListOrgReposFn,
})
}
func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateRepoFn")
args := req.GetArguments()
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
description, _ := args["description"].(string)
private, _ := args["private"].(bool)
issueLabels, _ := args["issue_labels"].(string)
autoInit, _ := args["auto_init"].(bool)
template, _ := args["template"].(bool)
gitignores, _ := args["gitignores"].(string)
license, _ := args["license"].(string)
readme, _ := args["readme"].(string)
defaultBranch, _ := args["default_branch"].(string)
organization, _ := args["organization"].(string)
opt := gitea_sdk.CreateRepoOption{
Name: name,
Description: description,
Private: private,
IssueLabels: issueLabels,
AutoInit: autoInit,
Template: template,
Gitignores: gitignores,
License: license,
Readme: readme,
DefaultBranch: defaultBranch,
}
var repo *gitea_sdk.Repository
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
if organization != "" {
repo, _, err = client.CreateOrgRepo(organization, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create organization repository '%s' in '%s' err: %v", name, organization, err))
}
} else {
repo, _, err = client.CreateRepo(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err))
}
}
return to.TextResult(slimRepo(repo))
}
func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ForkRepoFn")
args := req.GetArguments()
user, err := params.GetString(args, "user")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
organization, ok := args["organization"].(string)
organizationPtr := new(organization)
if !ok || organization == "" {
organizationPtr = nil
}
name, ok := args["name"].(string)
namePtr := new(name)
if !ok || name == "" {
namePtr = nil
}
opt := gitea_sdk.CreateForkOption{
Organization: organizationPtr,
Name: namePtr,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.CreateFork(user, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("fork repository error: %v", err))
}
return to.TextResult("Fork success")
}
func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListMyReposFn")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListReposOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.ListMyRepos(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err))
}
return to.TextResult(slimRepos(repos))
}
func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListOrgReposFn")
org, ok := req.GetArguments()["org"].(string)
if !ok {
return to.ErrorResult(errors.New("organization name is required"))
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListOrgReposOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.ListOrgRepos(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list organization '%s' repositories error: %v", org, err))
}
return to.TextResult(repos)
}
+201
View File
@@ -0,0 +1,201 @@
package repo
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func slimRepo(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
m := map[string]any{
"id": r.ID,
"full_name": r.FullName,
"description": r.Description,
"html_url": r.HTMLURL,
"clone_url": r.CloneURL,
"ssh_url": r.SSHURL,
"default_branch": r.DefaultBranch,
"private": r.Private,
"fork": r.Fork,
"archived": r.Archived,
"language": r.Language,
"stars_count": r.Stars,
"forks_count": r.Forks,
"open_issues_count": r.OpenIssues,
"open_pr_counter": r.OpenPulls,
"created_at": r.Created,
"updated_at": r.Updated,
}
if r.Owner != nil {
m["owner"] = r.Owner.UserName
}
if len(r.Topics) > 0 {
m["topics"] = r.Topics
}
return m
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimRepo(r))
}
return out
}
func slimBranch(b *gitea_sdk.Branch) map[string]any {
if b == nil {
return nil
}
m := map[string]any{
"name": b.Name,
"protected": b.Protected,
}
if b.Commit != nil {
m["commit_sha"] = b.Commit.ID
}
return m
}
func slimBranches(branches []*gitea_sdk.Branch) []map[string]any {
out := make([]map[string]any, 0, len(branches))
for _, b := range branches {
out = append(out, slimBranch(b))
}
return out
}
func slimCommit(c *gitea_sdk.Commit) map[string]any {
if c == nil {
return nil
}
m := map[string]any{
"sha": c.SHA,
"html_url": c.HTMLURL,
"created": c.Created,
}
if c.RepoCommit != nil {
m["message"] = c.RepoCommit.Message
if c.RepoCommit.Author != nil {
m["author"] = map[string]any{
"name": c.RepoCommit.Author.Name,
"email": c.RepoCommit.Author.Email,
"date": c.RepoCommit.Author.Date,
}
}
}
return m
}
func slimCommits(commits []*gitea_sdk.Commit) []map[string]any {
out := make([]map[string]any, 0, len(commits))
for _, c := range commits {
out = append(out, slimCommit(c))
}
return out
}
func slimTag(t *gitea_sdk.Tag) map[string]any {
if t == nil {
return nil
}
m := map[string]any{
"name": t.Name,
"message": t.Message,
}
if t.Commit != nil {
m["commit_sha"] = t.Commit.SHA
}
return m
}
func slimTags(tags []*gitea_sdk.Tag) []map[string]any {
out := make([]map[string]any, 0, len(tags))
for _, t := range tags {
m := map[string]any{
"name": t.Name,
}
if t.Commit != nil {
m["commit_sha"] = t.Commit.SHA
}
out = append(out, m)
}
return out
}
func slimRelease(r *gitea_sdk.Release) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"id": r.ID,
"tag_name": r.TagName,
"target": r.Target,
"title": r.Title,
"body": r.Note,
"draft": r.IsDraft,
"prerelease": r.IsPrerelease,
"html_url": r.HTMLURL,
"author": userLogin(r.Publisher),
"created_at": r.CreatedAt,
"published_at": r.PublishedAt,
}
}
func slimReleases(releases []*gitea_sdk.Release) []map[string]any {
out := make([]map[string]any, 0, len(releases))
for _, r := range releases {
out = append(out, slimRelease(r))
}
return out
}
func slimContents(c *gitea_sdk.ContentsResponse) map[string]any {
if c == nil {
return nil
}
m := map[string]any{
"name": c.Name,
"path": c.Path,
"sha": c.SHA,
"type": c.Type,
"size": c.Size,
}
if c.Content != nil {
m["content"] = *c.Content
}
if c.Encoding != nil {
m["encoding"] = *c.Encoding
}
if c.HTMLURL != nil {
m["html_url"] = *c.HTMLURL
}
if c.DownloadURL != nil {
m["download_url"] = *c.DownloadURL
}
return m
}
func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any {
out := make([]map[string]any, 0, len(entries))
for _, c := range entries {
if c == nil {
continue
}
out = append(out, map[string]any{
"name": c.Name,
"path": c.Path,
"type": c.Type,
"size": c.Size,
})
}
return out
}
+142
View File
@@ -0,0 +1,142 @@
package repo
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimRepo(t *testing.T) {
r := &gitea_sdk.Repository{
ID: 1,
FullName: "org/repo",
Description: "A test repo",
HTMLURL: "https://gitea.com/org/repo",
CloneURL: "https://gitea.com/org/repo.git",
SSHURL: "git@gitea.com:org/repo.git",
DefaultBranch: "main",
Private: false,
Fork: false,
Archived: false,
Language: "Go",
Stars: 10,
Forks: 2,
Owner: &gitea_sdk.User{UserName: "org"},
Topics: []string{"mcp", "gitea"},
}
m := slimRepo(r)
if m["full_name"] != "org/repo" {
t.Errorf("expected full_name org/repo, got %v", m["full_name"])
}
if m["owner"] != "org" {
t.Errorf("expected owner org, got %v", m["owner"])
}
topics := m["topics"].([]string)
if len(topics) != 2 {
t.Errorf("expected 2 topics, got %d", len(topics))
}
}
func TestSlimTag(t *testing.T) {
tag := &gitea_sdk.Tag{
Name: "v1.0.0",
Message: "Release v1.0.0",
Commit: &gitea_sdk.CommitMeta{SHA: "abc123"},
}
m := slimTag(tag)
if m["name"] != "v1.0.0" {
t.Errorf("expected name v1.0.0, got %v", m["name"])
}
if m["message"] != "Release v1.0.0" {
t.Errorf("expected message, got %v", m["message"])
}
// List variant omits message
list := slimTags([]*gitea_sdk.Tag{tag})
if _, ok := list[0]["message"]; ok {
t.Error("Tags list should omit message")
}
if list[0]["name"] != "v1.0.0" {
t.Errorf("expected name in list, got %v", list[0]["name"])
}
}
func TestSlimRelease(t *testing.T) {
r := &gitea_sdk.Release{
ID: 1,
TagName: "v1.0.0",
Title: "First Release",
Note: "Release notes",
IsDraft: false,
Publisher: &gitea_sdk.User{UserName: "alice"},
}
m := slimRelease(r)
if m["tag_name"] != "v1.0.0" {
t.Errorf("expected tag_name v1.0.0, got %v", m["tag_name"])
}
if m["body"] != "Release notes" {
t.Errorf("expected body from Note field, got %v", m["body"])
}
if m["author"] != "alice" {
t.Errorf("expected author alice, got %v", m["author"])
}
}
func TestSlimContents(t *testing.T) {
content := "package main"
encoding := "base64"
htmlURL := "https://gitea.com/org/repo/src/branch/main/main.go"
c := &gitea_sdk.ContentsResponse{
Name: "main.go",
Path: "main.go",
SHA: "abc123",
Type: "file",
Size: 12,
Content: &content,
Encoding: &encoding,
HTMLURL: &htmlURL,
}
m := slimContents(c)
if m["name"] != "main.go" {
t.Errorf("expected name main.go, got %v", m["name"])
}
if m["content"] != "package main" {
t.Errorf("expected content, got %v", m["content"])
}
}
func TestSlimDirEntries(t *testing.T) {
entries := []*gitea_sdk.ContentsResponse{
{Name: "src", Path: "src", Type: "dir", Size: 0},
{Name: "main.go", Path: "main.go", Type: "file", Size: 100},
}
result := slimDirEntries(entries)
if len(result) != 2 {
t.Fatalf("expected 2 entries, got %d", len(result))
}
if result[0]["name"] != "src" {
t.Errorf("expected first entry name src, got %v", result[0]["name"])
}
// Dir entries should not have content
if _, ok := result[0]["content"]; ok {
t.Error("dir entries should not have content field")
}
}
func TestSlimTags_Nil(t *testing.T) {
if r := slimTags(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}
func TestSlimReleases_Nil(t *testing.T) {
if r := slimReleases(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}
+138
View File
@@ -0,0 +1,138 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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 (
CreateCommitStatusToolName = "create_commit_status"
)
var (
CreateCommitStatusTool = mcp.NewTool(
CreateCommitStatusToolName,
mcp.WithDescription("Create a commit status check for a repository. Adds a new status context to a commit without overwriting existing statuses."),
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 (full 40-character SHA or short SHA)")),
mcp.WithString("state", mcp.Required(), mcp.Description("status state: pending, success, error, or failure")),
mcp.WithString("target_url", mcp.Description("URL with more details about the status (e.g., review environment link like https://review.lumbridgecorp.com)")),
mcp.WithString("context", mcp.Description("status context identifier (e.g., 'ci/metal', 'ci/cloud-1', 'continuous-integration/jenkins')"), mcp.DefaultString("default")),
mcp.WithString("description", mcp.Description("short description of the status")),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateCommitStatusTool,
Handler: CreateCommitStatusFn,
})
}
// CreateCommitStatusFn creates a status check for a commit
func CreateCommitStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateCommitStatusFn")
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)
}
state, err := params.GetString(args, "state")
if err != nil {
return to.ErrorResult(err)
}
statusState, err := parseStatusState(state)
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid state '%s': must be one of pending, success, failure, error", state))
}
targetURL, _ := args["target_url"].(string)
context, _ := args["context"].(string)
description, _ := args["description"].(string)
// Use default context if not provided
if context == "" {
context = "default"
}
opt := gitea_sdk.CreateStatusOption{
State: statusState,
TargetURL: targetURL,
Context: context,
Description: description,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
status, _, err := client.CreateStatus(owner, repo, sha, opt)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "CreateCommitStatus",
"owner": owner,
"repo": repo,
"sha": sha,
"state": state,
"context": context,
})
return to.ErrorResult(translatedErr)
}
return to.TextResult(slimStatus(status))
}
// parseStatusState converts a string state to gitea_sdk.StatusState
func parseStatusState(state string) (gitea_sdk.StatusState, error) {
switch state {
case "pending":
return gitea_sdk.StatusPending, nil
case "success":
return gitea_sdk.StatusSuccess, nil
case "failure":
return gitea_sdk.StatusFailure, nil
case "error":
return gitea_sdk.StatusError, nil
default:
return "", fmt.Errorf("invalid state: %s", state)
}
}
// slimStatus creates a slimmed down representation of a commit status
func slimStatus(s *gitea_sdk.Status) map[string]any {
if s == nil {
return nil
}
return map[string]any{
"id": s.ID,
"state": s.State,
"target_url": s.TargetURL,
"context": s.Context,
"description": s.Description,
}
}
+483
View File
@@ -0,0 +1,483 @@
package repo
import (
"errors"
"testing"
gitea_errors "gitea.com/gitea/gitea-mcp/pkg/errors"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestParseStatusState(t *testing.T) {
tests := []struct {
name string
state string
wantState gitea_sdk.StatusState
wantErr bool
errContains string
}{
{
name: "pending",
state: "pending",
wantState: gitea_sdk.StatusPending,
wantErr: false,
},
{
name: "success",
state: "success",
wantState: gitea_sdk.StatusSuccess,
wantErr: false,
},
{
name: "failure",
state: "failure",
wantState: gitea_sdk.StatusFailure,
wantErr: false,
},
{
name: "error",
state: "error",
wantState: gitea_sdk.StatusError,
wantErr: false,
},
{
name: "invalid state",
state: "invalid",
wantErr: true,
errContains: "invalid state",
},
{
name: "empty state",
state: "",
wantErr: true,
errContains: "invalid state",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseStatusState(tt.state)
if tt.wantErr {
if err == nil {
t.Errorf("parseStatusState() error = nil, wantErr %v", tt.wantErr)
return
}
if tt.errContains != "" && !errors.Is(err, errors.New(tt.errContains)) {
if !contains(err.Error(), tt.errContains) {
t.Errorf("parseStatusState() error = %v, should contain %v", err.Error(), tt.errContains)
}
}
return
}
if err != nil {
t.Errorf("parseStatusState() unexpected error = %v", err)
return
}
if got != tt.wantState {
t.Errorf("parseStatusState() = %v, want %v", got, tt.wantState)
}
})
}
}
func TestSlimStatus(t *testing.T) {
tests := []struct {
name string
status *gitea_sdk.Status
want map[string]any
}{
{
name: "nil status",
status: nil,
want: nil,
},
{
name: "full status",
status: &gitea_sdk.Status{
ID: 123,
State: gitea_sdk.StatusSuccess,
TargetURL: "https://review.lumbridgecorp.com/project/commit/248ade7",
Context: "ci/metal",
Description: "Build succeeded on Metal",
CreatedAt: "2024-01-15T10:30:00Z",
},
want: map[string]any{
"id": int64(123),
"state": gitea_sdk.StatusSuccess,
"target_url": "https://review.lumbridgecorp.com/project/commit/248ade7",
"context": "ci/metal",
"description": "Build succeeded on Metal",
"created_at": "2024-01-15T10:30:00Z",
},
},
{
name: "pending status",
status: &gitea_sdk.Status{
ID: 456,
State: gitea_sdk.StatusPending,
Context: "ci/cloud-1",
CreatedAt: "2024-01-15T10:31:00Z",
},
want: map[string]any{
"id": int64(456),
"state": gitea_sdk.StatusPending,
"target_url": "",
"context": "ci/cloud-1",
"created_at": "2024-01-15T10:31:00Z",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := slimStatus(tt.status)
if tt.want == nil {
if got != nil {
t.Errorf("slimStatus() = %v, want nil", got)
}
return
}
if got == nil {
t.Errorf("slimStatus() = nil, want %v", tt.want)
return
}
for key, wantVal := range tt.want {
gotVal, ok := got[key]
if !ok {
t.Errorf("slimStatus() missing key %s", key)
continue
}
if gotVal != wantVal {
t.Errorf("slimStatus()[%s] = %v, want %v", key, gotVal, wantVal)
}
}
})
}
}
func TestErrorTranslation_CreateCommitStatus(t *testing.T) {
tests := []struct {
name string
errMsg string
expectedOp string
expectedCtxKey string
}{
{
name: "404 not found",
errMsg: "CreateStatus: 404 Not Found",
expectedOp: "CreateCommitStatus",
expectedCtxKey: "sha",
},
{
name: "401 unauthorized",
errMsg: "CreateStatus: 401 Unauthorized",
expectedOp: "CreateCommitStatus",
expectedCtxKey: "context",
},
{
name: "403 forbidden",
errMsg: "CreateStatus: 403 Forbidden",
expectedOp: "CreateCommitStatus",
expectedCtxKey: "repo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, map[string]string{
"operation": "CreateCommitStatus",
"owner": "karti-ai",
"repo": "gitcoffee",
"sha": "248ade7a9c...",
"state": "success",
"context": "ci/metal",
})
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Operation != tt.expectedOp {
t.Errorf("expected operation %s, got %s", tt.expectedOp, enhanced.Operation)
}
if enhanced.Context[tt.expectedCtxKey] == "" {
t.Errorf("expected context key %s to be set", tt.expectedCtxKey)
}
t.Logf("Translated error: %s", enhanced.Error())
})
}
}
func TestParseStatusState_EdgeCases(t *testing.T) {
tests := []struct {
name string
state string
wantState gitea_sdk.StatusState
wantErr bool
errContains string
}{
{
name: "mixed case pending",
state: "Pending",
wantErr: true,
errContains: "invalid state",
},
{
name: "mixed case success",
state: "Success",
wantErr: true,
errContains: "invalid state",
},
{
name: "whitespace pending",
state: " pending",
wantErr: true,
errContains: "invalid state",
},
{
name: "whitespace success",
state: "success ",
wantErr: true,
errContains: "invalid state",
},
{
name: "long invalid string",
state: "this_is_not_a_valid_state",
wantErr: true,
errContains: "invalid state",
},
{
name: "numeric string",
state: "123",
wantErr: true,
errContains: "invalid state",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseStatusState(tt.state)
if tt.wantErr {
if err == nil {
t.Errorf("parseStatusState() error = nil, wantErr %v", tt.wantErr)
return
}
return
}
if err != nil {
t.Errorf("parseStatusState() unexpected error = %v", err)
return
}
if got != tt.wantState {
t.Errorf("parseStatusState() = %v, want %v", got, tt.wantState)
}
})
}
}
func TestSlimStatus_EdgeCases(t *testing.T) {
tests := []struct {
name string
status *gitea_sdk.Status
want map[string]any
}{
{
name: "nil status",
status: nil,
want: nil,
},
{
name: "status with zero ID",
status: &gitea_sdk.Status{
ID: 0,
State: gitea_sdk.StatusPending,
TargetURL: "",
Context: "",
CreatedAt: "2024-01-15T10:30:00Z",
},
want: map[string]any{
"id": int64(0),
"state": gitea_sdk.StatusPending,
"target_url": "",
"context": "",
"created_at": "2024-01-15T10:30:00Z",
},
},
{
name: "status with long URL",
status: &gitea_sdk.Status{
ID: 789,
State: gitea_sdk.StatusSuccess,
TargetURL: "https://very-long-review-environment-url.example.com/path/to/project/commit/248ade7a9c.../build/12345/logs?filter=all#section-2",
Context: "continuous-integration/jenkins/build-and-test-all-platforms",
Description: "Build succeeded on all platforms including Windows, macOS, and Linux with full test suite",
CreatedAt: "2024-01-15T10:30:00Z",
},
want: map[string]any{
"id": int64(789),
"state": gitea_sdk.StatusSuccess,
"target_url": "https://very-long-review-environment-url.example.com/path/to/project/commit/248ade7a9c.../build/12345/logs?filter=all#section-2",
"context": "continuous-integration/jenkins/build-and-test-all-platforms",
"created_at": "2024-01-15T10:30:00Z",
},
},
{
name: "failure status",
status: &gitea_sdk.Status{
ID: 101,
State: gitea_sdk.StatusFailure,
TargetURL: "https://ci.example.com/build/101",
Context: "ci/build",
Description: "Build failed",
CreatedAt: "2024-01-15T11:00:00Z",
},
want: map[string]any{
"id": int64(101),
"state": gitea_sdk.StatusFailure,
"target_url": "https://ci.example.com/build/101",
"context": "ci/build",
"created_at": "2024-01-15T11:00:00Z",
},
},
{
name: "error status",
status: &gitea_sdk.Status{
ID: 102,
State: gitea_sdk.StatusError,
TargetURL: "",
Context: "ci/error",
Description: "Error occurred",
CreatedAt: "2024-01-15T11:01:00Z",
},
want: map[string]any{
"id": int64(102),
"state": gitea_sdk.StatusError,
"target_url": "",
"context": "ci/error",
"created_at": "2024-01-15T11:01:00Z",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := slimStatus(tt.status)
if tt.want == nil {
if got != nil {
t.Errorf("slimStatus() = %v, want nil", got)
}
return
}
if got == nil {
t.Errorf("slimStatus() = nil, want %v", tt.want)
return
}
for key, wantVal := range tt.want {
gotVal, ok := got[key]
if !ok {
t.Errorf("slimStatus() missing key %s", key)
continue
}
if gotVal != wantVal {
t.Errorf("slimStatus()[%s] = %v, want %v", key, gotVal, wantVal)
}
}
})
}
}
func TestErrorTranslation_EdgeCases(t *testing.T) {
tests := []struct {
name string
errMsg string
ctx map[string]string
checkField string
wantValue string
}{
{
name: "timeout error",
errMsg: "request timeout",
ctx: map[string]string{"operation": "CreateCommitStatus"},
checkField: "operation",
wantValue: "CreateCommitStatus",
},
{
name: "network error",
errMsg: "connection refused",
ctx: map[string]string{"operation": "CreateCommitStatus"},
checkField: "operation",
wantValue: "CreateCommitStatus",
},
{
name: "500 server error",
errMsg: "500 Internal Server Error",
ctx: map[string]string{"operation": "CreateCommitStatus"},
checkField: "operation",
wantValue: "CreateCommitStatus",
},
{
name: "rate limit error",
errMsg: "429 Too Many Requests",
ctx: map[string]string{"operation": "CreateCommitStatus"},
checkField: "operation",
wantValue: "CreateCommitStatus",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errors.New(tt.errMsg)
translated := gitea_errors.TranslateError(err, tt.ctx)
var enhanced *gitea_errors.EnhancedError
if !errors.As(translated, &enhanced) {
t.Fatal("expected translated error to be EnhancedError")
}
if enhanced.Context[tt.checkField] != tt.wantValue {
t.Errorf("expected context[%s] = %s, got %s", tt.checkField, tt.wantValue, enhanced.Context[tt.checkField])
}
})
}
}
func TestSlimStatusFieldTypes(t *testing.T) {
status := &gitea_sdk.Status{
ID: int64(999),
State: gitea_sdk.StatusSuccess,
TargetURL: "https://example.com",
Context: "test",
Description: "desc",
CreatedAt: "2024-01-15T10:30:00Z",
}
slimmed := slimStatus(status)
if slimmed == nil {
t.Fatal("slimStatus returned nil")
}
if id, ok := slimmed["id"].(int64); !ok {
t.Errorf("id should be int64, got %T", slimmed["id"])
} else if id != 999 {
t.Errorf("id = %d, want 999", id)
}
if state, ok := slimmed["state"].(gitea_sdk.StatusState); !ok {
t.Errorf("state should be StatusState, got %T", slimmed["state"])
} else if state != gitea_sdk.StatusSuccess {
t.Errorf("state = %v, want %v", state, gitea_sdk.StatusSuccess)
}
for _, key := range []string{"target_url", "context", "description", "created_at"} {
if val, ok := slimmed[key].(string); !ok && slimmed[key] != nil {
t.Errorf("%s should be string, got %T", key, slimmed[key])
} else if !ok {
t.Errorf("%s should not be nil", key)
}
}
}
+199
View File
@@ -0,0 +1,199 @@
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 (
CreateTagToolName = "create_tag"
DeleteTagToolName = "delete_tag"
GetTagToolName = "get_tag"
ListTagsToolName = "list_tags"
)
var (
CreateTagTool = mcp.NewTool(
CreateTagToolName,
mcp.WithDescription("Create tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
mcp.WithString("target", mcp.Description("target commitish"), mcp.DefaultString("")),
mcp.WithString("message", mcp.Description("tag message"), mcp.DefaultString("")),
)
DeleteTagTool = mcp.NewTool(
DeleteTagToolName,
mcp.WithDescription("Delete tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
)
GetTagTool = mcp.NewTool(
GetTagToolName,
mcp.WithDescription("Get tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
)
ListTagsTool = mcp.NewTool(
ListTagsToolName,
mcp.WithDescription("List tags"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
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)),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateTagTool,
Handler: CreateTagFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteTagTool,
Handler: DeleteTagFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetTagTool,
Handler: GetTagFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListTagsTool,
Handler: ListTagsFn,
})
}
func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateTagFn")
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)
}
tagName, err := params.GetString(args, "tag_name")
if err != nil {
return to.ErrorResult(err)
}
target, _ := args["target"].(string)
message, _ := args["message"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.CreateTag(owner, repo, gitea_sdk.CreateTagOption{
TagName: tagName,
Target: target,
Message: message,
})
if err != nil {
return nil, fmt.Errorf("create tag error: %v", err)
}
return mcp.NewToolResultText("Tag Created"), nil
}
func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteTagFn")
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)
}
tagName, err := params.GetString(args, "tag_name")
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))
}
_, err = client.DeleteTag(owner, repo, tagName)
if err != nil {
return nil, fmt.Errorf("delete tag error: %v", err)
}
return to.TextResult("Tag deleted")
}
func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetTagFn")
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)
}
tagName, err := params.GetString(args, "tag_name")
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))
}
tag, _, err := client.GetTag(owner, repo, tagName)
if err != nil {
return nil, fmt.Errorf("get tag error: %v", err)
}
return to.TextResult(slimTag(tag))
}
func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListTagsFn")
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)
}
page := params.GetOptionalInt(args, "page", 1)
pageSize := params.GetOptionalInt(args, "perPage", 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
tags, _, err := client.ListRepoTags(owner, repo, gitea_sdk.ListRepoTagsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
})
if err != nil {
return nil, fmt.Errorf("list tags error: %v", err)
}
return to.TextResult(slimTags(tags))
}
+228
View File
@@ -0,0 +1,228 @@
package repo
import (
"context"
"encoding/json"
"fmt"
"net/url"
"path/filepath"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"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"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListRepoStructureToolName = "list_repo_structure"
)
type TreeEntry struct {
Path string `json:"path"`
Type string `json:"type"`
SHA string `json:"sha"`
Size int64 `json:"size,omitempty"`
}
type TreeResponse struct {
SHA string `json:"sha"`
URL string `json:"url,omitempty"`
Tree []TreeEntry `json:"tree"`
Truncated bool `json:"truncated,omitempty"`
}
var (
ListRepoStructureTool = mcp.NewTool(
ListRepoStructureToolName,
mcp.WithDescription("List the complete directory and file structure of a repository using Git tree API. Supports recursive listing and pattern filtering."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Description("Git reference (branch, tag, or commit SHA). Defaults to default branch.")),
mcp.WithString("pattern", mcp.Description("Glob pattern to filter files (e.g., '*.yml', '.gitea/*', 'src/**/*.go')")),
mcp.WithBoolean("recursive", mcp.Description("List contents recursively (default: true)")),
mcp.WithNumber("page", mcp.Description("Page number for pagination (1-based, default: 1)")),
mcp.WithNumber("per_page", mcp.Description("Number of items per page (default: 100, max: 1000)")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListRepoStructureTool,
Handler: ListRepoStructureFn,
})
}
func ListRepoStructureFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoStructureFn")
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)
}
ref, _ := args["ref"].(string)
if ref == "" {
ref = "HEAD"
}
pattern, _ := args["pattern"].(string)
recursive := true
if recursiveVal, ok := args["recursive"].(bool); ok {
recursive = recursiveVal
}
page := 1
if pageVal, ok := args["page"].(float64); ok && pageVal > 0 {
page = int(pageVal)
}
perPage := 100
if perPageVal, ok := args["per_page"].(float64); ok && perPageVal > 0 {
perPage = int(perPageVal)
if perPage > 1000 {
perPage = 1000
}
}
query := url.Values{}
if recursive {
query.Set("recursive", "1")
}
query.Set("page", fmt.Sprintf("%d", page))
query.Set("per_page", fmt.Sprintf("%d", perPage))
path := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, ref)
var treeResp TreeResponse
statusCode, err := gitea.DoJSON(ctx, "GET", path, query, nil, &treeResp)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "ListRepoStructure",
"owner": owner,
"repo": repo,
"ref": ref,
"status": fmt.Sprintf("%d", statusCode),
})
return to.ErrorResult(translatedErr)
}
filteredEntries := filterEntries(treeResp.Tree, pattern)
result := map[string]any{
"owner": owner,
"repo": repo,
"ref": ref,
"sha": treeResp.SHA,
"truncated": treeResp.Truncated,
"total_count": len(filteredEntries),
"page": page,
"per_page": perPage,
"tree": slimTreeEntries(filteredEntries),
}
if treeResp.Truncated {
result["warning"] = "Tree listing was truncated due to size. Consider using pattern filtering or pagination."
}
resultJSON, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("marshal result: %w", err))
}
return to.TextResult(string(resultJSON))
}
func filterEntries(entries []TreeEntry, pattern string) []TreeEntry {
if pattern == "" {
return entries
}
filtered := make([]TreeEntry, 0, len(entries))
for _, entry := range entries {
if matchPattern(entry.Path, pattern) {
filtered = append(filtered, entry)
}
}
return filtered
}
func matchPattern(path, pattern string) bool {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return true
}
negate := false
if strings.HasPrefix(pattern, "!") {
negate = true
pattern = strings.TrimPrefix(pattern, "!")
}
matched, err := filepath.Match(pattern, path)
if err == nil && matched {
return !negate
}
filename := filepath.Base(path)
matched, err = filepath.Match(pattern, filename)
if err == nil && matched {
return !negate
}
if strings.HasPrefix(pattern, "**/") {
suffix := strings.TrimPrefix(pattern, "**/")
if strings.HasSuffix(path, suffix) {
return !negate
}
parts := strings.Split(path, "/")
for i := range parts {
subPath := strings.Join(parts[i:], "/")
if matched, _ := filepath.Match(suffix, subPath); matched {
return !negate
}
}
}
if strings.HasSuffix(pattern, "/*") || strings.HasSuffix(pattern, "/**") {
dirPrefix := strings.TrimSuffix(pattern, "/*")
dirPrefix = strings.TrimSuffix(dirPrefix, "/**")
if strings.HasPrefix(path, dirPrefix+"/") {
return !negate
}
}
if strings.HasPrefix(path, pattern+"/") || path == pattern {
return !negate
}
return negate
}
func slimTreeEntries(entries []TreeEntry) []map[string]any {
out := make([]map[string]any, 0, len(entries))
for _, e := range entries {
m := map[string]any{
"path": e.Path,
"type": e.Type,
"sha": e.SHA,
}
if e.Type == "blob" && e.Size > 0 {
m["size"] = e.Size
}
out = append(out, m)
}
return out
}
+468
View File
@@ -0,0 +1,468 @@
package repo
import (
"testing"
)
func TestMatchPattern(t *testing.T) {
tests := []struct {
name string
path string
pattern string
want bool
}{
{
name: "exact match",
path: "README.md",
pattern: "README.md",
want: true,
},
{
name: "wildcard match - all md files",
path: "docs/README.md",
pattern: "*.md",
want: true,
},
{
name: "wildcard match - yaml files",
path: ".gitea/workflows/build.yml",
pattern: "*.yml",
want: true,
},
{
name: "directory prefix match",
path: ".gitea/workflows/build.yml",
pattern: ".gitea/*",
want: true,
},
{
name: "recursive directory match",
path: ".github/workflows/test.yml",
pattern: ".github/**",
want: true,
},
{
name: "double star pattern",
path: "src/components/Button.tsx",
pattern: "**/*.tsx",
want: true,
},
{
name: "double star with prefix",
path: "src/internal/utils/helpers.go",
pattern: "src/**/*.go",
want: true,
},
{
name: "no match - wrong extension",
path: "main.go",
pattern: "*.md",
want: false,
},
{
name: "no match - wrong directory",
path: "docs/readme.md",
pattern: ".gitea/*",
want: false,
},
{
name: "negation pattern - exclude",
path: "node_modules/lodash/index.js",
pattern: "!node_modules/**",
want: false,
},
{
name: "negation pattern - include others",
path: "src/main.js",
pattern: "!node_modules/**",
want: true,
},
{
name: "empty pattern matches all",
path: "any/path/file.txt",
pattern: "",
want: true,
},
{
name: "exact directory match",
path: "src/components",
pattern: "src",
want: true,
},
{
name: "file inside directory",
path: "src/components/Button.tsx",
pattern: "src",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchPattern(tt.path, tt.pattern)
if got != tt.want {
t.Errorf("matchPattern(%q, %q) = %v, want %v", tt.path, tt.pattern, got, tt.want)
}
})
}
}
func TestFilterEntries(t *testing.T) {
entries := []TreeEntry{
{Path: "README.md", Type: "blob", SHA: "abc123"},
{Path: "main.go", Type: "blob", SHA: "def456"},
{Path: "docs", Type: "tree", SHA: "ghi789"},
{Path: "docs/guide.md", Type: "blob", SHA: "jkl012"},
{Path: ".gitea/workflows/build.yml", Type: "blob", SHA: "mno345"},
}
tests := []struct {
name string
pattern string
expected int
}{
{
name: "no pattern returns all",
pattern: "",
expected: 5,
},
{
name: "filter markdown files",
pattern: "*.md",
expected: 2,
},
{
name: "filter yaml files",
pattern: "*.yml",
expected: 1,
},
{
name: "filter by directory",
pattern: ".gitea/*",
expected: 1,
},
{
name: "filter go files",
pattern: "*.go",
expected: 1,
},
{
name: "no match returns empty",
pattern: "*.py",
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filtered := filterEntries(entries, tt.pattern)
if len(filtered) != tt.expected {
t.Errorf("filterEntries() returned %d entries, want %d", len(filtered), tt.expected)
}
})
}
}
func TestSlimTreeEntries(t *testing.T) {
entries := []TreeEntry{
{Path: "README.md", Type: "blob", SHA: "abc123", Size: 1024},
{Path: "docs", Type: "tree", SHA: "def456", Size: 0},
{Path: "main.go", Type: "blob", SHA: "ghi789", Size: 2048},
}
slimmed := slimTreeEntries(entries)
if len(slimmed) != len(entries) {
t.Errorf("slimTreeEntries() returned %d entries, want %d", len(slimmed), len(entries))
}
for i, entry := range slimmed {
if _, ok := entry["path"]; !ok {
t.Errorf("entry %d missing 'path' field", i)
}
if _, ok := entry["type"]; !ok {
t.Errorf("entry %d missing 'type' field", i)
}
if _, ok := entry["sha"]; !ok {
t.Errorf("entry %d missing 'sha' field", i)
}
entryType := entry["type"](string)
_, hasSize := entry["size"]
if entryType == "blob" && entries[i].Size > 0 && !hasSize {
t.Errorf("blob entry %d should have size field", i)
}
if entryType == "tree" && hasSize {
t.Errorf("tree entry %d should not have size field", i)
}
}
}
func TestMatchPattern_EdgeCases(t *testing.T) {
tests := []struct {
name string
path string
pattern string
want bool
}{
{
name: "empty path with empty pattern",
path: "",
pattern: "",
want: true,
},
{
name: "empty path with pattern",
path: "",
pattern: "*.go",
want: false,
},
{
name: "path with spaces",
path: "path with spaces/file.txt",
pattern: "*.txt",
want: true,
},
{
name: "special characters in path",
path: "path-with-dashes/file_name.txt",
pattern: "*.txt",
want: true,
},
{
name: "double star at start and end",
path: "deep/nested/path/file.go",
pattern: "**/*.go",
want: true,
},
{
name: "negation with double star",
path: "node_modules/deep/package.json",
pattern: "!node_modules/**",
want: false,
},
{
name: "complex glob pattern",
path: "src/components/Button.test.tsx",
pattern: "**/*.test.tsx",
want: true,
},
{
name: "directory only pattern",
path: "src/components",
pattern: "src/*",
want: true,
},
{
name: "trailing slash in directory",
path: "src/components/",
pattern: "src/*",
want: true,
},
{
name: "single character wildcard",
path: "file1.txt",
pattern: "file?.txt",
want: true,
},
{
name: "range pattern",
path: "file5.txt",
pattern: "file[0-9].txt",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchPattern(tt.path, tt.pattern)
if got != tt.want {
t.Errorf("matchPattern(%q, %q) = %v, want %v", tt.path, tt.pattern, got, tt.want)
}
})
}
}
func TestFilterEntries_EdgeCases(t *testing.T) {
tests := []struct {
name string
entries []TreeEntry
pattern string
expected int
}{
{
name: "nil entries",
entries: nil,
pattern: "*.go",
expected: 0,
},
{
name: "empty entries",
entries: []TreeEntry{},
pattern: "*.go",
expected: 0,
},
{
name: "entries with empty paths",
entries: []TreeEntry{
{Path: "", Type: "blob", SHA: "abc123"},
{Path: "main.go", Type: "blob", SHA: "def456"},
},
pattern: "*.go",
expected: 1,
},
{
name: "negation pattern",
entries: []TreeEntry{
{Path: "test.go", Type: "blob", SHA: "abc123"},
{Path: "vendor/lib.go", Type: "blob", SHA: "def456"},
},
pattern: "!vendor/**",
expected: 1,
},
{
name: "complex pattern",
entries: []TreeEntry{
{Path: "src/main.go", Type: "blob", SHA: "abc123"},
{Path: "src/test/main_test.go", Type: "blob", SHA: "def456"},
{Path: "docs/readme.md", Type: "blob", SHA: "ghi789"},
},
pattern: "src/**/*.go",
expected: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filtered := filterEntries(tt.entries, tt.pattern)
if len(filtered) != tt.expected {
t.Errorf("filterEntries() returned %d entries, want %d", len(filtered), tt.expected)
}
})
}
}
func TestSlimTreeEntries_EdgeCases(t *testing.T) {
tests := []struct {
name string
entries []TreeEntry
wantLen int
wantErr bool
}{
{
name: "nil entries",
entries: nil,
wantLen: 0,
},
{
name: "empty entries",
entries: []TreeEntry{},
wantLen: 0,
},
{
name: "entry with zero size blob",
entries: []TreeEntry{
{Path: "empty.txt", Type: "blob", SHA: "abc", Size: 0},
},
wantLen: 1,
},
{
name: "entry with symlink type",
entries: []TreeEntry{
{Path: "link", Type: "symlink", SHA: "def", Size: 0},
},
wantLen: 1,
},
{
name: "many entries",
entries: []TreeEntry{
{Path: "file1.txt", Type: "blob", SHA: "a", Size: 100},
{Path: "file2.txt", Type: "blob", SHA: "b", Size: 200},
{Path: "file3.txt", Type: "blob", SHA: "c", Size: 300},
{Path: "dir1", Type: "tree", SHA: "d", Size: 0},
{Path: "dir2", Type: "tree", SHA: "e", Size: 0},
},
wantLen: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
slimmed := slimTreeEntries(tt.entries)
if len(slimmed) != tt.wantLen {
t.Errorf("slimTreeEntries() returned %d entries, want %d", len(slimmed), tt.wantLen)
}
})
}
}
func TestTreeEntryStruct(t *testing.T) {
tests := []struct {
name string
entry TreeEntry
}{
{
name: "minimal entry",
entry: TreeEntry{
Path: "file.txt",
Type: "blob",
SHA: "abc123",
},
},
{
name: "full entry",
entry: TreeEntry{
Path: "file.txt",
Type: "blob",
SHA: "def456789abc",
Size: 1024,
},
},
{
name: "tree entry",
entry: TreeEntry{
Path: "directory",
Type: "tree",
SHA: "ghi789",
Size: 0,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.entry.Path == "" {
t.Error("Path should not be empty")
}
if tt.entry.Type == "" {
t.Error("Type should not be empty")
}
if tt.entry.SHA == "" {
t.Error("SHA should not be empty")
}
})
}
}
func TestTreeResponseStruct(t *testing.T) {
response := TreeResponse{
SHA: "abc123def456",
URL: "https://api.example.com/repos/owner/repo/git/trees/abc123",
Tree: []TreeEntry{},
Truncated: false,
}
if response.SHA == "" {
t.Error("SHA should not be empty")
}
if response.URL == "" {
t.Error("URL should not be empty")
}
if response.Tree == nil {
t.Error("Tree should not be nil")
}
if response.Truncated {
t.Error("Truncated should be false")
}
}
+177
View File
@@ -0,0 +1,177 @@
package search
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.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 (
SearchUsersToolName = "search_users"
SearchOrgTeamsToolName = "search_org_teams"
SearchReposToolName = "search_repos"
)
var (
SearchUsersTool = mcp.NewTool(
SearchUsersToolName,
mcp.WithDescription("search users"),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
SearOrgTeamsTool = mcp.NewTool(
SearchOrgTeamsToolName,
mcp.WithDescription("search organization teams"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("query", mcp.Required(), mcp.Description("search organization teams")),
mcp.WithBoolean("includeDescription", mcp.Description("include description?")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
SearchReposTool = mcp.NewTool(
SearchReposToolName,
mcp.WithDescription("search repos"),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
mcp.WithBoolean("keywordIsTopic", mcp.Description("KeywordIsTopic")),
mcp.WithBoolean("keywordInDescription", mcp.Description("KeywordInDescription")),
mcp.WithNumber("ownerID", mcp.Description("OwnerID")),
mcp.WithBoolean("isPrivate", mcp.Description("IsPrivate")),
mcp.WithBoolean("isArchived", mcp.Description("IsArchived")),
mcp.WithString("sort", mcp.Description("Sort")),
mcp.WithString("order", mcp.Description("Order")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: SearchUsersTool,
Handler: UsersFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: SearOrgTeamsTool,
Handler: OrgTeamsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: SearchReposTool,
Handler: ReposFn,
})
}
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UsersFn")
keyword, err := params.GetString(req.GetArguments(), "keyword")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.SearchUsersOption{
KeyWord: keyword,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
users, _, err := client.SearchUsers(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search users err: %v", err))
}
return to.TextResult(slimUserDetails(users))
}
func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called OrgTeamsFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
query, err := params.GetString(req.GetArguments(), "query")
if err != nil {
return to.ErrorResult(err)
}
includeDescription, _ := req.GetArguments()["includeDescription"].(bool)
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.SearchTeamsOptions{
Query: query,
IncludeDescription: includeDescription,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
teams, _, err := client.SearchOrgTeams(org, &opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search organization teams error: %v", err))
}
return to.TextResult(slimTeams(teams))
}
func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ReposFn")
keyword, err := params.GetString(req.GetArguments(), "keyword")
if err != nil {
return to.ErrorResult(err)
}
keywordIsTopic, _ := req.GetArguments()["keywordIsTopic"].(bool)
keywordInDescription, _ := req.GetArguments()["keywordInDescription"].(bool)
ownerID := params.GetOptionalInt(req.GetArguments(), "ownerID", 0)
var pIsPrivate *bool
isPrivate, ok := req.GetArguments()["isPrivate"].(bool)
if ok {
pIsPrivate = new(isPrivate)
}
var pIsArchived *bool
isArchived, ok := req.GetArguments()["isArchived"].(bool)
if ok {
pIsArchived = new(isArchived)
}
sort, _ := req.GetArguments()["sort"].(string)
order, _ := req.GetArguments()["order"].(string)
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.SearchRepoOptions{
Keyword: keyword,
KeywordIsTopic: keywordIsTopic,
KeywordInDescription: keywordInDescription,
OwnerID: ownerID,
IsPrivate: pIsPrivate,
IsArchived: pIsArchived,
Sort: sort,
Order: order,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.SearchRepos(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search repos error: %v", err))
}
return to.TextResult(slimRepos(repos))
}
+42
View File
@@ -0,0 +1,42 @@
package search
import (
"slices"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestSearchToolsRequiredFields(t *testing.T) {
tests := []struct {
name string
tool mcp.Tool
required []string
}{
{
name: "search_users",
tool: SearchUsersTool,
required: []string{"keyword"},
},
{
name: "search_org_teams",
tool: SearOrgTeamsTool,
required: []string{"org", "query"},
},
{
name: "search_repos",
tool: SearchReposTool,
required: []string{"keyword"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, field := range tt.required {
if !slices.Contains(tt.tool.InputSchema.Required, field) {
t.Errorf("tool %s: expected %q to be required, got required=%v", tt.name, field, tt.tool.InputSchema.Required)
}
}
})
}
}
+88
View File
@@ -0,0 +1,88 @@
package search
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimUserDetail(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
"html_url": u.HTMLURL,
"is_admin": u.IsAdmin,
}
}
func slimUserDetails(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, slimUserDetail(u))
}
return out
}
func slimTeam(t *gitea_sdk.Team) map[string]any {
if t == nil {
return nil
}
return map[string]any{
"id": t.ID,
"name": t.Name,
"description": t.Description,
"permission": t.Permission,
}
}
func slimTeams(teams []*gitea_sdk.Team) []map[string]any {
out := make([]map[string]any, 0, len(teams))
for _, t := range teams {
out = append(out, slimTeam(t))
}
return out
}
func slimRepo(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
m := map[string]any{
"id": r.ID,
"full_name": r.FullName,
"description": r.Description,
"html_url": r.HTMLURL,
"clone_url": r.CloneURL,
"ssh_url": r.SSHURL,
"default_branch": r.DefaultBranch,
"private": r.Private,
"fork": r.Fork,
"archived": r.Archived,
"language": r.Language,
"stars_count": r.Stars,
"forks_count": r.Forks,
"open_issues_count": r.OpenIssues,
"open_pr_counter": r.OpenPulls,
"created_at": r.Created,
"updated_at": r.Updated,
}
if r.Owner != nil {
m["owner"] = r.Owner.UserName
}
if len(r.Topics) > 0 {
m["topics"] = r.Topics
}
return m
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimRepo(r))
}
return out
}
+232
View File
@@ -0,0 +1,232 @@
package settings
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.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"
)
const (
GetUserSettingsToolName = "get_user_settings"
UpdateUserSettingsToolName = "update_user_settings"
GetRepoSettingsToolName = "get_repo_settings"
UpdateRepoSettingsToolName = "update_repo_settings"
)
var Tool = tool.New()
var (
GetUserSettingsTool = mcp.NewTool(
GetUserSettingsToolName,
mcp.WithDescription("Get current user's settings"),
)
UpdateUserSettingsTool = mcp.NewTool(
UpdateUserSettingsToolName,
mcp.WithDescription("Update current user's settings"),
mcp.WithString("description", mcp.Description("User description")),
mcp.WithString("website", mcp.Description("Website URL")),
mcp.WithString("location", mcp.Description("Location")),
mcp.WithString("theme", mcp.Description("Theme preference")),
)
GetRepoSettingsTool = mcp.NewTool(
GetRepoSettingsToolName,
mcp.WithDescription("Get repository settings"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
UpdateRepoSettingsTool = mcp.NewTool(
UpdateRepoSettingsToolName,
mcp.WithDescription("Update repository settings"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("description", mcp.Description("Repository description")),
mcp.WithString("website", mcp.Description("Website URL")),
mcp.WithString("default_branch", mcp.Description("Default branch name")),
mcp.WithBoolean("private", mcp.Description("Whether repo is private")),
mcp.WithBoolean("protected", mcp.Description("Whether repo is protected")),
mcp.WithBoolean("enable_wiki", mcp.Description("Enable wiki")),
mcp.WithBoolean("enable_issues", mcp.Description("Enable issues")),
mcp.WithBoolean("enable_pull_requests", mcp.Description("Enable pull requests")),
mcp.WithString("default_merge_style", mcp.Description("Default merge style"), mcp.Enum("merge", "rebase", "squash")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetUserSettingsTool,
Handler: getUserSettingsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetRepoSettingsTool,
Handler: getRepoSettingsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: UpdateUserSettingsTool,
Handler: updateUserSettingsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: UpdateRepoSettingsTool,
Handler: updateRepoSettingsFn,
})
}
func getUserSettingsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Settings] Called getUserSettingsFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
settings, _, err := client.GetUserSettings()
if err != nil {
return to.ErrorResult(fmt.Errorf("get user settings err: %v", err))
}
return to.TextResult(slimUserSettings(settings))
}
func updateUserSettingsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Settings] Called updateUserSettingsFn")
args := req.GetArguments()
opt := gitea_sdk.UserSettingsOptions{}
if v, ok := args["description"].(string); ok && v != "" {
opt.Description = &v
}
if v, ok := args["website"].(string); ok && v != "" {
opt.Website = &v
}
if v, ok := args["location"].(string); ok && v != "" {
opt.Location = &v
}
if v, ok := args["theme"].(string); ok && v != "" {
opt.Theme = &v
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
settings, _, err := client.UpdateUserSettings(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update user settings err: %v", err))
}
return to.TextResult(slimUserSettings(settings))
}
func getRepoSettingsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Settings] Called getRepoSettingsFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
r, _, err := client.GetRepo(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repo err: %v", err))
}
return to.TextResult(slimRepoSettings(r))
}
func updateRepoSettingsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Settings] Called updateRepoSettingsFn")
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)
}
opt := gitea_sdk.EditRepoOption{}
if v, ok := args["description"].(string); ok && v != "" {
opt.Description = &v
}
if v, ok := args["website"].(string); ok && v != "" {
opt.Website = &v
}
if v, ok := args["default_branch"].(string); ok && v != "" {
opt.DefaultBranch = &v
}
if v, ok := args["private"].(bool); ok {
opt.Private = &v
}
if v, ok := args["protected"].(bool); ok {
opt.Archived = &v
}
if v, ok := args["enable_wiki"].(bool); ok {
opt.HasWiki = &v
}
if v, ok := args["enable_issues"].(bool); ok {
opt.HasIssues = &v
}
if v, ok := args["enable_pull_requests"].(bool); ok {
opt.HasPullRequests = &v
}
if v, ok := args["default_merge_style"].(string); ok && v != "" {
style := gitea_sdk.MergeStyle(v)
opt.DefaultMergeStyle = &style
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
r, _, err := client.EditRepo(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update repo settings err: %v", err))
}
return to.TextResult(slimRepoSettings(r))
}
func slimUserSettings(s *gitea_sdk.UserSettings) map[string]interface{} {
return map[string]interface{}{
"full_name": s.FullName,
"description": s.Description,
"website": s.Website,
"location": s.Location,
"theme": s.Theme,
"language": s.Language,
"diff_view_style": s.DiffViewStyle,
"hide_email": s.HideEmail,
"hide_activity": s.HideActivity,
}
}
func slimRepoSettings(r *gitea_sdk.Repository) map[string]interface{} {
return map[string]interface{}{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
"description": r.Description,
"website": r.Website,
"default_branch": r.DefaultBranch,
"private": r.Private,
"has_wiki": r.HasWiki,
"has_issues": r.HasIssues,
"has_projects": r.HasProjects,
"default_merge_style": r.DefaultMergeStyle,
"default_delete_branch_after_merge": r.DefaultDeleteBranchAfterMerge,
}
}
+192
View File
@@ -0,0 +1,192 @@
package sshkey
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.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"
)
const (
ListMySSHKeysToolName = "list_my_ssh_keys"
GetSSHKeyToolName = "get_ssh_key"
CreateSSHKeyToolName = "create_ssh_key"
DeleteSSHKeyToolName = "delete_ssh_key"
ListUserSSHKeysToolName = "list_user_ssh_keys"
)
var Tool = tool.New()
var (
ListMySSHKeysTool = mcp.NewTool(
ListMySSHKeysToolName,
mcp.WithDescription("List SSH keys for the authenticated user"),
)
GetSSHKeyTool = mcp.NewTool(
GetSSHKeyToolName,
mcp.WithDescription("Get a specific SSH key by ID"),
mcp.WithNumber("id", mcp.Required(), mcp.Description("SSH key ID")),
)
CreateSSHKeyTool = mcp.NewTool(
CreateSSHKeyToolName,
mcp.WithDescription("Create a new SSH key for the authenticated user"),
mcp.WithString("title", mcp.Required(), mcp.Description("Title/description for the SSH key")),
mcp.WithString("key", mcp.Required(), mcp.Description("The SSH public key content")),
)
DeleteSSHKeyTool = mcp.NewTool(
DeleteSSHKeyToolName,
mcp.WithDescription("Delete an SSH key"),
mcp.WithNumber("id", mcp.Required(), mcp.Description("SSH key ID to delete")),
)
ListUserSSHKeysTool = mcp.NewTool(
ListUserSSHKeysToolName,
mcp.WithDescription("List SSH keys for a specific user"),
mcp.WithString("username", mcp.Required(), mcp.Description("Username")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListMySSHKeysTool,
Handler: listMySSHKeysFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetSSHKeyTool,
Handler: getSSHKeyFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListUserSSHKeysTool,
Handler: listUserSSHKeysFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateSSHKeyTool,
Handler: createSSHKeyFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteSSHKeyTool,
Handler: deleteSSHKeyFn,
})
}
func listMySSHKeysFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[SSHKey] Called listMySSHKeysFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
keys, _, err := client.ListMyPublicKeys(gitea_sdk.ListPublicKeysOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list SSH keys err: %v", err))
}
return to.TextResult(slimSSHKeys(keys))
}
func getSSHKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[SSHKey] Called getSSHKeyFn")
args := req.GetArguments()
id, err := params.GetIndex(args, "id")
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid key id: %v", err))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
key, _, err := client.GetPublicKey(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get SSH key err: %v", err))
}
return to.TextResult(slimSSHKey(key))
}
func createSSHKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[SSHKey] Called createSSHKeyFn")
args := req.GetArguments()
title, err := params.GetString(args, "title")
if err != nil {
return to.ErrorResult(err)
}
key, err := params.GetString(args, "key")
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))
}
createOpt := gitea_sdk.CreateKeyOption{
Title: title,
Key: key,
}
respKey, _, err := client.CreatePublicKey(createOpt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create SSH key err: %v", err))
}
return to.TextResult(slimSSHKey(respKey))
}
func deleteSSHKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[SSHKey] Called deleteSSHKeyFn")
args := req.GetArguments()
id, err := params.GetIndex(args, "id")
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid key id: %v", err))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeletePublicKey(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete SSH key err: %v", err))
}
return to.TextResult("SSH key deleted successfully")
}
func listUserSSHKeysFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[SSHKey] Called listUserSSHKeysFn")
args := req.GetArguments()
username, err := params.GetString(args, "username")
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))
}
keys, _, err := client.ListPublicKeys(username, gitea_sdk.ListPublicKeysOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list user SSH keys err: %v", err))
}
return to.TextResult(slimSSHKeys(keys))
}
func slimSSHKeys(keys []*gitea_sdk.PublicKey) []map[string]interface{} {
result := make([]map[string]interface{}, len(keys))
for i, k := range keys {
result[i] = slimSSHKey(k)
}
return result
}
func slimSSHKey(k *gitea_sdk.PublicKey) map[string]interface{} {
return map[string]interface{}{
"id": k.ID,
"key": k.Key,
"title": k.Title,
"created": k.Created,
"fingerprint": k.Fingerprint,
}
}
+227
View File
@@ -0,0 +1,227 @@
package stars
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.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 (
StarsReadToolName = "stars_read"
StarsWriteToolName = "stars_write"
)
var (
StarsReadTool = mcp.NewTool(
StarsReadToolName,
mcp.WithDescription("Read stars information. Use method 'list_stargazers' to list repo stargazers, 'list_starred' to list user's starred repos, 'my_starred' for your starred repos, 'check' to check if user starred a repo."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_stargazers", "list_starred", "my_starred", "check")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("user", mcp.Description("username (for 'list_starred', 'check')")),
)
StarsWriteTool = mcp.NewTool(
StarsWriteToolName,
mcp.WithDescription("Star or unstar a repository."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("star", "unstar")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: StarsReadTool,
Handler: starsReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: StarsWriteTool,
Handler: starsWriteFn,
})
}
func starsReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_stargazers":
return listStargazersFn(ctx, req)
case "list_starred":
return listStarredFn(ctx, req)
case "my_starred":
return myStarredFn(ctx, req)
case "check":
return checkStarFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func starsWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "star":
return starRepoFn(ctx, req)
case "unstar":
return unstarRepoFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listStargazersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listStargazersFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
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))
}
users, _, err := client.ListRepoStargazers(owner, repo, gitea_sdk.ListStargazersOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list stargazers err: %v", err))
}
return to.TextResult(slimUsers(users))
}
func listStarredFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listStarredFn")
user, err := params.GetString(req.GetArguments(), "user")
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))
}
repos, _, err := client.GetStarredRepos(user)
if err != nil {
return to.ErrorResult(fmt.Errorf("list starred repos err: %v", err))
}
return to.TextResult(slimRepos(repos))
}
func myStarredFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called myStarredFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.GetMyStarredRepos()
if err != nil {
return to.ErrorResult(fmt.Errorf("list my starred repos err: %v", err))
}
return to.TextResult(slimRepos(repos))
}
func checkStarFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called checkStarFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
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))
}
starred, _, err := client.IsRepoStarring(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("check star err: %v", err))
}
return to.TextResult(map[string]any{"starred": starred})
}
func starRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called starRepoFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
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))
}
_, err = client.StarRepo(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("star repo err: %v", err))
}
return to.TextResult("Repository starred successfully")
}
func unstarRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called unstarRepoFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
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))
}
_, err = client.UnStarRepo(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("unstar repo err: %v", err))
}
return to.TextResult("Repository unstarred successfully")
}
func slimUsers(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"avatar_url": u.AvatarURL,
})
}
return out
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, map[string]any{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
"private": r.Private,
})
}
return out
}
+376
View File
@@ -0,0 +1,376 @@
package team
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.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 (
TeamReadToolName = "team_read"
TeamWriteToolName = "team_write"
)
var (
TeamReadTool = mcp.NewTool(
TeamReadToolName,
mcp.WithDescription("Read team information. Use method 'get' to get team details, 'list_members' to list team members, 'list_repos' to list team repositories."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list_members", "list_repos")),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("id", mcp.Description("team ID (required for 'get', 'list_members', 'list_repos')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
TeamWriteTool = mcp.NewTool(
TeamWriteToolName,
mcp.WithDescription("Create, update, or delete teams, manage team members."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete", "add_member", "remove_member", "add_repo", "remove_repo")),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("id", mcp.Description("team ID (required for 'edit', 'delete', 'add_member', 'remove_member', 'add_repo', 'remove_repo')")),
mcp.WithString("name", mcp.Description("team name (required for 'create', optional for 'edit')")),
mcp.WithString("description", mcp.Description("team description")),
mcp.WithString("permission", mcp.Description("permission level"), mcp.Enum("read", "write", "admin", "owner")),
mcp.WithString("user", mcp.Description("username (required for 'add_member', 'remove_member')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'add_repo', 'remove_repo')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: TeamReadTool,
Handler: teamReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: TeamWriteTool,
Handler: teamWriteFn,
})
}
func teamReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "get":
return getTeamFn(ctx, req)
case "list_members":
return listTeamMembersFn(ctx, req)
case "list_repos":
return listTeamReposFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func teamWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createTeamFn(ctx, req)
case "edit":
return editTeamFn(ctx, req)
case "delete":
return deleteTeamFn(ctx, req)
case "add_member":
return addTeamMemberFn(ctx, req)
case "remove_member":
return removeTeamMemberFn(ctx, req)
case "add_repo":
return addTeamRepoFn(ctx, req)
case "remove_repo":
return removeTeamRepoFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getTeamFn")
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))
}
team, _, err := client.GetTeam(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get team err: %v", err))
}
return to.TextResult(slimTeam(team))
}
func listTeamMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTeamMembersFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListTeamMembersOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
members, _, err := client.ListTeamMembers(id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list team members err: %v", err))
}
return to.TextResult(slimUsers(members))
}
func listTeamReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTeamReposFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.ListTeamRepositoriesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
repos, _, err := client.ListTeamRepositories(id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list team repos err: %v", err))
}
return to.TextResult(slimRepos(repos))
}
func createTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createTeamFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string)
permission, _ := req.GetArguments()["permission"].(string)
opt := gitea_sdk.CreateTeamOption{
Name: name,
Description: description,
Permission: gitea_sdk.AccessMode(permission),
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
team, _, err := client.CreateTeam(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create team err: %v", err))
}
return to.TextResult(slimTeam(team))
}
func editTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editTeamFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditTeamOption{}
if name, ok := req.GetArguments()["name"].(string); ok && name != "" {
opt.Name = name
}
if description, ok := req.GetArguments()["description"].(string); ok && description != "" {
opt.Description = &description
}
if permission, ok := req.GetArguments()["permission"].(string); ok {
opt.Permission = gitea_sdk.AccessMode(permission)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.EditTeam(id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit team err: %v", err))
}
return to.TextResult("Team updated successfully")
}
func deleteTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteTeamFn")
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))
}
_, err = client.DeleteTeam(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete team err: %v", err))
}
return to.TextResult("Team deleted successfully")
}
func addTeamMemberFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTeamMemberFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
user, err := params.GetString(req.GetArguments(), "user")
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))
}
_, err = client.AddTeamMember(id, user)
if err != nil {
return to.ErrorResult(fmt.Errorf("add team member err: %v", err))
}
return to.TextResult("Team member added successfully")
}
func removeTeamMemberFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called removeTeamMemberFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
user, err := params.GetString(req.GetArguments(), "user")
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))
}
_, err = client.RemoveTeamMember(id, user)
if err != nil {
return to.ErrorResult(fmt.Errorf("remove team member err: %v", err))
}
return to.TextResult("Team member removed successfully")
}
func addTeamRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTeamRepoFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
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))
}
_, err = client.AddTeamRepository(id, org, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("add team repo err: %v", err))
}
return to.TextResult("Team repository added successfully")
}
func removeTeamRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called removeTeamRepoFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
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))
}
_, err = client.RemoveTeamRepository(id, org, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("remove team repo err: %v", err))
}
return to.TextResult("Team repository removed successfully")
}
func slimTeam(t *gitea_sdk.Team) map[string]any {
if t == nil {
return nil
}
orgName := ""
if t.Organization != nil {
orgName = t.Organization.Name
}
return map[string]any{
"id": t.ID,
"name": t.Name,
"description": t.Description,
"permission": t.Permission,
"org_name": orgName,
}
}
func slimUsers(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"avatar_url": u.AvatarURL,
})
}
return out
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, map[string]any{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
})
}
return out
}
+47
View File
@@ -0,0 +1,47 @@
package timetracking
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimStopWatch(s *gitea_sdk.StopWatch) map[string]any {
if s == nil {
return nil
}
return map[string]any{
"issue_index": s.IssueIndex,
"issue_title": s.IssueTitle,
"repo_name": s.RepoName,
"repo_owner": s.RepoOwnerName,
"created": s.Created,
"seconds": s.Seconds,
}
}
func slimStopWatches(watches []*gitea_sdk.StopWatch) []map[string]any {
out := make([]map[string]any, 0, len(watches))
for _, s := range watches {
out = append(out, slimStopWatch(s))
}
return out
}
func slimTrackedTime(t *gitea_sdk.TrackedTime) map[string]any {
if t == nil {
return nil
}
return map[string]any{
"id": t.ID,
"time": t.Time,
"user_name": t.UserName,
"created": t.Created,
}
}
func slimTrackedTimes(times []*gitea_sdk.TrackedTime) []map[string]any {
out := make([]map[string]any, 0, len(times))
for _, t := range times {
out = append(out, slimTrackedTime(t))
}
return out
}
+332
View File
@@ -0,0 +1,332 @@
// Package timetracking provides MCP tools for Gitea time tracking operations
package timetracking
import (
"context"
"fmt"
gitea_sdk "code.gitea.io/sdk/gitea"
"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"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
TimetrackingReadToolName = "timetracking_read"
TimetrackingWriteToolName = "timetracking_write"
)
var (
TimetrackingReadTool = mcp.NewTool(
TimetrackingReadToolName,
mcp.WithDescription("Read time tracking data. Use method 'list_issue_times' for issue times, 'list_repo_times' for repository times, 'get_my_stopwatches' for active stopwatches, 'get_my_times' for all your tracked times."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")),
mcp.WithString("owner", mcp.Description("repository owner (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("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
TimetrackingWriteTool = mcp.NewTool(
TimetrackingWriteToolName,
mcp.WithDescription("Manage time tracking: stopwatches and tracked time entries."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")),
mcp.WithString("owner", mcp.Description("repository owner (required for all methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for all methods)")),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods)")),
mcp.WithNumber("time", mcp.Description("time to add in seconds (required for 'add_time')")),
mcp.WithNumber("id", mcp.Description("tracked time entry ID (required for 'delete_time')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: TimetrackingReadTool, Handler: readFn})
Tool.RegisterWrite(server.ServerTool{Tool: TimetrackingWriteTool, Handler: writeFn})
}
func readFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_issue_times":
return listTrackedTimesFn(ctx, req)
case "list_repo_times":
return listRepoTimesFn(ctx, req)
case "get_my_stopwatches":
return getMyStopwatchesFn(ctx, req)
case "get_my_times":
return getMyTimesFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func writeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "start_stopwatch":
return startStopwatchFn(ctx, req)
case "stop_stopwatch":
return stopStopwatchFn(ctx, req)
case "delete_stopwatch":
return deleteStopwatchFn(ctx, req)
case "add_time":
return addTrackedTimeFn(ctx, req)
case "delete_time":
return deleteTrackedTimeFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
// Stopwatch handler functions
func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called startStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
}
_, err = client.StartIssueStopWatch(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("start stopwatch on %s/%s#%d err: %v", owner, repo, index, err))
}
return to.TextResult(fmt.Sprintf("Stopwatch started on issue %s/%s#%d", owner, repo, index))
}
func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called stopStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
}
_, err = client.StopIssueStopWatch(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("stop stopwatch on %s/%s#%d err: %v", owner, repo, index, err))
}
return to.TextResult(fmt.Sprintf("Stopwatch stopped on issue %s/%s#%d - time recorded", owner, repo, index))
}
func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
}
_, err = client.DeleteIssueStopwatch(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete stopwatch on %s/%s#%d err: %v", owner, repo, index, err))
}
return to.TextResult(fmt.Sprintf("Stopwatch deleted/cancelled on issue %s/%s#%d", owner, repo, index))
}
func getMyStopwatchesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMyStopwatchesFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
stopwatches, _, err := client.ListMyStopwatches(gitea_sdk.ListStopwatchesOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get stopwatches err: %v", err))
}
if len(stopwatches) == 0 {
return to.TextResult("No active stopwatches")
}
return to.TextResult(slimStopWatches(stopwatches))
}
// Tracked time handler functions
func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTrackedTimesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
times, _, err := client.ListIssueTrackedTimes(owner, repo, index, gitea_sdk.ListTrackedTimesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list tracked times for %s/%s#%d err: %v", owner, repo, index, err))
}
if len(times) == 0 {
return to.TextResult(fmt.Sprintf("No tracked times for issue %s/%s#%d", owner, repo, index))
}
return to.TextResult(slimTrackedTimes(times))
}
func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTrackedTimeFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
timeSeconds, err := params.GetIndex(req.GetArguments(), "time")
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))
}
trackedTime, _, err := client.AddTime(owner, repo, index, gitea_sdk.AddTimeOption{
Time: timeSeconds,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("add tracked time to %s/%s#%d err: %v", owner, repo, index, err))
}
return to.TextResult(slimTrackedTime(trackedTime))
}
func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteTrackedTimeFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
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))
}
_, err = client.DeleteTime(owner, repo, index, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete tracked time %d from %s/%s#%d err: %v", id, owner, repo, index, err))
}
return to.TextResult(fmt.Sprintf("Tracked time entry %d deleted from issue %s/%s#%d", id, owner, repo, index))
}
func listRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoTimesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
times, _, err := client.ListRepoTrackedTimes(owner, repo, gitea_sdk.ListTrackedTimesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo tracked times for %s/%s err: %v", owner, repo, err))
}
if len(times) == 0 {
return to.TextResult(fmt.Sprintf("No tracked times for repository %s/%s", owner, repo))
}
return to.TextResult(slimTrackedTimes(times))
}
func getMyTimesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMyTimesFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
times, _, err := client.ListMyTrackedTimes(gitea_sdk.ListTrackedTimesOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get tracked times err: %v", err))
}
if len(times) == 0 {
return to.TextResult("No tracked times found")
}
return to.TextResult(slimTrackedTimes(times))
}
+157
View File
@@ -0,0 +1,157 @@
package transfer
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.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"
)
const (
TransferRepoToolName = "transfer_repo"
AcceptTransferToolName = "accept_transfer"
RejectTransferToolName = "reject_transfer"
)
var Tool = tool.New()
var (
TransferRepoTool = mcp.NewTool(
TransferRepoToolName,
mcp.WithDescription("Transfer repository ownership to another user or organization"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Current repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
mcp.WithString("new_owner", mcp.Required(), mcp.Description("New owner (user or org)")),
mcp.WithArray("teams", mcp.Description("Teams to transfer (for org-to-org)"), mcp.Items(map[string]any{"type": "string"})),
)
AcceptTransferTool = mcp.NewTool(
AcceptTransferToolName,
mcp.WithDescription("Accept a repository transfer"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
)
RejectTransferTool = mcp.NewTool(
RejectTransferToolName,
mcp.WithDescription("Reject a repository transfer"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: TransferRepoTool,
Handler: transferRepoFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: AcceptTransferTool,
Handler: acceptTransferFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: RejectTransferTool,
Handler: rejectTransferFn,
})
}
func transferRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Transfer] Called transferRepoFn")
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)
}
newOwner, err := params.GetString(args, "new_owner")
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))
}
opt := gitea_sdk.TransferRepoOption{
NewOwner: newOwner,
}
r, _, err := client.TransferRepo(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("transfer repo err: %v", err))
}
return to.TextResult(map[string]interface{}{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
"owner": r.Owner.UserName,
})
}
func acceptTransferFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Transfer] Called acceptTransferFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
r, _, err := client.AcceptRepoTransfer(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("accept transfer err: %v", err))
}
return to.TextResult(map[string]interface{}{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
"owner": r.Owner.UserName,
})
}
func rejectTransferFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[Transfer] Called rejectTransferFn")
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)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.RejectRepoTransfer(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("reject transfer err: %v", err))
}
return to.TextResult("Repository transfer rejected")
}
+42
View File
@@ -0,0 +1,42 @@
package user
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimUserDetail(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
"html_url": u.HTMLURL,
"is_admin": u.IsAdmin,
}
}
func slimOrg(o *gitea_sdk.Organization) map[string]any {
if o == nil {
return nil
}
return map[string]any{
"id": o.ID,
"name": o.Name,
"full_name": o.FullName,
"description": o.Description,
"avatar_url": o.AvatarURL,
"website": o.Website,
}
}
func slimOrgs(orgs []*gitea_sdk.Organization) []map[string]any {
out := make([]map[string]any, 0, len(orgs))
for _, o := range orgs {
out = append(out, slimOrg(o))
}
return out
}
+39
View File
@@ -0,0 +1,39 @@
package user
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimUserDetail(t *testing.T) {
u := &gitea_sdk.User{
ID: 42,
UserName: "alice",
FullName: "Alice Smith",
Email: "alice@example.com",
AvatarURL: "https://gitea.com/avatars/42",
HTMLURL: "https://gitea.com/alice",
IsAdmin: true,
}
m := slimUserDetail(u)
if m["id"] != int64(42) {
t.Errorf("expected id 42, got %v", m["id"])
}
if m["login"] != "alice" {
t.Errorf("expected login alice, got %v", m["login"])
}
if m["full_name"] != "Alice Smith" {
t.Errorf("expected full_name Alice Smith, got %v", m["full_name"])
}
if m["is_admin"] != true {
t.Errorf("expected is_admin true, got %v", m["is_admin"])
}
}
func TestSlimUserDetail_Nil(t *testing.T) {
if m := slimUserDetail(nil); m != nil {
t.Errorf("expected nil for nil user, got %v", m)
}
}
+106
View File
@@ -0,0 +1,106 @@
package user
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.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"
)
const (
// GetMyUserInfoToolName is the unique tool name used for MCP registration and lookup of the get_me command.
GetMyUserInfoToolName = "get_me"
// GetUserOrgsToolName is the unique tool name used for MCP registration and lookup of the get_user_orgs command.
GetUserOrgsToolName = "get_user_orgs"
// defaultPage is the default starting page number used for paginated organization listings.
defaultPage = 1
// defaultPageSize is the default number of organizations per page for paginated queries.
defaultPageSize = 30
)
// Tool is the MCP tool manager instance for registering all MCP tools in this package.
var Tool = tool.New()
var (
// GetMyUserInfoTool is the MCP tool for retrieving the current user's info.
// It is registered with a specific name and a description string.
GetMyUserInfoTool = mcp.NewTool(
GetMyUserInfoToolName,
mcp.WithDescription("Get my user info"),
)
// GetUserOrgsTool is the MCP tool for listing organizations for the authenticated user.
// It supports pagination via "page" and "perPage" arguments with default values specified above.
GetUserOrgsTool = mcp.NewTool(
GetUserOrgsToolName,
mcp.WithDescription("Get organizations associated with the authenticated user"),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(defaultPageSize)),
)
)
// init registers all MCP tools in Tool at package initialization.
// This function ensures the handler functions are registered before server usage.
func init() {
registerTools()
}
// registerTools registers all local MCP tool definitions and their handler functions.
// To add new functionality, append your tool/handler pair to the tools slice below.
func registerTools() {
tools := []server.ServerTool{
{Tool: GetMyUserInfoTool, Handler: GetUserInfoFn},
{Tool: GetUserOrgsTool, Handler: GetUserOrgsFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
// GetUserInfoFn is the handler for "get_me" MCP tool requests.
// Logs invocation, fetches current user info from gitea, wraps result for MCP.
func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserInfoFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
user, _, err := client.GetMyUserInfo()
if err != nil {
return to.ErrorResult(fmt.Errorf("get user info err: %v", err))
}
return to.TextResult(slimUserDetail(user))
}
// GetUserOrgsFn is the handler for "get_user_orgs" MCP tool requests.
// Logs invocation, pulls validated pagination arguments from request,
// performs Gitea organization listing, and wraps the result for MCP.
func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserOrgsFn")
page, pageSize := params.GetPagination(req.GetArguments(), defaultPageSize)
opt := gitea_sdk.ListOrgsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
orgs, _, err := client.ListMyOrgs(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get user orgs err: %v", err))
}
return to.TextResult(slimOrgs(orgs))
}
+182
View File
@@ -0,0 +1,182 @@
package version
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
GetGiteaMCPServerVersion = "get_gitea_mcp_server_version"
CheckGiteaVersion = "check_gitea_version"
)
var GetGiteaMCPServerVersionTool = mcp.NewTool(
GetGiteaMCPServerVersion,
mcp.WithDescription("Get Gitea MCP Server Version"),
)
var CheckGiteaVersionTool = mcp.NewTool(
CheckGiteaVersion,
mcp.WithDescription("Check the Gitea server version and API capabilities. Returns version string, parsed components, and capability matrix indicating which APIs are available based on the detected version."),
)
// VersionResponse represents the Gitea version API response
type VersionResponse struct {
Version string `json:"version"`
}
// VersionInfo represents the complete version information with capabilities
type VersionInfo struct {
Version string `json:"version"`
Major int `json:"major"`
Minor int `json:"minor"`
Patch int `json:"patch"`
Capabilities CapabilityMatrix `json:"capabilities"`
}
// CapabilityMatrix indicates which APIs are available
type CapabilityMatrix struct {
ActionsAPI bool `json:"actions_api"`
SecretsAPI bool `json:"secrets_api"`
VariablesAPI bool `json:"variables_api"`
RunnersAPI bool `json:"runners_api"`
ArtifactsAPI bool `json:"artifacts_api"`
CommitStatusAPI bool `json:"commit_status_api"`
}
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetGiteaMCPServerVersionTool,
Handler: GetGiteaMCPServerVersionFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: CheckGiteaVersionTool,
Handler: CheckGiteaVersionFn,
})
}
func GetGiteaMCPServerVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetGiteaMCPServerVersionFn")
version := flag.Version
if version == "" {
version = "dev"
}
return to.TextResult(fmt.Sprintf("Gitea MCP Server version: %v", version))
}
// CheckGiteaVersionFn retrieves Gitea server version and determines capabilities
func CheckGiteaVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CheckGiteaVersionFn")
var versionResp VersionResponse
status, err := gitea.DoJSON(ctx, "GET", "version", nil, nil, &versionResp)
if err != nil {
log.Errorf("Failed to get Gitea version: status=%d, err=%v", status, err)
return to.TextResult(fmt.Sprintf("Error: Failed to get Gitea version (status %d): %v", status, err))
}
major, minor, patch, err := parseVersion(versionResp.Version)
if err != nil {
log.Errorf("Failed to parse version string '%s': %v", versionResp.Version, err)
return to.TextResult(fmt.Sprintf("Error: Failed to parse version '%s': %v", versionResp.Version, err))
}
capabilities := determineCapabilities(major, minor, patch)
info := VersionInfo{
Version: versionResp.Version,
Major: major,
Minor: minor,
Patch: patch,
Capabilities: capabilities,
}
jsonBytes, err := json.MarshalIndent(info, "", " ")
if err != nil {
log.Errorf("Failed to marshal version info: %v", err)
return to.TextResult(fmt.Sprintf("Error: Failed to format response: %v", err))
}
return to.TextResult(string(jsonBytes))
}
// parseVersion parses a version string like "1.22.5" into major, minor, patch
func parseVersion(version string) (int, int, int, error) {
version = strings.TrimPrefix(version, "v")
version = strings.TrimPrefix(version, "V")
parts := strings.Split(version, ".")
if len(parts) < 2 {
return 0, 0, 0, fmt.Errorf("invalid version format: %s (expected major.minor.patch)", version)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid major version: %s", parts[0])
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid minor version: %s", parts[1])
}
patch := 0
if len(parts) >= 3 {
patch, err = strconv.Atoi(parts[2])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid patch version: %s", parts[2])
}
}
return major, minor, patch, nil
}
// determineCapabilities determines which APIs are available based on version
func determineCapabilities(major, minor, patch int) CapabilityMatrix {
cm := CapabilityMatrix{}
// Commit Status API: Available in 1.12+
if major > 1 || (major == 1 && minor >= 12) {
cm.CommitStatusAPI = true
}
// Secrets API: Available in 1.22.0+
if major > 1 || (major == 1 && minor >= 22) {
cm.SecretsAPI = true
}
// Variables API: Available in 1.22.0+ (same as secrets)
if major > 1 || (major == 1 && minor >= 22) {
cm.VariablesAPI = true
}
// Runners API: Available in 1.22.0+ with improvements in 1.23+
if major > 1 || (major == 1 && minor >= 22) {
cm.RunnersAPI = true
}
// Actions API: Full support in 1.23+, limited in 1.22
// Artifacts API: Full support in 1.23+
if major > 1 || (major == 1 && minor >= 23) {
cm.ActionsAPI = true
cm.ArtifactsAPI = true
// Runners API is more complete in 1.23+
cm.RunnersAPI = true
}
return cm
}
+488
View File
@@ -0,0 +1,488 @@
package version
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func Test_checkGiteaVersionFn_success(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/version" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.22.5"}`))
})
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{},
},
}
result, err := CheckGiteaVersionFn(context.Background(), req)
if err != nil {
t.Fatalf("CheckGiteaVersionFn() error = %v", err)
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed VersionInfo
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if parsed.Version != "1.22.5" {
t.Errorf("version = %q, want %q", parsed.Version, "1.22.5")
}
if parsed.Major != 1 {
t.Errorf("major = %d, want 1", parsed.Major)
}
if parsed.Minor != 22 {
t.Errorf("minor = %d, want 22", parsed.Minor)
}
if parsed.Patch != 5 {
t.Errorf("patch = %d, want 5", parsed.Patch)
}
// 1.22.5 should have secrets, variables, runners, commit_status but not actions/artifacts
if !parsed.Capabilities.SecretsAPI {
t.Error("expected secrets_api=true for 1.22.5")
}
if !parsed.Capabilities.VariablesAPI {
t.Error("expected variables_api=true for 1.22.5")
}
if !parsed.Capabilities.RunnersAPI {
t.Error("expected runners_api=true for 1.22.5")
}
if !parsed.Capabilities.CommitStatusAPI {
t.Error("expected commit_status_api=true for 1.22.5")
}
if parsed.Capabilities.ActionsAPI {
t.Error("expected actions_api=false for 1.22.5")
}
if parsed.Capabilities.ArtifactsAPI {
t.Error("expected artifacts_api=false for 1.22.5")
}
}
func Test_checkGiteaVersionFn_version123(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/version" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.23.0"}`))
})
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{},
},
}
result, err := CheckGiteaVersionFn(context.Background(), req)
if err != nil {
t.Fatalf("CheckGiteaVersionFn() error = %v", err)
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed VersionInfo
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if parsed.Version != "1.23.0" {
t.Errorf("version = %q, want %q", parsed.Version, "1.23.0")
}
// 1.23.0 should have all capabilities
if !parsed.Capabilities.ActionsAPI {
t.Error("expected actions_api=true for 1.23.0")
}
if !parsed.Capabilities.ArtifactsAPI {
t.Error("expected artifacts_api=true for 1.23.0")
}
if !parsed.Capabilities.SecretsAPI {
t.Error("expected secrets_api=true for 1.23.0")
}
if !parsed.Capabilities.VariablesAPI {
t.Error("expected variables_api=true for 1.23.0")
}
if !parsed.Capabilities.RunnersAPI {
t.Error("expected runners_api=true for 1.23.0")
}
if !parsed.Capabilities.CommitStatusAPI {
t.Error("expected commit_status_api=true for 1.23.0")
}
}
func Test_checkGiteaVersionFn_version111(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/version" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.11.0"}`))
})
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{},
},
}
result, err := CheckGiteaVersionFn(context.Background(), req)
if err != nil {
t.Fatalf("CheckGiteaVersionFn() error = %v", err)
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed VersionInfo
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
// 1.11.0 should only have commit_status
if !parsed.Capabilities.CommitStatusAPI {
t.Error("expected commit_status_api=true for 1.11.0")
}
if parsed.Capabilities.SecretsAPI {
t.Error("expected secrets_api=false for 1.11.0")
}
if parsed.Capabilities.VariablesAPI {
t.Error("expected variables_api=false for 1.11.0")
}
if parsed.Capabilities.RunnersAPI {
t.Error("expected runners_api=false for 1.11.0")
}
if parsed.Capabilities.ActionsAPI {
t.Error("expected actions_api=false for 1.11.0")
}
if parsed.Capabilities.ArtifactsAPI {
t.Error("expected artifacts_api=false for 1.11.0")
}
}
func Test_checkGiteaVersionFn_withVPrefix(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/version" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"v1.24.0"}`))
})
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{},
},
}
result, err := CheckGiteaVersionFn(context.Background(), req)
if err != nil {
t.Fatalf("CheckGiteaVersionFn() error = %v", err)
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed VersionInfo
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if parsed.Version != "v1.24.0" {
t.Errorf("version = %q, want %q", parsed.Version, "v1.24.0")
}
if parsed.Major != 1 {
t.Errorf("major = %d, want 1", parsed.Major)
}
if parsed.Minor != 24 {
t.Errorf("minor = %d, want 24", parsed.Minor)
}
if parsed.Patch != 0 {
t.Errorf("patch = %d, want 0", parsed.Patch)
}
// 1.24 should have all capabilities
if !parsed.Capabilities.ActionsAPI {
t.Error("expected actions_api=true for 1.24.0")
}
}
func Test_checkGiteaVersionFn_error(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"message":"Internal Server Error"}`, http.StatusInternalServerError)
})
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{},
},
}
result, err := CheckGiteaVersionFn(context.Background(), req)
if err != nil {
t.Fatalf("CheckGiteaVersionFn() error = %v", err)
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
// Should return error message in text result
if textContent.Text == "" {
t.Error("expected error message in result")
}
}
func Test_parseVersion(t *testing.T) {
tests := []struct {
version string
wantMajor int
wantMinor int
wantPatch int
wantErr bool
}{
{"1.22.5", 1, 22, 5, false},
{"1.23.0", 1, 23, 0, false},
{"1.24.1", 1, 24, 1, false},
{"v1.22.5", 1, 22, 5, false},
{"V1.22.5", 1, 22, 5, false},
{"1.22", 1, 22, 0, false},
{"1", 0, 0, 0, true},
{"", 0, 0, 0, true},
{"abc", 0, 0, 0, true},
{"1.x.5", 0, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
major, minor, patch, err := parseVersion(tt.version)
if (err != nil) != tt.wantErr {
t.Errorf("parseVersion(%q) error = %v, wantErr %v", tt.version, err, tt.wantErr)
return
}
if !tt.wantErr {
if major != tt.wantMajor {
t.Errorf("parseVersion(%q) major = %d, want %d", tt.version, major, tt.wantMajor)
}
if minor != tt.wantMinor {
t.Errorf("parseVersion(%q) minor = %d, want %d", tt.version, minor, tt.wantMinor)
}
if patch != tt.wantPatch {
t.Errorf("parseVersion(%q) patch = %d, want %d", tt.version, patch, tt.wantPatch)
}
}
})
}
}
func Test_determineCapabilities(t *testing.T) {
tests := []struct {
name string
major int
minor int
patch int
expected CapabilityMatrix
}{
{
name: "1.11.0",
major: 1, minor: 11, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: false,
SecretsAPI: false,
VariablesAPI: false,
RunnersAPI: false,
ArtifactsAPI: false,
CommitStatusAPI: true,
},
},
{
name: "1.12.0",
major: 1, minor: 12, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: false,
SecretsAPI: false,
VariablesAPI: false,
RunnersAPI: false,
ArtifactsAPI: false,
CommitStatusAPI: true,
},
},
{
name: "1.22.0",
major: 1, minor: 22, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: false,
SecretsAPI: true,
VariablesAPI: true,
RunnersAPI: true,
ArtifactsAPI: false,
CommitStatusAPI: true,
},
},
{
name: "1.22.5",
major: 1, minor: 22, patch: 5,
expected: CapabilityMatrix{
ActionsAPI: false,
SecretsAPI: true,
VariablesAPI: true,
RunnersAPI: true,
ArtifactsAPI: false,
CommitStatusAPI: true,
},
},
{
name: "1.23.0",
major: 1, minor: 23, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: true,
SecretsAPI: true,
VariablesAPI: true,
RunnersAPI: true,
ArtifactsAPI: true,
CommitStatusAPI: true,
},
},
{
name: "1.24.0",
major: 1, minor: 24, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: true,
SecretsAPI: true,
VariablesAPI: true,
RunnersAPI: true,
ArtifactsAPI: true,
CommitStatusAPI: true,
},
},
{
name: "2.0.0",
major: 2, minor: 0, patch: 0,
expected: CapabilityMatrix{
ActionsAPI: true,
SecretsAPI: true,
VariablesAPI: true,
RunnersAPI: true,
ArtifactsAPI: true,
CommitStatusAPI: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := determineCapabilities(tt.major, tt.minor, tt.patch)
if got != tt.expected {
t.Errorf("determineCapabilities(%d, %d, %d) = %+v, want %+v",
tt.major, tt.minor, tt.patch, got, tt.expected)
}
})
}
}
+345
View File
@@ -0,0 +1,345 @@
package webhook
import (
"context"
"encoding/json"
"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.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 (
WebhookReadToolName = "webhook_read"
WebhookWriteToolName = "webhook_write"
)
var (
WebhookReadTool = mcp.NewTool(
WebhookReadToolName,
mcp.WithDescription("Read webhooks. Use method 'list_repo' to list repo webhooks, 'list_org' for org webhooks, 'get' to get a specific webhook."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo", "list_org", "get")),
mcp.WithString("owner", mcp.Description("repository or organization owner (required for 'list_repo', 'get')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'list_repo', 'get')")),
mcp.WithNumber("id", mcp.Description("webhook ID (required for 'get')")),
)
WebhookWriteTool = mcp.NewTool(
WebhookWriteToolName,
mcp.WithDescription("Create, update, or delete webhooks."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create_repo", "create_org", "edit_repo", "edit_org", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository or organization owner")),
mcp.WithString("repo", mcp.Description("repository name (required for repo operations)")),
mcp.WithNumber("id", mcp.Description("webhook ID (required for 'edit', 'delete')")),
mcp.WithString("url", mcp.Required(), mcp.Description("webhook URL")),
mcp.WithString("secret", mcp.Description("webhook secret")),
mcp.WithBoolean("active", mcp.Description("whether webhook is active")),
mcp.WithArray("events", mcp.Description("events to trigger webhook"), mcp.Items(map[string]any{"type": "string"})),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: WebhookReadTool,
Handler: webhookReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: WebhookWriteTool,
Handler: webhookWriteFn,
})
}
func webhookReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_repo":
return listRepoHooksFn(ctx, req)
case "list_org":
return listOrgHooksFn(ctx, req)
case "get":
return getHookFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func webhookWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create_repo":
return createRepoHookFn(ctx, req)
case "create_org":
return createOrgHookFn(ctx, req)
case "edit_repo":
return editRepoHookFn(ctx, req)
case "edit_org":
return editOrgHookFn(ctx, req)
case "delete":
return deleteHookFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listRepoHooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoHooksFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
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))
}
hooks, _, err := client.ListRepoHooks(owner, repo, gitea_sdk.ListHooksOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo hooks err: %v", err))
}
return to.TextResult(slimHooks(hooks))
}
func listOrgHooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgHooksFn")
owner, err := params.GetString(req.GetArguments(), "owner")
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))
}
hooks, _, err := client.ListOrgHooks(owner, gitea_sdk.ListHooksOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org hooks err: %v", err))
}
return to.TextResult(slimHooks(hooks))
}
func getHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getHookFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, _ := req.GetArguments()["repo"].(string)
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))
}
var hook *gitea_sdk.Hook
if repo != "" {
hook, _, err = client.GetRepoHook(owner, repo, id)
} else {
hook, _, err = client.GetOrgHook(owner, id)
}
if err != nil {
return to.ErrorResult(fmt.Errorf("get hook err: %v", err))
}
return to.TextResult(slimHook(hook))
}
func createRepoHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoHookFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
url, err := params.GetString(req.GetArguments(), "url")
if err != nil {
return to.ErrorResult(err)
}
return createHook(ctx, owner, repo, url, req.GetArguments(), false)
}
func createOrgHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgHookFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
url, err := params.GetString(req.GetArguments(), "url")
if err != nil {
return to.ErrorResult(err)
}
return createHook(ctx, owner, "", url, req.GetArguments(), true)
}
func createHook(ctx context.Context, owner, repo, url string, args map[string]any, isOrg bool) (*mcp.CallToolResult, error) {
secret, _ := args["secret"].(string)
active, _ := args["active"].(bool)
events, _ := args["events"].([]any)
eventStr := "push"
if len(events) > 0 {
eventsJson, _ := json.Marshal(events)
eventStr = string(eventsJson)
}
opt := gitea_sdk.CreateHookOption{
Type: "gitea",
Active: active,
Events: []string{eventStr},
Config: map[string]string{
"url": url,
"content_type": "json",
"secret": secret,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
var hook *gitea_sdk.Hook
if isOrg {
hook, _, err = client.CreateOrgHook(owner, opt)
} else {
hook, _, err = client.CreateRepoHook(owner, repo, opt)
}
if err != nil {
return to.ErrorResult(fmt.Errorf("create hook err: %v", err))
}
return to.TextResult(slimHook(hook))
}
func editRepoHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editRepoHookFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
return editHook(ctx, owner, repo, id, req.GetArguments(), false)
}
func editOrgHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editOrgHookFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
return editHook(ctx, owner, "", id, req.GetArguments(), true)
}
func editHook(ctx context.Context, owner, repo string, id int64, args map[string]any, isOrg bool) (*mcp.CallToolResult, error) {
url, _ := args["url"].(string)
secret, _ := args["secret"].(string)
active, hasActive := args["active"].(bool)
opt := gitea_sdk.EditHookOption{}
if url != "" {
opt.Config = map[string]string{"url": url}
}
if secret != "" {
if opt.Config == nil {
opt.Config = map[string]string{}
}
opt.Config["secret"] = secret
}
if hasActive {
opt.Active = &active
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
if isOrg {
_, err = client.EditOrgHook(owner, id, opt)
} else {
_, err = client.EditRepoHook(owner, repo, id, opt)
}
if err != nil {
return to.ErrorResult(fmt.Errorf("edit hook err: %v", err))
}
return to.TextResult("Webhook updated successfully")
}
func deleteHookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteHookFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, hasRepo := req.GetArguments()["repo"].(string)
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))
}
if hasRepo && repo != "" {
_, err = client.DeleteRepoHook(owner, repo, id)
} else {
_, err = client.DeleteOrgHook(owner, id)
}
if err != nil {
return to.ErrorResult(fmt.Errorf("delete hook err: %v", err))
}
return to.TextResult("Webhook deleted successfully")
}
func slimHooks(hooks []*gitea_sdk.Hook) []map[string]any {
out := make([]map[string]any, 0, len(hooks))
for _, h := range hooks {
out = append(out, slimHook(h))
}
return out
}
func slimHook(h *gitea_sdk.Hook) map[string]any {
if h == nil {
return nil
}
return map[string]any{
"id": h.ID,
"type": h.Type,
"url": h.URL,
"active": h.Active,
"events": h.Events,
"created": h.Created,
}
}
+273
View File
@@ -0,0 +1,273 @@
package wiki
import (
"context"
"encoding/base64"
"fmt"
"net/url"
"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"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
WikiReadToolName = "wiki_read"
WikiWriteToolName = "wiki_write"
)
var (
WikiReadTool = mcp.NewTool(
WikiReadToolName,
mcp.WithDescription("Read wiki page information. Use method 'list' to list pages, 'get' to get page content, 'get_revisions' for revision history."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "get_revisions")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'get', 'get_revisions')")),
)
WikiWriteTool = mcp.NewTool(
WikiWriteToolName,
mcp.WithDescription("Create, update, or delete wiki pages."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'update', 'delete')")),
mcp.WithString("title", mcp.Description("wiki page title (required for 'create', optional for 'update')")),
mcp.WithString("content", mcp.Description("page content (required for 'create', 'update')")),
mcp.WithString("message", mcp.Description("commit message")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: WikiReadTool,
Handler: wikiReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: WikiWriteTool,
Handler: wikiWriteFn,
})
}
func wikiReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listWikiPagesFn(ctx, req)
case "get":
return getWikiPageFn(ctx, req)
case "get_revisions":
return getWikiRevisionsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func wikiWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createWikiPageFn(ctx, req)
case "update":
return updateWikiPageFn(ctx, req)
case "delete":
return deleteWikiPageFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listWikiPagesFn")
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)
}
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.PathEscape(owner), url.PathEscape(repo)), nil, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list wiki pages err: %v", err))
}
return to.TextResult(result)
}
func getWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getWikiPageFn")
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)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
}
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("get wiki page err: %v", err))
}
return to.TextResult(result)
}
func getWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getWikiRevisionsFn")
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)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
}
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("get wiki revisions err: %v", err))
}
return to.TextResult(result)
}
func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createWikiPageFn")
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)
}
title, err := params.GetString(args, "title")
if err != nil {
return to.ErrorResult(err)
}
content, err := params.GetString(args, "content")
if err != nil {
return to.ErrorResult(err)
}
message, _ := args["message"].(string)
if message == "" {
message = fmt.Sprintf("Create wiki page '%s'", title)
}
requestBody := map[string]string{
"title": title,
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
var result any
_, err = gitea.DoJSON(ctx, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.PathEscape(owner), url.PathEscape(repo)), nil, requestBody, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("create wiki page err: %v", err))
}
return to.TextResult(result)
}
func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateWikiPageFn")
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)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
}
content, err := params.GetString(args, "content")
if err != nil {
return to.ErrorResult(err)
}
requestBody := map[string]string{
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
}
// If title is given, use it. Otherwise, keep current page name
if title, ok := args["title"].(string); ok && title != "" {
requestBody["title"] = title
} else {
requestBody["title"] = pageName
}
if message, ok := args["message"].(string); ok && message != "" {
requestBody["message"] = message
} else {
requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName)
}
var result any
_, err = gitea.DoJSON(ctx, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, requestBody, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("update wiki page err: %v", err))
}
return to.TextResult(result)
}
func deleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteWikiPageFn")
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)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
}
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete wiki page err: %v", err))
}
return to.TextResult(map[string]string{"message": "Wiki page deleted successfully"})
}
+75
View File
@@ -0,0 +1,75 @@
package wiki
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func TestWikiWriteBase64Encoding(t *testing.T) {
tests := []struct {
name string
method string
content string
}{
{"create ascii", "create", "Hello, World!"},
{"create unicode", "create", "日本語テスト 🎉"},
{"create multiline", "create", "line1\nline2\nline3"},
{"update ascii", "update", "Updated content"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &gotBody)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"title":"test"}`))
}))
defer srv.Close()
origHost := flag.Host
flag.Host = srv.URL
defer func() { flag.Host = origHost }()
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
args := map[string]any{
"method": tt.method,
"owner": "org",
"repo": "repo",
"content": tt.content,
"pageName": "TestPage",
"title": "TestPage",
}
req := mcp.CallToolRequest{}
req.Params.Arguments = args
result, err := wikiWriteFn(ctx, req)
if err != nil {
t.Fatalf("wikiWriteFn() error: %v", err)
}
if result.IsError {
t.Fatalf("wikiWriteFn() returned error result")
}
got := gotBody["content_base64"]
want := base64.StdEncoding.EncodeToString([]byte(tt.content))
if got != want {
t.Errorf("content_base64 = %q, want %q", got, want)
}
})
}
}