Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5601927dc8 | ||
|
|
a16c5b0537 | ||
|
|
1eceb5f7fe |
@@ -4,6 +4,10 @@ name: CD
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
|
# Docs-only pushes don't change the image — skip the build/deploy roll.
|
||||||
|
# (Only applies to branch pushes; tag pushes always trigger.)
|
||||||
|
paths-ignore:
|
||||||
|
- 'docs/**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: gitea-mcp
|
IMAGE: gitea-mcp
|
||||||
|
|||||||
@@ -92,8 +92,10 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||||
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
auth.PassthroughMiddleware(giteaClient, mcpSrv,
|
||||||
auth.CallerMiddleware(logger, mcpSrv),
|
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||||
|
auth.CallerMiddleware(logger, mcpSrv),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
|||||||
"x_forwarded_user", fwdUser)
|
"x_forwarded_user", fwdUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), ctxKey{}, user)
|
next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), user)))
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withCaller(ctx context.Context, user string) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxKey{}, user)
|
||||||
|
}
|
||||||
|
|
||||||
func Caller(ctx context.Context) string {
|
func Caller(ctx context.Context) string {
|
||||||
if v, ok := ctx.Value(ctxKey{}).(string); ok {
|
if v, ok := ctx.Value(ctxKey{}).(string); ok {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenValidator asks the upstream service who a bearer token belongs to.
|
||||||
|
type TokenValidator interface {
|
||||||
|
ValidateToken(ctx context.Context, token string) (username string, ok bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassthroughMiddleware lets a caller authenticate with their own Gitea PAT:
|
||||||
|
// if the request's bearer token validates directly against Gitea, it's used
|
||||||
|
// as-is for every upstream call this request makes (gitea-mcp#59), instead of
|
||||||
|
// the server's shared default token. Any other bearer (static token, JWT, or
|
||||||
|
// none) falls through to fallback unchanged — this only adds a capability, it
|
||||||
|
// never removes the existing auth paths.
|
||||||
|
func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authz := r.Header.Get("Authorization")
|
||||||
|
token, hasBearer := strings.CutPrefix(authz, "Bearer ")
|
||||||
|
if hasBearer && token != "" {
|
||||||
|
if login, ok := validator.ValidateToken(r.Context(), token); ok {
|
||||||
|
ctx := withCaller(r.Context(), login)
|
||||||
|
ctx = gitea.WithToken(ctx, token)
|
||||||
|
onValid.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeValidator struct {
|
||||||
|
login string
|
||||||
|
ok bool
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeValidator) ValidateToken(_ context.Context, _ string) (string, bool) {
|
||||||
|
f.calls++
|
||||||
|
return f.login, f.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassthroughMiddleware_ValidPATGoesStraightToOnValid(t *testing.T) {
|
||||||
|
validator := &fakeValidator{login: "alice", ok: true}
|
||||||
|
var gotCaller string
|
||||||
|
var gotToken string
|
||||||
|
var gotOK bool
|
||||||
|
onValid := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
gotCaller = auth.Caller(r.Context())
|
||||||
|
gotToken, gotOK = gitea.TokenFromContext(r.Context())
|
||||||
|
})
|
||||||
|
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Fatal("fallback should not be called for a valid PAT")
|
||||||
|
})
|
||||||
|
|
||||||
|
h := auth.PassthroughMiddleware(validator, onValid, fallback)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer alices-pat")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
assert.Equal(t, "alice", gotCaller)
|
||||||
|
assert.True(t, gotOK)
|
||||||
|
assert.Equal(t, "alices-pat", gotToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassthroughMiddleware_InvalidTokenFallsThrough(t *testing.T) {
|
||||||
|
validator := &fakeValidator{ok: false}
|
||||||
|
fallbackCalled := false
|
||||||
|
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Fatal("onValid should not be called for an invalid token")
|
||||||
|
})
|
||||||
|
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
fallbackCalled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
h := auth.PassthroughMiddleware(validator, onValid, fallback)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer not-a-gitea-pat")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
assert.True(t, fallbackCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassthroughMiddleware_NoBearerFallsThroughWithoutCallingValidator(t *testing.T) {
|
||||||
|
validator := &fakeValidator{ok: true, login: "alice"}
|
||||||
|
fallbackCalled := false
|
||||||
|
onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Fatal("onValid should not be called with no Authorization header")
|
||||||
|
})
|
||||||
|
fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
fallbackCalled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
h := auth.PassthroughMiddleware(validator, onValid, fallback)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
assert.True(t, fallbackCalled)
|
||||||
|
assert.Equal(t, 0, validator.calls, "validator should not be invoked when there's no bearer to check")
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package gitea
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,6 +20,21 @@ type Client struct {
|
|||||||
branchCache *expirable.LRU[string, string]
|
branchCache *expirable.LRU[string, string]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ctxTokenKey struct{}
|
||||||
|
|
||||||
|
// WithToken overrides the token used for upstream Gitea calls made with the
|
||||||
|
// returned context, taking precedence over the Client's configured default
|
||||||
|
// token. Used for per-caller PAT pass-through (gitea-mcp#59).
|
||||||
|
func WithToken(ctx context.Context, token string) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxTokenKey{}, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenFromContext returns the token set by WithToken, if any.
|
||||||
|
func TokenFromContext(ctx context.Context) (string, bool) {
|
||||||
|
v, ok := ctx.Value(ctxTokenKey{}).(string)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
func NewClient(baseURL, token string) *Client {
|
func NewClient(baseURL, token string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
@@ -28,6 +44,23 @@ func NewClient(baseURL, token string) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateToken asks Gitea who a given token belongs to (GET /api/v1/user
|
||||||
|
// using that token, not the client's configured default token) and returns
|
||||||
|
// its login name. Used for per-caller PAT pass-through (gitea-mcp#59).
|
||||||
|
func (c *Client) ValidateToken(ctx context.Context, token string) (string, bool) {
|
||||||
|
body, status, err := c.doOnce(WithToken(ctx, token), http.MethodGet, "/api/v1/user", nil)
|
||||||
|
if err != nil || status != http.StatusOK {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var user struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &user); err != nil || user.Login == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return user.Login, true
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultBranch returns the default branch for a repo. Cached for 60s.
|
// DefaultBranch returns the default branch for a repo. Cached for 60s.
|
||||||
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
|
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
|
||||||
key := owner + "/" + name
|
key := owner + "/" + name
|
||||||
@@ -66,6 +99,9 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) (
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
token := c.token
|
token := c.token
|
||||||
|
if override, ok := TokenFromContext(ctx); ok {
|
||||||
|
token = override
|
||||||
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "token "+token)
|
req.Header.Set("Authorization", "token "+token)
|
||||||
}
|
}
|
||||||
@@ -135,6 +171,9 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
token := c.token
|
token := c.token
|
||||||
|
if override, ok := TokenFromContext(ctx); ok {
|
||||||
|
token = override
|
||||||
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "token "+token)
|
req.Header.Set("Authorization", "token "+token)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,49 @@ func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
|
|||||||
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
|
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientPrefersTokenFromContextOverDefaultToken(t *testing.T) {
|
||||||
|
var gotAuth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "default-token")
|
||||||
|
ctx := gitea.WithToken(context.Background(), "caller-token")
|
||||||
|
_, status, err := c.GetJSON(ctx, "/api/v1/user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 200, status)
|
||||||
|
assert.Equal(t, "token caller-token", gotAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTokenReturnsLoginOnSuccess(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "token candidate-token", r.Header.Get("Authorization"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"login":"alice"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "default-token")
|
||||||
|
login, ok := c.ValidateToken(context.Background(), "candidate-token")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "alice", login)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTokenReturnsFalseOn401(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "default-token")
|
||||||
|
login, ok := c.ValidateToken(context.Background(), "bad-token")
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Empty(t, login)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRetryOnPostNotRetried(t *testing.T) {
|
func TestRetryOnPostNotRetried(t *testing.T) {
|
||||||
var attempts int32
|
var attempts int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
+135
-15
@@ -1,10 +1,13 @@
|
|||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CodeSearchHit struct {
|
type CodeSearchHit struct {
|
||||||
@@ -14,30 +17,147 @@ type CodeSearchHit struct {
|
|||||||
Score float64 `json:"score,omitempty"`
|
Score float64 `json:"score,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type codeSearchEnvelope struct {
|
// codeSearchMaxFiles bounds how many files a single SearchCode call will fetch
|
||||||
Data []CodeSearchHit `json:"data"`
|
// and scan, so one call against a huge repo can't run indefinitely.
|
||||||
OK bool `json:"ok"`
|
const codeSearchMaxFiles = 2000
|
||||||
|
|
||||||
|
// codeSearchMaxFileSize skips blobs larger than this (per the tree listing,
|
||||||
|
// before any fetch) — almost certainly binary/vendor/generated content, not
|
||||||
|
// worth the cost of fetching just to reject.
|
||||||
|
const codeSearchMaxFileSize = 512 * 1024
|
||||||
|
|
||||||
|
// codeSearchBinaryExts are skipped WITHOUT fetching — a cheap pre-filter for
|
||||||
|
// obviously-binary content by extension, checked against the tree listing.
|
||||||
|
var codeSearchBinaryExts = map[string]bool{
|
||||||
|
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true, ".webp": true, ".bmp": true,
|
||||||
|
".pdf": true, ".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".xz": true, ".7z": true,
|
||||||
|
".exe": true, ".dll": true, ".so": true, ".dylib": true, ".bin": true, ".class": true, ".jar": true,
|
||||||
|
".woff": true, ".woff2": true, ".ttf": true, ".eot": true, ".otf": true,
|
||||||
|
".mp3": true, ".mp4": true, ".mov": true, ".avi": true, ".webm": true,
|
||||||
|
".pyc": true, ".o": true, ".a": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchCode does a client-side "git grep"-equivalent search. Gitea's REST API
|
||||||
|
// has no code-content-search endpoint — confirmed against a live 1.25.5
|
||||||
|
// instance's swagger spec (only /repos/search, /repos/issues/search,
|
||||||
|
// /topics/search etc exist). The web UI's OWN code search falls back to
|
||||||
|
// server-side `git grep` because no Repository Indexer is enabled on that
|
||||||
|
// instance, and that fallback is an HTML-only route, not JSON API. So this
|
||||||
|
// walks the tree, fetches text-like blobs (bounded by codeSearchMaxFiles /
|
||||||
|
// codeSearchMaxFileSize), and substring-matches q — case-insensitive, literal
|
||||||
|
// (not a regex, to keep behavior predictable and avoid a ReDoS surface from
|
||||||
|
// user-supplied input) — against file contents.
|
||||||
|
//
|
||||||
|
// Pagination is over the FULL sorted result set, recomputed on every call —
|
||||||
|
// there is no server-side index to page through incrementally, so requesting
|
||||||
|
// page 2 re-scans the tree. Acceptable for the repo sizes this targets; a real
|
||||||
|
// indexer (bleve/elasticsearch) enabled server-side would be the long-term
|
||||||
|
// fix, and is an infra decision, not something gitea-mcp controls.
|
||||||
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
|
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
|
||||||
|
if q == "" {
|
||||||
|
return nil, fmt.Errorf("q is required: %w", ErrValidation)
|
||||||
|
}
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
if limit < 1 {
|
if limit < 1 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d",
|
|
||||||
owner, repo, url.QueryEscape(q), page, limit)
|
r, err := c.GetRepo(ctx, owner, repo)
|
||||||
body, status, err := c.GetJSON(ctx, path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("resolve repo: %w", err)
|
||||||
}
|
}
|
||||||
if err := MapStatus(status, body); err != nil {
|
branch := r.DefaultBranch
|
||||||
return nil, err
|
if branch == "" {
|
||||||
|
branch = "main"
|
||||||
}
|
}
|
||||||
var env codeSearchEnvelope
|
|
||||||
if err := json.Unmarshal(body, &env); err != nil {
|
tree, err := c.GetTree(ctx, owner, repo, branch, true)
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tree walk: %w", err)
|
||||||
}
|
}
|
||||||
return env.Data, nil
|
|
||||||
|
qLower := strings.ToLower(q)
|
||||||
|
all := make([]CodeSearchHit, 0)
|
||||||
|
scanned := 0
|
||||||
|
for _, e := range tree.Tree {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if e.Type != "blob" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if codeSearchBinaryExts[strings.ToLower(path.Ext(e.Path))] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Size > codeSearchMaxFileSize {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if scanned >= codeSearchMaxFiles {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
scanned++
|
||||||
|
|
||||||
|
fc, ferr := c.GetFileContents(ctx, owner, repo, e.Path, branch)
|
||||||
|
if ferr != nil {
|
||||||
|
continue // vanished/unreadable between tree walk and read — skip, don't fail the whole search
|
||||||
|
}
|
||||||
|
decoded, derr := base64.StdEncoding.DecodeString(fc.Content)
|
||||||
|
if derr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bytes.IndexByte(decoded, 0) >= 0 {
|
||||||
|
continue // binary content the extension filter missed
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(decoded)
|
||||||
|
contentLower := strings.ToLower(content)
|
||||||
|
count := strings.Count(contentLower, qLower)
|
||||||
|
if count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := strings.Index(contentLower, qLower)
|
||||||
|
all = append(all, CodeSearchHit{
|
||||||
|
Path: e.Path,
|
||||||
|
Snippet: codeSearchSnippet(content, idx, len(q)),
|
||||||
|
HTMLURL: fmt.Sprintf("%s/%s/%s/src/branch/%s/%s", c.baseURL, owner, repo, branch, e.Path),
|
||||||
|
Score: float64(count),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(all, func(i, j int) bool {
|
||||||
|
if all[i].Score != all[j].Score {
|
||||||
|
return all[i].Score > all[j].Score
|
||||||
|
}
|
||||||
|
return all[i].Path < all[j].Path
|
||||||
|
})
|
||||||
|
|
||||||
|
start := (page - 1) * limit
|
||||||
|
if start >= len(all) {
|
||||||
|
return []CodeSearchHit{}, nil
|
||||||
|
}
|
||||||
|
end := start + limit
|
||||||
|
if end > len(all) {
|
||||||
|
end = len(all)
|
||||||
|
}
|
||||||
|
return all[start:end], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// codeSearchSnippet returns a short window of text centered on a match,
|
||||||
|
// trimmed to a single line-ish window so results read like a grep hit rather
|
||||||
|
// than a content dump.
|
||||||
|
func codeSearchSnippet(content string, idx, matchLen int) string {
|
||||||
|
const window = 60
|
||||||
|
start := idx - window
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := idx + matchLen + window
|
||||||
|
if end > len(content) {
|
||||||
|
end = len(content)
|
||||||
|
}
|
||||||
|
snippet := strings.ReplaceAll(content[start:end], "\n", " ")
|
||||||
|
return strings.TrimSpace(snippet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package gitea_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
@@ -11,22 +15,65 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSearchCode(t *testing.T) {
|
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
// codeSearchFake serves GetRepo + GetTree + GetFileContents off an in-memory
|
||||||
assert.Equal(t, "SearchCode", r.URL.Query().Get("q"))
|
// file map — the REAL endpoints SearchCode uses now that Gitea's REST API has
|
||||||
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
// no code-content-search endpoint (confirmed against a live 1.25.5 instance's
|
||||||
|
// swagger spec: only /repos/search, /repos/issues/search etc exist — the web
|
||||||
|
// UI's own code search falls back to server-side `git grep`, an HTML-only
|
||||||
|
// route, not JSON API). Records which paths were actually fetched, so tests
|
||||||
|
// can assert a file was SKIPPED (binary ext, oversized) without ever being
|
||||||
|
// read, not just absent from results.
|
||||||
|
type codeSearchFake struct {
|
||||||
|
files map[string]string // path -> content
|
||||||
|
sizes map[string]int64 // path -> tree-entry size (defaults to len(content))
|
||||||
|
fetched []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *codeSearchFake) handler(t *testing.T, owner, repo, branch string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{
|
p := r.URL.Path
|
||||||
"data":[{
|
switch {
|
||||||
"path":"internal/gitea/code_search.go",
|
case r.Method == http.MethodGet && p == "/api/v1/repos/"+owner+"/"+repo:
|
||||||
"snippet":"func (c *Client) SearchCode",
|
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":%q}`, repo, owner, repo, branch)
|
||||||
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go",
|
|
||||||
"score":2.5
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/git/trees/"):
|
||||||
}],
|
var entries []string
|
||||||
"ok":true
|
for path, content := range f.files {
|
||||||
}`))
|
size := int64(len(content))
|
||||||
}))
|
if s, ok := f.sizes[path]; ok {
|
||||||
|
size = s
|
||||||
|
}
|
||||||
|
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":%d}`, path, size))
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/"):
|
||||||
|
path := strings.TrimPrefix(p, "/api/v1/repos/"+owner+"/"+repo+"/contents/")
|
||||||
|
f.fetched = append(f.fetched, path)
|
||||||
|
content, ok := f.files[path]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), b64(content))
|
||||||
|
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, p)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_FindsMatchInTree(t *testing.T) {
|
||||||
|
f := &codeSearchFake{files: map[string]string{
|
||||||
|
"internal/gitea/code_search.go": "func (c *Client) SearchCode(ctx context.Context) {}\n",
|
||||||
|
"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n",
|
||||||
|
}}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
@@ -34,6 +81,92 @@ func TestSearchCode(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, hits, 1)
|
require.Len(t, hits, 1)
|
||||||
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
|
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
|
||||||
assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet)
|
assert.Contains(t, hits[0].Snippet, "SearchCode")
|
||||||
assert.InDelta(t, 2.5, hits[0].Score, 0.001)
|
assert.Contains(t, hits[0].HTMLURL, "internal/gitea/code_search.go")
|
||||||
|
assert.Equal(t, 1.0, hits[0].Score)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_CaseInsensitive(t *testing.T) {
|
||||||
|
f := &codeSearchFake{files: map[string]string{
|
||||||
|
"README.md": "# MyProject\n\nBuild instructions here.\n",
|
||||||
|
}}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "myproject", 1, 30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, hits, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_SkipsBinaryExtensionWithoutFetching(t *testing.T) {
|
||||||
|
f := &codeSearchFake{files: map[string]string{
|
||||||
|
"assets/logo.png": "SearchCode", // would match if fetched — must not be
|
||||||
|
"main.go": "package main\n",
|
||||||
|
}}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, hits)
|
||||||
|
assert.NotContains(t, f.fetched, "assets/logo.png", "binary extension must be skipped before fetch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_SkipsOversizedFileWithoutFetching(t *testing.T) {
|
||||||
|
f := &codeSearchFake{
|
||||||
|
files: map[string]string{"vendor/bundle.txt": "SearchCode"},
|
||||||
|
sizes: map[string]int64{"vendor/bundle.txt": 10 * 1024 * 1024}, // 10MB per the tree listing
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, hits)
|
||||||
|
assert.NotContains(t, f.fetched, "vendor/bundle.txt", "oversized file must be skipped before fetch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_SkipsBinaryContentNullByte(t *testing.T) {
|
||||||
|
f := &codeSearchFake{files: map[string]string{
|
||||||
|
"data.bin": "SearchCode\x00binary-marker",
|
||||||
|
}}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, hits, "content with a null byte must be treated as binary even past the extension filter")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_Pagination(t *testing.T) {
|
||||||
|
files := map[string]string{}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
files[fmt.Sprintf("file%d.go", i)] = strings.Repeat("hit ", i+1) // increasing occurrence count => increasing score
|
||||||
|
}
|
||||||
|
f := &codeSearchFake{files: files}
|
||||||
|
srv := httptest.NewServer(f.handler(t, "mathias", "infra", "main"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
page1, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 1, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, page1, 2)
|
||||||
|
page2, err := c.SearchCode(context.Background(), "mathias", "infra", "hit", 2, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, page2, 2)
|
||||||
|
|
||||||
|
assert.NotEqual(t, page1[0].Path, page2[0].Path, "pages must not overlap")
|
||||||
|
assert.True(t, page1[0].Score >= page1[1].Score, "page1 sorted desc by score")
|
||||||
|
assert.True(t, page1[1].Score >= page2[0].Score, "page1's worst must rank >= page2's best")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCode_EmptyQueryErrors(t *testing.T) {
|
||||||
|
c := gitea.NewClient("http://unused", "tok")
|
||||||
|
_, err := c.SearchCode(context.Background(), "mathias", "infra", "", 1, 30)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, gitea.ErrValidation))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package tools_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,22 +18,76 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCodeSearchSingleRepo(t *testing.T) {
|
// multiRepoSearchFake serves ListRepos + per-repo GetRepo/GetTree/GetFileContents
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
// for a set of repos — the real endpoints code_search's underlying SearchCode
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
|
// now uses (Gitea's REST API has no code-content-search endpoint; see
|
||||||
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
|
// internal/gitea/code_search.go's doc comment).
|
||||||
assert.Equal(t, "code", r.URL.Query().Get("type"))
|
type multiRepoSearchFake struct {
|
||||||
|
owner string
|
||||||
|
repos []string // ListRepos response, in this order
|
||||||
|
files map[string]map[string]string // repo -> path -> content
|
||||||
|
fail map[string]bool // repo -> GetRepo 500s for this repo (simulates a per-repo failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *multiRepoSearchFake) handler(t *testing.T) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{
|
p := r.URL.Path
|
||||||
"data":[{
|
|
||||||
"path":"internal/gitea/repos.go",
|
for _, repo := range f.repos {
|
||||||
"snippet":"func (c *Client) ListRepos",
|
base := "/api/v1/repos/" + f.owner + "/" + repo
|
||||||
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
|
switch {
|
||||||
"score":3.0
|
case p == base && f.fail[repo]:
|
||||||
}],
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
"ok":true
|
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
||||||
}`))
|
return
|
||||||
}))
|
case p == base:
|
||||||
|
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo)
|
||||||
|
return
|
||||||
|
case strings.HasPrefix(p, base+"/git/trees/"):
|
||||||
|
var entries []string
|
||||||
|
for path := range f.files[repo] {
|
||||||
|
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"s","size":100}`, path))
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
|
||||||
|
return
|
||||||
|
case strings.HasPrefix(p, base+"/contents/"):
|
||||||
|
path := strings.TrimPrefix(p, base+"/contents/")
|
||||||
|
content, ok := f.files[repo][path]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enc := base64.StdEncoding.EncodeToString([]byte(content))
|
||||||
|
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"s","size":%d,"content":%q,"encoding":"base64"}`, path, len(content), enc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p == "/api/v1/users/"+f.owner+"/repos" {
|
||||||
|
var entries []string
|
||||||
|
for _, repo := range f.repos {
|
||||||
|
entries = append(entries, fmt.Sprintf(`{"name":%q,"full_name":"%s/%s","default_branch":"main"}`, repo, f.owner, repo))
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `[%s]`, strings.Join(entries, ","))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, p)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodeSearchSingleRepo(t *testing.T) {
|
||||||
|
f := &multiRepoSearchFake{
|
||||||
|
owner: "mathias",
|
||||||
|
repos: []string{"infra"},
|
||||||
|
files: map[string]map[string]string{
|
||||||
|
"infra": {"internal/gitea/repos.go": "func (c *Client) ListRepos() {}\n"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(f.handler(t))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
@@ -50,7 +106,7 @@ func TestCodeSearchSingleRepo(t *testing.T) {
|
|||||||
require.Len(t, result.Results, 1)
|
require.Len(t, result.Results, 1)
|
||||||
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
|
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
|
||||||
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
|
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
|
||||||
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
|
assert.Contains(t, result.Results[0].Snippet, "ListRepos")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeSearchAllowlistRejects(t *testing.T) {
|
func TestCodeSearchAllowlistRejects(t *testing.T) {
|
||||||
@@ -67,22 +123,15 @@ func TestCodeSearchRequiresQ(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f := &multiRepoSearchFake{
|
||||||
w.Header().Set("Content-Type", "application/json")
|
owner: "mathias",
|
||||||
switch r.URL.Path {
|
repos: []string{"infra", "gitea-mcp"},
|
||||||
case "/api/v1/users/mathias/repos":
|
files: map[string]map[string]string{
|
||||||
_, _ = w.Write([]byte(`[
|
"infra": {"main.go": "this is an infra hit\n"},
|
||||||
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
"gitea-mcp": {"cmd/main.go": "this is a gitea-mcp hit\n"},
|
||||||
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
|
},
|
||||||
]`))
|
}
|
||||||
case "/api/v1/repos/mathias/infra/search":
|
srv := httptest.NewServer(f.handler(t))
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
|
|
||||||
case "/api/v1/repos/mathias/gitea-mcp/search":
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
@@ -110,23 +159,15 @@ func TestCodeSearchFanOutHappyPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f := &multiRepoSearchFake{
|
||||||
w.Header().Set("Content-Type", "application/json")
|
owner: "mathias",
|
||||||
switch r.URL.Path {
|
repos: []string{"infra", "broken"},
|
||||||
case "/api/v1/users/mathias/repos":
|
files: map[string]map[string]string{
|
||||||
_, _ = w.Write([]byte(`[
|
"infra": {"main.go": "this is an infra hit\n"},
|
||||||
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
|
},
|
||||||
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
|
fail: map[string]bool{"broken": true},
|
||||||
]`))
|
}
|
||||||
case "/api/v1/repos/mathias/infra/search":
|
srv := httptest.NewServer(f.handler(t))
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
|
|
||||||
case "/api/v1/repos/mathias/broken/search":
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"internal error"}`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
@@ -134,9 +175,11 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Results []struct{ Repo string `json:"repo"` } `json:"results"`
|
Results []struct {
|
||||||
Partial bool `json:"partial"`
|
Repo string `json:"repo"`
|
||||||
PartialRepos []string `json:"partial_repos"`
|
} `json:"results"`
|
||||||
|
Partial bool `json:"partial"`
|
||||||
|
PartialRepos []string `json:"partial_repos"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(out, &result))
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
assert.True(t, result.Partial)
|
assert.True(t, result.Partial)
|
||||||
@@ -147,41 +190,31 @@ func TestCodeSearchFanOutPartialFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
|
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f := &multiRepoSearchFake{
|
||||||
w.Header().Set("Content-Type", "application/json")
|
owner: "mathias",
|
||||||
switch r.URL.Path {
|
repos: []string{"alpha", "beta"},
|
||||||
case "/api/v1/users/mathias/repos":
|
files: map[string]map[string]string{
|
||||||
_, _ = w.Write([]byte(`[
|
"alpha": {"a.go": "one high here"}, // 1 occurrence => score 1
|
||||||
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
|
"beta": {"b.go": "high high high high high"}, // 5 occurrences => score 5
|
||||||
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
|
},
|
||||||
]`))
|
}
|
||||||
case "/api/v1/repos/mathias/alpha/search":
|
srv := httptest.NewServer(f.handler(t))
|
||||||
// low score
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
|
|
||||||
case "/api/v1/repos/mathias/beta/search":
|
|
||||||
// high score
|
|
||||||
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
|
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"high","owner":"mathias"}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Results []struct {
|
Results []struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
Snippet string `json:"snippet"`
|
Snippet string `json:"snippet"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(out, &result))
|
require.NoError(t, json.Unmarshal(out, &result))
|
||||||
require.Len(t, result.Results, 2)
|
require.Len(t, result.Results, 2)
|
||||||
// First result must be the high-score one
|
assert.Equal(t, "mathias/beta", result.Results[0].Repo, "higher-score repo (5 occurrences) must sort first")
|
||||||
assert.True(t, result.Results[0].Score > result.Results[1].Score,
|
assert.True(t, result.Results[0].Score > result.Results[1].Score,
|
||||||
"expected results sorted by score desc, got %v then %v",
|
"expected results sorted by score desc, got %v then %v", result.Results[0].Score, result.Results[1].Score)
|
||||||
result.Results[0].Score, result.Results[1].Score)
|
|
||||||
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user