Skip to content

Commit 88bbc6f

Browse files
Merge pull request #1800 from entireio/ent-1055-cli-v4-path
feat(search)!: route entire search to the v4 query-serve path (cross-cell fan-out, v3 removed)
2 parents 47b9a3e + 0f9efe4 commit 88bbc6f

10 files changed

Lines changed: 1598 additions & 599 deletions

File tree

cmd/entire/cli/api/base_url.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ func NormalizeOriginURL(raw string) string {
130130
}
131131

132132
// OriginOnly is a backwards-compatible alias for NormalizeOriginURL.
133-
// Callers reading raw URLs (e.g. ENTIRE_SEARCH_URL) and feeding them into
133+
// Callers reading raw URLs (e.g. ENTIRE_API_BASE_URL) and feeding them into
134134
// tokenmanager.TokenRequest.Resource use this to strip path/query/fragment
135135
// before the lib's stricter origin-only validator runs.
136136
func OriginOnly(raw string) string {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package search
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// TestConfig_ScopeSlugs verifies the shared scope predicate both backends
10+
// derive their repo scoping from. The precedence rule that matters most: an
11+
// explicit repo filter always wins over --all-repos (the more specific filter
12+
// scopes the search) — v3 and v4 must agree on this.
13+
func TestConfig_ScopeSlugs(t *testing.T) {
14+
t.Parallel()
15+
tests := []struct {
16+
name string
17+
cfg Config
18+
wantSlugs []string
19+
wantAllRepos bool
20+
}{
21+
{"all-repos flag", Config{AllRepos: true}, nil, true},
22+
{"repo:* filter", Config{Repos: []string{AllReposFilter}}, nil, true},
23+
{"explicit repo", Config{Repos: []string{"o/r"}}, []string{"o/r"}, false},
24+
{"current-repo default", Config{Owner: "o", Repo: "r"}, []string{"o/r"}, false},
25+
{"explicit filter wins over --all-repos", Config{AllRepos: true, Repos: []string{"o/r"}}, []string{"o/r"}, false},
26+
{"explicit filter wins over repo:*", Config{Repos: []string{"o/r", AllReposFilter}}, []string{"o/r"}, false},
27+
{"explicit filters win over current repo", Config{Repos: []string{"a/b", "c/d"}, Owner: "o", Repo: "r"}, []string{"a/b", "c/d"}, false},
28+
{"no scope", Config{}, nil, false},
29+
{"owner without repo is no scope", Config{Owner: "o"}, nil, false},
30+
}
31+
for _, tt := range tests {
32+
t.Run(tt.name, func(t *testing.T) {
33+
t.Parallel()
34+
slugs, allRepos := tt.cfg.ScopeSlugs()
35+
if strings.Join(slugs, ",") != strings.Join(tt.wantSlugs, ",") || allRepos != tt.wantAllRepos {
36+
t.Errorf("ScopeSlugs() = (%v, %v), want (%v, %v)", slugs, allRepos, tt.wantSlugs, tt.wantAllRepos)
37+
}
38+
})
39+
}
40+
}
41+
42+
// TestResultID_RawDataFallback verifies repo/pr rows — which have no typed
43+
// struct — expose the raw payload's id, so cross-cell dedup can identify the
44+
// same logical result from two cells.
45+
func TestResultID_RawDataFallback(t *testing.T) {
46+
t.Parallel()
47+
48+
var repoRow Result
49+
if err := json.Unmarshal([]byte(`{"type":"repo","data":{"id":"01JREPO","name":"x"},"searchMeta":{"score":1}}`), &repoRow); err != nil {
50+
t.Fatal(err)
51+
}
52+
if got := repoRow.ResultID(); got != "01JREPO" {
53+
t.Errorf("repo ResultID() = %q, want the rawData id \"01JREPO\"", got)
54+
}
55+
56+
var noID Result
57+
if err := json.Unmarshal([]byte(`{"type":"pr","data":{"title":"no id"},"searchMeta":{"score":1}}`), &noID); err != nil {
58+
t.Fatal(err)
59+
}
60+
if got := noID.ResultID(); got != "" {
61+
t.Errorf("pr-without-id ResultID() = %q, want \"\"", got)
62+
}
63+
64+
// Typed results are unaffected.
65+
ck := Result{Type: TypeCheckpoint, Checkpoint: &CheckpointResult{ID: "ck1"}}
66+
if got := ck.ResultID(); got != "ck1" {
67+
t.Errorf("checkpoint ResultID() = %q, want \"ck1\"", got)
68+
}
69+
}

cmd/entire/cli/search/search.go

Lines changed: 115 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,23 @@ import (
1212
"strings"
1313
"time"
1414

15-
"github.com/entireio/cli/cmd/entire/cli/versioninfo"
15+
"github.com/entireio/cli/cmd/entire/cli/api"
1616
)
1717

1818
const apiTimeout = 30 * time.Second
1919

20-
// DefaultServiceURL is the production search service URL.
21-
const DefaultServiceURL = "https://entire.io"
20+
// v4ServicePath is the per-repo v4 query-serve route exposed by the entire-api
21+
// cell gateway. It takes repo=<ULID>. The BFF (entire.io /api/v1/search)
22+
// forwards to this same path; the CLI dials the cell directly with a
23+
// jurisdictional identity token, skipping the BFF hop.
24+
const v4ServicePath = "/api/v1/semantic-search/search/v1/search"
25+
26+
// ErrCellUnavailable reports that a cell's gateway does not expose the
27+
// semantic-search route at all (HTTP 404 at the route level) — query-serve is
28+
// not deployed in that cell yet. Callers fanning out across cells match it
29+
// with errors.Is and skip the cell quietly instead of warning the user about
30+
// a "failed" region.
31+
var ErrCellUnavailable = errors.New("semantic search is not available in this cell")
2232

2333
// WildcardQuery is the query string used when only filters are provided (no search terms).
2434
const WildcardQuery = "*"
@@ -31,11 +41,13 @@ const (
3141
TypeCheckpoint = "checkpoint"
3242
TypeCommit = "commit"
3343
TypeSession = "session"
44+
// TypeRepo and TypePR are returned by the backend but have no typed struct
45+
// (decoded via rawData). They're named so the cross-cell v4 merge can bucket
46+
// and tally them without string literals.
47+
TypeRepo = "repo"
48+
TypePR = "pr"
3449
)
3550

36-
// MaxLimit is the maximum number of results the search API will return per request.
37-
const MaxLimit = 200
38-
3951
// DefaultLimit is the default number of results to fetch per request, matching the UI.
4052
const DefaultLimit = 100
4153

@@ -263,12 +275,26 @@ func (r *Result) ResultAuthor() string {
263275
})
264276
}
265277

266-
// ResultID returns the primary ID for any result type.
278+
// ResultID returns the primary ID for any result type. Types without a typed
279+
// struct (repo, pr) fall back to the "id" field of the raw payload, so a
280+
// cross-cell merge can still identify the same logical result returned by two
281+
// cells (e.g. a repo mirrored in both).
267282
func (r *Result) ResultID() string {
268-
return resultField(r,
283+
if id := resultField(r,
269284
func(c *CheckpointResult) string { return c.ID },
270285
func(c *CommitResult) string { return c.CommitSHA },
271-
func(s *SessionResult) string { return s.SessionID })
286+
func(s *SessionResult) string { return s.SessionID }); id != "" {
287+
return id
288+
}
289+
if len(r.rawData) > 0 {
290+
var d struct {
291+
ID string `json:"id"`
292+
}
293+
if err := json.Unmarshal(r.rawData, &d); err == nil {
294+
return d.ID
295+
}
296+
}
297+
return ""
272298
}
273299

274300
// ResultTitle returns the primary display text for any result type.
@@ -323,22 +349,48 @@ type Response struct {
323349
Timing *Timing `json:"timing,omitempty"`
324350
Reranked *bool `json:"reranked,omitempty"`
325351
Counts *TypeCounts `json:"counts,omitempty"`
352+
353+
// Warnings are client-side completeness notes (e.g. a truncated repo
354+
// index or a failed region in a cross-cell fan-out) surfaced to the user
355+
// on stderr. Never part of the wire format.
356+
Warnings []string `json:"-"`
326357
}
327358

328359
// Config holds the configuration for a search request.
329360
type Config struct {
330-
ServiceURL string // Base URL of the search service
331-
GitHubToken string
332-
Owner string
333-
Repo string
334-
Repos []string
335-
AllRepos bool // When true, search all accessible repos (no repo scoping)
336-
Query string
337-
Limit int
338-
Author string // Filter by author name
339-
Date string // Filter by time period: "week" or "month"
340-
Branch string // Filter by branch name
341-
Page int // 1-based page number (0 means omit, API defaults to 1)
361+
Owner string
362+
Repo string
363+
Repos []string
364+
AllRepos bool // When true, search all accessible repos (no repo scoping)
365+
Query string
366+
Limit int
367+
Author string // Filter by author name
368+
Date string // Filter by time period: "week" or "month"
369+
Branch string // Filter by branch name
370+
Page int // 1-based page number (0 means omit, API defaults to 1)
371+
}
372+
373+
// ScopeSlugs resolves the repo scope of a search: the explicit repo filters
374+
// (an explicit owner/name filter always scopes the search, even when
375+
// --all-repos is also set — the more specific filter wins), else allRepos for
376+
// an unfiltered repo:* / --all-repos search, else the current-repo default.
377+
// slugs empty with allRepos false means no scope could be determined.
378+
func (c Config) ScopeSlugs() (slugs []string, allRepos bool) {
379+
for _, repo := range c.Repos {
380+
if repo != AllReposFilter {
381+
slugs = append(slugs, repo)
382+
}
383+
}
384+
if len(slugs) > 0 {
385+
return slugs, false
386+
}
387+
if c.AllRepos || (len(c.Repos) == 1 && c.Repos[0] == AllReposFilter) {
388+
return nil, true
389+
}
390+
if c.Owner != "" && c.Repo != "" {
391+
return []string{c.Owner + "/" + c.Repo}, false
392+
}
393+
return nil, false
342394
}
343395

344396
// HasFilters reports whether any filter fields are set on the config.
@@ -483,52 +535,50 @@ func appendUnique(existing []string, values ...string) []string {
483535
return existing
484536
}
485537

486-
var httpClient = &http.Client{}
487-
488-
// Search calls the search service to perform a hybrid search.
489-
func Search(ctx context.Context, cfg Config) (*Response, error) {
538+
// CellV4 performs a v4 query-serve search against a single entire-api
539+
// cell, via the pre-authenticated client (bearer = jurisdictional identity
540+
// token; host = the cell). repoIDs are repo ULIDs to scope to (the v4 route is
541+
// per-repo and keys on ULIDs, not owner/name slugs); an empty repoIDs means
542+
// "every repo the caller can access in this cell" — query-serve fans out across
543+
// those namespaces itself. The cross-cell fan-out and merge live in the cli
544+
// layer (mirroring code search), so this is the single-cell primitive it calls.
545+
func CellV4(ctx context.Context, client *api.Client, cfg Config, repoIDs []string) (*Response, error) {
490546
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
491547
defer cancel()
492548

493-
serviceURL := cfg.ServiceURL
494-
if serviceURL == "" {
495-
serviceURL = DefaultServiceURL
549+
q := url.Values{}
550+
q.Set("q", cfg.Query)
551+
for _, id := range repoIDs {
552+
if id != "" {
553+
q.Add("repo", id)
554+
}
496555
}
556+
addCommonSearchParams(q, cfg)
497557

498-
u, err := url.Parse(serviceURL)
558+
resp, err := client.Get(ctx, v4ServicePath+"?"+q.Encode())
499559
if err != nil {
500-
return nil, fmt.Errorf("parsing service URL: %w", err)
560+
return nil, fmt.Errorf("calling search service: %w", err)
501561
}
502-
u.Path = "/search/v1/search"
562+
defer resp.Body.Close()
503563

504-
q := u.Query()
505-
q.Set("q", cfg.Query)
506-
if err := ValidateRepoFilters(cfg.Repos); err != nil {
507-
return nil, err
508-
}
509-
allRepos := cfg.AllRepos || (len(cfg.Repos) == 1 && cfg.Repos[0] == AllReposFilter)
510-
hasExplicitRepo := false
511-
for _, repo := range cfg.Repos {
512-
if repo != AllReposFilter {
513-
hasExplicitRepo = true
514-
break
515-
}
564+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
565+
if err != nil {
566+
return nil, fmt.Errorf("reading response: %w", err)
516567
}
517-
switch {
518-
case hasExplicitRepo:
519-
// An explicit owner/name filter always scopes the search, even when
520-
// --all-repos is also set (the more specific filter wins).
521-
for _, repo := range cfg.Repos {
522-
if repo != AllReposFilter {
523-
q.Add("repo", repo)
524-
}
525-
}
526-
case allRepos:
527-
// No repo scoping — search every accessible repo.
528-
case cfg.Owner != "" && cfg.Repo != "":
529-
q.Set("repo", cfg.Owner+"/"+cfg.Repo)
568+
if resp.StatusCode == http.StatusNotFound {
569+
// The gateway has no semantic-search route (plain "404 page not
570+
// found") — query-serve isn't deployed in this cell. Deployed cells
571+
// answer unknown repos with an empty 200, so a route-level 404 is
572+
// distinctive.
573+
return nil, ErrCellUnavailable
530574
}
531-
// Don't set types — let the API return all types (checkpoints, commits, sessions, etc.)
575+
return parseSearchResponse(resp.StatusCode, body)
576+
}
577+
578+
// addCommonSearchParams sets the query params other than the repo scoping
579+
// (repo IDs are added by CellV4's caller). types is deliberately never sent —
580+
// the backend returns all types.
581+
func addCommonSearchParams(q url.Values, cfg Config) {
532582
if cfg.Limit > 0 {
533583
q.Set("limit", strconv.Itoa(cfg.Limit))
534584
}
@@ -544,34 +594,20 @@ func Search(ctx context.Context, cfg Config) (*Response, error) {
544594
if cfg.Page > 0 {
545595
q.Set("page", strconv.Itoa(cfg.Page))
546596
}
547-
u.RawQuery = q.Encode()
548-
549-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
550-
if err != nil {
551-
return nil, fmt.Errorf("creating request: %w", err)
552-
}
553-
req.Header.Set("Authorization", "Bearer "+cfg.GitHubToken)
554-
req.Header.Set("User-Agent", versioninfo.UserAgent())
555-
556-
resp, err := httpClient.Do(req)
557-
if err != nil {
558-
return nil, fmt.Errorf("calling search service: %w", err)
559-
}
560-
defer resp.Body.Close()
561-
562-
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
563-
if err != nil {
564-
return nil, fmt.Errorf("reading response: %w", err)
565-
}
597+
}
566598

567-
if resp.StatusCode != http.StatusOK {
599+
// parseSearchResponse decodes a search response body, preserving the
600+
// long-standing error wording so callers (and error-message assertions) are
601+
// unchanged.
602+
func parseSearchResponse(statusCode int, body []byte) (*Response, error) {
603+
if statusCode != http.StatusOK {
568604
var errResp struct {
569605
Error string `json:"error"`
570606
}
571607
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
572-
return nil, fmt.Errorf("search service error (%d): %s", resp.StatusCode, errResp.Error)
608+
return nil, fmt.Errorf("search service error (%d): %s", statusCode, errResp.Error)
573609
}
574-
return nil, fmt.Errorf("search service returned %d: %s", resp.StatusCode, string(body))
610+
return nil, fmt.Errorf("search service returned %d: %s", statusCode, string(body))
575611
}
576612

577613
var result Response

0 commit comments

Comments
 (0)