@@ -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
1818const 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).
2434const 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.
4052const 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).
267282func (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.
329360type 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