feat(tools): code_search (org-wide fan-out)
When repo is omitted, lists owner's repos then concurrently searches each one (semaphore cap 5, 5s per-repo timeout). Merges and sorts hits by score desc with deterministic tiebreak. Partial failures tracked in partial_repos without aborting the whole fan-out. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,21 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type semaphore chan struct{}
|
||||
|
||||
func newSem(n int) semaphore { return make(semaphore, n) }
|
||||
func (s semaphore) acquire() { s <- struct{}{} }
|
||||
func (s semaphore) release() { <-s }
|
||||
|
||||
type CodeSearch struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
@@ -71,11 +80,13 @@ func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
||||
args.Limit = 30
|
||||
}
|
||||
|
||||
if args.Repo == "" {
|
||||
// Phase 7.2: leave fan-out unimplemented — just error out for now.
|
||||
return nil, fmt.Errorf("repo is required for single-repo search (org-wide fan-out lands in 7.3): %w", gitea.ErrValidation)
|
||||
if args.Repo != "" {
|
||||
return t.singleRepo(ctx, args)
|
||||
}
|
||||
return t.fanOut(ctx, args)
|
||||
}
|
||||
|
||||
func (t *CodeSearch) singleRepo(ctx context.Context, args codeSearchArgs) (json.RawMessage, error) {
|
||||
hits, err := t.c.SearchCode(ctx, args.Owner, args.Repo, args.Q, args.Page, args.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -102,3 +113,79 @@ func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
|
||||
}
|
||||
return textOK(out)
|
||||
}
|
||||
|
||||
func (t *CodeSearch) fanOut(ctx context.Context, args codeSearchArgs) (json.RawMessage, error) {
|
||||
repos, err := t.c.ListRepos(ctx, args.Owner, 1, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type repoResult struct {
|
||||
repo string
|
||||
hits []gitea.CodeSearchHit
|
||||
err error
|
||||
}
|
||||
resultsCh := make(chan repoResult, len(repos))
|
||||
sem := newSem(5)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, r := range repos {
|
||||
repo := r // capture
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem.acquire()
|
||||
defer sem.release()
|
||||
|
||||
rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hits, err := t.c.SearchCode(rctx, args.Owner, repo.Name, args.Q, 1, args.Limit)
|
||||
resultsCh <- repoResult{repo: args.Owner + "/" + repo.Name, hits: hits, err: err}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(resultsCh)
|
||||
|
||||
merged := make([]codeSearchResult, 0)
|
||||
var partialRepos []string
|
||||
for rr := range resultsCh {
|
||||
if rr.err != nil {
|
||||
partialRepos = append(partialRepos, rr.repo)
|
||||
continue
|
||||
}
|
||||
for _, h := range rr.hits {
|
||||
score := h.Score
|
||||
if score == 0 {
|
||||
score = 1.0
|
||||
}
|
||||
merged = append(merged, codeSearchResult{
|
||||
Repo: rr.repo, Path: h.Path, Snippet: h.Snippet, Score: score, HTMLURL: h.HTMLURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score desc, then by repo+path for determinism.
|
||||
sort.Slice(merged, func(i, j int) bool {
|
||||
if merged[i].Score != merged[j].Score {
|
||||
return merged[i].Score > merged[j].Score
|
||||
}
|
||||
if merged[i].Repo != merged[j].Repo {
|
||||
return merged[i].Repo < merged[j].Repo
|
||||
}
|
||||
return merged[i].Path < merged[j].Path
|
||||
})
|
||||
if len(merged) > args.Limit {
|
||||
merged = merged[:args.Limit]
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"results": merged,
|
||||
"partial": len(partialRepos) > 0,
|
||||
}
|
||||
if len(partialRepos) > 0 {
|
||||
sort.Strings(partialRepos)
|
||||
out["partial_repos"] = partialRepos
|
||||
}
|
||||
return textOK(out)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user