diff --git a/cmd/src/deepsearch.go b/cmd/src/deepsearch.go new file mode 100644 index 0000000000..6b9cd0d37d --- /dev/null +++ b/cmd/src/deepsearch.go @@ -0,0 +1,41 @@ +package main + +import ( + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/urfave/cli/v3" +) + +var deepsearchCommand = clicompat.Wrap(&cli.Command{ + Name: "deepsearch", + Aliases: []string{"ds"}, + Usage: "interacts with Sourcegraph Deep Search", + UsageText: "src deepsearch [command options]", + Description: deepsearchExamples, + HideVersion: true, + Commands: []*cli.Command{ + deepsearchAskCommand, + deepsearchAddQuestionCommand, + deepsearchGetCommand, + deepsearchListCommand, + deepsearchCancelCommand, + deepsearchDeleteCommand, + }, +}) + +const deepsearchExamples = `'src deepsearch' interacts with the Sourcegraph Deep Search API. + +Usage: + + src deepsearch command [command options] + +The commands are: + + ask starts a Deep Search conversation and waits for the answer + add-question adds a follow-up question to a conversation + get gets a conversation + list lists conversation summaries + cancel cancels an in-progress conversation + delete permanently deletes a conversation + +Use "src deepsearch [command] -h" for more information about a command. +` diff --git a/cmd/src/deepsearch_add_question.go b/cmd/src/deepsearch_add_question.go new file mode 100644 index 0000000000..1423efc19c --- /dev/null +++ b/cmd/src/deepsearch_add_question.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "strings" + + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/sourcegraph/src-cli/internal/deepsearch" + "github.com/urfave/cli/v3" +) + +const deepsearchAddQuestionExamples = ` +Examples: + + Ask a follow-up question in an existing conversation: + + $ src deepsearch add-question users/-/conversations/abc123 'What calls this code?' + +` + +var deepsearchAddQuestionCommand = clicompat.Wrap(&cli.Command{ + Name: "add-question", + Usage: "adds a follow-up question to a Deep Search conversation", + UsageText: "src deepsearch add-question [options] ", + Description: deepsearchAddQuestionExamples, + HideVersion: true, + Flags: clicompat.WithAPIFlags( + &cli.StringFlag{ + Name: "f", + Value: "{{.|json}}", + Usage: `Format for the output, using the syntax of Go package text/template.`, + }, + ), + Action: func(ctx context.Context, cmd *cli.Command) error { + parent, question, err := deepsearchParentAndQuestion(cmd) + if err != nil { + return err + } + + tmpl, err := parseTemplate(cmd.String("f")) + if err != nil { + return err + } + + response, ok, err := cfg.deepsearchClient(cmd).AddConversationQuestion(ctx, deepsearch.AddConversationQuestionRequest{ + Parent: parent, + Question: deepsearch.NewQuestion(question), + }) + if err != nil || !ok { + return err + } + return execTemplate(tmpl, response) + }, +}) + +func deepsearchParentAndQuestion(cmd *cli.Command) (string, string, error) { + args := cmd.Args().Slice() + if len(args) == 0 { + return "", "", cmderrors.Usage("must provide a conversation name") + } + + parent := strings.TrimSpace(args[0]) + if parent == "" { + return "", "", cmderrors.Usage("must provide a conversation name") + } + + question := strings.TrimSpace(strings.Join(args[1:], " ")) + if question == "" { + return "", "", cmderrors.Usage("must provide a question") + } + return parent, question, nil +} diff --git a/cmd/src/deepsearch_ask.go b/cmd/src/deepsearch_ask.go new file mode 100644 index 0000000000..75d4987404 --- /dev/null +++ b/cmd/src/deepsearch_ask.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/sourcegraph/sourcegraph/lib/errors" + + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/sourcegraph/src-cli/internal/deepsearch" + "github.com/urfave/cli/v3" +) + +const deepsearchAskExamples = ` +Examples: + + Ask a question and wait for the answer: + + $ src deepsearch ask 'How is authentication implemented?' + + Ask a question using the ds alias: + + $ src ds ask 'Which services write repository metadata?' + +` + +var deepsearchAskCommand = clicompat.Wrap(&cli.Command{ + Name: "ask", + Usage: "starts a Deep Search conversation and waits for the answer", + UsageText: "src deepsearch ask [options] ", + Description: deepsearchAskExamples, + HideVersion: true, + Flags: clicompat.WithAPIFlags( + &cli.StringFlag{ + Name: "parent", + Usage: `Parent resource for the conversation. Defaults to the authenticated user. (e.g. "users/-")`, + }, + &cli.DurationFlag{ + Name: "timeout", + Value: 5 * time.Minute, + Usage: "Maximum time to wait for an answer.", + }, + &cli.DurationFlag{ + Name: "poll-interval", + Value: 3 * time.Second, + Usage: "How often to poll for completion.", + }, + ), + Action: func(ctx context.Context, cmd *cli.Command) error { + question, err := deepsearchQuestion(cmd) + if err != nil { + return err + } + timeout := cmd.Duration("timeout") + if timeout <= 0 { + return cmderrors.Usage("timeout must be greater than 0") + } + pollInterval := cmd.Duration("poll-interval") + if pollInterval <= 0 { + return cmderrors.Usage("poll-interval must be greater than 0") + } + + client := cfg.deepsearchClient(cmd) + conversation, ok, err := client.CreateConversation(ctx, deepsearch.CreateConversationRequest{ + Parent: cmd.String("parent"), + Conversation: deepsearch.Conversation{ + Questions: []deepsearch.Question{deepsearch.NewQuestion(question)}, + }, + }) + if err != nil || !ok { + return err + } + + return waitForDeepsearchAnswer(ctx, cmd.Writer, client, conversation, timeout, pollInterval) + }, +}) + +func deepsearchQuestion(cmd *cli.Command) (string, error) { + question := strings.TrimSpace(strings.Join(cmd.Args().Slice(), " ")) + if question == "" { + return "", cmderrors.Usage("must provide a question") + } + return question, nil +} + +func waitForDeepsearchAnswer(ctx context.Context, out io.Writer, client *deepsearch.Client, conversation *deepsearch.Conversation, timeout, pollInterval time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + done, err := deepsearchDone(conversation) + if err != nil { + return err + } + if done { + return printDeepsearchAnswer(out, conversation) + } + if conversation.Name == "" { + return errors.New("deep search response did not include a conversation name") + } + + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return errors.Newf("timed out waiting for Deep Search answer after %s", timeout) + } + return ctx.Err() + case <-ticker.C: + } + + var ok bool + conversation, ok, err = client.GetConversation(ctx, deepsearch.GetConversationRequest{Name: conversation.Name}) + if err != nil || !ok { + return err + } + } +} + +func deepsearchDone(conversation *deepsearch.Conversation) (bool, error) { + if conversation == nil || conversation.State == nil || conversation.State.Processing != nil { + return false, nil + } + if conversation.State.Completed != nil { + return true, nil + } + if conversation.State.Canceled != nil { + return false, errors.New("Deep Search conversation was canceled") + } + if conversation.State.Error != nil { + message := conversation.State.Error.Message + if message == "" { + message = conversation.State.Error.Code + } + if message == "" { + message = "unknown error" + } + return false, errors.Newf("Deep Search failed: %s", message) + } + return false, nil +} + +func printDeepsearchAnswer(out io.Writer, conversation *deepsearch.Conversation) error { + for _, question := range conversation.Questions { + for _, answer := range question.Answer { + if answer.Markdown == nil { + continue + } + if _, err := fmt.Fprint(out, answer.Markdown.Text); err != nil { + return err + } + if !strings.HasSuffix(answer.Markdown.Text, "\n") { + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + } + } + return nil +} diff --git a/cmd/src/deepsearch_cancel.go b/cmd/src/deepsearch_cancel.go new file mode 100644 index 0000000000..7f5fc61108 --- /dev/null +++ b/cmd/src/deepsearch_cancel.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/sourcegraph/src-cli/internal/deepsearch" + "github.com/urfave/cli/v3" +) + +const deepsearchCancelExamples = ` +Examples: + + Cancel a conversation: + + $ src deepsearch cancel users/-/conversations/abc123 + +` + +var deepsearchCancelCommand = clicompat.Wrap(&cli.Command{ + Name: "cancel", + Usage: "cancels an in-progress Deep Search conversation", + UsageText: "src deepsearch cancel [options] ", + Description: deepsearchCancelExamples, + HideVersion: true, + Flags: clicompat.WithAPIFlags( + &cli.StringFlag{ + Name: "f", + Value: "{{.|json}}", + Usage: `Format for the output, using the syntax of Go package text/template.`, + }, + ), + Action: func(ctx context.Context, cmd *cli.Command) error { + name, err := deepsearchName(cmd) + if err != nil { + return err + } + tmpl, err := parseTemplate(cmd.String("f")) + if err != nil { + return err + } + + conversation, ok, err := cfg.deepsearchClient(cmd).CancelConversation(ctx, deepsearch.CancelConversationRequest{Name: name}) + if err != nil || !ok { + return err + } + return execTemplate(tmpl, conversation) + }, +}) diff --git a/cmd/src/deepsearch_client.go b/cmd/src/deepsearch_client.go new file mode 100644 index 0000000000..cd4d1dc914 --- /dev/null +++ b/cmd/src/deepsearch_client.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/sourcegraph/src-cli/internal/api/connect" + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/sourcegraph/src-cli/internal/deepsearch" + "github.com/urfave/cli/v3" +) + +func (c *config) connectClient(cmd *cli.Command) connect.Client { + flags := clicompat.APIFlagsFromCmd(cmd) + return connect.NewClient(c.apiClient(flags, cmd.Writer)) +} + +func (c *config) deepsearchClient(cmd *cli.Command) *deepsearch.Client { + return deepsearch.NewClient(c.connectClient(cmd)) +} diff --git a/cmd/src/deepsearch_delete.go b/cmd/src/deepsearch_delete.go new file mode 100644 index 0000000000..72439d105f --- /dev/null +++ b/cmd/src/deepsearch_delete.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "fmt" + + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/sourcegraph/src-cli/internal/deepsearch" + "github.com/urfave/cli/v3" +) + +const deepsearchDeleteExamples = ` +Examples: + + Permanently delete a conversation: + + $ src deepsearch delete users/-/conversations/abc123 + +` + +var deepsearchDeleteCommand = clicompat.Wrap(&cli.Command{ + Name: "delete", + Usage: "permanently deletes a Deep Search conversation", + UsageText: "src deepsearch delete [options] ", + Description: deepsearchDeleteExamples, + HideVersion: true, + Flags: clicompat.WithAPIFlags(), + Action: func(ctx context.Context, cmd *cli.Command) error { + name, err := deepsearchName(cmd) + if err != nil { + return err + } + + ok, err := cfg.deepsearchClient(cmd).DeleteConversation(ctx, deepsearch.DeleteConversationRequest{Name: name}) + if err != nil || !ok { + return err + } + _, err = fmt.Fprintf(cmd.Writer, "Deep Search conversation %q deleted.\n", name) + return err + }, +}) diff --git a/cmd/src/deepsearch_get.go b/cmd/src/deepsearch_get.go new file mode 100644 index 0000000000..4e183e91fe --- /dev/null +++ b/cmd/src/deepsearch_get.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "strings" + + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/sourcegraph/src-cli/internal/deepsearch" + "github.com/urfave/cli/v3" +) + +const deepsearchGetExamples = ` +Examples: + + Get a conversation by resource name: + + $ src deepsearch get users/-/conversations/abc123 + +` + +var deepsearchGetCommand = clicompat.Wrap(&cli.Command{ + Name: "get", + Usage: "gets a Deep Search conversation", + UsageText: "src deepsearch get [options] ", + Description: deepsearchGetExamples, + HideVersion: true, + Flags: clicompat.WithAPIFlags( + &cli.StringFlag{ + Name: "f", + Value: "{{.|json}}", + Usage: `Format for the output, using the syntax of Go package text/template.`, + }, + ), + Action: func(ctx context.Context, cmd *cli.Command) error { + name, err := deepsearchName(cmd) + if err != nil { + return err + } + + tmpl, err := parseTemplate(cmd.String("f")) + if err != nil { + return err + } + + conversation, ok, err := cfg.deepsearchClient(cmd).GetConversation(ctx, deepsearch.GetConversationRequest{Name: name}) + if err != nil || !ok { + return err + } + return execTemplate(tmpl, conversation) + }, +}) + +func deepsearchName(cmd *cli.Command) (string, error) { + if !cmd.Args().Present() { + return "", cmderrors.Usage("must provide a conversation name") + } + + name := strings.TrimSpace(cmd.Args().First()) + if name == "" { + return "", cmderrors.Usage("must provide a conversation name") + } + if cmd.Args().Len() > 1 { + return "", cmderrors.Usage("expected exactly one conversation name") + } + return name, nil +} diff --git a/cmd/src/deepsearch_list.go b/cmd/src/deepsearch_list.go new file mode 100644 index 0000000000..fbf01b9319 --- /dev/null +++ b/cmd/src/deepsearch_list.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/sourcegraph/src-cli/internal/deepsearch" + "github.com/urfave/cli/v3" +) + +const deepsearchListExamples = ` +Examples: + + List recent Deep Search conversation summaries: + + $ src deepsearch list + + List summaries whose content matches a query: + + $ src deepsearch list -query='auth' + +` + +var deepsearchListCommand = clicompat.Wrap(&cli.Command{ + Name: "list", + Usage: "lists Deep Search conversation summaries", + UsageText: "src deepsearch list [options]", + Description: deepsearchListExamples, + HideVersion: true, + Flags: clicompat.WithAPIFlags( + &cli.StringFlag{ + Name: "parent", + Usage: `Parent resource. Defaults to the authenticated user. (e.g. "users/-")`, + }, + &cli.IntFlag{ + Name: "page-size", + Value: 100, + Usage: "Maximum number of conversations to return.", + }, + &cli.StringFlag{ + Name: "page-token", + Usage: "Page token from a previous response.", + }, + &cli.StringFlag{ + Name: "query", + Usage: "Return conversations whose content matches the query.", + }, + &cli.BoolFlag{ + Name: "starred", + Usage: "Return only starred conversations. Use --starred=false for unstarred conversations.", + }, + &cli.BoolFlag{ + Name: "all", + Usage: "Fetch all pages.", + }, + &cli.StringFlag{ + Name: "f", + Value: "{{.|json}}", + Usage: `Format for the output, using the syntax of Go package text/template.`, + }, + ), + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() > 0 { + return cmderrors.Usage("additional arguments not allowed") + } + if cmd.Int("page-size") < 0 { + return cmderrors.Usage("page-size must be greater than or equal to 0") + } + + tmpl, err := parseTemplate(cmd.String("f")) + if err != nil { + return err + } + + request := deepsearch.ListConversationSummariesRequest{ + Parent: cmd.String("parent"), + PageSize: cmd.Int("page-size"), + PageToken: cmd.String("page-token"), + } + if query := cmd.String("query"); query != "" { + request.Filters = append(request.Filters, deepsearch.ListConversationSummariesFilter{ContentQuery: query}) + } + if cmd.IsSet("starred") { + starred := cmd.Bool("starred") + request.Filters = append(request.Filters, deepsearch.ListConversationSummariesFilter{Starred: &starred}) + } + + client := cfg.deepsearchClient(cmd) + var summaries []deepsearch.ConversationSummary + for { + response, ok, err := client.ListConversationSummaries(ctx, request) + if err != nil || !ok { + return err + } + if !cmd.Bool("all") { + return execTemplate(tmpl, response) + } + summaries = append(summaries, response.ConversationSummaries...) + if response.NextPageToken == "" { + return execTemplate(tmpl, deepsearch.ListConversationSummariesResponse{ConversationSummaries: summaries}) + } + request.PageToken = response.NextPageToken + } + }, +}) diff --git a/cmd/src/deepsearch_test.go b/cmd/src/deepsearch_test.go new file mode 100644 index 0000000000..04f8ff0484 --- /dev/null +++ b/cmd/src/deepsearch_test.go @@ -0,0 +1,226 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/urfave/cli/v3" +) + +func TestDeepsearchCommandHelpUsesPositionalOperands(t *testing.T) { + tests := []struct { + name string + command *cli.Command + wantUsage string + wantNotHave []string + }{ + { + name: "add question", + command: deepsearchAddQuestionCommand, + wantUsage: "src deepsearch add-question [options] ", + wantNotHave: []string{"--parent string", "--question string"}, + }, + { + name: "get", + command: deepsearchGetCommand, + wantUsage: "src deepsearch get [options] ", + wantNotHave: []string{"--name string"}, + }, + { + name: "cancel", + command: deepsearchCancelCommand, + wantUsage: "src deepsearch cancel [options] ", + wantNotHave: []string{"--name string"}, + }, + { + name: "delete", + command: deepsearchDeleteCommand, + wantUsage: "src deepsearch delete [options] ", + wantNotHave: []string{"--name string"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + help := deepsearchCommandHelp(t, tt.command) + if !strings.Contains(help, "USAGE:\n "+tt.wantUsage) { + t.Fatalf("help did not contain usage %q:\n%s", tt.wantUsage, help) + } + for _, notHave := range tt.wantNotHave { + if strings.Contains(help, notHave) { + t.Fatalf("help contained %q:\n%s", notHave, help) + } + } + }) + } +} + +func deepsearchCommandHelp(t *testing.T, command *cli.Command) string { + t.Helper() + + var out bytes.Buffer + cmd := *command + cmd.Writer = &out + cmd.ErrWriter = &out + + if err := cmd.Run(context.Background(), []string{cmd.Name, "-h"}); err != nil { + t.Fatal(err) + } + return out.String() +} + +func TestDeepsearchAskCommandPollsUntilCompleted(t *testing.T) { + var getCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/deepsearch.v1.Service/CreateConversation": + var payload struct { + Conversation struct { + Questions []struct { + Input []struct { + Question struct { + Text string `json:"text"` + } `json:"question"` + } `json:"input"` + } `json:"questions"` + } `json:"conversation"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + got := payload.Conversation.Questions[0].Input[0].Question.Text + if want := "How is search implemented?"; got != want { + t.Fatalf("question = %q, want %q", got, want) + } + _, _ = w.Write([]byte(`{ + "name":"users/-/conversations/1", + "state":{"processing":{}}, + "url":"https://example.com/deepsearch/1" + }`)) + case "/api/deepsearch.v1.Service/GetConversation": + getCalls++ + _, _ = w.Write([]byte(`{ + "name":"users/-/conversations/1", + "state":{"completed":{}}, + "questions":[{"answer":[{"markdown":{"text":"Deep Search uses search and code intelligence."}}]}] + }`)) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + endpointURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + previousCfg := cfg + cfg = &config{endpointURL: endpointURL, accessToken: "token"} + defer func() { cfg = previousCfg }() + + var out bytes.Buffer + cmd := *deepsearchAskCommand + cmd.Writer = &out + cmd.ErrWriter = &out + + err = cmd.Run(context.Background(), []string{"ask", "How is search implemented?", "--poll-interval", "1ms", "--timeout", "1s"}) + if err != nil { + t.Fatal(err) + } + if got, want := out.String(), "Deep Search uses search and code intelligence.\n"; got != want { + t.Fatalf("output = %q, want %q", got, want) + } + if getCalls != 1 { + t.Fatalf("GetConversation calls = %d, want 1", getCalls) + } +} + +func TestDeepsearchAddQuestionCommand(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.URL.Path, "/api/deepsearch.v1.Service/AddConversationQuestion"; got != want { + t.Fatalf("path = %q, want %q", got, want) + } + var payload struct { + Parent string `json:"parent"` + Question struct { + Input []struct { + Question struct { + Text string `json:"text"` + } `json:"question"` + } `json:"input"` + } `json:"question"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if got, want := payload.Parent, "users/-/conversations/abc123"; got != want { + t.Fatalf("parent = %q, want %q", got, want) + } + gotQuestion := payload.Question.Input[0].Question.Text + if want := "What calls this code?"; gotQuestion != want { + t.Fatalf("question = %q, want %q", gotQuestion, want) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"input":[{"question":{"text":"What calls this code?"}}]}`)) + })) + defer server.Close() + + endpointURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + previousCfg := cfg + cfg = &config{endpointURL: endpointURL, accessToken: "token"} + defer func() { cfg = previousCfg }() + + var cmdOut bytes.Buffer + cmd := *deepsearchAddQuestionCommand + cmd.Writer = &cmdOut + cmd.ErrWriter = &cmdOut + + stdout := captureStdout(t, func() error { + return cmd.Run(context.Background(), []string{ + "add-question", + "users/-/conversations/abc123", + "What calls this code?", + "-f", "{{range .Input}}{{.Question.Text}}{{end}}", + }) + }) + if got, want := stdout, "What calls this code?\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func captureStdout(t *testing.T, run func() error) string { + t.Helper() + previousStdout := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + defer func() { os.Stdout = previousStdout }() + + if err := run(); err != nil { + _ = writer.Close() + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + output, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + return string(output) +} diff --git a/cmd/src/main.go b/cmd/src/main.go index fd217ba51f..31511913f4 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -58,6 +58,7 @@ The commands are: batch manages batch changes code-intel manages code intelligence data config manages global, org, and user settings + deepsearch,ds interacts with Sourcegraph Deep Search extensions,ext manages extensions (experimental) extsvc manages external services gateway interacts with Cody Gateway diff --git a/cmd/src/run_migration_compat.go b/cmd/src/run_migration_compat.go index 6671b07da3..532b2f44db 100644 --- a/cmd/src/run_migration_compat.go +++ b/cmd/src/run_migration_compat.go @@ -23,13 +23,15 @@ var migratedCommands = map[string]*cli.Command{ "auth": authCommand, "codeowners": codeownersCommand, // instead of writing lots of plumbing to handle an alias, lets just register it explicitly for now - "codeowner": codeownersCommand, - "login": loginCommand, - "orgs": orgsCommand, - "org": orgsCommand, - "users": usersCommand, - "user": usersCommand, - "version": versionCommand, + "codeowner": codeownersCommand, + "deepsearch": deepsearchCommand, + "ds": deepsearchCommand, + "login": loginCommand, + "orgs": orgsCommand, + "org": orgsCommand, + "users": usersCommand, + "user": usersCommand, + "version": versionCommand, } func maybeRunMigratedCommand() (isMigrated bool, exitCode int, err error) { diff --git a/internal/api/api.go b/internal/api/api.go index 800d8d41a2..02771f00aa 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -40,6 +40,13 @@ type Client interface { // Do runs an http.Request against the Sourcegraph API. Do(req *http.Request) (*http.Response, error) + + // Flags returns the diagnostic flags configured on the client. + Flags() *Flags + + // Out returns the writer used for diagnostic output (e.g. -get-curl, + // -dump-requests, -trace). + Out() io.Writer } // Request instances represent GraphQL requests. @@ -178,6 +185,14 @@ func (c *client) Do(req *http.Request) (*http.Response, error) { return c.httpClient.Do(req) } +func (c *client) Flags() *Flags { + return c.opts.Flags +} + +func (c *client) Out() io.Writer { + return c.opts.Out +} + func (c *client) NewHTTPRequest(ctx context.Context, method, p string, body io.Reader) (*http.Request, error) { req, err := c.createHTTPRequest(ctx, method, p, body) if err != nil { diff --git a/internal/api/connect/client.go b/internal/api/connect/client.go new file mode 100644 index 0000000000..b9bbcb1da0 --- /dev/null +++ b/internal/api/connect/client.go @@ -0,0 +1,142 @@ +// Package connect provides a minimal JSON client for Sourcegraph's public +// Connect RPC APIs (https://connectrpc.com/) under /api. +// +// It is a thin layer over internal/api so that authentication, proxying, TLS, +// tracing, additional headers, and diagnostic flags (-get-curl, +// -dump-requests, -trace) all share behavior with GraphQL requests. +package connect + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/sourcegraph/src-cli/internal/api" +) + +const protocolVersion = "1" + +// Client calls Connect RPC unary endpoints using JSON encoding. +type Client interface { + // NewCall creates a Call for procedure with the given request payload. + // + // procedure should be the fully-qualified Connect procedure path, e.g. + // "/deepsearch.v1.Service/GetConversation". + NewCall(procedure string, request any) Call +} + +// Call represents a unary Connect RPC. +type Call interface { + // Do sends the request and decodes the JSON response into response. + // + // ok is false when the request was intentionally not sent, such as when + // -get-curl is set. If response is nil, the body is discarded. + Do(ctx context.Context, response any) (ok bool, err error) +} + +// NewClient creates a Connect JSON client on top of an api.Client. Diagnostic +// flags and the output writer are taken from the underlying api.Client so +// behavior matches GraphQL requests. +func NewClient(apiClient api.Client) Client { + return &client{apiClient: apiClient} +} + +type client struct { + apiClient api.Client +} + +func (c *client) NewCall(procedure string, request any) Call { + return &call{ + client: c, + procedure: procedure, + request: request, + } +} + +type call struct { + client *client + procedure string + request any +} + +func (c *call) Do(ctx context.Context, response any) (bool, error) { + body, err := json.Marshal(c.request) + if err != nil { + return false, err + } + + req, err := c.client.apiClient.NewHTTPRequest(ctx, http.MethodPost, procedurePath(c.procedure), bytes.NewReader(body)) + if err != nil { + return false, err + } + req.Header.Set("Connect-Protocol-Version", protocolVersion) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + flags := c.client.apiClient.Flags() + out := c.client.apiClient.Out() + if out == nil { + out = io.Discard + } + + if flags != nil && flags.GetCurl() { + _, err := fmt.Fprintln(out, api.CurlCommand(req, body)) + return false, err + } + + if flags != nil && flags.Dump() { + fmt.Fprintf(out, "<-- connect request %s:\n%s\n\n", c.procedure, prettyJSON(body)) + } + + resp, err := c.client.apiClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if flags != nil && flags.Trace() { + if _, err := fmt.Fprintf(out, "x-trace: %s\n", resp.Header.Get("x-trace")); err != nil { + return false, err + } + } + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + + if flags != nil && flags.Dump() { + fmt.Fprintf(out, "--> %s\n\n", prettyJSON(responseBody)) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return false, parseError(resp.Status, responseBody) + } + + if response == nil || len(bytes.TrimSpace(responseBody)) == 0 { + return true, nil + } + if err := json.Unmarshal(responseBody, response); err != nil { + return false, err + } + return true, nil +} + +// procedurePath joins the Connect procedure name onto the /api route. The +// leading slash is trimmed so that api.Client.NewHTTPRequest can build the +// final URL by appending to EndpointURL. +func procedurePath(procedure string) string { + return "api/" + strings.TrimPrefix(procedure, "/") +} + +func prettyJSON(data []byte) string { + var out bytes.Buffer + if err := json.Indent(&out, data, "", " "); err == nil { + return out.String() + } + return string(data) +} diff --git a/internal/api/connect/client_test.go b/internal/api/connect/client_test.go new file mode 100644 index 0000000000..ed5a8ccbc1 --- /dev/null +++ b/internal/api/connect/client_test.go @@ -0,0 +1,160 @@ +package connect + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + + "github.com/sourcegraph/src-cli/internal/api" +) + +func TestCallSendsConnectJSONRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, http.MethodPost; got != want { + t.Fatalf("method = %q, want %q", got, want) + } + if got, want := r.URL.Path, "/api/deepsearch.v1.Service/CreateConversation"; got != want { + t.Fatalf("path = %q, want %q", got, want) + } + if got, want := r.Header.Get("Connect-Protocol-Version"), "1"; got != want { + t.Fatalf("Connect-Protocol-Version = %q, want %q", got, want) + } + if got, want := r.Header.Get("Content-Type"), "application/json"; got != want { + t.Fatalf("Content-Type = %q, want %q", got, want) + } + if got, want := r.Header.Get("Authorization"), "token secret"; got != want { + t.Fatalf("Authorization = %q, want %q", got, want) + } + if got, want := r.Header.Get("X-Test"), "yes"; got != want { + t.Fatalf("X-Test = %q, want %q", got, want) + } + + var payload map[string]string + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if got, want := payload["question"], "how does search work?"; got != want { + t.Fatalf("question = %q, want %q", got, want) + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-trace", "trace-id") + _, _ = io.WriteString(w, `{"name":"users/-/conversations/1"}`) + })) + defer server.Close() + + var out bytes.Buffer + client := newTestClient(t, server.URL, api.NewFlagsFromValues(false, false, true, false, false), &out) + + var response struct { + Name string `json:"name"` + } + ok, err := client.NewCall("/deepsearch.v1.Service/CreateConversation", map[string]string{ + "question": "how does search work?", + }).Do(context.Background(), &response) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("ok = false, want true") + } + if got, want := response.Name, "users/-/conversations/1"; got != want { + t.Fatalf("response name = %q, want %q", got, want) + } + if got, want := out.String(), "x-trace: trace-id\n"; got != want { + t.Fatalf("trace output = %q, want %q", got, want) + } +} + +func TestCallGetCurlDoesNotSendRequest(t *testing.T) { + var called atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called.Store(true) + })) + defer server.Close() + + var out bytes.Buffer + client := newTestClient(t, server.URL, api.NewFlagsFromValues(false, true, false, false, false), &out) + + ok, err := client.NewCall("/deepsearch.v1.Service/GetConversation", map[string]string{ + "name": "users/-/conversations/1", + }).Do(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("ok = true, want false") + } + if called.Load() { + t.Fatal("server received request despite get-curl") + } + output := out.String() + for _, want := range []string{ + "curl", + "Authorization: token secret", + "Connect-Protocol-Version: 1", + "/api/deepsearch.v1.Service/GetConversation", + "users/-/conversations/1", + } { + if !strings.Contains(output, want) { + t.Fatalf("curl output %q does not contain %q", output, want) + } + } +} + +func TestCallConnectError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"code":"invalid_argument","message":"question is required"}`) + })) + defer server.Close() + + client := newTestClient(t, server.URL, api.NewFlagsFromValues(false, false, false, false, false), io.Discard) + + ok, err := client.NewCall("/deepsearch.v1.Service/CreateConversation", map[string]string{}).Do(context.Background(), nil) + if err == nil { + t.Fatal("expected error") + } + if ok { + t.Fatal("ok = true, want false") + } + + var connectErr *Error + if !errors.As(err, &connectErr) { + t.Fatalf("err = %v (%T), want *connect.Error", err, err) + } + if got, want := connectErr.Code, "invalid_argument"; got != want { + t.Fatalf("Code = %q, want %q", got, want) + } + if got, want := connectErr.Message, "question is required"; got != want { + t.Fatalf("Message = %q, want %q", got, want) + } + if got, want := err.Error(), "error: 400 Bad Request\n\ninvalid_argument: question is required"; got != want { + t.Fatalf("error = %q, want %q", got, want) + } +} + +func newTestClient(t *testing.T, endpoint string, flags *api.Flags, out io.Writer) Client { + t.Helper() + endpointURL, err := url.Parse(endpoint) + if err != nil { + t.Fatal(err) + } + apiClient := api.NewClient(api.ClientOpts{ + EndpointURL: endpointURL, + AccessToken: "secret", + AdditionalHeaders: map[string]string{"X-Test": "yes"}, + Flags: flags, + Out: out, + }) + return NewClient(apiClient) +} diff --git a/internal/api/connect/error.go b/internal/api/connect/error.go new file mode 100644 index 0000000000..391d3f80e0 --- /dev/null +++ b/internal/api/connect/error.go @@ -0,0 +1,57 @@ +package connect + +import ( + "encoding/json" + "fmt" +) + +// Error is a Connect RPC error returned for a non-2xx response. It carries the +// HTTP status line plus, when present, the structured Connect error code and +// message from the JSON body. Callers may use errors.As to inspect Code and +// Message for branching. +type Error struct { + // Status is the HTTP status line (e.g. "400 Bad Request"). + Status string + // Code is the Connect error code from the response body, if any (e.g. + // "invalid_argument"). + Code string + // Message is the Connect error message from the response body, if any. + Message string + // Body is the raw response body, used when the body could not be parsed as + // a Connect error envelope. + Body []byte +} + +func (e *Error) Error() string { + if e.Code == "" && e.Message == "" { + return fmt.Sprintf("error: %s\n\n%s", e.Status, e.Body) + } + if e.Code == "" { + return fmt.Sprintf("error: %s\n\n%s", e.Status, e.Message) + } + if e.Message == "" { + return fmt.Sprintf("error: %s\n\n%s", e.Status, e.Code) + } + return fmt.Sprintf("error: %s\n\n%s: %s", e.Status, e.Code, e.Message) +} + +// parseError builds an *Error from an HTTP status and response body. If the +// body is a Connect error envelope, Code and Message are populated; otherwise +// only Body is set. +func parseError(status string, body []byte) *Error { + var envelope struct { + Code string `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &envelope); err == nil && (envelope.Code != "" || envelope.Message != "") { + return &Error{ + Status: status, + Code: envelope.Code, + Message: envelope.Message, + } + } + return &Error{ + Status: status, + Body: body, + } +} diff --git a/internal/api/connect/mock/connect.go b/internal/api/connect/mock/connect.go new file mode 100644 index 0000000000..b9e985c607 --- /dev/null +++ b/internal/api/connect/mock/connect.go @@ -0,0 +1,28 @@ +// Package mock provides testify mocks for internal/api/connect. +package mock + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/sourcegraph/src-cli/internal/api/connect" +) + +type Client struct { + mock.Mock +} + +func (m *Client) NewCall(procedure string, request any) connect.Call { + args := m.Called(procedure, request) + return args.Get(0).(connect.Call) +} + +type Call struct { + mock.Mock +} + +func (c *Call) Do(ctx context.Context, response any) (bool, error) { + args := c.Called(ctx, response) + return args.Bool(0), args.Error(1) +} diff --git a/internal/api/curl.go b/internal/api/curl.go new file mode 100644 index 0000000000..a14ea6cd45 --- /dev/null +++ b/internal/api/curl.go @@ -0,0 +1,34 @@ +package api + +import ( + "net/http" + "sort" + + "github.com/kballard/go-shellquote" +) + +// CurlCommand renders req (with the given body) as a single-line, shell-safe +// curl invocation. Headers are sorted for deterministic output. The body is +// included verbatim with -d when non-empty. +func CurlCommand(req *http.Request, body []byte) string { + args := []string{"curl", "-X", req.Method} + + headerNames := make([]string, 0, len(req.Header)) + for name := range req.Header { + headerNames = append(headerNames, name) + } + sort.Strings(headerNames) + for _, name := range headerNames { + values := append([]string{}, req.Header.Values(name)...) + sort.Strings(values) + for _, value := range values { + args = append(args, "-H", name+": "+value) + } + } + + if len(body) > 0 { + args = append(args, "-d", string(body)) + } + args = append(args, req.URL.String()) + return shellquote.Join(args...) +} diff --git a/internal/api/flags.go b/internal/api/flags.go index be2397e818..513922de59 100644 --- a/internal/api/flags.go +++ b/internal/api/flags.go @@ -15,6 +15,13 @@ type Flags struct { userAgentTelemetry *bool } +func (f *Flags) Dump() bool { + if f.dump == nil { + return false + } + return *(f.dump) +} + func (f *Flags) Trace() bool { if f.trace == nil { return false diff --git a/internal/api/mock/api.go b/internal/api/mock/api.go index 710b11aff6..4e5627f88d 100644 --- a/internal/api/mock/api.go +++ b/internal/api/mock/api.go @@ -52,6 +52,24 @@ func (m *Client) Do(req *http.Request) (*http.Response, error) { return obj, args.Error(1) } +func (m *Client) Flags() *api.Flags { + args := m.Called() + var obj *api.Flags + if args.Get(0) != nil { + obj = args.Get(0).(*api.Flags) + } + return obj +} + +func (m *Client) Out() io.Writer { + args := m.Called() + var obj io.Writer + if args.Get(0) != nil { + obj = args.Get(0).(io.Writer) + } + return obj +} + type Request struct { mock.Mock Response string diff --git a/internal/deepsearch/types.go b/internal/deepsearch/types.go new file mode 100644 index 0000000000..bd7541d4bd --- /dev/null +++ b/internal/deepsearch/types.go @@ -0,0 +1,187 @@ +// Package deepsearch contains a small local model for the Sourcegraph +// deepsearch.v1 external API. +package deepsearch + +import ( + "context" + + "github.com/sourcegraph/src-cli/internal/api/connect" +) + +const ( + createConversationProcedure = "/deepsearch.v1.Service/CreateConversation" + getConversationProcedure = "/deepsearch.v1.Service/GetConversation" + cancelConversationProcedure = "/deepsearch.v1.Service/CancelConversation" + deleteConversationProcedure = "/deepsearch.v1.Service/DeleteConversation" + addConversationQuestionProcedure = "/deepsearch.v1.Service/AddConversationQuestion" + listConversationSummariesProcedure = "/deepsearch.v1.Service/ListConversationSummaries" +) + +// Client calls the Sourcegraph Deep Search external API. +type Client struct { + connect connect.Client +} + +func NewClient(client connect.Client) *Client { + return &Client{connect: client} +} + +func (c *Client) CreateConversation(ctx context.Context, request CreateConversationRequest) (*Conversation, bool, error) { + var response Conversation + ok, err := c.connect.NewCall(createConversationProcedure, request).Do(ctx, &response) + if !ok || err != nil { + return nil, ok, err + } + return &response, true, nil +} + +func (c *Client) GetConversation(ctx context.Context, request GetConversationRequest) (*Conversation, bool, error) { + var response Conversation + ok, err := c.connect.NewCall(getConversationProcedure, request).Do(ctx, &response) + if !ok || err != nil { + return nil, ok, err + } + return &response, true, nil +} + +func (c *Client) CancelConversation(ctx context.Context, request CancelConversationRequest) (*Conversation, bool, error) { + var response Conversation + ok, err := c.connect.NewCall(cancelConversationProcedure, request).Do(ctx, &response) + if !ok || err != nil { + return nil, ok, err + } + return &response, true, nil +} + +func (c *Client) DeleteConversation(ctx context.Context, request DeleteConversationRequest) (bool, error) { + return c.connect.NewCall(deleteConversationProcedure, request).Do(ctx, nil) +} + +func (c *Client) AddConversationQuestion(ctx context.Context, request AddConversationQuestionRequest) (*Question, bool, error) { + var response Question + ok, err := c.connect.NewCall(addConversationQuestionProcedure, request).Do(ctx, &response) + if !ok || err != nil { + return nil, ok, err + } + return &response, true, nil +} + +func (c *Client) ListConversationSummaries(ctx context.Context, request ListConversationSummariesRequest) (*ListConversationSummariesResponse, bool, error) { + var response ListConversationSummariesResponse + ok, err := c.connect.NewCall(listConversationSummariesProcedure, request).Do(ctx, &response) + if !ok || err != nil { + return nil, ok, err + } + return &response, true, nil +} + +func NewQuestion(text string) Question { + return Question{ + Input: []InputContentBlock{ + {Question: &QuestionContent{Text: text}}, + }, + } +} + +type CreateConversationRequest struct { + Parent string `json:"parent,omitempty"` + Conversation Conversation `json:"conversation"` +} + +type GetConversationRequest struct { + Name string `json:"name"` +} + +type CancelConversationRequest struct { + Name string `json:"name"` +} + +type DeleteConversationRequest struct { + Name string `json:"name"` +} + +type AddConversationQuestionRequest struct { + Parent string `json:"parent"` + Question Question `json:"question"` +} + +type ListConversationSummariesRequest struct { + Parent string `json:"parent,omitempty"` + PageSize int `json:"pageSize,omitempty"` + PageToken string `json:"pageToken,omitempty"` + Filters []ListConversationSummariesFilter `json:"filters,omitempty"` +} + +type ListConversationSummariesFilter struct { + ContentQuery string `json:"contentQuery,omitempty"` + Starred *bool `json:"starred,omitempty"` +} + +type ListConversationSummariesResponse struct { + ConversationSummaries []ConversationSummary `json:"conversationSummaries,omitempty"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + +type Conversation struct { + Name string `json:"name,omitempty"` + State *ConversationState `json:"state,omitempty"` + CreateTime string `json:"createTime,omitempty"` + UpdateTime string `json:"updateTime,omitempty"` + Title string `json:"title,omitempty"` + Questions []Question `json:"questions,omitempty"` + URL string `json:"url,omitempty"` +} + +type ConversationSummary struct { + Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` + CreateTime string `json:"createTime,omitempty"` + UpdateTime string `json:"updateTime,omitempty"` + URL string `json:"url,omitempty"` +} + +type ConversationState struct { + Processing *StateProcessing `json:"processing,omitempty"` + Completed *StateCompleted `json:"completed,omitempty"` + Error *StateError `json:"error,omitempty"` + Canceled *StateCanceled `json:"canceled,omitempty"` +} + +type StateProcessing struct{} + +type StateCompleted struct{} + +type StateCanceled struct{} + +type StateError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + RetryTime string `json:"retryTime,omitempty"` +} + +type Question struct { + Input []InputContentBlock `json:"input,omitempty"` + Answer []AnswerContentBlock `json:"answer,omitempty"` + CreateTime string `json:"createTime,omitempty"` +} + +type InputContentBlock struct { + Question *QuestionContent `json:"question,omitempty"` + Image *ImageContent `json:"image,omitempty"` +} + +type QuestionContent struct { + Text string `json:"text"` +} + +type ImageContent struct { + URI string `json:"uri"` +} + +type AnswerContentBlock struct { + Markdown *MarkdownContent `json:"markdown,omitempty"` +} + +type MarkdownContent struct { + Text string `json:"text"` +}