327 lines
8.6 KiB
Go
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
|
|
}
|
|
}
|