Initial commit: Gitea MCP Server

This commit is contained in:
2026-04-10 21:57:19 -07:00
commit 1f27844dce
119 changed files with 25001 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
package actions
import (
"gitea.com/gitea/gitea-mcp/pkg/tool"
)
// Tool is the registry for all Actions-related MCP tools.
var Tool = tool.New()
+345
View File
@@ -0,0 +1,345 @@
package actions
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
DefaultMaxArtifactSize = 100 * 1024 * 1024
ActionsArtifactToolName = "list_action_artifacts"
)
var (
ActionsArtifactTool = mcp.NewTool(
ActionsArtifactToolName,
mcp.WithDescription("List and download artifacts from workflow runs. Use method 'list' to list artifacts, 'get' to get a specific artifact, 'download' to download artifact content."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "download")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Description("run ID to filter artifacts (optional for list, required for get/download)")),
mcp.WithString("artifact_name", mcp.Description("artifact name to filter (optional)")),
mcp.WithNumber("artifact_id", mcp.Description("artifact ID (required for 'get' and 'download' methods)")),
mcp.WithString("output_path", mcp.Description("output file path (for 'download' method). If not specified, saves to ~/.gitea-mcp/artifacts/")),
mcp.WithNumber("max_size", mcp.Description("maximum artifact size in bytes to download (default 100MB)"), mcp.DefaultNumber(DefaultMaxArtifactSize), mcp.Min(1024)),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsArtifactTool, Handler: artifactHandler})
}
func artifactHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionArtifacts",
"param": "method",
}))
}
switch method {
case "list":
return listActionArtifactsFn(ctx, req)
case "get":
return getActionArtifactFn(ctx, req)
case "download":
return downloadActionArtifactFn(ctx, req)
default:
return to.ErrorResult(errors.NewEnhancedError(
fmt.Errorf("unknown method: %s", method),
"Invalid method. Use 'list', 'get', or 'download'",
errors.CategoryActions,
).WithOperation("ListActionArtifacts"))
}
}
func listActionArtifactsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listActionArtifactsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"owner is required",
errors.CategoryActions,
).WithOperation("ListActionArtifacts"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"repo is required",
errors.CategoryActions,
).WithOperation("ListActionArtifacts"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
artifactName, _ := req.GetArguments()["artifact_name"].(string)
var runID int64
if runIDVal, exists := req.GetArguments()["run_id"]; exists {
if runIDFloat, ok := runIDVal.(float64); ok {
runID = int64(runIDFloat)
}
}
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if artifactName != "" {
query.Set("name", artifactName)
}
if runID > 0 {
query.Set("run_id", strconv.FormatInt(runID, 10))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
if errors.IsActionsAPIUnavailable(err) {
return to.TextResult(map[string]any{
"artifacts": []any{},
"total_count": 0,
"message": "Actions API not available on this Gitea version",
})
}
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionArtifacts",
"owner": owner,
"repo": repo,
}))
}
return to.TextResult(slimActionArtifacts(result))
}
func getActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getActionArtifactFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"owner is required",
errors.CategoryActions,
).WithOperation("GetActionArtifact"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"repo is required",
errors.CategoryActions,
).WithOperation("GetActionArtifact"))
}
artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id")
if err != nil || artifactID <= 0 {
return to.ErrorResult(errors.NewEnhancedError(
err,
"artifact_id is required",
errors.CategoryActions,
).WithOperation("GetActionArtifact"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d", url.PathEscape(owner), url.PathEscape(repo), artifactID),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "GetActionArtifact",
"owner": owner,
"repo": repo,
"artifact_id": strconv.FormatInt(artifactID, 10),
}))
}
return to.TextResult(slimActionArtifact(result))
}
func downloadActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called downloadActionArtifactFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"owner is required",
errors.CategoryActions,
).WithOperation("DownloadActionArtifact"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.NewEnhancedError(
err,
"repo is required",
errors.CategoryActions,
).WithOperation("DownloadActionArtifact"))
}
artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id")
if err != nil || artifactID <= 0 {
return to.ErrorResult(errors.NewEnhancedError(
err,
"artifact_id is required",
errors.CategoryActions,
).WithOperation("DownloadActionArtifact"))
}
maxSize := int64(params.GetOptionalInt(req.GetArguments(), "max_size", DefaultMaxArtifactSize))
outputPath, _ := req.GetArguments()["output_path"].(string)
var artifactInfo any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d", url.PathEscape(owner), url.PathEscape(repo), artifactID),
},
nil, nil, &artifactInfo,
)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"owner": owner,
"repo": repo,
"artifact_id": strconv.FormatInt(artifactID, 10),
}))
}
var artifactSize int64
if info, ok := artifactInfo.(map[string]any); ok {
if size, ok := info["size_in_bytes"].(float64); ok {
artifactSize = int64(size)
}
}
if artifactSize > maxSize {
return to.ErrorResult(errors.NewEnhancedError(
fmt.Errorf("artifact size %d exceeds maximum allowed size %d", artifactSize, maxSize),
fmt.Sprintf("Artifact size (%s) exceeds maximum allowed size (%s). Use max_size parameter to increase limit.",
formatBytes(artifactSize), formatBytes(maxSize)),
errors.CategoryActions,
).WithOperation("DownloadActionArtifact").
WithParam("owner", owner).
WithParam("repo", repo).
WithParam("artifact_id", strconv.FormatInt(artifactID, 10)))
}
artifactBytes, _, err := gitea.DoBytes(ctx, "GET",
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d/download", url.PathEscape(owner), url.PathEscape(repo), artifactID),
nil, nil, "application/zip",
)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"owner": owner,
"repo": repo,
"artifact_id": strconv.FormatInt(artifactID, 10),
}))
}
if outputPath == "" {
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
var artifactName string
if info, ok := artifactInfo.(map[string]any); ok {
if name, ok := info["name"].(string); ok {
artifactName = name
}
}
if artifactName == "" {
artifactName = fmt.Sprintf("artifact-%d", artifactID)
}
outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", owner, repo, fmt.Sprintf("%s.zip", artifactName))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"action": "create_output_dir",
}))
}
if err := os.WriteFile(outputPath, artifactBytes, 0o600); err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DownloadActionArtifact",
"action": "write_file",
}))
}
var artifactName string
if info, ok := artifactInfo.(map[string]any); ok {
if name, ok := info["name"].(string); ok {
artifactName = name
}
}
return to.TextResult(map[string]any{
"artifact_id": artifactID,
"name": artifactName,
"path": outputPath,
"size_in_bytes": len(artifactBytes),
"message": "artifact downloaded successfully",
})
}
func slimActionArtifact(raw any) any {
if m, ok := raw.(map[string]any); ok {
return pick(m, "id", "name", "size_in_bytes", "download_url", "run_id", "created_at", "expires_at")
}
return raw
}
func slimActionArtifacts(raw any) any {
return slimPaginated(raw, func(m map[string]any) map[string]any {
return pick(m, "id", "name", "size_in_bytes", "download_url", "run_id", "created_at", "expires_at")
})
}
func formatBytes(bytes int64) string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/GB)
case bytes >= MB:
return fmt.Sprintf("%.2f MB", float64(bytes)/MB)
case bytes >= KB:
return fmt.Sprintf("%.2f KB", float64(bytes)/KB)
default:
return fmt.Sprintf("%d B", bytes)
}
}
+415
View File
@@ -0,0 +1,415 @@
package actions
import (
"testing"
)
func TestSlimActionArtifact(t *testing.T) {
tests := []struct {
name string
input map[string]any
expected map[string]any
}{
{
name: "complete artifact",
input: map[string]any{
"id": float64(123),
"name": "build-output",
"size_in_bytes": float64(1024000),
"download_url": "https://gitea.example.com/api/v1/repos/owner/repo/actions/artifacts/123/download",
"run_id": float64(456),
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-02-15T10:30:00Z",
"extra_field": "should be removed",
},
expected: map[string]any{
"id": float64(123),
"name": "build-output",
"size_in_bytes": float64(1024000),
"download_url": "https://gitea.example.com/api/v1/repos/owner/repo/actions/artifacts/123/download",
"run_id": float64(456),
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-02-15T10:30:00Z",
},
},
{
name: "minimal artifact",
input: map[string]any{
"id": float64(789),
"name": "test-results",
},
expected: map[string]any{
"id": float64(789),
"name": "test-results",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifact(tt.input)
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("slimActionArtifact() did not return a map")
}
for key, expectedValue := range tt.expected {
if resultMap[key] != expectedValue {
t.Fatalf("slimActionArtifact()[%q] = %v, want %v", key, resultMap[key], expectedValue)
}
}
if _, exists := resultMap["extra_field"]; exists {
t.Fatalf("slimActionArtifact() should not include 'extra_field'")
}
})
}
}
func TestSlimActionArtifactNonMap(t *testing.T) {
tests := []struct {
name string
input any
}{
{
name: "nil input",
input: nil,
},
{
name: "string input",
input: "not a map",
},
{
name: "int input",
input: 123,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifact(tt.input)
if result != tt.input {
t.Fatalf("slimActionArtifact() = %v, want %v", result, tt.input)
}
})
}
}
func TestSlimActionArtifacts(t *testing.T) {
tests := []struct {
name string
input map[string]any
expected map[string]any
}{
{
name: "artifacts with total count",
input: map[string]any{
"total_count": float64(2),
"artifacts": []any{
map[string]any{
"id": float64(1),
"name": "artifact-1",
"size_in_bytes": float64(1000),
"download_url": "url1",
},
map[string]any{
"id": float64(2),
"name": "artifact-2",
"size_in_bytes": float64(2000),
"download_url": "url2",
},
},
},
expected: map[string]any{
"total_count": float64(2),
},
},
{
name: "empty artifacts list",
input: map[string]any{
"total_count": float64(0),
"artifacts": []any{},
},
expected: map[string]any{
"total_count": float64(0),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifacts(tt.input)
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("slimActionArtifacts() did not return a map")
}
if resultMap["total_count"] != tt.expected["total_count"] {
t.Fatalf("total_count = %v, want %v", resultMap["total_count"], tt.expected["total_count"])
}
if artifacts, ok := resultMap["artifacts"].([]any); ok {
for _, item := range artifacts {
if artifact, ok := item.(map[string]any); ok {
if _, exists := artifact["id"]; !exists {
t.Fatalf("slimmed artifact should have 'id' field")
}
}
}
}
})
}
}
func TestFormatBytes(t *testing.T) {
tests := []struct {
bytes int64
expected string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1.00 KB"},
{1536, "1.50 KB"},
{1024 * 1024, "1.00 MB"},
{1536 * 1024, "1.50 MB"},
{100 * 1024 * 1024, "100.00 MB"},
{1024 * 1024 * 1024, "1.00 GB"},
{1536 * 1024 * 1024, "1.50 GB"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := formatBytes(tt.bytes)
if result != tt.expected {
t.Fatalf("formatBytes(%d) = %q, want %q", tt.bytes, result, tt.expected)
}
})
}
}
func TestFormatBytes_EdgeCases(t *testing.T) {
tests := []struct {
bytes int64
expected string
}{
{-1, "-1 B"},
{0, "0 B"},
{1, "1 B"},
{512, "512 B"},
{1023, "1023 B"},
{1024, "1.00 KB"},
{1025, "1.00 KB"},
{1536, "1.50 KB"},
{1024 * 1024, "1.00 MB"},
{1024*1024 + 1, "1.00 MB"},
{1536 * 1024 * 1024, "1.50 GB"},
{1024 * 1024 * 1024, "1.00 GB"},
{1024 * 1024 * 1024 * 1024, "1024.00 GB"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := formatBytes(tt.bytes)
if result != tt.expected {
t.Fatalf("formatBytes(%d) = %q, want %q", tt.bytes, result, tt.expected)
}
})
}
}
func TestSlimActionArtifact_EdgeCases(t *testing.T) {
tests := []struct {
name string
input any
check func(t *testing.T, result any)
}{
{
name: "nil input",
input: nil,
check: func(t *testing.T, result any) {
if result != nil {
t.Fatalf("expected nil, got %v", result)
}
},
},
{
name: "string input",
input: "not a map",
check: func(t *testing.T, result any) {
if result != "not a map" {
t.Fatalf("expected 'not a map', got %v", result)
}
},
},
{
name: "int input",
input: 123,
check: func(t *testing.T, result any) {
if result != 123 {
t.Fatalf("expected 123, got %v", result)
}
},
},
{
name: "empty map",
input: map[string]any{},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if len(resultMap) != 0 {
t.Fatalf("expected empty map, got %d fields", len(resultMap))
}
},
},
{
name: "map with only extra fields",
input: map[string]any{
"extra1": "value1",
"extra2": "value2",
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if len(resultMap) != 0 {
t.Fatalf("expected empty map after filtering, got %d fields", len(resultMap))
}
},
},
{
name: "map with null values",
input: map[string]any{
"id": float64(123),
"name": nil,
"size_in_bytes": float64(1000),
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if resultMap["name"] != nil {
t.Fatalf("expected nil name, got %v", resultMap["name"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifact(tt.input)
tt.check(t, result)
})
}
}
func TestSlimActionArtifacts_EdgeCases(t *testing.T) {
tests := []struct {
name string
input any
check func(t *testing.T, result any)
}{
{
name: "nil input",
input: nil,
check: func(t *testing.T, result any) {
if result != nil {
t.Fatalf("expected nil, got %v", result)
}
},
},
{
name: "string input",
input: "not a map",
check: func(t *testing.T, result any) {
if result != "not a map" {
t.Fatalf("expected 'not a map', got %v", result)
}
},
},
{
name: "map without artifacts key",
input: map[string]any{
"total_count": float64(0),
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if resultMap["total_count"] != float64(0) {
t.Fatalf("expected total_count 0, got %v", resultMap["total_count"])
}
},
},
{
name: "map with nil artifacts",
input: map[string]any{
"total_count": float64(0),
"artifacts": nil,
},
check: func(t *testing.T, result any) {
resultMap, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result)
}
if resultMap["total_count"] != float64(0) {
t.Fatalf("expected total_count 0, got %v", resultMap["total_count"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifacts(tt.input)
tt.check(t, result)
})
}
}
func TestDefaultMaxArtifactSize_Constant(t *testing.T) {
expectedSize := int64(100 * 1024 * 1024)
if DefaultMaxArtifactSize != expectedSize {
t.Fatalf("DefaultMaxArtifactSize = %d, want %d", DefaultMaxArtifactSize, expectedSize)
}
}
func TestActionsArtifactToolName_Constant(t *testing.T) {
expectedName := "list_action_artifacts"
if ActionsArtifactToolName != expectedName {
t.Fatalf("ActionsArtifactToolName = %q, want %q", ActionsArtifactToolName, expectedName)
}
}
func TestSlimActionArtifactsNonMap(t *testing.T) {
tests := []struct {
name string
input any
}{
{
name: "nil input",
input: nil,
},
{
name: "string input",
input: "not a map",
},
{
name: "int input",
input: 123,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionArtifacts(tt.input)
if result != tt.input {
t.Fatalf("slimActionArtifacts() = %v, want %v", result, tt.input)
}
})
}
}
+555
View File
@@ -0,0 +1,555 @@
package actions
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"time"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ActionsConfigReadToolName = "actions_config_read"
ActionsConfigWriteToolName = "actions_config_write"
)
type secretMeta struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at,omitzero"`
}
func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta {
metas := make([]secretMeta, 0, len(secrets))
for _, s := range secrets {
if s == nil {
continue
}
metas = append(metas, secretMeta{
Name: s.Name,
Description: s.Description,
CreatedAt: s.Created,
})
}
return metas
}
var (
ActionsConfigReadTool = mcp.NewTool(
ActionsConfigReadToolName,
mcp.WithDescription("Read Actions secrets and variables configuration."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("variable name (required for get methods)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsConfigWriteTool = mcp.NewTool(
ActionsConfigWriteToolName,
mcp.WithDescription("Manage Actions secrets and variables: create, update, or delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("secret or variable name (required for most methods)")),
mcp.WithString("data", mcp.Description("secret value (required for upsert secret methods)")),
mcp.WithString("value", mcp.Description("variable value (required for create/update variable methods)")),
mcp.WithString("description", mcp.Description("description for secret or variable")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsConfigReadTool, Handler: configReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsConfigWriteTool, Handler: configWriteFn})
}
func configReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_repo_secrets":
return listRepoActionSecretsFn(ctx, req)
case "list_org_secrets":
return listOrgActionSecretsFn(ctx, req)
case "list_repo_variables":
return listRepoActionVariablesFn(ctx, req)
case "get_repo_variable":
return getRepoActionVariableFn(ctx, req)
case "list_org_variables":
return listOrgActionVariablesFn(ctx, req)
case "get_org_variable":
return getOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func configWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "upsert_repo_secret":
return upsertRepoActionSecretFn(ctx, req)
case "delete_repo_secret":
return deleteRepoActionSecretFn(ctx, req)
case "upsert_org_secret":
return upsertOrgActionSecretFn(ctx, req)
case "delete_org_secret":
return deleteOrgActionSecretFn(ctx, req)
case "create_repo_variable":
return createRepoActionVariableFn(ctx, req)
case "update_repo_variable":
return updateRepoActionVariableFn(ctx, req)
case "delete_repo_variable":
return deleteRepoActionVariableFn(ctx, req)
case "create_org_variable":
return createOrgActionVariableFn(ctx, req)
case "update_org_variable":
return updateOrgActionVariableFn(ctx, req)
case "delete_org_variable":
return deleteOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
// Secret functions
func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionSecretsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListRepoActionSecret(owner, repo, gitea_sdk.ListRepoActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionSecret(owner, repo, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionSecret(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode})
}
func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionSecretsFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListOrgActionSecret(org, gitea_sdk.ListOrgActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionSecret(org, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
escapedOrg := url.PathEscape(org)
escapedSecret := url.PathEscape(name)
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/secrets/%s", escapedOrg, escapedSecret), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted"})
}
// Variable functions
func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionVariablesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/variables", url.PathEscape(owner), url.PathEscape(repo)), query, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action variables err: %v", err))
}
return to.TextResult(result)
}
func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repo action variable err: %v", err))
}
return to.TextResult(variable)
}
func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("create repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("update repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode})
}
func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionVariablesFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variables, _, err := client.ListOrgActionVariable(org, gitea_sdk.ListOrgActionVariableOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action variables err: %v", err))
}
return to.TextResult(variables)
}
func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetOrgActionVariable(org, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get org action variable err: %v", err))
}
return to.TextResult(variable)
}
func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionVariable(org, gitea_sdk.CreateOrgActionVariableOption{
Name: name,
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateOrgActionVariable(org, name, gitea_sdk.UpdateOrgActionVariableOption{
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("update org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted"})
}
+22
View File
@@ -0,0 +1,22 @@
package actions
import "testing"
func TestTailByLines(t *testing.T) {
in := []byte("a\nb\nc\nd\n")
got := string(tailByLines(in, 2))
if got != "c\nd\n" {
t.Fatalf("tailByLines(...,2) = %q", got)
}
}
func TestLimitBytesKeepsTail(t *testing.T) {
in := []byte("0123456789")
out, truncated := limitBytes(in, 4)
if !truncated {
t.Fatalf("expected truncated=true")
}
if string(out) != "6789" {
t.Fatalf("limitBytes tail = %q, want %q", string(out), "6789")
}
}
+598
View File
@@ -0,0 +1,598 @@
package actions
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
MonitorWorkflowDispatchToolName = "monitor_workflow_dispatch"
DefaultPollInterval = 10 * time.Second
DefaultTimeout = 5 * time.Minute
)
var (
MonitorWorkflowDispatchTool = mcp.NewTool(
MonitorWorkflowDispatchToolName,
mcp.WithDescription("Dispatch a workflow and monitor its execution until completion. Returns full execution summary including run ID, status, conclusion, duration, and logs."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("workflow ID or filename")),
mcp.WithString("ref", mcp.Required(), mcp.Description("git ref (branch/tag) to run workflow on")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object")),
mcp.WithNumber("timeout_seconds", mcp.Description("polling timeout in seconds (default: 300 = 5 minutes)"), mcp.DefaultNumber(300), mcp.Min(10)),
mcp.WithNumber("poll_interval_seconds", mcp.Description("poll interval in seconds (default: 10)"), mcp.DefaultNumber(10), mcp.Min(5)),
)
)
type MonitorResult struct {
RunID int64 `json:"run_id"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
WorkflowID string `json:"workflow_id"`
WorkflowName string `json:"workflow_name,omitempty"`
Branch string `json:"branch"`
CommitSHA string `json:"commit_sha"`
Duration string `json:"duration"`
DurationSec float64 `json:"duration_seconds"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Jobs []JobSummary `json:"jobs"`
Logs map[string]JobLogs `json:"logs,omitempty"`
Error string `json:"error,omitempty"`
TimedOut bool `json:"timed_out"`
}
type JobSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Steps []StepInfo `json:"steps,omitempty"`
}
type StepInfo struct {
Name string `json:"name"`
Number int `json:"number"`
Status string `json:"status"`
Conclusion string `json:"conclusion,omitempty"`
}
type JobLogs struct {
JobID int64 `json:"job_id"`
JobName string `json:"job_name"`
Log string `json:"log,omitempty"`
Bytes int `json:"bytes"`
Truncated bool `json:"truncated"`
}
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: MonitorWorkflowDispatchTool,
Handler: monitorWorkflowDispatchFn,
})
}
func monitorWorkflowDispatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called monitorWorkflowDispatchFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("owner is required"),
map[string]string{"operation": "MonitorWorkflowDispatch"},
))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("repo is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner},
))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("workflow_id is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner, "repo": repo},
))
}
ref, err := params.GetString(req.GetArguments(), "ref")
if err != nil || ref == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("ref is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner, "repo": repo, "workflow_id": workflowID},
))
}
timeoutSec := int(params.GetOptionalInt(req.GetArguments(), "timeout_seconds", 300))
pollIntervalSec := int(params.GetOptionalInt(req.GetArguments(), "poll_interval_seconds", 10))
var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists {
if m, ok := raw.(map[string]any); ok {
inputs = m
}
}
if err := checkActionsAPIAvailable(ctx); err != nil {
return to.ErrorResult(err)
}
log.Infof("Dispatching workflow %s for %s/%s on ref %s", workflowID, owner, repo, ref)
_, err = dispatchWorkflow(ctx, owner, repo, workflowID, ref, inputs)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DispatchWorkflow",
"owner": owner,
"repo": repo,
"workflow": workflowID,
}))
}
timeout := time.Duration(timeoutSec) * time.Second
pollInterval := time.Duration(pollIntervalSec) * time.Second
log.Infof("Waiting for workflow run to start (timeout: %v)...", timeout)
runID, err := waitForRunToStart(ctx, owner, repo, workflowID, ref, pollInterval, timeout)
if err != nil {
result := MonitorResult{
WorkflowID: workflowID,
Branch: ref,
Error: fmt.Sprintf("Workflow dispatched but run never started: %v", err),
}
return toErrorResultWithJSON(result)
}
log.Infof("Run %d started, monitoring until completion...", runID)
monitorResult, err := monitorRunUntilComplete(ctx, owner, repo, runID, pollInterval, timeout)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "MonitorRun",
"owner": owner,
"repo": repo,
"run_id": fmt.Sprintf("%d", runID),
}))
}
log.Infof("Retrieving logs for run %d...", runID)
logs, err := retrieveJobLogs(ctx, owner, repo, monitorResult.Jobs)
if err != nil {
log.Warnf("Failed to retrieve some job logs: %v", err)
}
monitorResult.Logs = logs
return toTextResultWithJSON(monitorResult)
}
func checkActionsAPIAvailable(ctx context.Context) error {
var versionResp struct {
Version string `json:"version"`
}
status, err := gitea.DoJSON(ctx, "GET", "version", nil, nil, &versionResp)
if err != nil {
return errors.TranslateError(
fmt.Errorf("failed to check Gitea version: status=%d, err=%v", status, err),
map[string]string{"operation": "CheckGiteaVersion"},
)
}
major, minor, patch, err := parseVersionForCheck(versionResp.Version)
if err != nil {
return errors.TranslateError(
fmt.Errorf("failed to parse version '%s': %v", versionResp.Version, err),
map[string]string{"operation": "ParseVersion"},
)
}
if major < 1 || (major == 1 && minor < 23) {
return errors.NewEnhancedError(
stderrors.New("Actions API not available"),
fmt.Sprintf("Actions API requires Gitea 1.23+, found %d.%d.%d", major, minor, patch),
errors.CategoryActions,
).WithOperation("CheckActionsAPIAvailable")
}
return nil
}
func parseVersionForCheck(version string) (int, int, int, error) {
version = trimVersionPrefix(version)
parts := splitVersion(version)
if len(parts) < 2 {
return 0, 0, 0, fmt.Errorf("invalid version format: %s", version)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid major version: %s", parts[0])
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid minor version: %s", parts[1])
}
patch := 0
if len(parts) >= 3 {
patch, err = strconv.Atoi(parts[2])
if err != nil {
patch = 0
}
}
return major, minor, patch, nil
}
func trimVersionPrefix(v string) string {
v = trimOnePrefix(v, "v")
v = trimOnePrefix(v, "V")
return v
}
func trimOnePrefix(s, prefix string) string {
if len(s) > 0 && s[0] == prefix[0] {
return s[1:]
}
return s
}
func splitVersion(v string) []string {
var parts []string
start := 0
for i := 0; i < len(v); i++ {
if v[i] == '.' {
if start < i {
parts = append(parts, v[start:i])
}
start = i + 1
}
}
if start < len(v) {
parts = append(parts, v[start:])
}
return parts
}
func dispatchWorkflow(ctx context.Context, owner, repo, workflowID, ref string, inputs map[string]any) (map[string]any, error) {
body := map[string]any{
"ref": ref,
}
if inputs != nil {
body["inputs"] = inputs
}
err := doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, body, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if stderrors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return nil, errors.NewEnhancedError(
err,
fmt.Sprintf("workflow dispatch not supported on this Gitea version (endpoint returned %d)", httpErr.StatusCode),
errors.CategoryActions,
).WithOperation("DispatchWorkflow")
}
return nil, err
}
return map[string]any{"message": "workflow dispatched"}, nil
}
func waitForRunToStart(ctx context.Context, owner, repo, workflowID, ref string, pollInterval, timeout time.Duration) (int64, error) {
startTime := time.Now()
seenRunIDs := make(map[int64]bool)
for time.Since(startTime) < timeout {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
runs, err := listRecentRuns(ctx, owner, repo, workflowID, ref, 10)
if err != nil {
log.Warnf("Failed to list runs: %v", err)
time.Sleep(pollInterval)
continue
}
for _, run := range runs {
runID := int64(run["id"].(float64))
if seenRunIDs[runID] {
continue
}
seenRunIDs[runID] = true
status := getString(run, "status")
if status == "queued" || status == "in_progress" || status == "waiting" {
return runID, nil
}
createdAt := getString(run, "created_at")
if createdAt != "" {
runTime, err := time.Parse(time.RFC3339, createdAt)
if err == nil && time.Since(runTime) < 2*time.Minute {
return runID, nil
}
}
}
time.Sleep(pollInterval)
}
return 0, fmt.Errorf("timeout waiting for run to start after %v", timeout)
}
func listRecentRuns(ctx context.Context, owner, repo, workflowID, ref string, limit int) ([]map[string]any, error) {
query := url.Values{}
query.Set("limit", strconv.Itoa(limit))
var result struct {
WorkflowRuns []map[string]any `json:"workflow_runs"`
}
err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return nil, err
}
var matchingRuns []map[string]any
for _, run := range result.WorkflowRuns {
if workflowID != "" {
runWorkflowID := getString(run, "workflow_id")
runPath := getString(run, "path")
if runWorkflowID != workflowID && runPath != workflowID &&
runWorkflowID != "" && !containsPath(runPath, workflowID) {
continue
}
}
if ref != "" {
headBranch := getString(run, "head_branch")
if headBranch != "" && headBranch != ref {
continue
}
}
matchingRuns = append(matchingRuns, run)
}
return matchingRuns, nil
}
func containsPath(path, substring string) bool {
return len(path) > 0 && len(substring) > 0 &&
(path == substring ||
(len(path) > len(substring) && (path[len(path)-len(substring):] == substring ||
path[:len(substring)] == substring)))
}
func getString(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if v, ok := m[key].(float64); ok {
return strconv.FormatInt(int64(v), 10)
}
return ""
}
func monitorRunUntilComplete(ctx context.Context, owner, repo string, runID int64, pollInterval, timeout time.Duration) (*MonitorResult, error) {
startTime := time.Now()
var firstSeen time.Time
for time.Since(startTime) < timeout {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
run, err := getRun(ctx, owner, repo, runID)
if err != nil {
return nil, fmt.Errorf("failed to get run %d: %v", runID, err)
}
status := getString(run, "status")
conclusion := getString(run, "conclusion")
if firstSeen.IsZero() {
firstSeen = time.Now()
}
if status == "completed" {
return buildMonitorResult(run, runID, owner, repo, firstSeen, time.Now())
}
log.Debugf("Run %d status: %s (conclusion: %s), waiting...", runID, status, conclusion)
time.Sleep(pollInterval)
}
run, err := getRun(ctx, owner, repo, runID)
if err != nil {
return nil, fmt.Errorf("timeout after %v and failed to get final status: %v", timeout, err)
}
result, _ := buildMonitorResult(run, runID, owner, repo, firstSeen, time.Now())
result.TimedOut = true
result.Error = fmt.Sprintf("Polling timed out after %v", timeout)
return result, nil
}
func getRun(ctx context.Context, owner, repo string, runID int64) (map[string]any, error) {
var result map[string]any
err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, &result,
)
return result, err
}
func buildMonitorResult(run map[string]any, runID int64, owner, repo string, started, ended time.Time) (*MonitorResult, error) {
result := &MonitorResult{
RunID: runID,
Status: getString(run, "status"),
Conclusion: getString(run, "conclusion"),
WorkflowID: getString(run, "workflow_id"),
WorkflowName: getString(run, "name"),
Branch: getString(run, "head_branch"),
CommitSHA: getString(run, "head_sha"),
StartedAt: getString(run, "created_at"),
CompletedAt: getString(run, "updated_at"),
}
if createdAt := getString(run, "run_started_at"); createdAt != "" {
result.StartedAt = createdAt
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
started = t
}
}
if updatedAt := getString(run, "updated_at"); updatedAt != "" {
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
ended = t
}
}
duration := ended.Sub(started)
if duration < 0 {
duration = 0
}
result.Duration = duration.String()
result.DurationSec = duration.Seconds()
jobs, err := listRunJobs(runID, owner, repo, run)
if err == nil {
result.Jobs = jobs
}
return result, nil
}
func listRunJobs(runID int64, owner, repo string, run map[string]any) ([]JobSummary, error) {
if jobsData, ok := run["jobs"].([]any); ok && len(jobsData) > 0 {
return parseJobSummaries(jobsData), nil
}
return nil, nil
}
func parseJobSummaries(jobsData []any) []JobSummary {
var summaries []JobSummary
for _, j := range jobsData {
job, ok := j.(map[string]any)
if !ok {
continue
}
summary := JobSummary{
ID: int64(job["id"].(float64)),
Name: getString(job, "name"),
Status: getString(job, "status"),
Conclusion: getString(job, "conclusion"),
StartedAt: getString(job, "started_at"),
CompletedAt: getString(job, "completed_at"),
}
if stepsData, ok := job["steps"].([]any); ok {
summary.Steps = parseSteps(stepsData)
}
summaries = append(summaries, summary)
}
return summaries
}
func parseSteps(stepsData []any) []StepInfo {
var steps []StepInfo
for _, s := range stepsData {
step, ok := s.(map[string]any)
if !ok {
continue
}
steps = append(steps, StepInfo{
Name: getString(step, "name"),
Number: int(step["number"].(float64)),
Status: getString(step, "status"),
Conclusion: getString(step, "conclusion"),
})
}
return steps
}
func retrieveJobLogs(ctx context.Context, owner, repo string, jobs []JobSummary) (map[string]JobLogs, error) {
logs := make(map[string]JobLogs)
for _, job := range jobs {
if job.ID == 0 {
continue
}
logData, _, err := fetchJobLogBytes(ctx, owner, repo, job.ID)
if err != nil {
log.Warnf("Failed to fetch logs for job %d: %v", job.ID, err)
continue
}
maxLogBytes := 100 * 1024
truncated := false
if len(logData) > maxLogBytes {
logData = logData[len(logData)-maxLogBytes:]
truncated = true
}
logs[job.Name] = JobLogs{
JobID: job.ID,
JobName: job.Name,
Log: string(logData),
Bytes: len(logData),
Truncated: truncated,
}
}
return logs, nil
}
func toTextResultWithJSON(result *MonitorResult) (*mcp.CallToolResult, error) {
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("failed to marshal result: %v", err))
}
return to.TextResult(string(jsonBytes))
}
func toErrorResultWithJSON(result MonitorResult) (*mcp.CallToolResult, error) {
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("%s (marshal error: %v)", result.Error, err))
}
return to.TextResult(fmt.Sprintf("Error: %s\n\nPartial Result:\n%s", result.Error, string(jsonBytes)))
}
+607
View File
@@ -0,0 +1,607 @@
package actions
import (
"testing"
)
func TestTrimVersionPrefix(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"v1.22.5", "1.22.5"},
{"V1.22.5", "1.22.5"},
{"1.22.5", "1.22.5"},
{"v1.23.0", "1.23.0"},
{"2.0.0", "2.0.0"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := trimVersionPrefix(tt.input)
if result != tt.expected {
t.Errorf("trimVersionPrefix(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestSplitVersion(t *testing.T) {
tests := []struct {
input string
expected []string
}{
{"1.22.5", []string{"1", "22", "5"}},
{"1.23", []string{"1", "23"}},
{"2.0.0", []string{"2", "0", "0"}},
{"1", []string{"1"}},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := splitVersion(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("splitVersion(%q) = %v, want %v", tt.input, result, tt.expected)
return
}
for i := range result {
if result[i] != tt.expected[i] {
t.Errorf("splitVersion(%q)[%d] = %q, want %q", tt.input, i, result[i], tt.expected[i])
}
}
})
}
}
func TestParseVersionForCheck(t *testing.T) {
tests := []struct {
version string
wantMajor int
wantMinor int
wantPatch int
wantErr bool
}{
{"1.22.5", 1, 22, 5, false},
{"1.23.0", 1, 23, 0, false},
{"v1.22.5", 1, 22, 5, false},
{"V1.23.0", 1, 23, 0, false},
{"2.0.0", 2, 0, 0, false},
{"1.22", 1, 22, 0, false},
{"invalid", 0, 0, 0, true},
{"", 0, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
major, minor, patch, err := parseVersionForCheck(tt.version)
if (err != nil) != tt.wantErr {
t.Errorf("parseVersionForCheck(%q) error = %v, wantErr %v", tt.version, err, tt.wantErr)
return
}
if !tt.wantErr {
if major != tt.wantMajor || minor != tt.wantMinor || patch != tt.wantPatch {
t.Errorf("parseVersionForCheck(%q) = (%d, %d, %d), want (%d, %d, %d)",
tt.version, major, minor, patch, tt.wantMajor, tt.wantMinor, tt.wantPatch)
}
}
})
}
}
func TestContainsPath(t *testing.T) {
tests := []struct {
path string
substr string
expected bool
}{
{".gitea/workflows/build.yml", "build.yml", true},
{".github/workflows/test.yml", "test.yml", true},
{".gitea/workflows/build.yml", "deploy.yml", false},
{"build.yml", "build.yml", true},
{"", "test", false},
{"test", "", false},
}
for _, tt := range tests {
t.Run(tt.path+"_"+tt.substr, func(t *testing.T) {
result := containsPath(tt.path, tt.substr)
if result != tt.expected {
t.Errorf("containsPath(%q, %q) = %v, want %v", tt.path, tt.substr, result, tt.expected)
}
})
}
}
func TestGetString(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
expected string
}{
{
name: "string value",
m: map[string]any{"name": "test"},
key: "name",
expected: "test",
},
{
name: "float64 value",
m: map[string]any{"id": float64(123)},
key: "id",
expected: "123",
},
{
name: "missing key",
m: map[string]any{"other": "value"},
key: "name",
expected: "",
},
{
name: "nil map",
m: nil,
key: "name",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getString(tt.m, tt.key)
if result != tt.expected {
t.Errorf("getString(%v, %q) = %q, want %q", tt.m, tt.key, result, tt.expected)
}
})
}
}
func TestParseJobSummaries(t *testing.T) {
jobsData := []any{
map[string]any{
"id": float64(1),
"name": "build",
"status": "completed",
"conclusion": "success",
"started_at": "2024-01-15T10:00:00Z",
"completed_at": "2024-01-15T10:05:00Z",
"steps": []any{
map[string]any{
"name": "Checkout",
"number": float64(1),
"status": "completed",
"conclusion": "success",
},
},
},
}
summaries := parseJobSummaries(jobsData)
if len(summaries) != 1 {
t.Fatalf("expected 1 summary, got %d", len(summaries))
}
if summaries[0].ID != 1 {
t.Errorf("expected ID 1, got %d", summaries[0].ID)
}
if summaries[0].Name != "build" {
t.Errorf("expected name 'build', got %s", summaries[0].Name)
}
if len(summaries[0].Steps) != 1 {
t.Errorf("expected 1 step, got %d", len(summaries[0].Steps))
}
}
func TestParseSteps(t *testing.T) {
stepsData := []any{
map[string]any{
"name": "Checkout",
"number": float64(1),
"status": "completed",
"conclusion": "success",
},
map[string]any{
"name": "Build",
"number": float64(2),
"status": "completed",
"conclusion": "success",
},
}
steps := parseSteps(stepsData)
if len(steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(steps))
}
if steps[0].Name != "Checkout" || steps[0].Number != 1 {
t.Errorf("first step mismatch: %+v", steps[0])
}
if steps[1].Name != "Build" || steps[1].Number != 2 {
t.Errorf("second step mismatch: %+v", steps[1])
}
}
func TestMonitorResultTypes(t *testing.T) {
result := &MonitorResult{
RunID: 123,
Status: "completed",
Conclusion: "success",
WorkflowID: "build.yml",
Branch: "main",
CommitSHA: "abc123",
Duration: "5m0s",
DurationSec: 300,
Jobs: []JobSummary{
{
ID: 1,
Name: "build",
Status: "completed",
Conclusion: "success",
Steps: []StepInfo{
{Name: "Checkout", Number: 1, Status: "completed", Conclusion: "success"},
},
},
},
Logs: map[string]JobLogs{
"build": {
JobID: 1,
JobName: "build",
Log: "Building...",
Bytes: 100,
},
},
}
if result.RunID != 123 {
t.Errorf("RunID mismatch")
}
if result.Status != "completed" {
t.Errorf("Status mismatch")
}
if len(result.Jobs) != 1 {
t.Errorf("Jobs length mismatch")
}
if len(result.Logs) != 1 {
t.Errorf("Logs length mismatch")
}
}
func TestTrimOnePrefix(t *testing.T) {
tests := []struct {
s string
prefix string
expected string
}{
{"v1.22.5", "v", "1.22.5"},
{"V1.22.5", "V", "1.22.5"},
{"1.22.5", "v", "1.22.5"},
{"", "v", ""},
{"v", "v", ""},
{"test", "x", "test"},
}
for _, tt := range tests {
t.Run(tt.s+"_"+tt.prefix, func(t *testing.T) {
result := trimOnePrefix(tt.s, tt.prefix)
if result != tt.expected {
t.Errorf("trimOnePrefix(%q, %q) = %q, want %q", tt.s, tt.prefix, result, tt.expected)
}
})
}
}
func TestParseVersionForCheck_EdgeCases(t *testing.T) {
tests := []struct {
version string
wantMajor int
wantMinor int
wantPatch int
wantErr bool
}{
{"1.0.0", 1, 0, 0, false},
{"0.0.1", 0, 0, 1, false},
{"1.23.0+build", 1, 23, 0, false},
{"1.23.0-rc1", 1, 23, 0, false},
{"1.23", 1, 23, 0, false},
{"1", 1, 0, 0, false},
{"", 0, 0, 0, true},
{"abc", 0, 0, 0, true},
{"1.abc.5", 0, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
major, minor, patch, err := parseVersionForCheck(tt.version)
if (err != nil) != tt.wantErr {
t.Errorf("parseVersionForCheck(%q) error = %v, wantErr %v", tt.version, err, tt.wantErr)
return
}
if !tt.wantErr {
if major != tt.wantMajor || minor != tt.wantMinor || patch != tt.wantPatch {
t.Errorf("parseVersionForCheck(%q) = (%d, %d, %d), want (%d, %d, %d)",
tt.version, major, minor, patch, tt.wantMajor, tt.wantMinor, tt.wantPatch)
}
}
})
}
}
func TestContainsPath_EdgeCases(t *testing.T) {
tests := []struct {
path string
substr string
expected bool
}{
{"", "", false},
{"build.yml", "", false},
{"", "build", false},
{"a/b/c/d.yml", "c/d.yml", true},
{"a/b/c/d.yml", "b/c", false},
{"build.yml", "build.yml", true},
{"/absolute/path", "path", true},
}
for _, tt := range tests {
t.Run(tt.path+"_"+tt.substr, func(t *testing.T) {
result := containsPath(tt.path, tt.substr)
if result != tt.expected {
t.Errorf("containsPath(%q, %q) = %v, want %v", tt.path, tt.substr, result, tt.expected)
}
})
}
}
func TestGetString_EdgeCases(t *testing.T) {
tests := []struct {
name string
m map[string]any
key string
expected string
}{
{
name: "int value",
m: map[string]any{"count": int(42)},
key: "count",
expected: "",
},
{
name: "bool value",
m: map[string]any{"active": true},
key: "active",
expected: "",
},
{
name: "empty string value",
m: map[string]any{"name": ""},
key: "name",
expected: "",
},
{
name: "large float64",
m: map[string]any{"id": float64(9223372036854775807)},
key: "id",
expected: "9223372036854775807",
},
{
name: "zero float64",
m: map[string]any{"count": float64(0)},
key: "count",
expected: "0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getString(tt.m, tt.key)
if result != tt.expected {
t.Errorf("getString(%v, %q) = %q, want %q", tt.m, tt.key, result, tt.expected)
}
})
}
}
func TestParseJobSummaries_EdgeCases(t *testing.T) {
tests := []struct {
name string
jobsData []any
wantLen int
}{
{
name: "empty jobs",
jobsData: []any{},
wantLen: 0,
},
{
name: "nil jobs",
jobsData: nil,
wantLen: 0,
},
{
name: "job without steps",
jobsData: []any{
map[string]any{
"id": float64(1),
"name": "build",
"status": "completed",
"conclusion": "success",
},
},
wantLen: 1,
},
{
name: "job with empty steps",
jobsData: []any{
map[string]any{
"id": float64(1),
"name": "build",
"status": "completed",
"conclusion": "success",
"steps": []any{},
},
},
wantLen: 1,
},
{
name: "invalid job entry",
jobsData: []any{
"not a map",
map[string]any{
"id": float64(2),
"name": "test",
"status": "completed",
"conclusion": "success",
},
},
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summaries := parseJobSummaries(tt.jobsData)
if len(summaries) != tt.wantLen {
t.Errorf("parseJobSummaries() returned %d summaries, want %d", len(summaries), tt.wantLen)
}
})
}
}
func TestParseSteps_EdgeCases(t *testing.T) {
tests := []struct {
name string
stepsData []any
wantLen int
}{
{
name: "empty steps",
stepsData: []any{},
wantLen: 0,
},
{
name: "nil steps",
stepsData: nil,
wantLen: 0,
},
{
name: "invalid step entry",
stepsData: []any{
"not a map",
map[string]any{
"name": "Checkout",
"number": float64(1),
"status": "completed",
"conclusion": "success",
},
},
wantLen: 1,
},
{
name: "step without conclusion",
stepsData: []any{
map[string]any{
"name": "Setup",
"number": float64(1),
"status": "in_progress",
},
},
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
steps := parseSteps(tt.stepsData)
if len(steps) != tt.wantLen {
t.Errorf("parseSteps() returned %d steps, want %d", len(steps), tt.wantLen)
}
})
}
}
func TestMonitorResultStruct(t *testing.T) {
result := &MonitorResult{
RunID: 123,
Status: "completed",
Conclusion: "success",
WorkflowID: "build.yml",
WorkflowName: "Build",
Branch: "main",
CommitSHA: "abc123",
Duration: "5m0s",
DurationSec: 300,
StartedAt: "2024-01-15T10:00:00Z",
CompletedAt: "2024-01-15T10:05:00Z",
Jobs: []JobSummary{},
Logs: map[string]JobLogs{},
Error: "",
TimedOut: false,
}
if result.RunID != 123 {
t.Errorf("RunID = %d, want 123", result.RunID)
}
if result.Status != "completed" {
t.Errorf("Status = %s, want completed", result.Status)
}
if result.Conclusion != "success" {
t.Errorf("Conclusion = %s, want success", result.Conclusion)
}
if result.TimedOut {
t.Error("TimedOut should be false")
}
}
func TestMonitorResultErrorState(t *testing.T) {
result := &MonitorResult{
RunID: 456,
Status: "completed",
Conclusion: "failure",
WorkflowID: "test.yml",
Branch: "develop",
CommitSHA: "def456",
Duration: "2m30s",
DurationSec: 150,
Error: "Test failed",
TimedOut: false,
Jobs: []JobSummary{
{
ID: 1,
Name: "test",
Status: "completed",
Conclusion: "failure",
},
},
}
if result.Conclusion != "failure" {
t.Errorf("Conclusion = %s, want failure", result.Conclusion)
}
if result.Error != "Test failed" {
t.Errorf("Error = %s, want 'Test failed'", result.Error)
}
if len(result.Jobs) != 1 {
t.Errorf("Jobs count = %d, want 1", len(result.Jobs))
}
}
func TestMonitorResultTimeoutState(t *testing.T) {
result := &MonitorResult{
RunID: 789,
Status: "in_progress",
Conclusion: "",
WorkflowID: "deploy.yml",
Branch: "main",
CommitSHA: "ghi789",
Duration: "10m0s",
DurationSec: 600,
Error: "Polling timed out after 10m0s",
TimedOut: true,
Jobs: []JobSummary{},
}
if !result.TimedOut {
t.Error("TimedOut should be true")
}
if result.Error == "" {
t.Error("Error should not be empty for timeout")
}
}
+193
View File
@@ -0,0 +1,193 @@
package actions
import (
"context"
"fmt"
"net/http"
"net/url"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListActionRunnersToolName = "list_action_runners"
)
// ActionRunner represents a self-hosted action runner
// This is a local type since Gitea SDK v0.23.2 doesn't include it
type ActionRunner struct {
ID int64 `json:"id"`
Name string `json:"name"`
UUID string `json:"uuid"`
Status string `json:"status"`
Online bool `json:"online"`
Busy bool `json:"busy"`
Version string `json:"version,omitempty"`
Labels []string `json:"labels,omitempty"`
LastOnline string `json:"last_online,omitempty"`
}
// ActionRunnersResponse represents the API response for listing runners
type ActionRunnersResponse struct {
TotalCount int `json:"total_count"`
Runners []*ActionRunner `json:"runners"`
}
var (
ListActionRunnersTool = mcp.NewTool(
ListActionRunnersToolName,
mcp.WithDescription("List self-hosted action runners for a repository. Shows runner status, labels, and availability. Filter by status (online/offline). Note: Requires Gitea 1.23+; Gitea 1.22.5 does not support Actions API."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("status", mcp.Description("optional status filter (online, offline, busy, idle)"), mcp.Enum("online", "offline", "busy", "idle")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListActionRunnersTool,
Handler: listActionRunnersFn,
})
}
func listActionRunnersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listActionRunnersFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionRunners",
"param": "owner",
}))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionRunners",
"param": "repo",
}))
}
statusFilter, _ := req.GetArguments()["status"].(string)
page, pageSize := params.GetPagination(req.GetArguments(), 30)
// Use REST API to get runners
apiPath := fmt.Sprintf("/repos/%s/%s/actions/runners", owner, repo)
query := url.Values{
"page": []string{fmt.Sprintf("%d", page)},
"per_page": []string{fmt.Sprintf("%d", pageSize)},
}
var runnersResp ActionRunnersResponse
statusCode, err := gitea.DoJSON(ctx, http.MethodGet, apiPath, query, nil, &runnersResp)
if err != nil {
if statusCode == http.StatusNotFound {
// Gitea 1.22.5 doesn't have Actions API - return empty list with message
return to.TextResult(map[string]interface{}{
"total_count": 0,
"runners": []interface{}{},
"note": "Actions API not available in Gitea 1.22.5. Requires Gitea 1.23+.",
})
}
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "ListActionRunners",
"owner": owner,
"repo": repo,
}))
}
// Filter by status if requested
filteredRunners := make([]*ActionRunner, 0, len(runnersResp.Runners))
if statusFilter != "" {
for _, runner := range runnersResp.Runners {
switch statusFilter {
case "online":
if runner.Online {
filteredRunners = append(filteredRunners, runner)
}
case "offline":
if !runner.Online {
filteredRunners = append(filteredRunners, runner)
}
case "busy":
if runner.Busy {
filteredRunners = append(filteredRunners, runner)
}
case "idle":
if runner.Online && !runner.Busy {
filteredRunners = append(filteredRunners, runner)
}
default:
if runner.Status == statusFilter {
filteredRunners = append(filteredRunners, runner)
}
}
}
} else {
filteredRunners = runnersResp.Runners
}
result := slimActionRunners(filteredRunners)
return to.TextResult(result)
}
func slimActionRunners(runners []*ActionRunner) map[string]interface{} {
if len(runners) == 0 {
return map[string]interface{}{
"total_count": 0,
"runners": []interface{}{},
}
}
slimmed := make([]map[string]interface{}, 0, len(runners))
for _, runner := range runners {
slimmed = append(slimmed, slimActionRunner(runner))
}
return map[string]interface{}{
"total_count": len(runners),
"runners": slimmed,
}
}
func slimActionRunner(runner *ActionRunner) map[string]interface{} {
if runner == nil {
return nil
}
result := map[string]interface{}{
"id": runner.ID,
"name": runner.Name,
"uuid": runner.UUID,
"status": runner.Status,
"online": runner.Online,
"busy": runner.Busy,
}
if runner.Version != "" {
result["version"] = runner.Version
}
if len(runner.Labels) > 0 {
result["labels"] = runner.Labels
} else {
result["labels"] = []string{}
}
if runner.LastOnline != "" {
result["last_online"] = runner.LastOnline
}
return result
}
+354
View File
@@ -0,0 +1,354 @@
package actions
import (
"testing"
)
func TestSlimActionRunner(t *testing.T) {
tests := []struct {
name string
runner *ActionRunner
expected map[string]interface{}
}{
{
name: "complete runner",
runner: &ActionRunner{
ID: 1,
Name: "metal",
UUID: "uuid-123",
Status: "online",
Online: true,
Busy: false,
Version: "1.22.5",
Labels: []string{"metal", "self-hosted"},
LastOnline: "2024-01-01T00:00:00Z",
},
expected: map[string]interface{}{
"id": int64(1),
"name": "metal",
"uuid": "uuid-123",
"status": "online",
"online": true,
"busy": false,
"version": "1.22.5",
"labels": []string{"metal", "self-hosted"},
},
},
{
name: "runner without labels",
runner: &ActionRunner{
ID: 2,
Name: "cloud-1",
UUID: "uuid-456",
Status: "offline",
Online: false,
Busy: false,
Version: "1.22.5",
},
expected: map[string]interface{}{
"id": int64(2),
"name": "cloud-1",
"uuid": "uuid-456",
"status": "offline",
"online": false,
"busy": false,
"version": "1.22.5",
"labels": []string{},
},
},
{
name: "nil runner",
runner: nil,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunner(tt.runner)
if !runnerMapsEqual(result, tt.expected) {
t.Fatalf("slimActionRunner() = %v, want %v", result, tt.expected)
}
})
}
}
func TestSlimActionRunners(t *testing.T) {
tests := []struct {
name string
runners []*ActionRunner
expected map[string]interface{}
}{
{
name: "multiple runners",
runners: []*ActionRunner{
{
ID: 1,
Name: "metal",
Status: "online",
Online: true,
Labels: []string{"metal"},
},
{
ID: 2,
Name: "cloud-1",
Status: "offline",
Online: false,
Labels: []string{"cloud-1"},
},
},
expected: map[string]interface{}{
"total_count": 2,
},
},
{
name: "empty list",
runners: []*ActionRunner{},
expected: map[string]interface{}{
"total_count": 0,
"runners": []interface{}{},
},
},
{
name: "nil runners",
runners: nil,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunners(tt.runners)
if result["total_count"] != tt.expected["total_count"] {
t.Fatalf("total_count = %v, want %v", result["total_count"], tt.expected["total_count"])
}
})
}
}
func TestSlimActionRunner_EdgeCases(t *testing.T) {
tests := []struct {
name string
runner *ActionRunner
check func(t *testing.T, result map[string]interface{})
}{
{
name: "runner with zero ID",
runner: &ActionRunner{
ID: 0,
Name: "zero-runner",
Status: "offline",
},
check: func(t *testing.T, result map[string]interface{}) {
if result["id"] != int64(0) {
t.Errorf("expected ID 0, got %v", result["id"])
}
},
},
{
name: "runner with empty name",
runner: &ActionRunner{
ID: 1,
Name: "",
Status: "online",
},
check: func(t *testing.T, result map[string]interface{}) {
if result["name"] != "" {
t.Errorf("expected empty name, got %v", result["name"])
}
},
},
{
name: "runner with empty labels",
runner: &ActionRunner{
ID: 2,
Name: "no-labels",
Status: "online",
Labels: []string{},
},
check: func(t *testing.T, result map[string]interface{}) {
labels, ok := result["labels"].([]string)
if !ok || len(labels) != 0 {
t.Errorf("expected empty labels slice, got %v", result["labels"])
}
},
},
{
name: "runner with empty LastOnline",
runner: &ActionRunner{
ID: 3,
Name: "never-online",
Status: "offline",
Online: false,
LastOnline: "",
},
check: func(t *testing.T, result map[string]interface{}) {
if _, exists := result["last_online"]; exists {
t.Error("should not have last_online when empty")
}
},
},
{
name: "runner with many labels",
runner: &ActionRunner{
ID: 4,
Name: "many-labels",
Status: "online",
Labels: []string{"label1", "label2", "label3", "label4", "label5"},
},
check: func(t *testing.T, result map[string]interface{}) {
labels, ok := result["labels"].([]string)
if !ok || len(labels) != 5 {
t.Errorf("expected 5 labels, got %v", result["labels"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunner(tt.runner)
if result == nil {
t.Fatal("slimActionRunner returned nil")
}
tt.check(t, result)
})
}
}
func TestSlimActionRunners_EdgeCases(t *testing.T) {
tests := []struct {
name string
runners []*ActionRunner
check func(t *testing.T, result map[string]interface{})
}{
{
name: "nil runners slice",
runners: nil,
check: func(t *testing.T, result map[string]interface{}) {
if result["total_count"] != 0 {
t.Errorf("expected total_count 0, got %v", result["total_count"])
}
runners, ok := result["runners"].([]interface{})
if !ok || len(runners) != 0 {
t.Errorf("expected empty runners slice, got %v", result["runners"])
}
},
},
{
name: "single runner",
runners: []*ActionRunner{{ID: 1, Name: "single"}},
check: func(t *testing.T, result map[string]interface{}) {
if result["total_count"] != 1 {
t.Errorf("expected total_count 1, got %v", result["total_count"])
}
runners, ok := result["runners"].([]map[string]interface{})
if !ok || len(runners) != 1 {
t.Errorf("expected 1 runner, got %v", result["runners"])
}
},
},
{
name: "many runners",
runners: []*ActionRunner{
{ID: 1, Name: "runner1"},
{ID: 2, Name: "runner2"},
{ID: 3, Name: "runner3"},
{ID: 4, Name: "runner4"},
{ID: 5, Name: "runner5"},
},
check: func(t *testing.T, result map[string]interface{}) {
if result["total_count"] != 5 {
t.Errorf("expected total_count 5, got %v", result["total_count"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := slimActionRunners(tt.runners)
tt.check(t, result)
})
}
}
func TestRunnerMapsEqual_EdgeCases(t *testing.T) {
tests := []struct {
name string
a map[string]interface{}
b map[string]interface{}
expected bool
}{
{
name: "both nil",
a: nil,
b: nil,
expected: true,
},
{
name: "one nil",
a: map[string]interface{}{"key": "value"},
b: nil,
expected: false,
},
{
name: "different lengths",
a: map[string]interface{}{"a": 1},
b: map[string]interface{}{"a": 1, "b": 2},
expected: false,
},
{
name: "same keys different values",
a: map[string]interface{}{"key": "value1"},
b: map[string]interface{}{"key": "value2"},
expected: false,
},
{
name: "empty maps",
a: map[string]interface{}{},
b: map[string]interface{}{},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := runnerMapsEqual(tt.a, tt.b)
if result != tt.expected {
t.Errorf("runnerMapsEqual() = %v, want %v", result, tt.expected)
}
})
}
}
// runnerMapsEqual compares two map[string]interface{} values for equality
func runnerMapsEqual(a, b map[string]interface{}) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
bv, ok := b[k]
if !ok {
return false
}
// Simple comparison for basic types
switch vv := v.(type) {
case []string:
bvv, ok := bv.([]string)
if !ok || len(vv) != len(bvv) {
return false
}
for i, sv := range vv {
if sv != bvv[i] {
return false
}
}
default:
if v != bv {
return false
}
}
}
return true
}
+549
View File
@@ -0,0 +1,549 @@
package actions
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ActionsRunReadToolName = "actions_run_read"
ActionsRunWriteToolName = "actions_run_write"
)
var (
ActionsRunReadTool = mcp.NewTool(
ActionsRunReadToolName,
mcp.WithDescription("Read Actions workflow, run, and job data. Use method 'list_workflows'/'get_workflow' for workflows, 'list_runs'/'get_run' for runs, 'list_jobs'/'list_run_jobs' for jobs, 'get_job_log_preview'/'download_job_log' for logs."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'get_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'get_run', 'list_run_jobs')")),
mcp.WithNumber("job_id", mcp.Description("job ID (required for 'get_job_log_preview', 'download_job_log')")),
mcp.WithString("status", mcp.Description("optional status filter (for 'list_runs', 'list_jobs')")),
mcp.WithNumber("tail_lines", mcp.Description("number of lines from end of log (for 'get_job_log_preview')"), mcp.DefaultNumber(200), mcp.Min(1)),
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return (for 'get_job_log_preview')"), mcp.DefaultNumber(65536), mcp.Min(1024)),
mcp.WithString("output_path", mcp.Description("output file path (for 'download_job_log')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsRunWriteTool = mcp.NewTool(
ActionsRunWriteToolName,
mcp.WithDescription("Trigger, cancel, or rerun Actions workflows."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'dispatch_workflow')")),
mcp.WithString("ref", mcp.Description("git ref branch or tag (required for 'dispatch_workflow')")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object (for 'dispatch_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'cancel_run', 'rerun_run')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsRunReadTool, Handler: runReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsRunWriteTool, Handler: runWriteFn})
}
func runReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_workflows":
return listRepoActionWorkflowsFn(ctx, req)
case "get_workflow":
return getRepoActionWorkflowFn(ctx, req)
case "list_runs":
return listRepoActionRunsFn(ctx, req)
case "get_run":
return getRepoActionRunFn(ctx, req)
case "list_jobs":
return listRepoActionJobsFn(ctx, req)
case "list_run_jobs":
return listRepoActionRunJobsFn(ctx, req)
case "get_job_log_preview":
return getRepoActionJobLogPreviewFn(ctx, req)
case "download_job_log":
return downloadRepoActionJobLogFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func runWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "dispatch_workflow":
return dispatchRepoActionWorkflowFn(ctx, req)
case "cancel_run":
return cancelRepoActionRunFn(ctx, req)
case "rerun_run":
return rerunRepoActionRunFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func doJSONWithFallback(ctx context.Context, method string, paths []string, query url.Values, body, respOut any) error {
var lastErr error
for _, p := range paths {
_, err := gitea.DoJSON(ctx, method, p, query, body, respOut)
if err == nil {
return nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
continue
}
return err
}
return lastErr
}
func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionWorkflowsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action workflows err: %v", err))
}
return to.TextResult(slimActionWorkflows(result))
}
func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.New("workflow_id is required"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action workflow err: %v", err))
}
return to.TextResult(slimActionWorkflow(result))
}
func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called dispatchRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.New("workflow_id is required"))
}
ref, err := params.GetString(req.GetArguments(), "ref")
if err != nil || ref == "" {
return to.ErrorResult(errors.New("ref is required"))
}
var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists {
if m, ok := raw.(map[string]any); ok {
inputs = m
}
}
body := map[string]any{
"ref": ref,
}
if inputs != nil {
body["inputs"] = inputs
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, body, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return to.ErrorResult(fmt.Errorf("workflow dispatch not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
}
return to.ErrorResult(fmt.Errorf("dispatch action workflow err: %v", err))
}
return to.TextResult(map[string]any{"message": "workflow dispatched"})
}
func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionRunsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action runs err: %v", err))
}
return to.TextResult(slimActionRuns(result))
}
func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action run err: %v", err))
}
return to.TextResult(slimActionRun(result))
}
func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called cancelRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/cancel", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, nil,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("cancel action run err: %v", err))
}
return to.TextResult(map[string]any{"message": "run cancellation requested"})
}
func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called rerunRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun", url.PathEscape(owner), url.PathEscape(repo), runID),
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun-failed-jobs", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return to.ErrorResult(fmt.Errorf("workflow rerun not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
}
return to.ErrorResult(fmt.Errorf("rerun action run err: %v", err))
}
return to.TextResult(map[string]any{"message": "run rerun requested"})
}
func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/jobs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action jobs err: %v", err))
}
return to.TextResult(slimActionJobs(result))
}
func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionRunJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/jobs", url.PathEscape(owner), url.PathEscape(repo), runID),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action run jobs err: %v", err))
}
return to.TextResult(slimActionJobs(result))
}
// Log functions (merged from logs.go)
func logPaths(owner, repo string, jobID int64) []string {
return []string{
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/tasks/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/task/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
}
}
func fetchJobLogBytes(ctx context.Context, owner, repo string, jobID int64) ([]byte, string, error) {
var lastErr error
for _, p := range logPaths(owner, repo, jobID) {
b, _, err := gitea.DoBytes(ctx, "GET", p, nil, nil, "text/plain")
if err == nil {
return b, p, nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
continue
}
return nil, p, err
}
return nil, "", lastErr
}
func tailByLines(data []byte, tailLines int) []byte {
if tailLines <= 0 || len(data) == 0 {
return data
}
lines := 0
i := len(data) - 1
for i >= 0 {
if data[i] == '\n' {
lines++
if lines > tailLines {
return data[i+1:]
}
}
i--
}
return data
}
func limitBytes(data []byte, maxBytes int) ([]byte, bool) {
if maxBytes <= 0 {
return data, false
}
if len(data) <= maxBytes {
return data, false
}
return data[len(data)-maxBytes:], true
}
func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionJobLogPreviewFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
jobID, err := params.GetIndex(req.GetArguments(), "job_id")
if err != nil {
return to.ErrorResult(err)
}
tailLines := int(params.GetOptionalInt(req.GetArguments(), "tail_lines", 200))
maxBytes := int(params.GetOptionalInt(req.GetArguments(), "max_bytes", 65536))
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("get job log err: %v", err))
}
tailed := tailByLines(raw, tailLines)
limited, truncated := limitBytes(tailed, maxBytes)
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"bytes": len(raw),
"tail_lines": tailLines,
"max_bytes": maxBytes,
"truncated": truncated,
"log": string(limited),
})
}
func downloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called downloadRepoActionJobLogFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
jobID, err := params.GetIndex(req.GetArguments(), "job_id")
if err != nil {
return to.ErrorResult(err)
}
outputPath, _ := req.GetArguments()["output_path"].(string)
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("download job log err: %v", err))
}
if outputPath == "" {
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", "actions-logs", owner, repo, fmt.Sprintf("%d.log", jobID))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return to.ErrorResult(fmt.Errorf("create output dir err: %v", err))
}
if err := os.WriteFile(outputPath, raw, 0o600); err != nil {
return to.ErrorResult(fmt.Errorf("write log file err: %v", err))
}
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"path": outputPath,
"bytes": len(raw),
})
}
+92
View File
@@ -0,0 +1,92 @@
package actions
func pick(m map[string]any, keys ...string) map[string]any {
out := make(map[string]any, len(keys))
for _, k := range keys {
if v, ok := m[k]; ok {
out[k] = v
}
}
return out
}
func slimPaginated(raw any, itemFn func(map[string]any) map[string]any) any {
m, ok := raw.(map[string]any)
if !ok {
return raw
}
result := make(map[string]any)
if tc, ok := m["total_count"]; ok {
result["total_count"] = tc
}
for key, val := range m {
if key == "total_count" {
continue
}
arr, ok := val.([]any)
if !ok {
continue
}
slimmed := make([]any, 0, len(arr))
for _, item := range arr {
if im, ok := item.(map[string]any); ok {
slimmed = append(slimmed, itemFn(im))
}
}
result[key] = slimmed
break
}
return result
}
func slimRun(m map[string]any) map[string]any {
return pick(m, "id", "name", "head_branch", "head_sha", "run_number",
"event", "status", "conclusion", "workflow_id",
"html_url", "created_at", "updated_at")
}
func slimJob(m map[string]any) map[string]any {
out := pick(m, "id", "run_id", "name", "workflow_name",
"status", "conclusion", "html_url",
"started_at", "completed_at")
if steps, ok := m["steps"].([]any); ok {
slim := make([]any, 0, len(steps))
for _, s := range steps {
if sm, ok := s.(map[string]any); ok {
slim = append(slim, pick(sm, "name", "number", "status", "conclusion"))
}
}
out["steps"] = slim
}
return out
}
func slimWorkflow(m map[string]any) map[string]any {
return pick(m, "id", "name", "path", "state", "html_url", "created_at", "updated_at")
}
func slimActionRun(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimRun(m)
}
return raw
}
func slimActionRuns(raw any) any {
return slimPaginated(raw, slimRun)
}
func slimActionJobs(raw any) any {
return slimPaginated(raw, slimJob)
}
func slimActionWorkflow(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimWorkflow(m)
}
return raw
}
func slimActionWorkflows(raw any) any {
return slimPaginated(raw, slimWorkflow)
}
+326
View File
@@ -0,0 +1,326 @@
package actions
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"path/filepath"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"gopkg.in/yaml.v3"
)
const (
GetWorkflowFileContentToolName = "get_workflow_file_content"
)
var (
GetWorkflowFileContentTool = mcp.NewTool(
GetWorkflowFileContentToolName,
mcp.WithDescription("Get workflow file content from .gitea/workflows/ or .github/workflows/ directories. Auto-discovers workflow files and returns parsed YAML as JSON."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Description("git ref (branch/tag/commit). Defaults to default branch if not specified")),
mcp.WithString("pattern", mcp.Description("file pattern to match (e.g., '*.yml', 'build-*.yml'). Defaults to all workflow files")),
mcp.WithString("filename", mcp.Description("specific workflow filename to retrieve. If provided, pattern is ignored")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetWorkflowFileContentTool,
Handler: getWorkflowFileContentFn,
})
}
type WorkflowFile struct {
Name string `json:"name"`
Path string `json:"path"`
SHA string `json:"sha"`
Size int64 `json:"size"`
Content interface{} `json:"content"`
RawContent string `json:"raw_content,omitempty"`
Encoding string `json:"encoding,omitempty"`
}
type WorkflowFilesResult struct {
Directory string `json:"directory"`
Files []WorkflowFile `json:"files"`
TotalCount int `json:"total_count"`
}
func getWorkflowFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getWorkflowFileContentFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "GetWorkflowFileContent",
"param": "owner",
}))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "GetWorkflowFileContent",
"param": "repo",
}))
}
ref, _ := req.GetArguments()["ref"].(string)
pattern, _ := req.GetArguments()["pattern"].(string)
filename, _ := req.GetArguments()["filename"].(string)
directories := []string{".gitea/workflows", ".github/workflows"}
var result WorkflowFilesResult
var lastErr error
for _, dir := range directories {
if filename != "" {
file, err := getWorkflowFile(ctx, owner, repo, ref, filepath.Join(dir, filename))
if err == nil {
result.Directory = dir
result.Files = []WorkflowFile{*file}
result.TotalCount = 1
return to.TextResult(result)
}
if lastErr == nil {
lastErr = err
}
continue
}
files, err := discoverWorkflowFiles(ctx, owner, repo, ref, dir, pattern)
if err == nil && len(files) > 0 {
result.Directory = dir
result.Files = files
result.TotalCount = len(files)
return to.TextResult(result)
}
if err != nil && lastErr == nil {
lastErr = err
}
}
return to.TextResult(WorkflowFilesResult{
Directory: "",
Files: []WorkflowFile{},
TotalCount: 0,
})
}
func discoverWorkflowFiles(ctx context.Context, owner, repo, ref, dir, pattern string) ([]WorkflowFile, error) {
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "DiscoverWorkflowFiles",
"owner": owner,
"repo": repo,
})
}
contents, _, err := client.ListContents(owner, repo, ref, dir)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "ListWorkflowDirectory",
"owner": owner,
"repo": repo,
"path": dir,
})
}
var files []WorkflowFile
for _, content := range contents {
if content.Type != "file" {
continue
}
ext := strings.ToLower(filepath.Ext(content.Name))
if ext != ".yml" && ext != ".yaml" {
continue
}
if pattern != "" && !matchPattern(content.Name, pattern) {
continue
}
filePath := filepath.Join(dir, content.Name)
file, err := getWorkflowFile(ctx, owner, repo, ref, filePath)
if err != nil {
log.Debugf("Failed to get workflow file %s: %v", filePath, err)
continue
}
files = append(files, *file)
}
return files, nil
}
func getWorkflowFile(ctx context.Context, owner, repo, ref, path string) (*WorkflowFile, error) {
escapedOwner := url.PathEscape(owner)
escapedRepo := url.PathEscape(repo)
escapedPath := url.PathEscape(path)
var result any
_, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/contents/%s", escapedOwner, escapedRepo, escapedPath), nil, nil, &result)
if err != nil {
return nil, errors.TranslateError(err, map[string]string{
"operation": "GetWorkflowFile",
"owner": owner,
"repo": repo,
"path": path,
})
}
contentMap, ok := result.(map[string]any)
if !ok {
return nil, fmt.Errorf("unexpected response type for workflow file")
}
workflowFile := &WorkflowFile{
Path: path,
}
if name, ok := contentMap["name"].(string); ok {
workflowFile.Name = name
}
if sha, ok := contentMap["sha"].(string); ok {
workflowFile.SHA = sha
}
if size, ok := contentMap["size"].(float64); ok {
workflowFile.Size = int64(size)
}
if encoding, ok := contentMap["encoding"].(string); ok {
workflowFile.Encoding = encoding
}
if contentStr, ok := contentMap["content"].(string); ok && contentStr != "" {
var rawContent []byte
if workflowFile.Encoding == "base64" {
decoded, err := base64.StdEncoding.DecodeString(contentStr)
if err != nil {
return nil, errors.TranslateError(
errors.NewEnhancedError(err, "Failed to decode workflow file content", errors.CategoryActions),
map[string]string{
"operation": "DecodeWorkflowFile",
"path": path,
"encoding": workflowFile.Encoding,
},
)
}
rawContent = decoded
} else {
rawContent = []byte(contentStr)
}
workflowFile.RawContent = string(rawContent)
var yamlContent interface{}
if err := yaml.Unmarshal(rawContent, &yamlContent); err != nil {
log.Debugf("Failed to parse YAML for %s: %v", path, err)
yamlContent = nil
} else {
workflowFile.Content = convertYamlToInterface(yamlContent)
}
}
return workflowFile, nil
}
func matchPattern(filename, pattern string) bool {
pattern = strings.ToLower(pattern)
filename = strings.ToLower(filename)
if pattern == "*" || pattern == "*.*" {
return true
}
if strings.HasPrefix(pattern, "*") {
suffix := pattern[1:]
return strings.HasSuffix(filename, suffix)
}
if strings.HasSuffix(pattern, "*") {
prefix := pattern[:len(pattern)-1]
return strings.HasPrefix(filename, prefix)
}
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
mid := pattern[1 : len(pattern)-1]
return strings.Contains(filename, mid)
}
return filename == pattern || strings.HasPrefix(filename, pattern)
}
func convertYamlToInterface(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
result := make(map[string]interface{})
for k, v := range val {
result[k] = convertYamlToInterface(v)
}
return result
case map[interface{}]interface{}:
result := make(map[string]interface{})
for k, v := range val {
key := fmt.Sprintf("%v", k)
result[key] = convertYamlToInterface(v)
}
return result
case []interface{}:
result := make([]interface{}, len(val))
for i, v := range val {
result[i] = convertYamlToInterface(v)
}
return result
case []yaml.Node:
result := make([]interface{}, len(val))
for i, v := range val {
result[i] = convertYamlToInterface(&v)
}
return result
case *yaml.Node:
switch val.Kind {
case yaml.ScalarNode:
s := val.Value
var parsed interface{}
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
return parsed
}
return s
case yaml.SequenceNode:
result := make([]interface{}, len(val.Content))
for i, n := range val.Content {
result[i] = convertYamlToInterface(&n)
}
return result
case yaml.MappingNode:
result := make(map[string]interface{})
for i := 0; i < len(val.Content); i += 2 {
key := val.Content[i].Value
result[key] = convertYamlToInterface(&val.Content[i+1])
}
return result
default:
return val.Value
}
default:
return val
}
}
+398
View File
@@ -0,0 +1,398 @@
package actions
import (
"testing"
)
func TestMatchPattern(t *testing.T) {
tests := []struct {
filename string
pattern string
want bool
}{
{"build.yml", "*", true},
{"build.yml", "*.*", true},
{"build.yml", "*.yml", true},
{"build.yaml", "*.yml", true},
{"build.yaml", "*.yaml", true},
{"build-test.yml", "build-*.yml", true},
{"build-test.yml", "*-test.yml", true},
{"build.yml", "deploy*.yml", false},
{"build.yml", "*.yaml", false},
{"BUILD.YML", "*.yml", true},
{"build.yml", "build.yml", true},
{"deploy.yml", "build.yml", false},
}
for _, tt := range tests {
t.Run(tt.filename+"_"+tt.pattern, func(t *testing.T) {
got := matchPattern(tt.filename, tt.pattern)
if got != tt.want {
t.Fatalf("matchPattern(%q, %q) = %v, want %v", tt.filename, tt.pattern, got, tt.want)
}
})
}
}
func TestConvertYamlToInterface(t *testing.T) {
tests := []struct {
name string
input interface{}
expected interface{}
}{
{
name: "string value",
input: "hello",
expected: "hello",
},
{
name: "int value",
input: 42,
expected: 42,
},
{
name: "simple map",
input: map[string]interface{}{
"name": "test",
"val": 123,
},
expected: map[string]interface{}{
"name": "test",
"val": 123,
},
},
{
name: "nested map",
input: map[string]interface{}{
"level1": map[string]interface{}{
"level2": "value",
},
},
expected: map[string]interface{}{
"level1": map[string]interface{}{
"level2": "value",
},
},
},
{
name: "slice",
input: []interface{}{"a", "b", "c"},
expected: []interface{}{"a", "b", "c"},
},
{
name: "map with interface keys",
input: map[interface{}]interface{}{
"key": "value",
123: "numeric key",
},
expected: map[string]interface{}{
"key": "value",
"123": "numeric key",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertYamlToInterface(tt.input)
if !deepEqual(got, tt.expected) {
t.Fatalf("convertYamlToInterface() = %v, want %v", got, tt.expected)
}
})
}
}
func deepEqual(a, b interface{}) bool {
switch av := a.(type) {
case map[string]interface{}:
bv, ok := b.(map[string]interface{})
if !ok || len(av) != len(bv) {
return false
}
for k, v := range av {
if !deepEqual(v, bv[k]) {
return false
}
}
return true
case []interface{}:
bv, ok := b.([]interface{})
if !ok || len(av) != len(bv) {
return false
}
for i, v := range av {
if !deepEqual(v, bv[i]) {
return false
}
}
return true
default:
return a == b
}
}
func TestWorkflowFileStruct(t *testing.T) {
wf := WorkflowFile{
Name: "test.yml",
Path: ".gitea/workflows/test.yml",
SHA: "abc123",
Size: 1024,
Content: map[string]interface{}{"name": "Test Workflow"},
RawContent: "name: Test Workflow",
Encoding: "base64",
}
if wf.Name != "test.yml" {
t.Fatalf("Name = %q, want %q", wf.Name, "test.yml")
}
if wf.Path != ".gitea/workflows/test.yml" {
t.Fatalf("Path = %q, want %q", wf.Path, ".gitea/workflows/test.yml")
}
}
func TestMatchPattern_EdgeCases(t *testing.T) {
tests := []struct {
filename string
pattern string
want bool
}{
// Edge cases for empty strings
{"", "*", true},
{"file.yml", "", false},
{"", "", true},
// Edge cases for special characters
{"file-name.yml", "*-name.yml", true},
{"file_name.yml", "*.yml", true},
{"file.name.yml", "*.yml", true},
// Edge case: only wildcard
{"anything", "*", true},
{"", "*", true},
// Edge case: pattern equals filename
{"exact.yml", "exact.yml", true},
{"exact.yml", "exact.yaml", false},
// Edge case: case sensitivity
{"FILE.YML", "*.yml", true},
{"File.Yml", "*.yml", true},
// Edge case: middle wildcards
{"build-test-deploy.yml", "*test*.yml", true},
{"build-prod-deploy.yml", "*test*.yml", false},
// Edge case: multiple extensions
{"file.tar.gz", "*.gz", true},
{"file.tar.gz", "*.tar.gz", true},
// Edge case: dots in filename
{".github/workflows/build.yml", "*.yml", true},
// Edge case: numeric patterns
{"build-123.yml", "build-*.yml", true},
{"build-abc.yml", "build-*.yml", true},
}
for _, tt := range tests {
t.Run(tt.filename+"_"+tt.pattern, func(t *testing.T) {
got := matchPattern(tt.filename, tt.pattern)
if got != tt.want {
t.Fatalf("matchPattern(%q, %q) = %v, want %v", tt.filename, tt.pattern, got, tt.want)
}
})
}
}
func TestConvertYamlToInterface_EdgeCases(t *testing.T) {
tests := []struct {
name string
input interface{}
expected interface{}
}{
{
name: "nil value",
input: nil,
expected: nil,
},
{
name: "empty map",
input: map[string]interface{}{},
expected: map[string]interface{}{},
},
{
name: "empty slice",
input: []interface{}{},
expected: []interface{}{},
},
{
name: "nested empty structures",
input: map[string]interface{}{
"empty_map": map[string]interface{}{},
"empty_slice": []interface{}{},
},
expected: map[string]interface{}{
"empty_map": map[string]interface{}{},
"empty_slice": []interface{}{},
},
},
{
name: "boolean value",
input: true,
expected: true,
},
{
name: "float value",
input: 3.14,
expected: 3.14,
},
{
name: "deeply nested map",
input: map[string]interface{}{
"level1": map[string]interface{}{
"level2": map[string]interface{}{
"level3": map[string]interface{}{
"value": "deep",
},
},
},
},
expected: map[string]interface{}{
"level1": map[string]interface{}{
"level2": map[string]interface{}{
"level3": map[string]interface{}{
"value": "deep",
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertYamlToInterface(tt.input)
if !deepEqual(got, tt.expected) {
t.Fatalf("convertYamlToInterface() = %v, want %v", got, tt.expected)
}
})
}
}
func TestWorkflowFileStruct_EdgeCases(t *testing.T) {
tests := []struct {
name string
file WorkflowFile
want WorkflowFile
}{
{
name: "empty file",
file: WorkflowFile{},
want: WorkflowFile{},
},
{
name: "file with zero size",
file: WorkflowFile{
Name: "empty.yml",
Path: ".gitea/workflows/empty.yml",
SHA: "abc123",
Size: 0,
Content: nil,
RawContent: "",
Encoding: "",
},
want: WorkflowFile{
Name: "empty.yml",
Path: ".gitea/workflows/empty.yml",
SHA: "abc123",
Size: 0,
},
},
{
name: "file with large size",
file: WorkflowFile{
Name: "large.yml",
Path: ".gitea/workflows/large.yml",
SHA: "def456",
Size: 1024 * 1024 * 10, // 10MB
Content: map[string]interface{}{"name": "Large Workflow"},
RawContent: "name: Large Workflow\\n# ... lots of content ...",
Encoding: "base64",
},
want: WorkflowFile{
Name: "large.yml",
Path: ".gitea/workflows/large.yml",
SHA: "def456",
Size: 1024 * 1024 * 10,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.file.Name != tt.want.Name {
t.Errorf("Name = %q, want %q", tt.file.Name, tt.want.Name)
}
if tt.file.Path != tt.want.Path {
t.Errorf("Path = %q, want %q", tt.file.Path, tt.want.Path)
}
if tt.file.SHA != tt.want.SHA {
t.Errorf("SHA = %q, want %q", tt.file.SHA, tt.want.SHA)
}
if tt.file.Size != tt.want.Size {
t.Errorf("Size = %d, want %d", tt.file.Size, tt.want.Size)
}
})
}
}
func TestWorkflowFilesResultStruct_EdgeCases(t *testing.T) {
tests := []struct {
name string
result WorkflowFilesResult
}{
{
name: "empty result",
result: WorkflowFilesResult{
Directory: "",
Files: []WorkflowFile{},
TotalCount: 0,
},
},
{
name: "nil files",
result: WorkflowFilesResult{
Directory: ".gitea/workflows",
Files: nil,
TotalCount: 0,
},
},
{
name: "single file",
result: WorkflowFilesResult{
Directory: ".github/workflows",
Files: []WorkflowFile{
{Name: "ci.yml"},
},
TotalCount: 1,
},
},
{
name: "many files",
result: WorkflowFilesResult{
Directory: ".gitea/workflows",
Files: []WorkflowFile{
{Name: "build.yml"},
{Name: "test.yml"},
{Name: "deploy.yml"},
{Name: "lint.yml"},
{Name: "security.yml"},
},
TotalCount: 5,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if len(tt.result.Files) != tt.result.TotalCount {
t.Errorf("Files length (%d) != TotalCount (%d)", len(tt.result.Files), tt.result.TotalCount)
}
})
}
}