feat: add bulk operations - 4 new MCP tools for repo/file management #1
@@ -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 <target_dir>", path)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("tar xzf %q -C <target_dir>", path)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// release_asset.go - upload_release_attachment
|
||||||
|
//
|
||||||
|
// מטרה: השלמת workflow הפצה - העלאת קובץ ZIP של extension ל-release ב-Gitea.
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||||
|
|
||||||
|
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
UploadReleaseAttachmentToolName = "upload_release_attachment"
|
||||||
|
|
||||||
|
// MaxAttachmentSizeBytes — תקרה לגודל attachment (100MB).
|
||||||
|
MaxAttachmentSizeBytes = 100 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
var UploadReleaseAttachmentTool = mcp.NewTool(
|
||||||
|
UploadReleaseAttachmentToolName,
|
||||||
|
mcp.WithDescription(
|
||||||
|
"Upload a file as an asset to an existing release. "+
|
||||||
|
"Essential for publishing extension .zip packages. "+
|
||||||
|
"The file is read from the local filesystem and uploaded via multipart form.",
|
||||||
|
),
|
||||||
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||||
|
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||||
|
mcp.WithNumber("release_id", mcp.Required(), mcp.Description("release ID")),
|
||||||
|
mcp.WithString("file_path", mcp.Required(),
|
||||||
|
mcp.Description("absolute path to the local file to upload")),
|
||||||
|
mcp.WithString("name",
|
||||||
|
mcp.Description("filename in the release (defaults to basename of file_path)")),
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Tool.RegisterWrite(server.ServerTool{
|
||||||
|
Tool: UploadReleaseAttachmentTool,
|
||||||
|
Handler: UploadReleaseAttachmentFn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseAsset — תוצאה מ-Gitea API.
|
||||||
|
type ReleaseAsset struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
DownloadCount int64 `json:"download_count"`
|
||||||
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func UploadReleaseAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called UploadReleaseAttachmentFn")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
releaseID, err := params.GetIndex(args, "release_id")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
filePath, err := params.GetString(args, "file_path")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
name, _ := args["name"].(string)
|
||||||
|
|
||||||
|
// אבטחה: דרוש absolute path
|
||||||
|
if !filepath.IsAbs(filePath) {
|
||||||
|
return to.ErrorResult(fmt.Errorf("file_path must be absolute"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("stat file: %w", err))
|
||||||
|
}
|
||||||
|
if fileInfo.Size() > MaxAttachmentSizeBytes {
|
||||||
|
return to.ErrorResult(fmt.Errorf(
|
||||||
|
"file too large (%d bytes > max %d)",
|
||||||
|
fileInfo.Size(), MaxAttachmentSizeBytes,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = filepath.Base(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("open file: %w", err))
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// בניית multipart form
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("attachment", name)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("create form file: %w", err))
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(part, file); err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("copy file content: %w", err))
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("close writer: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
host := flag.Host
|
||||||
|
if host == "" {
|
||||||
|
return to.ErrorResult(fmt.Errorf("gitea host not configured"))
|
||||||
|
}
|
||||||
|
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/%d/assets",
|
||||||
|
host,
|
||||||
|
url.PathEscape(owner),
|
||||||
|
url.PathEscape(repo),
|
||||||
|
releaseID,
|
||||||
|
)
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, &body)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("create request: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// אימות - token מ-context או flag
|
||||||
|
token := flag.Token
|
||||||
|
if t, ok := ctx.Value(mcpContext.TokenContextKey).(string); ok && t != "" {
|
||||||
|
token = t
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "token "+token)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("upload request: %w", err))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return to.ErrorResult(fmt.Errorf(
|
||||||
|
"upload failed (status %d): %s",
|
||||||
|
resp.StatusCode, string(respBody),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
var asset ReleaseAsset
|
||||||
|
if err := json.Unmarshal(respBody, &asset); err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("parse response: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"Asset uploaded successfully:\n"+
|
||||||
|
" Asset ID: %d\n"+
|
||||||
|
" Name: %s\n"+
|
||||||
|
" Size: %s\n"+
|
||||||
|
" Download URL: %s\n"+
|
||||||
|
" Created at: %s\n",
|
||||||
|
asset.ID,
|
||||||
|
asset.Name,
|
||||||
|
formatBytes(asset.Size),
|
||||||
|
asset.BrowserDownloadURL,
|
||||||
|
asset.CreatedAt,
|
||||||
|
)
|
||||||
|
return to.TextResult(msg)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user