Initial commit: Gitea code search with MeiliSearch + MCP

Go indexer (full re-index + webhook), MeiliSearch integration,
MCP server exposing gitea_search tool for LLM agents.
K8s manifests for MeiliSearch + indexer CronJob.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Raymond Scott Pert
2026-04-08 01:27:42 +00:00
commit 61574855bf
11 changed files with 1318 additions and 0 deletions

267
internal/meili/client.go Normal file
View File

@@ -0,0 +1,267 @@
package meili
import (
"crypto/sha256"
"fmt"
"log"
"time"
"github.com/meilisearch/meilisearch-go"
)
// Document represents an indexed file in MeiliSearch.
type Document struct {
ID string `json:"id"`
Repo string `json:"repo"`
Branch string `json:"branch"`
Path string `json:"path"`
Filename string `json:"filename"`
Extension string `json:"extension"`
Content string `json:"content"`
Language string `json:"language"`
UpdatedAt int64 `json:"updated_at"`
}
// SearchResult holds a single search hit.
type SearchResult struct {
Repo string `json:"repo"`
Branch string `json:"branch"`
Path string `json:"path"`
Filename string `json:"filename"`
Extension string `json:"extension"`
Snippet string `json:"snippet"`
}
// Client wraps the MeiliSearch SDK.
type Client struct {
client meilisearch.ServiceManager
indexName string
}
// NewClient creates a new MeiliSearch client.
func NewClient(url, apiKey, indexName string) (*Client, error) {
client := meilisearch.New(url, meilisearch.WithAPIKey(apiKey))
c := &Client{
client: client,
indexName: indexName,
}
if err := c.ensureIndex(); err != nil {
return nil, fmt.Errorf("ensuring index: %w", err)
}
return c, nil
}
// ensureIndex creates the index if it doesn't exist and configures settings.
func (c *Client) ensureIndex() error {
_, err := c.client.GetIndex(c.indexName)
if err != nil {
log.Printf("Creating index %q", c.indexName)
task, err := c.client.CreateIndex(&meilisearch.IndexConfig{
Uid: c.indexName,
PrimaryKey: "id",
})
if err != nil {
return fmt.Errorf("creating index: %w", err)
}
if _, err := c.client.WaitForTask(task.TaskUID, 500 * time.Millisecond); err != nil {
return fmt.Errorf("waiting for index creation: %w", err)
}
}
index := c.client.Index(c.indexName)
// Configure searchable attributes
task, err := index.UpdateSearchableAttributes(&[]string{
"content", "path", "filename", "repo",
})
if err != nil {
return fmt.Errorf("updating searchable attributes: %w", err)
}
if _, err := c.client.WaitForTask(task.TaskUID, 500 * time.Millisecond); err != nil {
return fmt.Errorf("waiting for searchable attributes: %w", err)
}
// Configure filterable attributes
task, err = index.UpdateFilterableAttributes(&[]string{
"repo", "extension", "branch",
})
if err != nil {
return fmt.Errorf("updating filterable attributes: %w", err)
}
if _, err := c.client.WaitForTask(task.TaskUID, 500 * time.Millisecond); err != nil {
return fmt.Errorf("waiting for filterable attributes: %w", err)
}
// Configure displayed attributes (exclude full content)
task, err = index.UpdateDisplayedAttributes(&[]string{
"id", "repo", "branch", "path", "filename", "extension", "updated_at",
})
if err != nil {
return fmt.Errorf("updating displayed attributes: %w", err)
}
if _, err := c.client.WaitForTask(task.TaskUID, 500 * time.Millisecond); err != nil {
return fmt.Errorf("waiting for displayed attributes: %w", err)
}
log.Printf("Index %q configured", c.indexName)
return nil
}
// DocumentID generates a deterministic ID from repo, branch, and path.
func DocumentID(repo, branch, path string) string {
h := sha256.Sum256([]byte(repo + ":" + branch + ":" + path))
return fmt.Sprintf("%x", h[:16])
}
// IndexDocuments adds or updates documents in MeiliSearch.
// It batches documents in chunks to avoid overwhelming MeiliSearch.
func (c *Client) IndexDocuments(docs []Document) error {
if len(docs) == 0 {
return nil
}
const batchSize = 100
index := c.client.Index(c.indexName)
for i := 0; i < len(docs); i += batchSize {
end := i + batchSize
if end > len(docs) {
end = len(docs)
}
batch := docs[i:end]
task, err := index.AddDocuments(batch, "id")
if err != nil {
return fmt.Errorf("adding documents batch %d-%d: %w", i, end, err)
}
if _, err := c.client.WaitForTask(task.TaskUID, 500 * time.Millisecond); err != nil {
return fmt.Errorf("waiting for batch %d-%d: %w", i, end, err)
}
log.Printf("Indexed documents %d-%d of %d", i+1, end, len(docs))
}
return nil
}
// Search queries MeiliSearch and returns formatted results.
func (c *Client) Search(query string, repo string, filetype string, limit int64) ([]SearchResult, error) {
if limit <= 0 {
limit = 10
}
index := c.client.Index(c.indexName)
// Build filter
var filters []string
if repo != "" {
filters = append(filters, fmt.Sprintf("repo = %q", repo))
}
if filetype != "" {
filters = append(filters, fmt.Sprintf("extension = %q", filetype))
}
filterStr := ""
if len(filters) > 0 {
filterStr = filters[0]
for _, f := range filters[1:] {
filterStr += " AND " + f
}
}
searchReq := &meilisearch.SearchRequest{
Limit: limit,
AttributesToRetrieve: []string{"repo", "branch", "path", "filename", "extension"},
AttributesToCrop: []string{"content:40"},
CropLength: 40,
AttributesToHighlight: []string{"content"},
ShowMatchesPosition: true,
}
if filterStr != "" {
searchReq.Filter = filterStr
}
resp, err := index.Search(query, searchReq)
if err != nil {
return nil, fmt.Errorf("searching: %w", err)
}
var results []SearchResult
for _, hit := range resp.Hits {
m, ok := hit.(map[string]interface{})
if !ok {
continue
}
result := SearchResult{
Repo: strVal(m, "repo"),
Branch: strVal(m, "branch"),
Path: strVal(m, "path"),
Filename: strVal(m, "filename"),
Extension: strVal(m, "extension"),
}
// Extract highlighted snippet from _formatted
if formatted, ok := m["_formatted"].(map[string]interface{}); ok {
if content, ok := formatted["content"].(string); ok {
result.Snippet = content
}
}
// Fall back to cropped content
if result.Snippet == "" {
if cropped, ok := m["_croppped"].(map[string]interface{}); ok {
if content, ok := cropped["content"].(string); ok {
result.Snippet = content
}
}
}
results = append(results, result)
}
return results, nil
}
// DeleteByRepo removes all documents for a given repo.
func (c *Client) DeleteByRepo(repo string) error {
index := c.client.Index(c.indexName)
task, err := index.DeleteDocumentsByFilter(fmt.Sprintf("repo = %q", repo))
if err != nil {
return fmt.Errorf("deleting documents for repo %s: %w", repo, err)
}
if _, err := c.client.WaitForTask(task.TaskUID, 500 * time.Millisecond); err != nil {
return fmt.Errorf("waiting for deletion: %w", err)
}
return nil
}
// DeleteAll removes all documents from the index.
func (c *Client) DeleteAll() error {
index := c.client.Index(c.indexName)
task, err := index.DeleteAllDocuments()
if err != nil {
return fmt.Errorf("deleting all documents: %w", err)
}
if _, err := c.client.WaitForTask(task.TaskUID, 500 * time.Millisecond); err != nil {
return fmt.Errorf("waiting for deletion: %w", err)
}
return nil
}
func strVal(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}