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:
139
internal/gitea/client.go
Normal file
139
internal/gitea/client.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Repo represents a Gitea repository.
|
||||
type Repo struct {
|
||||
ID int64 `json:"id"`
|
||||
FullName string `json:"full_name"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
Empty bool `json:"empty"`
|
||||
Archived bool `json:"archived"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Client is a Gitea API client.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new Gitea API client.
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ListAllRepos returns all repositories accessible to the authenticated user.
|
||||
// It paginates through all results automatically.
|
||||
func (c *Client) ListAllRepos() ([]Repo, error) {
|
||||
var allRepos []Repo
|
||||
page := 1
|
||||
limit := 50
|
||||
|
||||
for {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/search?page=%d&limit=%d&token=%s",
|
||||
c.baseURL, page, limit, c.token)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching repos page %d: %w", page, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("gitea API returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []Repo `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if len(result.Data) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
allRepos = append(allRepos, result.Data...)
|
||||
|
||||
if len(result.Data) < limit {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
// Filter out empty repos
|
||||
filtered := make([]Repo, 0, len(allRepos))
|
||||
for _, r := range allRepos {
|
||||
if !r.Empty {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// GetRepo returns a single repository by owner/name.
|
||||
func (c *Client) GetRepo(fullName string) (*Repo, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s?token=%s", c.baseURL, fullName, c.token)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching repo %s: %w", fullName, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("gitea API returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var repo Repo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
return &repo, nil
|
||||
}
|
||||
|
||||
// AuthenticatedCloneURL returns the clone URL with the token embedded for private repos.
|
||||
func (c *Client) AuthenticatedCloneURL(repo Repo) string {
|
||||
// Insert token into https URL: https://token@host/path.git
|
||||
if len(c.baseURL) > 8 {
|
||||
return fmt.Sprintf("%s://%s@%s",
|
||||
c.baseURL[:5], // "https"
|
||||
c.token,
|
||||
repo.CloneURL[8:]) // strip "https://"
|
||||
}
|
||||
return repo.CloneURL
|
||||
}
|
||||
348
internal/mcp/server.go
Normal file
348
internal/mcp/server.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.rspworks.tech/rpert/gitea-search/internal/meili"
|
||||
)
|
||||
|
||||
// JSON-RPC message types
|
||||
type jsonRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type jsonRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error *jsonRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type jsonRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// MCP protocol types
|
||||
type serverInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type serverCapabilities struct {
|
||||
Tools *toolsCapability `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type toolsCapability struct {
|
||||
ListChanged bool `json:"listChanged,omitempty"`
|
||||
}
|
||||
|
||||
type initializeResult struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
ServerInfo serverInfo `json:"serverInfo"`
|
||||
Capabilities serverCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
type toolDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema inputSchema `json:"inputSchema"`
|
||||
}
|
||||
|
||||
type inputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Properties map[string]property `json:"properties"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type property struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
type toolsListResult struct {
|
||||
Tools []toolDefinition `json:"tools"`
|
||||
}
|
||||
|
||||
type toolCallParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
|
||||
type searchArgs struct {
|
||||
Query string `json:"query"`
|
||||
Repo string `json:"repo"`
|
||||
Filetype string `json:"filetype"`
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
|
||||
type contentItem struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type toolCallResult struct {
|
||||
Content []contentItem `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// Server is the MCP server that handles stdio JSON-RPC.
|
||||
type Server struct {
|
||||
meiliClient *meili.Client
|
||||
version string
|
||||
}
|
||||
|
||||
// NewServer creates a new MCP server.
|
||||
func NewServer(meiliClient *meili.Client, version string) *Server {
|
||||
return &Server{
|
||||
meiliClient: meiliClient,
|
||||
version: version,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the MCP server, reading from stdin and writing to stdout.
|
||||
func (s *Server) Run() error {
|
||||
// Log to stderr so it doesn't interfere with JSON-RPC on stdout
|
||||
log.SetOutput(os.Stderr)
|
||||
log.SetPrefix("[mcp-server] ")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
writer := os.Stdout
|
||||
|
||||
log.Println("MCP server started, waiting for requests on stdin")
|
||||
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
log.Println("stdin closed, shutting down")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("reading stdin: %w", err)
|
||||
}
|
||||
|
||||
line = []byte(strings.TrimSpace(string(line)))
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var req jsonRPCRequest
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
log.Printf("invalid JSON-RPC request: %s", string(line))
|
||||
continue
|
||||
}
|
||||
|
||||
resp := s.handleRequest(req)
|
||||
if resp == nil {
|
||||
// Notification, no response needed
|
||||
continue
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
log.Printf("error marshaling response: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
respBytes = append(respBytes, '\n')
|
||||
if _, err := writer.Write(respBytes); err != nil {
|
||||
return fmt.Errorf("writing response: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRequest(req jsonRPCRequest) *jsonRPCResponse {
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return s.handleInitialize(req)
|
||||
case "notifications/initialized":
|
||||
log.Println("Client initialized")
|
||||
return nil // notification, no response
|
||||
case "tools/list":
|
||||
return s.handleToolsList(req)
|
||||
case "tools/call":
|
||||
return s.handleToolsCall(req)
|
||||
case "ping":
|
||||
return &jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]interface{}{},
|
||||
}
|
||||
default:
|
||||
log.Printf("Unknown method: %s", req.Method)
|
||||
return &jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &jsonRPCError{
|
||||
Code: -32601,
|
||||
Message: fmt.Sprintf("method not found: %s", req.Method),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleInitialize(req jsonRPCRequest) *jsonRPCResponse {
|
||||
log.Println("Handling initialize")
|
||||
return &jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: initializeResult{
|
||||
ProtocolVersion: "2024-11-05",
|
||||
ServerInfo: serverInfo{
|
||||
Name: "gitea-search",
|
||||
Version: s.version,
|
||||
},
|
||||
Capabilities: serverCapabilities{
|
||||
Tools: &toolsCapability{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleToolsList(req jsonRPCRequest) *jsonRPCResponse {
|
||||
log.Println("Handling tools/list")
|
||||
return &jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: toolsListResult{
|
||||
Tools: []toolDefinition{
|
||||
{
|
||||
Name: "gitea_search",
|
||||
Description: "Search across all Gitea repositories. Returns matching files with code snippets. Use this to find code, configuration, documentation, or any file content across the codebase.",
|
||||
InputSchema: inputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]property{
|
||||
"query": {
|
||||
Type: "string",
|
||||
Description: "Search terms. Supports natural language queries and exact phrases.",
|
||||
},
|
||||
"repo": {
|
||||
Type: "string",
|
||||
Description: "Filter to a specific repo by full name (e.g., 'rpert/infra-ssh'). Omit to search all repos.",
|
||||
},
|
||||
"filetype": {
|
||||
Type: "string",
|
||||
Description: "Filter by file extension without dot (e.g., 'go', 'md', 'yaml', 'py').",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of results to return.",
|
||||
Default: 10,
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleToolsCall(req jsonRPCRequest) *jsonRPCResponse {
|
||||
var params toolCallParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return &jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &jsonRPCError{
|
||||
Code: -32602,
|
||||
Message: fmt.Sprintf("invalid params: %v", err),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if params.Name != "gitea_search" {
|
||||
return &jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &jsonRPCError{
|
||||
Code: -32602,
|
||||
Message: fmt.Sprintf("unknown tool: %s", params.Name),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var args searchArgs
|
||||
if err := json.Unmarshal(params.Arguments, &args); err != nil {
|
||||
return &jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: toolCallResult{
|
||||
Content: []contentItem{{Type: "text", Text: fmt.Sprintf("Error parsing arguments: %v", err)}},
|
||||
IsError: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if args.Query == "" {
|
||||
return &jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: toolCallResult{
|
||||
Content: []contentItem{{Type: "text", Text: "Error: query parameter is required"}},
|
||||
IsError: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if args.Limit <= 0 {
|
||||
args.Limit = 10
|
||||
}
|
||||
|
||||
log.Printf("Searching: query=%q repo=%q filetype=%q limit=%d", args.Query, args.Repo, args.Filetype, args.Limit)
|
||||
|
||||
results, err := s.meiliClient.Search(args.Query, args.Repo, args.Filetype, args.Limit)
|
||||
if err != nil {
|
||||
return &jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: toolCallResult{
|
||||
Content: []contentItem{{Type: "text", Text: fmt.Sprintf("Search error: %v", err)}},
|
||||
IsError: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
text := formatResults(args.Query, results)
|
||||
|
||||
return &jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: toolCallResult{
|
||||
Content: []contentItem{{Type: "text", Text: text}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func formatResults(query string, results []meili.SearchResult) string {
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No results found for %q.", query)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found %d results for %q:\n\n", len(results), query))
|
||||
|
||||
for i, r := range results {
|
||||
sb.WriteString(fmt.Sprintf("### %d. %s — `%s`\n", i+1, r.Repo, r.Path))
|
||||
sb.WriteString(fmt.Sprintf("- Branch: `%s`\n", r.Branch))
|
||||
if r.Extension != "" {
|
||||
sb.WriteString(fmt.Sprintf("- Type: `%s`\n", r.Extension))
|
||||
}
|
||||
if r.Snippet != "" {
|
||||
sb.WriteString(fmt.Sprintf("- Snippet: ...%s...\n", r.Snippet))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
267
internal/meili/client.go
Normal file
267
internal/meili/client.go
Normal 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user