Files
gitea-mcp-server/mcp/operation/actions/workflows.go
T

327 lines
8.6 KiB
Go

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