Initial commit: Gitea MCP Server
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user