Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions cmd/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,28 @@ func init() {
// Register schemas for all commands at startup.
schema.Register(schema.CommandSchema{
Command: "search",
Description: "Search for content in your Glean instance. Results are JSON.",
Description: "Search for content in your Glean instance via the platform API (POST /api/search); responses use its snake_case shape. Falls back to the classic API with a warning when the platform API is not enabled; GLEAN_LEGACY_APIS=1 forces the classic API. Results are JSON.",
Flags: map[string]schema.FlagSchema{
"--json": {Type: "string", Description: "Complete JSON request body (overrides individual flags)", Required: false},
"--json": {Type: "string", Description: "Complete JSON request body in the platform shape: query, page_size, cursor, datasources, datasource_instances, filters, time_range (overrides individual flags). With GLEAN_LEGACY_APIS=1, parsed as the classic shape", Required: false},
"--query": {Type: "string", Description: "Search query (positional arg)", Required: true},
"--page-size": {Type: "integer", Default: 10, Description: "Number of results per page"},
"--max-snippet-size": {Type: "integer", Default: 0, Description: "Maximum snippet size in characters"},
"--max-snippet-size": {Type: "integer", Default: 0, Description: "Maximum snippet size in characters (legacy API only)"},
"--timeout": {Type: "integer", Default: 30000, Description: "Request timeout in milliseconds"},
"--disable-spellcheck": {Type: "boolean", Default: false, Description: "Disable spellcheck"},
"--disable-spellcheck": {Type: "boolean", Default: false, Description: "Disable spellcheck (legacy API only)"},
"--datasource": {Type: "[]string", Description: "Filter by datasource (repeatable)"},
"--type": {Type: "[]string", Description: "Filter by document type (repeatable)"},
"--tab": {Type: "[]string", Description: "Filter by result tab IDs (repeatable)"},
"--response-hints": {Type: "[]string", Default: []string{"RESULTS", "QUERY_METADATA"}, Description: "Response hints"},
"--facet-bucket-size": {Type: "integer", Default: 10, Description: "Maximum facet buckets per result"},
"--disable-query-autocorrect": {Type: "boolean", Default: false, Description: "Disable automatic query corrections"},
"--fetch-all-datasource-counts": {Type: "boolean", Default: false, Description: "Return counts for all datasources"},
"--query-overrides-facet-filters": {Type: "boolean", Default: false, Description: "Allow query operators to override facet filters"},
"--return-llm-content": {Type: "boolean", Default: false, Description: "Return expanded LLM-friendly content"},
"--tab": {Type: "[]string", Description: "Filter by result tab IDs (repeatable, legacy API only)"},
"--response-hints": {Type: "[]string", Default: []string{"RESULTS", "QUERY_METADATA"}, Description: "Response hints (legacy API only)"},
"--facet-bucket-size": {Type: "integer", Default: 10, Description: "Maximum facet buckets per result (legacy API only)"},
"--disable-query-autocorrect": {Type: "boolean", Default: false, Description: "Disable automatic query corrections (legacy API only)"},
"--fetch-all-datasource-counts": {Type: "boolean", Default: false, Description: "Return counts for all datasources (legacy API only)"},
"--query-overrides-facet-filters": {Type: "boolean", Default: false, Description: "Allow query operators to override facet filters (legacy API only)"},
"--return-llm-content": {Type: "boolean", Default: false, Description: "Return expanded LLM-friendly content (legacy API only)"},
"--output": {Type: "enum", Enum: []string{"json", "ndjson", "text"}, Default: "json", Description: "Output format"},
"--dry-run": {Type: "boolean", Default: false, Description: "Print request body without sending"},
},
Example: `glean search "vacation policy" | jq '.results[].document.title'
glean search --json '{"query":"Q1 reports","pageSize":5,"datasources":["confluence"]}' | jq .`,
Example: `glean search "vacation policy" | jq '.results[].title'
glean search --json '{"query":"Q1 reports","page_size":5,"datasources":["confluence"]}' | jq .`,
})

schema.Register(schema.CommandSchema{
Expand Down
161 changes: 132 additions & 29 deletions cmd/search.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
Expand All @@ -9,15 +10,42 @@ import (
"github.com/gleanwork/api-client-go/models/components"
gleanClient "github.com/gleanwork/glean-cli/internal/client"
"github.com/gleanwork/glean-cli/internal/output"
"github.com/gleanwork/glean-cli/internal/platform"
"github.com/gleanwork/glean-cli/internal/search"
"github.com/spf13/cobra"
)

// searchTextFn renders whichever search response shape the request produced:
// the platform response (default) or the classic response (legacy fallback).
func searchTextFn(w io.Writer, v any) error {
resp, ok := v.(*components.SearchResponse)
if !ok {
switch resp := v.(type) {
case *components.PlatformSearchResponse:
return platformSearchTextFn(w, resp)
case *components.SearchResponse:
return legacySearchTextFn(w, resp)
default:
return output.WriteJSON(w, v)
}
}

func platformSearchTextFn(w io.Writer, resp *components.PlatformSearchResponse) error {
var rows [][]string
for _, r := range resp.Results {
snippet := ""
if len(r.Snippets) > 0 {
snippet = r.Snippets[0]
}
rows = append(rows, []string{
output.Truncate(r.Title, 50),
r.Datasource,
r.URL,
output.Truncate(snippet, 60),
})
}
return output.WriteTable(w, []string{"TITLE", "SOURCE", "URL", "SNIPPET"}, rows)
}

func legacySearchTextFn(w io.Writer, resp *components.SearchResponse) error {
var rows [][]string
for _, r := range resp.Results {
title, source, url, snippet := "", "", "", ""
Expand Down Expand Up @@ -70,24 +98,34 @@ func NewCmdSearch() *cobra.Command {
Short: "Search for content in your Glean instance",
Long: `Search for content in your Glean instance.

Search is served by the platform API (POST /api/search) and results use its
snake_case response shape. If the platform API is not enabled on your
instance, the command falls back to the classic API with a warning; set
GLEAN_LEGACY_APIS=1 to use the classic API directly.

Results are written as JSON to stdout by default, making the output easy to
pipe to jq or other tools.

Example:
glean search "vacation policy"
glean search "vacation policy" | jq '.results[].document.title'
glean search --json '{"query":"Q1 reports","pageSize":5}' | jq .
glean search --output ndjson "engineering docs" | head -3 | jq .document.title
glean search "vacation policy" | jq '.results[].title'
glean search --json '{"query":"Q1 reports","page_size":5}' | jq .
glean search --output ndjson "engineering docs" | head -3 | jq .title
glean search --dry-run "test"`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if jsonPayload == "" && len(args) == 0 {
return fmt.Errorf("requires a query argument or --json payload")
}

// --json path: parse directly into SearchRequest
// --json path: the payload shape is coupled to the endpoint, so a
// user-authored body is never replayed against the other surface.
// Platform shape by default; classic shape under GLEAN_LEGACY_APIS=1.
if jsonPayload != "" {
var req components.SearchRequest
if platform.Legacy() {
return runLegacyJSONSearch(cmd, jsonPayload, dryRun, outputFormat, raw)
}
var req components.PlatformSearchRequest
if err := json.Unmarshal([]byte(jsonPayload), &req); err != nil {
return fmt.Errorf("invalid --json payload: %w", err)
}
Expand All @@ -98,23 +136,14 @@ Example:
if err != nil {
return err
}
resp, err := sdk.Client.Search.Query(cmd.Context(), req, nil)
resp, err := sdk.Search.Query(cmd.Context(), req)
if err != nil {
return fmt.Errorf("search request failed: %w", err)
}
// Text output operates on the raw SDK struct directly;
// cleansing is redundant since the table selects its own fields.
if outputFormat == output.OutputText {
return output.WriteFormatted(cmd.OutOrStdout(), resp.SearchResponse, outputFormat, searchTextFn)
}
var result any = resp.SearchResponse
if !raw {
result, err = output.CleanseSearchResponse(result)
if err != nil {
return err
if platform.IsGateClosed(err) {
return platform.GateClosedErr("/api/search", err)
}
return fmt.Errorf("search request failed: %w", err)
}
return output.WriteFormatted(cmd.OutOrStdout(), result, outputFormat, searchTextFn)
return output.WriteFormatted(cmd.OutOrStdout(), resp.PlatformSearchResponse, outputFormat, searchTextFn)
}

// flag-based path
Expand Down Expand Up @@ -153,31 +182,46 @@ Example:
opts.Query = args[0]

if dryRun {
return output.WriteJSON(cmd.OutOrStdout(), search.BuildSearchRequest(opts))
if platform.Legacy() {
return output.WriteJSON(cmd.OutOrStdout(), search.BuildSearchRequest(opts))
}
return output.WriteJSON(cmd.OutOrStdout(), search.BuildPlatformSearchRequest(opts))
}

sdk, err := gleanClient.NewFromConfig()
if err != nil {
return err
}
resp, err := search.RunSearchSDK(cmd.Context(), opts, sdk)
if !platform.Legacy() {
if ignored := platformIgnoredFlags(cmd); len(ignored) > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The note about legacy-only flags being ignored by platform search is printed before platform.Run executes, so it is emitted whenever GLEAN_LEGACY_APIS is not set, even if platform.Run falls back to the legacy API and those flags are actually honored.

Suggested fix: Move the platformIgnoredFlags(cmd) check to after the platform.Run call and only emit the "flags ignored by platform search" note when viaLegacy is false (i.e., the platform API was used and legacy-only flags truly had no effect).

🔧 Tag @ glean-for-engineering to fix or click here to fix in Glean

💬 Help us improve! Was this comment helpful? React with 👍 or 👎

fmt.Fprintf(cmd.ErrOrStderr(),
"Note: flags ignored by platform search (legacy-only): %s\n",
strings.Join(ignored, ", "))
}
}

result, viaLegacy, err := platform.Run(cmd.Context(), cmd.ErrOrStderr(), "/api/search",
func(ctx context.Context) (any, error) { return search.RunPlatformSearch(ctx, opts, sdk) },
func(ctx context.Context) (any, error) { return search.RunSearchSDK(ctx, opts, sdk) },
)
if err != nil {
return err
}
// Text output operates on the raw SDK struct directly;
// cleansing is redundant since the table selects its own fields.
if outputFormat == output.OutputText {
return output.WriteFormatted(cmd.OutOrStdout(), resp, outputFormat, searchTextFn)
return output.WriteFormatted(cmd.OutOrStdout(), result, outputFormat, searchTextFn)
}
var result any = resp
if !raw {
// The classic response carries superfluous properties and is
// cleansed unless --raw; platform responses are emitted as-is.
if viaLegacy && !raw {
result, err = output.CleanseSearchResponse(result)
if err != nil {
return err
}
}
if fields != "" {
if !raw {
if viaLegacy && !raw {
if stripped := output.WarnStrippedFields(fields); len(stripped) > 0 {
fmt.Fprintf(cmd.ErrOrStderr(),
"Warning: field(s) %s not available in cleansed output (use --raw for the full response)\n",
Expand All @@ -190,11 +234,11 @@ Example:
},
}

cmd.Flags().StringVar(&jsonPayload, "json", "", "Complete JSON request body (overrides all other flags)")
cmd.Flags().StringVar(&jsonPayload, "json", "", "Complete JSON request body in the platform shape (overrides all other flags); with GLEAN_LEGACY_APIS=1, parsed as the classic shape")
cmd.Flags().StringVar(&outputFormat, "output", "json", "Output format: json, ndjson, or text")
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated dot-path fields to include (e.g. results.document.title,results.document.url). Results where all projected fields are missing appear as {}")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print the request body without sending it")
cmd.Flags().BoolVar(&raw, "raw", false, "Output the full SDK response without cleansing")
cmd.Flags().BoolVar(&raw, "raw", false, "Output the full SDK response without cleansing (classic API responses only; platform responses are never cleansed)")
cmd.Flags().IntVar(&opts.PageSize, "page-size", 10, "Number of results per page")
cmd.Flags().IntVar(&opts.MaxSnippetSize, "max-snippet-size", 0, "Maximum size of snippets")
cmd.Flags().IntVar(&opts.TimeoutMillis, "timeout", 30000, "Request timeout in milliseconds")
Expand All @@ -213,3 +257,62 @@ Example:

return cmd
}

// platformIgnoredFlags returns the explicitly-set search flags that have no
// platform API equivalent, so users learn their flag did nothing rather than
// silently getting unfiltered results.
func platformIgnoredFlags(cmd *cobra.Command) []string {
legacyOnly := []string{
"max-snippet-size",
"disable-spellcheck",
"tab",
"response-hints",
"facet-bucket-size",
"disable-query-autocorrect",
"fetch-all-datasource-counts",
"query-overrides-facet-filters",
"return-llm-content",
}
var ignored []string
for _, name := range legacyOnly {
if cmd.Flags().Changed(name) {
ignored = append(ignored, "--"+name)
}
}
return ignored
}

// runLegacyJSONSearch handles --json under GLEAN_LEGACY_APIS=1: the payload
// is parsed as the classic /rest/api/v1/search request shape and the
// response is cleansed unless --raw, exactly as before the platform
// migration.
func runLegacyJSONSearch(cmd *cobra.Command, jsonPayload string, dryRun bool, outputFormat string, raw bool) error {
var req components.SearchRequest
if err := json.Unmarshal([]byte(jsonPayload), &req); err != nil {
return fmt.Errorf("invalid --json payload: %w", err)
}
if dryRun {
return output.WriteJSON(cmd.OutOrStdout(), req)
}
sdk, err := gleanClient.NewFromConfig()
if err != nil {
return err
}
resp, err := sdk.Client.Search.Query(cmd.Context(), req, nil)
if err != nil {
return fmt.Errorf("search request failed: %w", err)
}
// Text output operates on the raw SDK struct directly;
// cleansing is redundant since the table selects its own fields.
if outputFormat == output.OutputText {
return output.WriteFormatted(cmd.OutOrStdout(), resp.SearchResponse, outputFormat, searchTextFn)
}
var result any = resp.SearchResponse
if !raw {
result, err = output.CleanseSearchResponse(result)
if err != nil {
return err
}
}
return output.WriteFormatted(cmd.OutOrStdout(), result, outputFormat, searchTextFn)
}
Loading