Skip to content

Commit 942220f

Browse files
Merge pull request #1845 from entireio/alisha/ent-1047-semantic-search-cli-multi-repo
feat(search): accept multiple repos in semantic `entire search` (ENT-1047)
2 parents 5e38b50 + 11bc97f commit 942220f

6 files changed

Lines changed: 152 additions & 39 deletions

File tree

cmd/entire/cli/search/search.go

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

15+
ulid "github.com/oklog/ulid/v2"
16+
1517
"github.com/entireio/cli/cmd/entire/cli/api"
1618
)
1719

@@ -424,7 +426,7 @@ func ParseSearchInput(raw string) ParsedInput {
424426
case strings.HasPrefix(tok, "branch:"):
425427
p.Branch = strings.Trim(tok[len("branch:"):], "\"")
426428
case strings.HasPrefix(tok, "repo:"):
427-
p.Repos = appendUnique(p.Repos, parseListFilter(strings.TrimPrefix(tok, "repo:"))...)
429+
p.Repos = AppendUnique(p.Repos, parseListFilter(strings.TrimPrefix(tok, "repo:"))...)
428430
default:
429431
queryParts = append(queryParts, tok)
430432
}
@@ -490,32 +492,57 @@ func parseListFilter(raw string) []string {
490492
return values
491493
}
492494

493-
// ValidateRepoFilters ensures repo filters match backend semantics.
495+
// ValidateRepoFilters ensures each repo filter matches backend semantics.
496+
// Multiple explicit repo filters are accepted: the v4 query-serve path resolves
497+
// each and fans out across the cells hosting them, mirroring code search.
494498
func ValidateRepoFilters(repos []string) error {
495-
if len(repos) > 1 {
496-
return errors.New("only one explicit repo filter is currently supported")
497-
}
498-
if len(repos) == 1 && !isValidRepoFilter(repos[0]) {
499-
return fmt.Errorf(
500-
"invalid repo filter %q: expected owner/name or *; if you meant all repos, quote the asterisk: --repo '*'",
501-
repos[0],
502-
)
499+
for _, repo := range repos {
500+
if !isValidRepoFilter(repo) {
501+
return fmt.Errorf(
502+
"invalid repo filter %q: expected owner/name, gh/owner/repo, a repo ULID, or *; if you meant all repos, quote the asterisk: --repo '*'",
503+
repo,
504+
)
505+
}
503506
}
504507
return nil
505508
}
506509

510+
// isValidRepoFilter reports whether repo is a filter shape the search backends
511+
// can resolve. It accepts every form the CLI help advertises and that the
512+
// resolvers handle downstream — a bare owner/name slug, a prefixed path
513+
// (gh/owner/repo, et/proj/repo, git/owner/repo), a raw repo ULID, or the
514+
// all-repos wildcard — so validation never rejects a filter the semantic v4
515+
// lookup (lookupFilter) or code-search resolver (resolveRepoFilters) would
516+
// otherwise resolve. It still rejects obvious mistakes like a bare filename.
507517
func isValidRepoFilter(repo string) bool {
508518
if repo == AllReposFilter {
509519
return true
510520
}
511-
if strings.Contains(repo, " ") {
521+
if repo == "" || strings.Contains(repo, " ") {
512522
return false
513523
}
524+
// Raw repo ULID: the v4 route keys on ULIDs and lookupFilter matches a
525+
// prefix-less token against repo IDs.
526+
if _, err := ulid.Parse(repo); err == nil {
527+
return true
528+
}
529+
// A slug or prefixed path: owner/name or <prefix>/owner/repo. Every
530+
// path segment must be non-empty.
514531
parts := strings.Split(repo, "/")
515-
return len(parts) == 2 && parts[0] != "" && parts[1] != ""
532+
if len(parts) < 2 || len(parts) > 3 {
533+
return false
534+
}
535+
for _, part := range parts {
536+
if part == "" {
537+
return false
538+
}
539+
}
540+
return true
516541
}
517542

518-
func appendUnique(existing []string, values ...string) []string {
543+
// AppendUnique appends values to existing, skipping any already present, and
544+
// returns the result. Order is preserved (first occurrence wins).
545+
func AppendUnique(existing []string, values ...string) []string {
519546
if len(values) == 0 {
520547
return existing
521548
}

cmd/entire/cli/search/search_test.go

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -572,15 +572,23 @@ func TestParseSearchInput_AllReposFilter(t *testing.T) {
572572
}
573573
}
574574

575-
func TestValidateRepoFilters_RejectsMultipleRepos(t *testing.T) {
575+
func TestValidateRepoFilters_AllowsMultipleRepos(t *testing.T) {
576576
t.Parallel()
577577

578-
err := ValidateRepoFilters([]string{"entirehq/entire.io", "entireio/cli"})
578+
if err := ValidateRepoFilters([]string{"entirehq/entire.io", "entireio/cli"}); err != nil {
579+
t.Errorf("expected multiple valid repo filters to be accepted, got: %v", err)
580+
}
581+
}
582+
583+
func TestValidateRepoFilters_RejectsInvalidAmongMultiple(t *testing.T) {
584+
t.Parallel()
585+
586+
err := ValidateRepoFilters([]string{"entireio/cli", "AGENTS.md"})
579587
if err == nil {
580-
t.Fatal("expected validation error")
588+
t.Fatal("expected validation error for an invalid repo among valid ones")
581589
}
582-
if got := err.Error(); got != "only one explicit repo filter is currently supported" {
583-
t.Errorf("error = %q", got)
590+
if got := err.Error(); !strings.Contains(got, `invalid repo filter "AGENTS.md"`) {
591+
t.Errorf("error = %q, want it to name the invalid repo", got)
584592
}
585593
}
586594

@@ -591,12 +599,52 @@ func TestValidateRepoFilters_RejectsInvalidRepoValue(t *testing.T) {
591599
if err == nil {
592600
t.Fatal("expected validation error")
593601
}
594-
want := "invalid repo filter \"AGENTS.md\": expected owner/name or *; if you meant all repos, quote the asterisk: --repo '*'"
602+
want := "invalid repo filter \"AGENTS.md\": expected owner/name, gh/owner/repo, a repo ULID, or *; if you meant all repos, quote the asterisk: --repo '*'"
595603
if got := err.Error(); got != want {
596604
t.Errorf("error = %q, want %q", got, want)
597605
}
598606
}
599607

608+
// The CLI --repo help advertises gh/owner/repo, et/proj/repo, and raw ULIDs,
609+
// and the semantic v4 lookup + code-search resolver both handle them. Validation
610+
// must accept the same set so it never rejects a filter that would resolve
611+
// downstream (ENT-1047 review finding).
612+
func TestValidateRepoFilters_AcceptsAdvertisedFormats(t *testing.T) {
613+
t.Parallel()
614+
615+
valid := []string{
616+
"entireio/cli", // bare owner/name slug
617+
"gh/entireio/cli", // GitHub prefixed path
618+
"et/proj/repo", // Entire project prefixed path
619+
"git/owner/repo", // generic git prefixed path
620+
"01ARZ3NDEKTSV4RRFFQ69G5FAV", // raw repo ULID (canonical)
621+
"*", // all-repos wildcard
622+
}
623+
for _, repo := range valid {
624+
if err := ValidateRepoFilters([]string{repo}); err != nil {
625+
t.Errorf("ValidateRepoFilters(%q) = %v, want nil", repo, err)
626+
}
627+
}
628+
}
629+
630+
func TestValidateRepoFilters_RejectsMalformed(t *testing.T) {
631+
t.Parallel()
632+
633+
invalid := []string{
634+
"AGENTS.md", // bare filename, not a ULID or slug
635+
"owner/", // empty name segment
636+
"/repo", // empty owner segment
637+
"a/b/c/d", // too many path segments
638+
"owner name", // contains a space
639+
"gh//repo", // empty middle segment in a prefixed path
640+
}
641+
for _, repo := range invalid {
642+
if err := ValidateRepoFilters([]string{repo}); err == nil {
643+
t.Errorf("ValidateRepoFilters(%q) = nil, want validation error", repo)
644+
}
645+
}
646+
}
647+
600648
func TestParseSearchInput_QuotedAuthor(t *testing.T) {
601649
t.Parallel()
602650
p := ParseSearchInput(`author:"` + testAuthor + ` smith" fix bug`)

cmd/entire/cli/search_cmd.go

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func newSearchCmd() *cobra.Command { //nolint:maintidx // command wiring is inhe
3434
authorFlag string
3535
dateFlag string
3636
branchFlag string
37-
repoFlag string
37+
repoFlags []string
3838
allReposFlag bool
3939
insecureHTTPAuth bool
4040
)
@@ -87,11 +87,8 @@ branch:<name>, repo:<owner/name>, and repo:* to search all accessible repos.`,
8787
// literal search text so "author:foo" searches for that
8888
// string in code rather than being silently consumed.
8989
codeQuery, inlineRepos := extractInlineRepoFilters(query)
90-
var codeRepos []string
91-
if repoFlag != "" {
92-
codeRepos = []string{repoFlag}
93-
}
94-
codeRepos = append(codeRepos, inlineRepos...)
90+
codeRepos := search.AppendUnique(nil, repoFlags...)
91+
codeRepos = search.AppendUnique(codeRepos, inlineRepos...)
9592
// repo:* or --all-repos means "all repos" — no filter.
9693
// Otherwise, if no explicit filter was given, scope to the
9794
// current repo (matching the checkpoint-search default).
@@ -149,10 +146,10 @@ branch:<name>, repo:<owner/name>, and repo:* to search all accessible repos.`,
149146
if branchFlag == "" {
150147
branchFlag = parsed.Branch
151148
}
152-
repos := parsed.Repos
153-
if repoFlag != "" {
154-
repos = []string{repoFlag}
155-
}
149+
// Merge --repo flag values with inline repo: filters (flags first),
150+
// deduped. Repeatable/comma-separated --repo mirrors code-search UX.
151+
repos := search.AppendUnique(nil, repoFlags...)
152+
repos = search.AppendUnique(repos, parsed.Repos...)
156153
if err := search.ValidateRepoFilters(repos); err != nil {
157154
return fmt.Errorf("validating repo filter: %w", err)
158155
}
@@ -306,7 +303,7 @@ branch:<name>, repo:<owner/name>, and repo:* to search all accessible repos.`,
306303
cmd.Flags().StringVar(&authorFlag, "author", "", "Filter by author name")
307304
cmd.Flags().StringVar(&dateFlag, "date", "", "Filter by time period (week or month)")
308305
cmd.Flags().StringVar(&branchFlag, "branch", "", "Filter by branch name")
309-
cmd.Flags().StringVar(&repoFlag, "repo", "", "Filter by repository (gh/owner/repo, et/proj/repo, owner/repo, ULID, or *)")
306+
cmd.Flags().StringSliceVar(&repoFlags, "repo", nil, "Filter by repository (gh/owner/repo, et/proj/repo, owner/repo, ULID, or *); repeatable and comma-separated for multiple repos")
310307
cmd.Flags().BoolVar(&allReposFlag, "all-repos", false, "Search all accessible repos instead of just the current one")
311308
addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth)
312309

cmd/entire/cli/search_cmd_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,40 @@ func TestSearchCmd_MultipleInlineRepoFilters(t *testing.T) {
815815
}
816816
}
817817

818+
func TestSearchCmd_SemanticMultipleRepoFlags(t *testing.T) {
819+
// Semantic search (no --code) must accept multiple repos via a repeatable
820+
// --repo flag (ENT-1047) — parity with code search. It fails later at
821+
// auth/git, but must not be rejected as an invalid/unsupported filter.
822+
root := NewRootCmd()
823+
root.SetArgs([]string{"search", "auth", "--repo", "entirehq/entire.io", "--repo", "entireio/cli"})
824+
825+
err := root.Execute()
826+
if err != nil {
827+
if strings.Contains(err.Error(), "validating repo filter") {
828+
t.Errorf("multiple --repo flags should pass validation, got: %v", err)
829+
}
830+
if strings.Contains(err.Error(), "only one explicit repo filter") {
831+
t.Errorf("multiple repos should no longer be rejected, got: %v", err)
832+
}
833+
}
834+
}
835+
836+
func TestSearchCmd_SemanticCommaSeparatedRepoFlag(t *testing.T) {
837+
// A single comma-separated --repo value must expand to multiple repos.
838+
root := NewRootCmd()
839+
root.SetArgs([]string{"search", "auth", "--repo", "entirehq/entire.io,entireio/cli"})
840+
841+
err := root.Execute()
842+
if err != nil {
843+
if strings.Contains(err.Error(), "validating repo filter") {
844+
t.Errorf("comma-separated --repo should pass validation, got: %v", err)
845+
}
846+
if strings.Contains(err.Error(), "only one explicit repo filter") {
847+
t.Errorf("comma-separated repos should no longer be rejected, got: %v", err)
848+
}
849+
}
850+
}
851+
818852
func TestWriteCodeSearchJSON_RepoFilteredEmpty(t *testing.T) {
819853
t.Parallel()
820854

cmd/entire/cli/search_tui.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,8 +433,8 @@ func (m searchModel) updateSearchMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
433433
return m, nil
434434
}
435435
// Checkpoint search: ParseSearchInput extracts author:/date:/branch:/repo:.
436-
// ValidateRepoFilters only applies to checkpoint search (single repo limit);
437-
// code search handles multiple repos via fan-out.
436+
// ValidateRepoFilters only checks each repo value's shape; both semantic
437+
// and code search accept multiple repos and fan out across cells.
438438
parsed := search.ParseSearchInput(raw)
439439
checkpointRepoErr := search.ValidateRepoFilters(parsed.Repos)
440440

cmd/entire/cli/search_tui_test.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,7 +1405,7 @@ func TestSearchModel_NewSearchAllReposFilter(t *testing.T) {
14051405
}
14061406
}
14071407

1408-
func TestSearchModel_NewSearchRejectsMultipleExplicitRepos(t *testing.T) {
1408+
func TestSearchModel_NewSearchAcceptsMultipleExplicitRepos(t *testing.T) {
14091409
t.Parallel()
14101410

14111411
ss := statusStyles{colorEnabled: false, width: 100}
@@ -1425,13 +1425,20 @@ func TestSearchModel_NewSearchRejectsMultipleExplicitRepos(t *testing.T) {
14251425
t.Fatalf("Update returned %T, want searchModel", updated)
14261426
}
14271427

1428-
// Multi-repo filters are invalid for checkpoint search and code search is
1429-
// off (nil codeOpts) — stay in search mode so the user can correct input.
1430-
if m.mode != modeSearch {
1431-
t.Errorf("mode = %d, want modeSearch", m.mode)
1428+
// Multiple explicit repos are now valid: the semantic search fires (the v4
1429+
// path fans out across the hosting cells), so no error and we leave search
1430+
// mode to show loading results.
1431+
if m.searchErr != "" {
1432+
t.Errorf("searchErr = %q, want empty", m.searchErr)
1433+
}
1434+
if m.mode != modeBrowse {
1435+
t.Errorf("mode = %d, want modeBrowse", m.mode)
1436+
}
1437+
if !m.loading {
1438+
t.Error("loading = false, want true (semantic search should fire)")
14321439
}
1433-
if m.searchErr != "only one explicit repo filter is currently supported" {
1434-
t.Errorf("searchErr = %q", m.searchErr)
1440+
if got, want := m.searchCfg.Repos, []string{"entirehq/entire.io", "entireio/cli"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
1441+
t.Errorf("searchCfg.Repos = %v, want %v", got, want)
14351442
}
14361443
}
14371444

0 commit comments

Comments
 (0)