diff --git a/operation/repo/file_batch.go b/operation/repo/file_batch.go new file mode 100644 index 0000000..62048bf --- /dev/null +++ b/operation/repo/file_batch.go @@ -0,0 +1,361 @@ +// file_batch.go - get_files_batch + commit_multiple_files +// +// מטרה: 2 כלים שמייעלים פעולות קבצים מרובות בקריאה אחת. + +package repo + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "sync" + + "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 ( + GetFilesBatchToolName = "get_files_batch" + CommitMultipleFilesToolName = "commit_multiple_files" + + MaxFilesPerBatch = 20 + MaxFilesPerCommit = 50 + MaxBatchTotalSizeBytes = 1024 * 1024 + DefaultMaxTotalSizeKB = 500 +) + +// ============================================================================ +// Tool 1: get_files_batch +// ============================================================================ + +var GetFilesBatchTool = mcp.NewTool( + GetFilesBatchToolName, + mcp.WithDescription( + "Get multiple files from a repository in a single response. "+ + "Useful when you know exactly which files you need. Files are fetched in parallel. "+ + "Max 20 files per call.", + ), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("ref", mcp.Required(), mcp.Description("ref: branch / tag / commit")), + mcp.WithArray("file_paths", + mcp.Required(), + mcp.Description("List of file paths to fetch (relative to repo root). Max 20 entries."), + mcp.Items(map[string]any{"type": "string"}), + ), + mcp.WithNumber("max_total_size_kb", + mcp.Description("Safety cap on total response size in KB. Default 500."), + mcp.DefaultNumber(float64(DefaultMaxTotalSizeKB)), + ), +) + +// FileFetchResult — תוצאה לקובץ יחיד. +type FileFetchResult struct { + Path string `json:"path"` + Content string `json:"content,omitempty"` + Encoding string `json:"encoding,omitempty"` + SHA string `json:"sha,omitempty"` + Size int `json:"size"` + Error string `json:"error,omitempty"` +} + +// BatchFetchResult — תוצאה מאוחדת של batch. +type BatchFetchResult struct { + Files []FileFetchResult `json:"files"` + TotalFiles int `json:"total_files"` + TotalSizeBytes int `json:"total_size_bytes"` + Truncated bool `json:"truncated"` + TruncatedAt string `json:"truncated_at,omitempty"` +} + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: GetFilesBatchTool, + Handler: GetFilesBatchFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: CommitMultipleFilesTool, + Handler: CommitMultipleFilesFn, + }) +} + +func GetFilesBatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetFilesBatchFn") + args := req.GetArguments() + + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + ref, err := params.GetString(args, "ref") + if err != nil { + return to.ErrorResult(err) + } + + filePathsRaw, ok := args["file_paths"].([]any) + if !ok { + return to.ErrorResult(fmt.Errorf("file_paths must be an array")) + } + if len(filePathsRaw) == 0 { + return to.ErrorResult(fmt.Errorf("file_paths must not be empty")) + } + if len(filePathsRaw) > MaxFilesPerBatch { + return to.ErrorResult(fmt.Errorf( + "too many files (%d > max %d). Use download_repo_archive for whole-repo fetches.", + len(filePathsRaw), MaxFilesPerBatch, + )) + } + + filePaths := make([]string, 0, len(filePathsRaw)) + for _, p := range filePathsRaw { + s, ok := p.(string) + if !ok || s == "" { + return to.ErrorResult(fmt.Errorf("file_paths entries must be non-empty strings")) + } + filePaths = append(filePaths, s) + } + + maxKB := DefaultMaxTotalSizeKB + if v, ok := args["max_total_size_kb"].(float64); ok && v > 0 { + maxKB = int(v) + } + maxBytes := maxKB * 1024 + if maxBytes > MaxBatchTotalSizeBytes { + maxBytes = MaxBatchTotalSizeBytes + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + // שליפה במקביל באמצעות goroutines + results := make([]FileFetchResult, len(filePaths)) + var wg sync.WaitGroup + for i, p := range filePaths { + wg.Add(1) + go func(idx int, path string) { + defer wg.Done() + res := FileFetchResult{Path: path} + content, _, err := client.GetContents(owner, repo, ref, path) + if err != nil { + res.Error = err.Error() + results[idx] = res + return + } + if content != nil { + if content.Content != nil { + res.Content = *content.Content + res.Encoding = "base64" + } + res.SHA = content.SHA + res.Size = int(content.Size) + } + results[idx] = res + }(i, p) + } + wg.Wait() + + out := BatchFetchResult{ + Files: make([]FileFetchResult, 0, len(results)), + } + for _, r := range results { + out.TotalSizeBytes += len(r.Content) + if out.TotalSizeBytes > maxBytes { + out.Truncated = true + out.TruncatedAt = r.Path + break + } + out.Files = append(out.Files, r) + } + out.TotalFiles = len(out.Files) + + jsonBytes, err := json.MarshalIndent(out, "", " ") + if err != nil { + return to.ErrorResult(fmt.Errorf("marshal result: %w", err)) + } + return to.TextResult(string(jsonBytes)) +} + +// ============================================================================ +// Tool 2: commit_multiple_files +// ============================================================================ + +var CommitMultipleFilesTool = mcp.NewTool( + CommitMultipleFilesToolName, + mcp.WithDescription( + "Atomically commit multiple file changes (create/update/delete) in a single commit. "+ + "Max 50 files per commit.", + ), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("branch", mcp.Required(), mcp.Description("target branch")), + mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), + mcp.WithString("new_branch", mcp.Description("create new branch (optional)")), + mcp.WithArray("files", + mcp.Required(), + mcp.Description( + "Array of file operations. Each item: "+ + "{operation: 'create'|'update'|'delete', path: string, content: string (for create/update), sha: string (for update/delete)}. "+ + "Max 50 entries.", + ), + mcp.Items(map[string]any{ + "type": "object", + "properties": map[string]any{ + "operation": map[string]any{ + "type": "string", + "enum": []string{"create", "update", "delete"}, + }, + "path": map[string]any{"type": "string"}, + "content": map[string]any{"type": "string"}, + "sha": map[string]any{"type": "string"}, + }, + "required": []string{"operation", "path"}, + }), + ), +) + +// CommitFilesResponse — תוצאה ל-LLM. +type CommitFilesResponse struct { + CommitSHA string `json:"commit_sha"` + FilesChanged int `json:"files_changed"` + Operations []string `json:"operations"` + Branch string `json:"branch"` +} + +func CommitMultipleFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called CommitMultipleFilesFn") + args := req.GetArguments() + + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + branch, err := params.GetString(args, "branch") + if err != nil { + return to.ErrorResult(err) + } + message, err := params.GetString(args, "message") + if err != nil { + return to.ErrorResult(err) + } + newBranch, _ := args["new_branch"].(string) + + filesRaw, ok := args["files"].([]any) + if !ok { + return to.ErrorResult(fmt.Errorf("files must be an array")) + } + if len(filesRaw) == 0 { + return to.ErrorResult(fmt.Errorf("files must not be empty")) + } + if len(filesRaw) > MaxFilesPerCommit { + return to.ErrorResult(fmt.Errorf( + "too many files (%d > max %d)", + len(filesRaw), MaxFilesPerCommit, + )) + } + + sdkFiles := make([]gitea_sdk.ChangeFileOperation, 0, len(filesRaw)) + operations := make([]string, 0, len(filesRaw)) + + for i, f := range filesRaw { + fileMap, ok := f.(map[string]any) + if !ok { + return to.ErrorResult(fmt.Errorf("files[%d] must be an object", i)) + } + opStr, _ := fileMap["operation"].(string) + path, _ := fileMap["path"].(string) + content, _ := fileMap["content"].(string) + sha, _ := fileMap["sha"].(string) + + if opStr == "" || path == "" { + return to.ErrorResult(fmt.Errorf("files[%d]: operation and path are required", i)) + } + + var op gitea_sdk.FileOperationType + switch opStr { + case "create": + op = "create" + case "update": + op = "update" + if sha == "" { + return to.ErrorResult(fmt.Errorf("files[%d]: sha required for update", i)) + } + case "delete": + op = "delete" + if sha == "" { + return to.ErrorResult(fmt.Errorf("files[%d]: sha required for delete", i)) + } + default: + return to.ErrorResult(fmt.Errorf("files[%d]: invalid operation %q", i, opStr)) + } + + sdkFile := gitea_sdk.ChangeFileOperation{ + Operation: op, + Path: path, + SHA: sha, + } + if op != "delete" { + sdkFile.Content = base64.StdEncoding.EncodeToString([]byte(content)) + } + sdkFiles = append(sdkFiles, sdkFile) + operations = append(operations, fmt.Sprintf("%s %s", opStr, path)) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + opt := gitea_sdk.ChangeFilesOptions{ + Files: sdkFiles, + FileOptions: gitea_sdk.FileOptions{ + Message: message, + BranchName: branch, + }, + } + if newBranch != "" { + opt.NewBranchName = newBranch + } + + commitResp, _, err := client.ChangeFiles(owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("commit files err: %v", err)) + } + + commitSHA := "" + if commitResp != nil && commitResp.Commit != nil { + commitSHA = commitResp.Commit.SHA + } + + resp := CommitFilesResponse{ + CommitSHA: commitSHA, + FilesChanged: len(sdkFiles), + Operations: operations, + Branch: branch, + } + if newBranch != "" { + resp.Branch = newBranch + } + + jsonBytes, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return to.ErrorResult(fmt.Errorf("marshal result: %w", err)) + } + return to.TextResult(string(jsonBytes)) +}