229 lines
5.6 KiB
Go
229 lines
5.6 KiB
Go
package repo
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
ListRepoStructureToolName = "list_repo_structure"
|
|
)
|
|
|
|
type TreeEntry struct {
|
|
Path string `json:"path"`
|
|
Type string `json:"type"`
|
|
SHA string `json:"sha"`
|
|
Size int64 `json:"size,omitempty"`
|
|
}
|
|
|
|
type TreeResponse struct {
|
|
SHA string `json:"sha"`
|
|
URL string `json:"url,omitempty"`
|
|
Tree []TreeEntry `json:"tree"`
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
}
|
|
|
|
var (
|
|
ListRepoStructureTool = mcp.NewTool(
|
|
ListRepoStructureToolName,
|
|
mcp.WithDescription("List the complete directory and file structure of a repository using Git tree API. Supports recursive listing and pattern filtering."),
|
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
|
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
|
mcp.WithString("ref", mcp.Description("Git reference (branch, tag, or commit SHA). Defaults to default branch.")),
|
|
mcp.WithString("pattern", mcp.Description("Glob pattern to filter files (e.g., '*.yml', '.gitea/*', 'src/**/*.go')")),
|
|
mcp.WithBoolean("recursive", mcp.Description("List contents recursively (default: true)")),
|
|
mcp.WithNumber("page", mcp.Description("Page number for pagination (1-based, default: 1)")),
|
|
mcp.WithNumber("per_page", mcp.Description("Number of items per page (default: 100, max: 1000)")),
|
|
)
|
|
)
|
|
|
|
func init() {
|
|
Tool.RegisterRead(server.ServerTool{
|
|
Tool: ListRepoStructureTool,
|
|
Handler: ListRepoStructureFn,
|
|
})
|
|
}
|
|
|
|
func ListRepoStructureFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
log.Debugf("Called ListRepoStructureFn")
|
|
|
|
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, _ := args["ref"].(string)
|
|
if ref == "" {
|
|
ref = "HEAD"
|
|
}
|
|
|
|
pattern, _ := args["pattern"].(string)
|
|
|
|
recursive := true
|
|
if recursiveVal, ok := args["recursive"].(bool); ok {
|
|
recursive = recursiveVal
|
|
}
|
|
|
|
page := 1
|
|
if pageVal, ok := args["page"].(float64); ok && pageVal > 0 {
|
|
page = int(pageVal)
|
|
}
|
|
|
|
perPage := 100
|
|
if perPageVal, ok := args["per_page"].(float64); ok && perPageVal > 0 {
|
|
perPage = int(perPageVal)
|
|
if perPage > 1000 {
|
|
perPage = 1000
|
|
}
|
|
}
|
|
|
|
query := url.Values{}
|
|
if recursive {
|
|
query.Set("recursive", "1")
|
|
}
|
|
query.Set("page", fmt.Sprintf("%d", page))
|
|
query.Set("per_page", fmt.Sprintf("%d", perPage))
|
|
|
|
path := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, ref)
|
|
|
|
var treeResp TreeResponse
|
|
statusCode, err := gitea.DoJSON(ctx, "GET", path, query, nil, &treeResp)
|
|
if err != nil {
|
|
translatedErr := errors.TranslateError(err, map[string]string{
|
|
"operation": "ListRepoStructure",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"ref": ref,
|
|
"status": fmt.Sprintf("%d", statusCode),
|
|
})
|
|
return to.ErrorResult(translatedErr)
|
|
}
|
|
|
|
filteredEntries := filterEntries(treeResp.Tree, pattern)
|
|
|
|
result := map[string]any{
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"ref": ref,
|
|
"sha": treeResp.SHA,
|
|
"truncated": treeResp.Truncated,
|
|
"total_count": len(filteredEntries),
|
|
"page": page,
|
|
"per_page": perPage,
|
|
"tree": slimTreeEntries(filteredEntries),
|
|
}
|
|
|
|
if treeResp.Truncated {
|
|
result["warning"] = "Tree listing was truncated due to size. Consider using pattern filtering or pagination."
|
|
}
|
|
|
|
resultJSON, err := json.MarshalIndent(result, "", " ")
|
|
if err != nil {
|
|
return to.ErrorResult(fmt.Errorf("marshal result: %w", err))
|
|
}
|
|
|
|
return to.TextResult(string(resultJSON))
|
|
}
|
|
|
|
func filterEntries(entries []TreeEntry, pattern string) []TreeEntry {
|
|
if pattern == "" {
|
|
return entries
|
|
}
|
|
|
|
filtered := make([]TreeEntry, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if matchPattern(entry.Path, pattern) {
|
|
filtered = append(filtered, entry)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func matchPattern(path, pattern string) bool {
|
|
pattern = strings.TrimSpace(pattern)
|
|
if pattern == "" {
|
|
return true
|
|
}
|
|
|
|
negate := false
|
|
if strings.HasPrefix(pattern, "!") {
|
|
negate = true
|
|
pattern = strings.TrimPrefix(pattern, "!")
|
|
}
|
|
|
|
matched, err := filepath.Match(pattern, path)
|
|
if err == nil && matched {
|
|
return !negate
|
|
}
|
|
|
|
filename := filepath.Base(path)
|
|
matched, err = filepath.Match(pattern, filename)
|
|
if err == nil && matched {
|
|
return !negate
|
|
}
|
|
|
|
if strings.HasPrefix(pattern, "**/") {
|
|
suffix := strings.TrimPrefix(pattern, "**/")
|
|
if strings.HasSuffix(path, suffix) {
|
|
return !negate
|
|
}
|
|
parts := strings.Split(path, "/")
|
|
for i := range parts {
|
|
subPath := strings.Join(parts[i:], "/")
|
|
if matched, _ := filepath.Match(suffix, subPath); matched {
|
|
return !negate
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.HasSuffix(pattern, "/*") || strings.HasSuffix(pattern, "/**") {
|
|
dirPrefix := strings.TrimSuffix(pattern, "/*")
|
|
dirPrefix = strings.TrimSuffix(dirPrefix, "/**")
|
|
if strings.HasPrefix(path, dirPrefix+"/") {
|
|
return !negate
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(path, pattern+"/") || path == pattern {
|
|
return !negate
|
|
}
|
|
|
|
return negate
|
|
}
|
|
|
|
func slimTreeEntries(entries []TreeEntry) []map[string]any {
|
|
out := make([]map[string]any, 0, len(entries))
|
|
for _, e := range entries {
|
|
m := map[string]any{
|
|
"path": e.Path,
|
|
"type": e.Type,
|
|
"sha": e.SHA,
|
|
}
|
|
if e.Type == "blob" && e.Size > 0 {
|
|
m["size"] = e.Size
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|