diff --git a/internal/store/search.go b/internal/store/search.go new file mode 100644 index 0000000..5fa6e09 --- /dev/null +++ b/internal/store/search.go @@ -0,0 +1,53 @@ +package store + +import ( + "context" + "database/sql" + "fmt" +) + +type SearchHit struct { + ThreadID int64 `json:"thread_id"` + Number int `json:"number"` + Kind string `json:"kind"` + State string `json:"state"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + AuthorLogin string `json:"author_login,omitempty"` + Snippet string `json:"snippet"` +} + +func (s *Store) SearchDocuments(ctx context.Context, repoID int64, query string, limit int) ([]SearchHit, error) { + if limit <= 0 { + limit = 20 + } + rows, err := s.db.QueryContext(ctx, ` + select t.id, t.number, t.kind, t.state, t.title, t.html_url, t.author_login, + snippet(documents_fts, 3, '[', ']', '...', 18) + from documents_fts + join documents d on d.id = documents_fts.rowid + join threads t on t.id = d.thread_id + where t.repo_id = ? and documents_fts match ? + order by bm25(documents_fts) + limit ? + `, repoID, query, limit) + if err != nil { + return nil, fmt.Errorf("search documents: %w", err) + } + defer rows.Close() + + var out []SearchHit + for rows.Next() { + var hit SearchHit + var author sql.NullString + if err := rows.Scan(&hit.ThreadID, &hit.Number, &hit.Kind, &hit.State, &hit.Title, &hit.HTMLURL, &author, &hit.Snippet); err != nil { + return nil, fmt.Errorf("scan search hit: %w", err) + } + hit.AuthorLogin = author.String + out = append(out, hit) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate search hits: %w", err) + } + return out, nil +} diff --git a/internal/store/search_test.go b/internal/store/search_test.go new file mode 100644 index 0000000..4e6ddc4 --- /dev/null +++ b/internal/store/search_test.go @@ -0,0 +1,40 @@ +package store + +import ( + "context" + "path/filepath" + "testing" +) + +func TestSearchDocuments(t *testing.T) { + ctx := context.Background() + st, err := Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer st.Close() + + repoID, err := st.UpsertRepository(ctx, Repository{Owner: "openclaw", Name: "gitcrawl", FullName: "openclaw/gitcrawl", RawJSON: "{}", UpdatedAt: "2026-04-26T00:00:00Z"}) + if err != nil { + t.Fatalf("repo: %v", err) + } + threadID, err := st.UpsertThread(ctx, Thread{ + RepoID: repoID, GitHubID: "1", Number: 1, Kind: "issue", State: "open", + Title: "download stalls", HTMLURL: "https://github.com/openclaw/gitcrawl/issues/1", + LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: "hash", UpdatedAt: "2026-04-26T00:00:00Z", + }) + if err != nil { + t.Fatalf("thread: %v", err) + } + if _, err := st.UpsertDocument(ctx, Document{ThreadID: threadID, Title: "download stalls", RawText: "artifact download stalls", DedupeText: "artifact download stalls", UpdatedAt: "2026-04-26T00:00:00Z"}); err != nil { + t.Fatalf("document: %v", err) + } + + hits, err := st.SearchDocuments(ctx, repoID, "artifact", 10) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(hits) != 1 || hits[0].Number != 1 { + t.Fatalf("unexpected hits: %#v", hits) + } +}