diff --git a/operation/repo/archive.go b/operation/repo/archive.go new file mode 100644 index 0000000..0bd0f84 --- /dev/null +++ b/operation/repo/archive.go @@ -0,0 +1,201 @@ +// archive.go - download_repo_archive tool +// +// מטרה: הורדת ריפו שלם כ-zip/tar.gz בקריאה אחת. +// קוראים ל-Gitea archive endpoint ושומרים ל-disk. + +package repo + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "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 ( + DownloadRepoArchiveToolName = "download_repo_archive" + + // MaxArchiveSizeBytes — תקרת ביטחון לגודל ארכיון (500MB). + MaxArchiveSizeBytes = 500 * 1024 * 1024 +) + +var DownloadRepoArchiveTool = mcp.NewTool( + DownloadRepoArchiveToolName, + mcp.WithDescription( + "Download an entire repository as a zip or tar.gz archive in a single call. "+ + "Much more efficient than calling get_file_contents for each file. "+ + "Saves to the local filesystem; returns the saved path, size and sha256.", + ), + 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 sha")), + mcp.WithString("format", + mcp.Description("archive format: zip (default) or tar.gz"), + mcp.DefaultString("zip"), + ), + mcp.WithString("output_path", + mcp.Required(), + mcp.Description("absolute path on the host where the archive should be saved"), + ), +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: DownloadRepoArchiveTool, + Handler: DownloadRepoArchiveFn, + }) +} + +// ArchiveResult — הפלט שמוחזר ל-LLM. +type ArchiveResult struct { + SavedTo string `json:"saved_to"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` + Format string `json:"format"` + Repository string `json:"repository"` + Ref string `json:"ref"` +} + +func DownloadRepoArchiveFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called DownloadRepoArchiveFn") + 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) + } + outputPath, err := params.GetString(args, "output_path") + if err != nil { + return to.ErrorResult(err) + } + + format, _ := args["format"].(string) + if format == "" { + format = "zip" + } + if format != "zip" && format != "tar.gz" { + return to.ErrorResult(fmt.Errorf("invalid format %q: must be 'zip' or 'tar.gz'", format)) + } + + // אבטחה: ודא ש-output_path absolute (לא relative שיוכל לדלוף). + if !filepath.IsAbs(outputPath) { + return to.ErrorResult(fmt.Errorf("output_path must be absolute, got: %s", outputPath)) + } + + // ודא שתיקיית היעד קיימת + parent := filepath.Dir(outputPath) + if err := os.MkdirAll(parent, 0o755); err != nil { + return to.ErrorResult(fmt.Errorf("create output dir %q: %w", parent, err)) + } + + // בנה את הנתיב ל-API של Gitea + // GET /repos/{owner}/{repo}/archive/{ref}.{format} + apiPath := fmt.Sprintf("repos/%s/%s/archive/%s.%s", + url.PathEscape(owner), + url.PathEscape(repo), + ref, + format, + ) + + // קבע accept header לפי format + accept := "application/zip" + if format == "tar.gz" { + accept = "application/x-gzip" + } + + // השתמש ב-DoBytes הקיים ב-rest.go + archiveBytes, statusCode, err := gitea.DoBytes(ctx, "GET", apiPath, nil, nil, accept) + if err != nil { + return to.ErrorResult(fmt.Errorf("download archive (status %d): %w", statusCode, err)) + } + + // בדיקת גודל + if int64(len(archiveBytes)) > MaxArchiveSizeBytes { + return to.ErrorResult(fmt.Errorf( + "archive too large (%d bytes > %d limit). "+ + "Use a more specific ref or fetch individual files via get_files_batch.", + len(archiveBytes), MaxArchiveSizeBytes, + )) + } + + // כתיבה ל-disk + if err := os.WriteFile(outputPath, archiveBytes, 0o644); err != nil { + return to.ErrorResult(fmt.Errorf("write archive to %q: %w", outputPath, err)) + } + + // חישוב SHA-256 לוידוא integrity + hash := sha256.Sum256(archiveBytes) + sha := hex.EncodeToString(hash[:]) + + result := ArchiveResult{ + SavedTo: outputPath, + SizeBytes: int64(len(archiveBytes)), + SHA256: sha, + Format: format, + Repository: fmt.Sprintf("%s/%s", owner, repo), + Ref: ref, + } + + msg := fmt.Sprintf( + "Repository archive downloaded successfully:\n"+ + " Repository: %s\n"+ + " Ref: %s\n"+ + " Format: %s\n"+ + " Saved to: %s\n"+ + " Size: %s\n"+ + " SHA-256: %s\n\n"+ + "Next steps: extract with %s\n", + result.Repository, + result.Ref, + result.Format, + result.SavedTo, + formatBytes(result.SizeBytes), + result.SHA256, + extractCommand(format, outputPath), + ) + + return to.TextResult(msg) +} + +func formatBytes(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", + float64(b)/float64(div), + "KMGTPE"[exp], + ) +} + +func extractCommand(format, path string) string { + if strings.HasSuffix(format, "zip") { + return fmt.Sprintf("unzip %q -d ", path) + } + return fmt.Sprintf("tar xzf %q -C ", path) +}