Files

346 lines
10 KiB
Go

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)
}
}