From ee7a50490641c54b246018a9c9549c517959a666 Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Sun, 16 Feb 2025 01:00:56 -0500 Subject: [PATCH 01/12] feat: Add search-jobs CLI commands for managing search jobs Add new `src search-jobs` command with subcommands to manage search jobs: - create: Create new search jobs with query validation - get: Retrieve search job details by ID - list: List search jobs with sorting and pagination - cancel: Cancel running search jobs - delete: Remove search jobs The command provides functionality to: - Format output using Go templates - Sort and filter search jobs - Track job status --- cmd/src/main.go | 1 + cmd/src/search_jobs.go | 104 ++++++++++++++++++++ cmd/src/search_jobs_cancel.go | 78 +++++++++++++++ cmd/src/search_jobs_cancel_test.go | 130 ++++++++++++++++++++++++ cmd/src/search_jobs_create.go | 102 +++++++++++++++++++ cmd/src/search_jobs_create_test.go | 84 ++++++++++++++++ cmd/src/search_jobs_delete.go | 77 +++++++++++++++ cmd/src/search_jobs_delete_test.go | 117 ++++++++++++++++++++++ cmd/src/search_jobs_get.go | 84 ++++++++++++++++ cmd/src/search_jobs_get_test.go | 152 +++++++++++++++++++++++++++++ cmd/src/search_jobs_list.go | 123 +++++++++++++++++++++++ cmd/src/search_jobs_list_test.go | 69 +++++++++++++ cmd/src/search_jobs_test.go | 48 +++++++++ internal/testing/testutil.go | 28 ++++++ 14 files changed, 1197 insertions(+) create mode 100644 cmd/src/search_jobs.go create mode 100644 cmd/src/search_jobs_cancel.go create mode 100644 cmd/src/search_jobs_cancel_test.go create mode 100644 cmd/src/search_jobs_create.go create mode 100644 cmd/src/search_jobs_create_test.go create mode 100644 cmd/src/search_jobs_delete.go create mode 100644 cmd/src/search_jobs_delete_test.go create mode 100644 cmd/src/search_jobs_get.go create mode 100644 cmd/src/search_jobs_get_test.go create mode 100644 cmd/src/search_jobs_list.go create mode 100644 cmd/src/search_jobs_list_test.go create mode 100644 cmd/src/search_jobs_test.go create mode 100644 internal/testing/testutil.go diff --git a/cmd/src/main.go b/cmd/src/main.go index 06feaa5ac0..38c63afa45 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -60,6 +60,7 @@ The commands are: repos,repo manages repositories sbom manages SBOM (Software Bill of Materials) data search search for results on Sourcegraph + search-jobs manages search jobs serve-git serves your local git repositories over HTTP for Sourcegraph to pull users,user manages users codeowners manages code ownership information diff --git a/cmd/src/search_jobs.go b/cmd/src/search_jobs.go new file mode 100644 index 0000000000..d5092a1b7e --- /dev/null +++ b/cmd/src/search_jobs.go @@ -0,0 +1,104 @@ +package main + +import ( + "flag" + "fmt" + "text/template" + "os" +) + +// searchJobFragment is a GraphQL fragment that defines the fields to be queried +// for a SearchJob. It includes the job's ID, query, state, creator information, +// timestamps, URLs, and repository statistics. +const SearchJobFragment = ` +fragment SearchJobFields on SearchJob { + id + query + state + creator { + username + } + createdAt + startedAt + finishedAt + URL + logURL + repoStats { + total + completed + failed + inProgress + } +}` + +var searchJobsCommands commander + +// init registers the 'src search-jobs' command with the CLI. It provides subcommands +// for managing search jobs, including creating, listing, getting, canceling and deleting +// jobs. The command uses a flagset for parsing options and displays usage information +// when help is requested. +func init() { + usage := `'src search-jobs' is a tool that manages search jobs on a Sourcegraph instance. + +Usage: + + src search-jobs command [command options] + +The commands are: + + cancel cancels a search job by ID + create creates a search job + delete deletes a search job by ID + get gets a search job by ID + list lists search jobs + +Use "src search-jobs [command] -h" for more information about a command. +` + + flagSet := flag.NewFlagSet("search-jobs", flag.ExitOnError) + handler := func(args []string) error { + searchJobsCommands.run(flagSet, "src search-jobs", usage, args) + return nil + } + + commands = append(commands, &command{ + flagSet: flagSet, + aliases: []string{"search-job"}, + handler: handler, + usageFunc: func() { + fmt.Println(usage) + }, + }) +} + +// printSearchJob formats and prints a search job to stdout using the provided format template. +// Returns an error if the template parsing or execution fails. +func printSearchJob(job *SearchJob, format string) error { + tmpl, err := template.New("searchJob").Parse(format) + if err != nil { + return err + } + return tmpl.Execute(os.Stdout, job) +} + +// SearchJob represents a search job with its metadata, including the search query, +// execution state, creator information, timestamps, URLs, and repository statistics. +type SearchJob struct { + ID string + Query string + State string + Creator struct { + Username string + } + CreatedAt string + StartedAt string + FinishedAt string + URL string + LogURL string + RepoStats struct { + Total int + Completed int + Failed int + InProgress int + } +} diff --git a/cmd/src/search_jobs_cancel.go b/cmd/src/search_jobs_cancel.go new file mode 100644 index 0000000000..8348223435 --- /dev/null +++ b/cmd/src/search_jobs_cancel.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "flag" + "fmt" + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +const CancelSearchJobMutation = `mutation CancelSearchJob($id: ID!) { + cancelSearchJob(id: $id) { + alwaysNil + } +}` + +// init registers the 'cancel' subcommand for search jobs, which allows users to cancel +// a running search job by its ID. It sets up the command's flag parsing, usage information, +// and handles the GraphQL mutation to cancel the specified search job. +func init() { + usage := ` +Examples: + + Cancel a search job: + + $ src search-jobs cancel --id U2VhcmNoSm9iOjY5 +` + flagSet := flag.NewFlagSet("cancel", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + idFlag = flagSet.String("id", "", "ID of the search job to cancel") + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + client := api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: flagSet.Output(), + Flags: apiFlags, + }) + + if *idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + query := CancelSearchJobMutation + + var result struct { + CancelSearchJob struct { + AlwaysNil bool + } + } + + if ok, err := client.NewRequest(query, map[string]interface{}{ + "id": *idFlag, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + fmt.Fprintf(flagSet.Output(), "Search job %s canceled successfully\n", *idFlag) + return nil + } + + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/cmd/src/search_jobs_cancel_test.go b/cmd/src/search_jobs_cancel_test.go new file mode 100644 index 0000000000..d61d33fd34 --- /dev/null +++ b/cmd/src/search_jobs_cancel_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "flag" + "fmt" + "testing" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" + mockclient "github.com/sourcegraph/src-cli/internal/api/mock" + "github.com/stretchr/testify/mock" +) + +func TestSearchJobsCancel(t *testing.T) { + t.Run("running job", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", + CancelSearchJobMutation, + map[string]interface{}{"id": "test-id"}, + ).Return(mockRequest) + + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + CancelSearchJob struct { + AlwaysNil interface{} + } + }) + result.CancelSearchJob.AlwaysNil = nil + }).Return(true, nil) + + err := executeSearchJobCancel(mockClient, []string{"-id", "test-id"}) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("completed job", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, fmt.Errorf("cannot cancel completed job")) + + err := executeSearchJobCancel(mockClient, []string{"-id", "completed-id"}) + if err == nil { + t.Error("expected error for completed job, got none") + } + }) + + t.Run("non-existent job", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, fmt.Errorf("job not found")) + + err := executeSearchJobCancel(mockClient, []string{"-id", "non-existent"}) + if err == nil { + t.Error("expected error for non-existent job, got none") + } + }) + + t.Run("empty ID", func(t *testing.T) { + mockClient := new(mockclient.Client) + err := executeSearchJobCancel(mockClient, []string{"-id", ""}) + if err == nil { + t.Error("expected error for empty ID, got none") + } + }) + + t.Run("error handling", func(t *testing.T) { + testCases := []struct { + name string + id string + mockErr error + }{ + {"network error", "test-id", fmt.Errorf("network error")}, + {"server error", "test-id", fmt.Errorf("internal server error")}, + {"invalid ID", "invalid-id", fmt.Errorf("invalid ID format")}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, tc.mockErr) + + err := executeSearchJobCancel(mockClient, []string{"-id", tc.id}) + if err == nil { + t.Errorf("expected error for %s, got none", tc.name) + } + }) + } + }) +} + +func executeSearchJobCancel(client api.Client, args []string) error { + flagSet := flag.NewFlagSet("cancel", flag.ExitOnError) + var idFlag string + flagSet.StringVar(&idFlag, "id", "", "") + + if err := flagSet.Parse(args); err != nil { + return err + } + + if idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + var result struct { + CancelSearchJob struct { + AlwaysNil interface{} + } + } + + if ok, err := client.NewRequest(CancelSearchJobMutation, map[string]interface{}{ + "id": idFlag, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + + fmt.Printf("Search job %s canceled successfully\n", idFlag) + return nil +} diff --git a/cmd/src/search_jobs_create.go b/cmd/src/search_jobs_create.go new file mode 100644 index 0000000000..4560510971 --- /dev/null +++ b/cmd/src/search_jobs_create.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "flag" + "fmt" + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +// ValidateSearchJobQuery defines the GraphQL query for validating search jobs +const ValidateSearchJobQuery = `query ValidateSearchJob($query: String!) { + validateSearchJob(query: $query) { + alwaysNil + } +}` + +// CreateSearchJobQuery defines the GraphQL mutation for creating search jobs +const CreateSearchJobQuery = `mutation CreateSearchJob($query: String!) { + createSearchJob(query: $query) { + ...SearchJobFields + } +}` + SearchJobFragment + +// init registers the "search-jobs create" subcommand. It allows users to create a search job +// with a specified query, validates the query before creation, and outputs the result in a +// customizable format. The command requires a search query and supports custom output formatting +// using Go templates. +func init() { + usage := ` +Examples: + + Create a search job: + + $ src search-jobs create -query "repo:^github\.com/sourcegraph/sourcegraph$ sort:indexed-desc" +` + + flagSet := flag.NewFlagSet("create", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + queryFlag = flagSet.String("query", "", "Search query") + formatFlag = flagSet.String("f", "{{.ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + client := api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: flagSet.Output(), + Flags: apiFlags, + }) + + tmpl, err := parseTemplate(*formatFlag) + if err != nil { + return err + } + + if *queryFlag == "" { + return cmderrors.Usage("must provide a query") + } + + var validateResult struct { + ValidateSearchJob interface{} `json:"validateSearchJob"` + } + + if ok, err := client.NewRequest(ValidateSearchJobQuery, map[string]interface{}{ + "query": *queryFlag, + }).Do(context.Background(), &validateResult); err != nil || !ok { + return err + } + + query := CreateSearchJobQuery + + var result struct { + CreateSearchJob *SearchJob `json:"createSearchJob"` + } + + if ok, err := client.NewRequest(query, map[string]interface{}{ + "query": *queryFlag, + }).Do(context.Background(), &result); !ok { + return err + } + + return execTemplate(tmpl, result.CreateSearchJob) + } + + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/cmd/src/search_jobs_create_test.go b/cmd/src/search_jobs_create_test.go new file mode 100644 index 0000000000..292b701e9f --- /dev/null +++ b/cmd/src/search_jobs_create_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "flag" + "testing" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" + testutil "github.com/sourcegraph/src-cli/internal/testing" + mockclient "github.com/sourcegraph/src-cli/internal/api/mock" + "github.com/stretchr/testify/mock" +) +func TestSearchJobsCreate(t *testing.T) { + t.Run("valid query", func(t *testing.T) { + mockClient := new(mockclient.Client) + + // Set up validation request + validationRequest := new(mockclient.Request) + mockClient.On("NewRequest", + ValidateSearchJobQuery, + map[string]interface{}{"query": "repo:test"}, + ).Return(validationRequest) + + validationRequest.On("Do", mock.Anything, mock.Anything).Return(true, nil) + + // Set up creation request + creationRequest := new(mockclient.Request) + mockClient.On("NewRequest", + CreateSearchJobQuery, + map[string]interface{}{"query": "repo:test"}, + ).Return(creationRequest) + + creationRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + CreateSearchJob *SearchJob `json:"createSearchJob"` + }) + result.CreateSearchJob = &SearchJob{ + ID: "test-id", + Query: "repo:test", + State: "QUEUED", + Creator: struct{ Username string }{Username: "test-user"}, + } + }).Return(true, nil) + + err := executeSearchJobCreate(mockClient, []string{"-query", "repo:test"}) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) +} + +func executeSearchJobCreate(client api.Client, args []string) error { + flagSet := flag.NewFlagSet("create", flag.ExitOnError) + var ( + queryFlag string + formatFlag string + ) + flagSet.StringVar(&queryFlag, "query", "", "") + flagSet.StringVar(&formatFlag, "f", "{{.ID}}", "") + + if err := flagSet.Parse(args); err != nil { + return err + } + + if queryFlag == "" { + return cmderrors.Usage("must provide a query") + } + + // Define result structure before using it + var result struct { + CreateSearchJob *SearchJob `json:"createSearchJob"` + } + + if ok, err := client.NewRequest(CreateSearchJobQuery, map[string]interface{}{ + "query": queryFlag, + }).Do(context.Background(), &result); !ok { + return err + } + + // Now we can use result with proper scoping + return testutil.ExecTemplateWithParsing(formatFlag, result.CreateSearchJob) +} \ No newline at end of file diff --git a/cmd/src/search_jobs_delete.go b/cmd/src/search_jobs_delete.go new file mode 100644 index 0000000000..4694306849 --- /dev/null +++ b/cmd/src/search_jobs_delete.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "flag" + "fmt" + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +const DeleteSearchJobQuery = `mutation DeleteSearchJob($id: ID!) { + deleteSearchJob(id: $id) { + alwaysNil + } +}` + +// init registers the 'delete' subcommand for search-jobs which allows users to delete +// a search job by its ID. The command requires a search job ID to be provided via +// the -id flag and will make a GraphQL mutation to delete the specified job. +func init() { + usage := ` +Examples: + + Delete a search job by ID: + + $ src search-jobs delete U2VhcmNoSm9iOjY5 +` + + flagSet := flag.NewFlagSet("delete", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + idFlag = flagSet.String("id", "", "ID of the search job to delete") + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + client := api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: flagSet.Output(), + Flags: apiFlags, + }) + + if *idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + var result struct { + DeleteSearchJob struct { + AlwaysNil bool + } + } + + if ok, err := client.NewRequest(DeleteSearchJobQuery, map[string]interface{}{ + "id": *idFlag, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + fmt.Fprintf(flagSet.Output(), "Search job %s deleted successfully\n", *idFlag) + return nil + } + + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/cmd/src/search_jobs_delete_test.go b/cmd/src/search_jobs_delete_test.go new file mode 100644 index 0000000000..ceb0528a84 --- /dev/null +++ b/cmd/src/search_jobs_delete_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "flag" + "fmt" + "testing" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" + mockclient "github.com/sourcegraph/src-cli/internal/api/mock" + "github.com/stretchr/testify/mock" +) +func TestSearchJobsDelete(t *testing.T) { + t.Run("existing job", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", + DeleteSearchJobQuery, + map[string]interface{}{"id": "test-id"}, + ).Return(mockRequest) + + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + DeleteSearchJob struct { + AlwaysNil interface{} + } + }) + // Simulate successful deletion + result.DeleteSearchJob.AlwaysNil = nil + }).Return(true, nil) + + err := executeSearchJobDelete(mockClient, []string{"-id", "test-id"}) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("non-existent job", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, fmt.Errorf("job not found")) + + err := executeSearchJobDelete(mockClient, []string{"-id", "non-existent"}) + if err == nil { + t.Error("expected error for non-existent job, got none") + } + }) + + t.Run("empty ID", func(t *testing.T) { + mockClient := new(mockclient.Client) + err := executeSearchJobDelete(mockClient, []string{"-id", ""}) + if err == nil { + t.Error("expected error for empty ID, got none") + } + }) + + t.Run("error handling", func(t *testing.T) { + testCases := []struct { + name string + id string + mockErr error + }{ + {"network error", "test-id", fmt.Errorf("network error")}, + {"server error", "test-id", fmt.Errorf("internal server error")}, + {"invalid ID", "invalid-id", fmt.Errorf("invalid ID format")}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, tc.mockErr) + + err := executeSearchJobDelete(mockClient, []string{"-id", tc.id}) + if err == nil { + t.Errorf("expected error for %s, got none", tc.name) + } + }) + } + }) +} + +func executeSearchJobDelete(client api.Client, args []string) error { + flagSet := flag.NewFlagSet("delete", flag.ExitOnError) + var idFlag string + flagSet.StringVar(&idFlag, "id", "", "") + + if err := flagSet.Parse(args); err != nil { + return err + } + + if idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + var result struct { + DeleteSearchJob struct { + AlwaysNil interface{} + } + } + + if ok, err := client.NewRequest(DeleteSearchJobQuery, map[string]interface{}{ + "id": idFlag, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + + fmt.Printf("Search job %s deleted successfully\n", idFlag) + return nil +} \ No newline at end of file diff --git a/cmd/src/search_jobs_get.go b/cmd/src/search_jobs_get.go new file mode 100644 index 0000000000..17b5873bfb --- /dev/null +++ b/cmd/src/search_jobs_get.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "flag" + "fmt" + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +const GetSearchJobQuery = `query SearchJob($id: ID!) { + node(id: $id) { + ... on SearchJob { + ...SearchJobFields + } + } +} +` + +// init registers the "get" subcommand for search-jobs which retrieves a search job by ID. +// It supports formatting the output using Go templates and requires authentication via API flags. +func init() { + usage := ` +Examples: + + Get a search job by ID: + + $ src search-jobs get U2VhcmNoSm9iOjY5 +` + + flagSet := flag.NewFlagSet("get", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + idFlag = flagSet.String("id", "", "ID of the search job") + formatFlag = flagSet.String("f", "{{.ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + client := api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: flagSet.Output(), + Flags: apiFlags, + }) + + tmpl, err := parseTemplate(*formatFlag) + if err != nil { + return err + } + + if *idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + query := GetSearchJobQuery + SearchJobFragment + + var result struct { + Node *SearchJob + } + + if ok, err := client.NewRequest(query, map[string]interface{}{ + "id": api.NullString(*idFlag), + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + return execTemplate(tmpl, result.Node) + } + + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/cmd/src/search_jobs_get_test.go b/cmd/src/search_jobs_get_test.go new file mode 100644 index 0000000000..064e92eafa --- /dev/null +++ b/cmd/src/search_jobs_get_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "flag" + "fmt" + "testing" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" + testutil "github.com/sourcegraph/src-cli/internal/testing" + mockclient "github.com/sourcegraph/src-cli/internal/api/mock" + "github.com/stretchr/testify/mock" +) + +func TestSearchJobsGet(t *testing.T) { + t.Run("existing job", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", + GetSearchJobQuery + SearchJobFragment, + map[string]interface{}{"id": "test-id"}, + ).Return(mockRequest) + + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + Node *SearchJob + }) + result.Node = &SearchJob{ + ID: "test-id", + Query: "repo:test", + State: "COMPLETED", + Creator: struct{ Username string }{Username: "test-user"}, + } + }).Return(true, nil) + + err := executeSearchJobGet(mockClient, []string{"-id", "test-id"}) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("non-existent job", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + Node *SearchJob + }) + result.Node = nil + }).Return(true, nil) + + err := executeSearchJobGet(mockClient, []string{"-id", "non-existent"}) + if err == nil { + t.Error("expected error for non-existent job, got none") + } + }) + + t.Run("empty ID", func(t *testing.T) { + mockClient := new(mockclient.Client) + err := executeSearchJobGet(mockClient, []string{"-id", ""}) + if err == nil { + t.Error("expected error for empty ID, got none") + } + }) + + t.Run("output formatting", func(t *testing.T) { + formats := []string{ + "{{.ID}}", + "{{.Query}}", + "{{.State}}", + "{{.Creator.Username}}", + "{{.|json}}", + } + + for _, format := range formats { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + Node *SearchJob + }) + result.Node = &SearchJob{ + ID: "test-id", + Query: "repo:test", + State: "COMPLETED", + Creator: struct{ Username string }{Username: "test-user"}, + } + }).Return(true, nil) + + err := executeSearchJobGet(mockClient, []string{"-id", "test-id", "-f", format}) + if err != nil { + t.Errorf("format %q failed: %v", format, err) + } + } + }) + + t.Run("invalid ID format", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, fmt.Errorf("invalid ID format")) + + err := executeSearchJobGet(mockClient, []string{"-id", "invalid-format"}) + if err == nil { + t.Error("expected error for invalid ID format, got none") + } + }) +} + +func executeSearchJobGet(client api.Client, args []string) error { + flagSet := flag.NewFlagSet("get", flag.ExitOnError) + var ( + idFlag string + formatFlag string + ) + flagSet.StringVar(&idFlag, "id", "", "") + flagSet.StringVar(&formatFlag, "f", "{{.ID}}", "") + + if err := flagSet.Parse(args); err != nil { + return err + } + + if idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + var result struct { + Node *SearchJob + } + + if ok, err := client.NewRequest(GetSearchJobQuery + SearchJobFragment, map[string]interface{}{ + "id": idFlag, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + + if result.Node == nil { + return fmt.Errorf("search job not found: %q", idFlag) + } + + return testutil.ExecTemplateWithParsing(formatFlag, result.Node) +} \ No newline at end of file diff --git a/cmd/src/search_jobs_list.go b/cmd/src/search_jobs_list.go new file mode 100644 index 0000000000..04c95c6d71 --- /dev/null +++ b/cmd/src/search_jobs_list.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "flag" + "fmt" + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +const ListSearchJobsQuery = `query SearchJobs($first: Int!, $descending: Boolean!, $orderBy: SearchJobsOrderBy!) { + searchJobs(first: $first, orderBy: $orderBy, descending: $descending) { + nodes { + ...SearchJobFields + } + } +} +` + +// init registers the "list" subcommand for search-jobs which displays search jobs +// based on the provided filtering and formatting options. It supports pagination, +// sorting by different fields, and custom output formatting using Go templates. +func init() { + usage := ` +Examples: + + List all search jobs: + + $ src search-jobs list + + List all search jobs in ascending order: + + $ src search-jobs list --asc + + Limit the number of search jobs returned: + + $ src search-jobs list --limit 5 + + Order search jobs by a field (must be one of: QUERY, CREATED_AT, STATE): + + $ src search-jobs list --order-by QUERY +` + flagSet := flag.NewFlagSet("list", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + formatFlag = flagSet.String("f", "{{.ID}}: {{.Creator.Username}} {{.State}}", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) + limitFlag = flagSet.Int("limit", 10, "Limit the number of search jobs returned") + ascFlag = flagSet.Bool("asc", false, "Sort search jobs in ascending order") + orderByFlag = flagSet.String("order-by", "CREATED_AT", "Sort search jobs by a field") + apiFlags = api.NewFlags(flagSet) + ) + + validOrderBy := map[string]bool{ + "QUERY": true, + "CREATED_AT": true, + "STATE": true, + } + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + client := api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: flagSet.Output(), + Flags: apiFlags, + }) + + if *limitFlag < 1 { + return cmderrors.Usage("limit flag must be greater than 0") + } + + if !validOrderBy[*orderByFlag] { + return cmderrors.Usage("order-by must be one of: QUERY, CREATED_AT, STATE") + } + + tmpl, err := parseTemplate(*formatFlag) + if err != nil { + return err + } + + query := ListSearchJobsQuery + SearchJobFragment + + var result struct { + SearchJobs struct { + Nodes []SearchJob + } + } + + if ok,err := client.NewRequest(query, map[string]interface{}{ + "first": *limitFlag, + "descending": !*ascFlag, + "orderBy": *orderByFlag, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + + if len(result.SearchJobs.Nodes) == 0 { + return cmderrors.ExitCode(1, fmt.Errorf("no search jobs found")) + } + + for _, job := range result.SearchJobs.Nodes { + if err := execTemplate(tmpl, job); err != nil { + return err + } + } + + return nil + } + + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/cmd/src/search_jobs_list_test.go b/cmd/src/search_jobs_list_test.go new file mode 100644 index 0000000000..eb1d67642b --- /dev/null +++ b/cmd/src/search_jobs_list_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "flag" + "fmt" + "testing" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" + testutil "github.com/sourcegraph/src-cli/internal/testing" + mockclient "github.com/sourcegraph/src-cli/internal/api/mock" + "github.com/stretchr/testify/mock" +) +func TestSearchJobsList(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + // Use mock package for expectations + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) +} +// Helper function that mimics the actual command execution +func executeSearchJobsList(client api.Client, args []string) error { + flagSet := flag.NewFlagSet("list", flag.ExitOnError) + var ( + formatFlag = flagSet.String("f", "{{.ID}}", "") + limitFlag = flagSet.Int("limit", 10, "") + ascFlag = flagSet.Bool("asc", false, "") + orderByFlag = flagSet.String("order-by", "CREATED_AT", "") + ) + + if err := flagSet.Parse(args); err != nil { + return err + } + + if *limitFlag < 1 { + return cmderrors.Usage("limit flag must be greater than 0") + } + + validOrderBy := map[string]bool{ + "QUERY": true, + "CREATED_AT": true, + "STATE": true, + } + + if !validOrderBy[*orderByFlag] { + return cmderrors.Usage("order-by must be one of: QUERY, CREATED_AT, STATE") + } + + var result struct { + SearchJobs struct { + Nodes []SearchJob + } + } + + if ok, err := client.NewRequest(ListSearchJobsQuery, map[string]interface{}{ + "first": *limitFlag, + "descending": !*ascFlag, + "orderBy": *orderByFlag, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + + if len(result.SearchJobs.Nodes) == 0 { + return cmderrors.ExitCode(1, fmt.Errorf("no search jobs found")) + } + + return testutil.ExecTemplateWithParsing(*formatFlag, result.SearchJobs.Nodes) +} \ No newline at end of file diff --git a/cmd/src/search_jobs_test.go b/cmd/src/search_jobs_test.go new file mode 100644 index 0000000000..853fdc5bd4 --- /dev/null +++ b/cmd/src/search_jobs_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "testing" +) + +func TestSearchJobsCommandRegistration(t *testing.T) { + // Command registration test + t.Run("command registration", func(t *testing.T) { + var found bool + for _, cmd := range commands { + if cmd.flagSet.Name() == "search-jobs" { + found = true + expectedAliases := []string{"search-job"} + if len(cmd.aliases) != len(expectedAliases) { + t.Errorf("got %d aliases, want %d", len(cmd.aliases), len(expectedAliases)) + } + for i, alias := range cmd.aliases { + if alias != expectedAliases[i] { + t.Errorf("got alias %s, want %s", alias, expectedAliases[i]) + } + } + break + } + } + if !found { + t.Error("search-jobs command not registered") + } + }) + + // Test subcommands registration + t.Run("subcommands", func(t *testing.T) { + expectedCommands := []string{"cancel", "create", "delete", "get", "list"} + + for _, expected := range expectedCommands { + var found bool + for _, cmd := range searchJobsCommands { + if cmd.flagSet.Name() == expected { + found = true + break + } + } + if !found { + t.Errorf("subcommand %s not registered", expected) + } + } + }) +} \ No newline at end of file diff --git a/internal/testing/testutil.go b/internal/testing/testutil.go new file mode 100644 index 0000000000..894d649b1b --- /dev/null +++ b/internal/testing/testutil.go @@ -0,0 +1,28 @@ +package testing + +import ( + "encoding/json" + "os" + "text/template" +) + +// ExecTemplateWithParsing formats and prints data using the provided template format. +// It handles both template parsing and execution in a single call. +func ExecTemplateWithParsing(format string, data interface{}) error { + funcMap := template.FuncMap{ + "json": func(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return err.Error() + } + return string(b) + }, + } + + // Use a generic template name or allow it to be passed as parameter + tmpl, err := template.New("template").Funcs(funcMap).Parse(format) + if err != nil { + return err + } + return tmpl.Execute(os.Stdout, data) +} \ No newline at end of file From 4f57cbc514f09393b5b3d6d5eb42e2fda82cdfa2 Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Wed, 19 Feb 2025 09:40:01 -0500 Subject: [PATCH 02/12] feat: implement search-jobs logs command search_jobs_logs: Implement a new search-jobs subcommand to retrieve jobs logs from the configured Sourcegraph instance. search_jobs_get.go: Extract the GraphQL query logic for fetching a search job into a separate helper function to improve code organization and reusability. This change: - Creates new getSearchJob helper function that encapsulates the GraphQL query logic - Simplifies the main handler function by delegating job fetching - Maintains existing functionality while improving code structure --- cmd/src/search_jobs_get.go | 34 ++++--- cmd/src/search_jobs_logs.go | 108 ++++++++++++++++++++++ cmd/src/search_jobs_logs_test.go | 152 +++++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 cmd/src/search_jobs_logs.go create mode 100644 cmd/src/search_jobs_logs_test.go diff --git a/cmd/src/search_jobs_get.go b/cmd/src/search_jobs_get.go index 17b5873bfb..7aaec4dd38 100644 --- a/cmd/src/search_jobs_get.go +++ b/cmd/src/search_jobs_get.go @@ -53,7 +53,7 @@ Examples: Flags: apiFlags, }) - tmpl, err := parseTemplate(*formatFlag) + tmpl, err := parseTemplate(*formatFlag) if err != nil { return err } @@ -62,23 +62,31 @@ Examples: return cmderrors.Usage("must provide a search job ID") } - query := GetSearchJobQuery + SearchJobFragment - - var result struct { - Node *SearchJob + job, err := getSearchJob(client, *idFlag) + if err != nil { + return err } - - if ok, err := client.NewRequest(query, map[string]interface{}{ - "id": api.NullString(*idFlag), - }).Do(context.Background(), &result); err != nil || !ok { - return err - } - return execTemplate(tmpl, result.Node) + return execTemplate(tmpl, job) } - searchJobsCommands = append(searchJobsCommands, &command{ flagSet: flagSet, handler: handler, usageFunc: usageFunc, }) } + +func getSearchJob(client api.Client, id string) (*SearchJob, error) { + query := GetSearchJobQuery + SearchJobFragment + + var result struct { + Node *SearchJob + } + + if ok, err := client.NewRequest(query, map[string]interface{}{ + "id": api.NullString(id), + }).Do(context.Background(), &result); err != nil || !ok { + return nil, err + } + + return result.Node, nil +} diff --git a/cmd/src/search_jobs_logs.go b/cmd/src/search_jobs_logs.go new file mode 100644 index 0000000000..daa7f108ef --- /dev/null +++ b/cmd/src/search_jobs_logs.go @@ -0,0 +1,108 @@ +package main + +import ( + "flag" + "fmt" + "io" + "net/http" + "os" + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + + +// init registers the 'logs' subcommand for search jobs, which allows users to view +// logs for a specific search job by its ID. The command requires a search job ID +// and uses the configured API client to fetch and display the logs. +func init() { + usage := `retrieves the logs of a search job in CSV format. +Examples: + + View the logs of a search job: + $ src search-jobs logs U2VhcmNoSm9iOjY5 + + Save the logs to a file: + $ src search-jobs logs U2VhcmNoSm9iOjY5 -out logs.csv + +` + flagSet := flag.NewFlagSet("logs", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + idFlag = flagSet.String("id", "", "ID of the search job to view logs for") + outFlag = flagSet.String("out", "", "File path to save the logs (optional)") + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + if *idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + client := api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: flagSet.Output(), + Flags: apiFlags, + }) + + if *idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + job, err := getSearchJob(client, *idFlag) + if err != nil { + return err + } + + if job == nil || job.LogURL == "" { + return fmt.Errorf("no logs URL found for search job %s", *idFlag) + } + + req, err := http.NewRequest("GET", job.LogURL, nil) + if err != nil { + return err + } + + req.Header.Add("Authorization", "token "+cfg.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if *outFlag != "" { + + file, err := os.Create(*outFlag) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return fmt.Errorf("failed to write to output file: %w", err) + } + return nil + } + + _, err = io.Copy(os.Stdout, resp.Body) + return err + } + + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} \ No newline at end of file diff --git a/cmd/src/search_jobs_logs_test.go b/cmd/src/search_jobs_logs_test.go new file mode 100644 index 0000000000..40cb2691f0 --- /dev/null +++ b/cmd/src/search_jobs_logs_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" + mockclient "github.com/sourcegraph/src-cli/internal/api/mock" + "github.com/stretchr/testify/mock" +) +func TestSearchJobsLogs(t *testing.T) { + t.Run("successful log retrieval", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + expectedLogs := "test log content" + mockHTTPClient := &http.Client{ + Transport: &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(expectedLogs)), + }, + }, + } + + // Inject the mock HTTP client into the test environment + originalClient := http.DefaultClient + http.DefaultClient = mockHTTPClient + defer func() { + http.DefaultClient = originalClient + }() + + mockClient.On("NewRequest", + GetSearchJobQuery + SearchJobFragment, + map[string]interface{}{"id": "test-id"}, + ).Return(mockRequest) + + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + Node *SearchJob + }) + result.Node = &SearchJob{ + ID: "test-id", + LogURL: "http://test.com/logs", + } + }).Return(true, nil) + + err := executeSearchJobLogs(mockClient, []string{"-id", "test-id"}) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("empty ID", func(t *testing.T) { + mockClient := new(mockclient.Client) + err := executeSearchJobLogs(mockClient, []string{"-id", ""}) + if err == nil { + t.Error("expected error for empty ID, got none") + } + }) + + t.Run("non-existent job", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + Node *SearchJob + }) + result.Node = nil + }).Return(true, nil) + + err := executeSearchJobLogs(mockClient, []string{"-id", "non-existent"}) + if err == nil { + t.Error("expected error for non-existent job, got none") + } + }) + + t.Run("invalid log URL", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + Node *SearchJob + }) + result.Node = &SearchJob{ + ID: "test-id", + LogURL: "", + } + }).Return(true, nil) + + err := executeSearchJobLogs(mockClient, []string{"-id", "test-id"}) + if err == nil { + t.Error("expected error for invalid log URL, got none") + } + }) +} + +func executeSearchJobLogs(client api.Client, args []string) error { + flagSet := flag.NewFlagSet("logs", flag.ExitOnError) + var ( + idFlag string + outFlag string + ) + flagSet.StringVar(&idFlag, "id", "", "") + flagSet.StringVar(&outFlag, "out", "", "") + + if err := flagSet.Parse(args); err != nil { + return err + } + + if idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + var result struct { + Node *SearchJob + } + + if ok, err := client.NewRequest(GetSearchJobQuery + SearchJobFragment, map[string]interface{}{ + "id": idFlag, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + + if result.Node == nil || result.Node.LogURL == "" { + return fmt.Errorf("no logs URL found for search job %s", idFlag) + } + + // Mock HTTP request handling would go here in a real implementation + return nil +} + +type mockTransport struct { + response *http.Response +} + +func (t *mockTransport) RoundTrip(*http.Request) (*http.Response, error) { + return t.response, nil +} From 45339c27e71992742edcd2751ec801d942250bed Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Wed, 19 Feb 2025 09:58:22 -0500 Subject: [PATCH 03/12] feat: Add search jobs results retrieval command Add new command to retrieve search job results in JSONL format with the following capabilities: - Fetch results using search job ID - Optional file output with -out flag --- cmd/src/search_jobs_results.go | 98 +++++++++++++++++++ cmd/src/search_jobs_results_test.go | 144 ++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 cmd/src/search_jobs_results.go create mode 100644 cmd/src/search_jobs_results_test.go diff --git a/cmd/src/search_jobs_results.go b/cmd/src/search_jobs_results.go new file mode 100644 index 0000000000..505a4606a9 --- /dev/null +++ b/cmd/src/search_jobs_results.go @@ -0,0 +1,98 @@ +package main + +import ( + "flag" + "fmt" + "io" + "net/http" + "os" + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +func init() { + usage := `retrieves the results of a search job in JSON Lines (jsonl) format. +Examples: + + Get the results of a search job: + $ src search-jobs results -id U2VhcmNoSm9iOjY5 + + Save search results to a file: + $ src search-jobs results -id U2VhcmNoSm9iOjY5 -out results.jsonl +` + + flagSet := flag.NewFlagSet("results", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + idFlag = flagSet.String("id", "", "ID of the search job to get results for") + outFlag = flagSet.String("out", "", "File path to save the results (optional)") + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + if *idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + client := api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: flagSet.Output(), + Flags: apiFlags, + }) + + job, err := getSearchJob(client, *idFlag) + if err != nil { + return err + } + + if job == nil || job.URL == "" { + return fmt.Errorf("no results URL found for search job %s", *idFlag) + } + + req, err := http.NewRequest("GET", job.URL, nil) + if err != nil { + return err + } + + req.Header.Add("Authorization", "token "+cfg.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if *outFlag != "" { + file, err := os.Create(*outFlag) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return fmt.Errorf("failed to write to output file: %w", err) + } + return nil + } + + _, err = io.Copy(os.Stdout, resp.Body) + return err + } + + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/cmd/src/search_jobs_results_test.go b/cmd/src/search_jobs_results_test.go new file mode 100644 index 0000000000..24c3d15be4 --- /dev/null +++ b/cmd/src/search_jobs_results_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "flag" + "io/ioutil" + "net/http" + "strings" + "testing" + "fmt" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" + mockclient "github.com/sourcegraph/src-cli/internal/api/mock" + "github.com/stretchr/testify/mock" +) + +func TestSearchJobsResults(t *testing.T) { + t.Run("successful results retrieval", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + expectedResults := `{"result": "test search results"}` + mockHTTPClient := &http.Client{ + Transport: &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(expectedResults)), + }, + }, + } + + // Inject mock HTTP client + originalClient := http.DefaultClient + http.DefaultClient = mockHTTPClient + defer func() { + http.DefaultClient = originalClient + }() + + mockClient.On("NewRequest", + GetSearchJobQuery + SearchJobFragment, + map[string]interface{}{"id": "test-id"}, + ).Return(mockRequest) + + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + Node *SearchJob + }) + result.Node = &SearchJob{ + ID: "test-id", + URL: "http://test.com/results", + } + }).Return(true, nil) + + err := executeSearchJobResults(mockClient, []string{"-id", "test-id"}) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("empty ID", func(t *testing.T) { + mockClient := new(mockclient.Client) + err := executeSearchJobResults(mockClient, []string{"-id", ""}) + if err == nil { + t.Error("expected error for empty ID, got none") + } + }) + + t.Run("non-existent job", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + Node *SearchJob + }) + result.Node = nil + }).Return(true, nil) + + err := executeSearchJobResults(mockClient, []string{"-id", "non-existent"}) + if err == nil { + t.Error("expected error for non-existent job, got none") + } + }) + + t.Run("invalid results URL", func(t *testing.T) { + mockClient := new(mockclient.Client) + mockRequest := new(mockclient.Request) + + mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) + mockRequest.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + result := args.Get(1).(*struct { + Node *SearchJob + }) + result.Node = &SearchJob{ + ID: "test-id", + URL: "", + } + }).Return(true, nil) + + err := executeSearchJobResults(mockClient, []string{"-id", "test-id"}) + if err == nil { + t.Error("expected error for invalid results URL, got none") + } + }) +} + +func executeSearchJobResults(client api.Client, args []string) error { + flagSet := flag.NewFlagSet("results", flag.ExitOnError) + var ( + idFlag string + outFlag string + ) + flagSet.StringVar(&idFlag, "id", "", "") + flagSet.StringVar(&outFlag, "out", "", "") + + if err := flagSet.Parse(args); err != nil { + return err + } + + if idFlag == "" { + return cmderrors.Usage("must provide a search job ID") + } + + var result struct { + Node *SearchJob + } + + if ok, err := client.NewRequest(GetSearchJobQuery + SearchJobFragment, map[string]interface{}{ + "id": idFlag, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + + if result.Node == nil || result.Node.URL == "" { + return fmt.Errorf("no results URL found for search job %s", idFlag) + } + + return nil +} From bcf88c43df17a28b50612a417a27b244c2ffdad1 Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Fri, 21 Feb 2025 09:42:35 -0500 Subject: [PATCH 04/12] cleanup trailing whitespace --- cmd/src/search_jobs.go | 2 +- cmd/src/search_jobs_cancel.go | 2 +- cmd/src/search_jobs_cancel_test.go | 6 +++--- cmd/src/search_jobs_create.go | 2 +- cmd/src/search_jobs_create_test.go | 6 +++--- cmd/src/search_jobs_delete.go | 4 ++-- cmd/src/search_jobs_delete_test.go | 6 +++--- cmd/src/search_jobs_get.go | 12 ++++++------ cmd/src/search_jobs_get_test.go | 8 ++++---- cmd/src/search_jobs_list.go | 10 +++++----- cmd/src/search_jobs_list_test.go | 6 +++--- cmd/src/search_jobs_logs_test.go | 10 +++++----- cmd/src/search_jobs_results_test.go | 8 ++++---- cmd/src/search_jobs_test.go | 4 ++-- 14 files changed, 43 insertions(+), 43 deletions(-) diff --git a/cmd/src/search_jobs.go b/cmd/src/search_jobs.go index d5092a1b7e..ca8cf03bea 100644 --- a/cmd/src/search_jobs.go +++ b/cmd/src/search_jobs.go @@ -51,7 +51,7 @@ The commands are: delete deletes a search job by ID get gets a search job by ID list lists search jobs - + Use "src search-jobs [command] -h" for more information about a command. ` diff --git a/cmd/src/search_jobs_cancel.go b/cmd/src/search_jobs_cancel.go index 8348223435..df77d710ce 100644 --- a/cmd/src/search_jobs_cancel.go +++ b/cmd/src/search_jobs_cancel.go @@ -22,7 +22,7 @@ func init() { Examples: Cancel a search job: - + $ src search-jobs cancel --id U2VhcmNoSm9iOjY5 ` flagSet := flag.NewFlagSet("cancel", flag.ExitOnError) diff --git a/cmd/src/search_jobs_cancel_test.go b/cmd/src/search_jobs_cancel_test.go index d61d33fd34..d7e71b3a15 100644 --- a/cmd/src/search_jobs_cancel_test.go +++ b/cmd/src/search_jobs_cancel_test.go @@ -5,7 +5,7 @@ import ( "flag" "fmt" "testing" - + "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" mockclient "github.com/sourcegraph/src-cli/internal/api/mock" @@ -17,7 +17,7 @@ func TestSearchJobsCancel(t *testing.T) { mockClient := new(mockclient.Client) mockRequest := new(mockclient.Request) - mockClient.On("NewRequest", + mockClient.On("NewRequest", CancelSearchJobMutation, map[string]interface{}{"id": "test-id"}, ).Return(mockRequest) @@ -104,7 +104,7 @@ func executeSearchJobCancel(client api.Client, args []string) error { flagSet := flag.NewFlagSet("cancel", flag.ExitOnError) var idFlag string flagSet.StringVar(&idFlag, "id", "", "") - + if err := flagSet.Parse(args); err != nil { return err } diff --git a/cmd/src/search_jobs_create.go b/cmd/src/search_jobs_create.go index 4560510971..95d795f4f4 100644 --- a/cmd/src/search_jobs_create.go +++ b/cmd/src/search_jobs_create.go @@ -31,7 +31,7 @@ func init() { Examples: Create a search job: - + $ src search-jobs create -query "repo:^github\.com/sourcegraph/sourcegraph$ sort:indexed-desc" ` diff --git a/cmd/src/search_jobs_create_test.go b/cmd/src/search_jobs_create_test.go index 292b701e9f..8fc27f7e73 100644 --- a/cmd/src/search_jobs_create_test.go +++ b/cmd/src/search_jobs_create_test.go @@ -17,11 +17,11 @@ func TestSearchJobsCreate(t *testing.T) { // Set up validation request validationRequest := new(mockclient.Request) - mockClient.On("NewRequest", + mockClient.On("NewRequest", ValidateSearchJobQuery, map[string]interface{}{"query": "repo:test"}, ).Return(validationRequest) - + validationRequest.On("Do", mock.Anything, mock.Anything).Return(true, nil) // Set up creation request @@ -59,7 +59,7 @@ func executeSearchJobCreate(client api.Client, args []string) error { ) flagSet.StringVar(&queryFlag, "query", "", "") flagSet.StringVar(&formatFlag, "f", "{{.ID}}", "") - + if err := flagSet.Parse(args); err != nil { return err } diff --git a/cmd/src/search_jobs_delete.go b/cmd/src/search_jobs_delete.go index 4694306849..01b9b98b1b 100644 --- a/cmd/src/search_jobs_delete.go +++ b/cmd/src/search_jobs_delete.go @@ -22,7 +22,7 @@ func init() { Examples: Delete a search job by ID: - + $ src search-jobs delete U2VhcmNoSm9iOjY5 ` @@ -32,7 +32,7 @@ Examples: flagSet.PrintDefaults() fmt.Println(usage) } - + var ( idFlag = flagSet.String("id", "", "ID of the search job to delete") apiFlags = api.NewFlags(flagSet) diff --git a/cmd/src/search_jobs_delete_test.go b/cmd/src/search_jobs_delete_test.go index ceb0528a84..86fda9ad51 100644 --- a/cmd/src/search_jobs_delete_test.go +++ b/cmd/src/search_jobs_delete_test.go @@ -5,7 +5,7 @@ import ( "flag" "fmt" "testing" - + "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" mockclient "github.com/sourcegraph/src-cli/internal/api/mock" @@ -16,7 +16,7 @@ func TestSearchJobsDelete(t *testing.T) { mockClient := new(mockclient.Client) mockRequest := new(mockclient.Request) - mockClient.On("NewRequest", + mockClient.On("NewRequest", DeleteSearchJobQuery, map[string]interface{}{"id": "test-id"}, ).Return(mockRequest) @@ -91,7 +91,7 @@ func executeSearchJobDelete(client api.Client, args []string) error { flagSet := flag.NewFlagSet("delete", flag.ExitOnError) var idFlag string flagSet.StringVar(&idFlag, "id", "", "") - + if err := flagSet.Parse(args); err != nil { return err } diff --git a/cmd/src/search_jobs_get.go b/cmd/src/search_jobs_get.go index 7aaec4dd38..ffa10b68da 100644 --- a/cmd/src/search_jobs_get.go +++ b/cmd/src/search_jobs_get.go @@ -15,7 +15,7 @@ const GetSearchJobQuery = `query SearchJob($id: ID!) { } } } -` +` // init registers the "get" subcommand for search-jobs which retrieves a search job by ID. // It supports formatting the output using Go templates and requires authentication via API flags. @@ -24,7 +24,7 @@ func init() { Examples: Get a search job by ID: - + $ src search-jobs get U2VhcmNoSm9iOjY5 ` @@ -34,7 +34,7 @@ Examples: flagSet.PrintDefaults() fmt.Println(usage) } - + var ( idFlag = flagSet.String("id", "", "ID of the search job") formatFlag = flagSet.String("f", "{{.ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) @@ -77,16 +77,16 @@ Examples: func getSearchJob(client api.Client, id string) (*SearchJob, error) { query := GetSearchJobQuery + SearchJobFragment - + var result struct { Node *SearchJob } - + if ok, err := client.NewRequest(query, map[string]interface{}{ "id": api.NullString(id), }).Do(context.Background(), &result); err != nil || !ok { return nil, err } - + return result.Node, nil } diff --git a/cmd/src/search_jobs_get_test.go b/cmd/src/search_jobs_get_test.go index 064e92eafa..3a9144eb91 100644 --- a/cmd/src/search_jobs_get_test.go +++ b/cmd/src/search_jobs_get_test.go @@ -5,7 +5,7 @@ import ( "flag" "fmt" "testing" - + "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" testutil "github.com/sourcegraph/src-cli/internal/testing" @@ -18,7 +18,7 @@ func TestSearchJobsGet(t *testing.T) { mockClient := new(mockclient.Client) mockRequest := new(mockclient.Request) - mockClient.On("NewRequest", + mockClient.On("NewRequest", GetSearchJobQuery + SearchJobFragment, map[string]interface{}{"id": "test-id"}, ).Return(mockRequest) @@ -125,7 +125,7 @@ func executeSearchJobGet(client api.Client, args []string) error { ) flagSet.StringVar(&idFlag, "id", "", "") flagSet.StringVar(&formatFlag, "f", "{{.ID}}", "") - + if err := flagSet.Parse(args); err != nil { return err } @@ -149,4 +149,4 @@ func executeSearchJobGet(client api.Client, args []string) error { } return testutil.ExecTemplateWithParsing(formatFlag, result.Node) -} \ No newline at end of file +} diff --git a/cmd/src/search_jobs_list.go b/cmd/src/search_jobs_list.go index 04c95c6d71..da9ced137f 100644 --- a/cmd/src/search_jobs_list.go +++ b/cmd/src/search_jobs_list.go @@ -25,19 +25,19 @@ func init() { Examples: List all search jobs: - + $ src search-jobs list List all search jobs in ascending order: - + $ src search-jobs list --asc Limit the number of search jobs returned: - + $ src search-jobs list --limit 5 Order search jobs by a field (must be one of: QUERY, CREATED_AT, STATE): - + $ src search-jobs list --order-by QUERY ` flagSet := flag.NewFlagSet("list", flag.ExitOnError) @@ -87,7 +87,7 @@ Examples: } query := ListSearchJobsQuery + SearchJobFragment - + var result struct { SearchJobs struct { Nodes []SearchJob diff --git a/cmd/src/search_jobs_list_test.go b/cmd/src/search_jobs_list_test.go index eb1d67642b..58fed55bb3 100644 --- a/cmd/src/search_jobs_list_test.go +++ b/cmd/src/search_jobs_list_test.go @@ -5,7 +5,7 @@ import ( "flag" "fmt" "testing" - + "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" testutil "github.com/sourcegraph/src-cli/internal/testing" @@ -15,7 +15,7 @@ import ( func TestSearchJobsList(t *testing.T) { mockClient := new(mockclient.Client) mockRequest := new(mockclient.Request) - + // Use mock package for expectations mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) } @@ -28,7 +28,7 @@ func executeSearchJobsList(client api.Client, args []string) error { ascFlag = flagSet.Bool("asc", false, "") orderByFlag = flagSet.String("order-by", "CREATED_AT", "") ) - + if err := flagSet.Parse(args); err != nil { return err } diff --git a/cmd/src/search_jobs_logs_test.go b/cmd/src/search_jobs_logs_test.go index 40cb2691f0..f7d0eacdff 100644 --- a/cmd/src/search_jobs_logs_test.go +++ b/cmd/src/search_jobs_logs_test.go @@ -8,7 +8,7 @@ import ( "net/http" "strings" "testing" - + "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" mockclient "github.com/sourcegraph/src-cli/internal/api/mock" @@ -18,7 +18,7 @@ func TestSearchJobsLogs(t *testing.T) { t.Run("successful log retrieval", func(t *testing.T) { mockClient := new(mockclient.Client) mockRequest := new(mockclient.Request) - + expectedLogs := "test log content" mockHTTPClient := &http.Client{ Transport: &mockTransport{ @@ -35,8 +35,8 @@ func TestSearchJobsLogs(t *testing.T) { defer func() { http.DefaultClient = originalClient }() - - mockClient.On("NewRequest", + + mockClient.On("NewRequest", GetSearchJobQuery + SearchJobFragment, map[string]interface{}{"id": "test-id"}, ).Return(mockRequest) @@ -116,7 +116,7 @@ func executeSearchJobLogs(client api.Client, args []string) error { ) flagSet.StringVar(&idFlag, "id", "", "") flagSet.StringVar(&outFlag, "out", "", "") - + if err := flagSet.Parse(args); err != nil { return err } diff --git a/cmd/src/search_jobs_results_test.go b/cmd/src/search_jobs_results_test.go index 24c3d15be4..17827fa5c6 100644 --- a/cmd/src/search_jobs_results_test.go +++ b/cmd/src/search_jobs_results_test.go @@ -19,7 +19,7 @@ func TestSearchJobsResults(t *testing.T) { t.Run("successful results retrieval", func(t *testing.T) { mockClient := new(mockclient.Client) mockRequest := new(mockclient.Request) - + expectedResults := `{"result": "test search results"}` mockHTTPClient := &http.Client{ Transport: &mockTransport{ @@ -36,8 +36,8 @@ func TestSearchJobsResults(t *testing.T) { defer func() { http.DefaultClient = originalClient }() - - mockClient.On("NewRequest", + + mockClient.On("NewRequest", GetSearchJobQuery + SearchJobFragment, map[string]interface{}{"id": "test-id"}, ).Return(mockRequest) @@ -117,7 +117,7 @@ func executeSearchJobResults(client api.Client, args []string) error { ) flagSet.StringVar(&idFlag, "id", "", "") flagSet.StringVar(&outFlag, "out", "", "") - + if err := flagSet.Parse(args); err != nil { return err } diff --git a/cmd/src/search_jobs_test.go b/cmd/src/search_jobs_test.go index 853fdc5bd4..400f48167e 100644 --- a/cmd/src/search_jobs_test.go +++ b/cmd/src/search_jobs_test.go @@ -31,7 +31,7 @@ func TestSearchJobsCommandRegistration(t *testing.T) { // Test subcommands registration t.Run("subcommands", func(t *testing.T) { expectedCommands := []string{"cancel", "create", "delete", "get", "list"} - + for _, expected := range expectedCommands { var found bool for _, cmd := range searchJobsCommands { @@ -45,4 +45,4 @@ func TestSearchJobsCommandRegistration(t *testing.T) { } } }) -} \ No newline at end of file +} From f46b05ad90e6a850335d7ccdbb1d6a4e31e04e9d Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Fri, 21 Feb 2025 09:47:59 -0500 Subject: [PATCH 05/12] remove tests as they are not functionally testing production code --- cmd/src/search_jobs_cancel_test.go | 130 ------------------------ cmd/src/search_jobs_create_test.go | 84 --------------- cmd/src/search_jobs_delete_test.go | 117 --------------------- cmd/src/search_jobs_get_test.go | 152 ---------------------------- cmd/src/search_jobs_list_test.go | 69 ------------- cmd/src/search_jobs_logs_test.go | 152 ---------------------------- cmd/src/search_jobs_results_test.go | 144 -------------------------- cmd/src/search_jobs_test.go | 48 --------- 8 files changed, 896 deletions(-) delete mode 100644 cmd/src/search_jobs_cancel_test.go delete mode 100644 cmd/src/search_jobs_create_test.go delete mode 100644 cmd/src/search_jobs_delete_test.go delete mode 100644 cmd/src/search_jobs_get_test.go delete mode 100644 cmd/src/search_jobs_list_test.go delete mode 100644 cmd/src/search_jobs_logs_test.go delete mode 100644 cmd/src/search_jobs_results_test.go delete mode 100644 cmd/src/search_jobs_test.go diff --git a/cmd/src/search_jobs_cancel_test.go b/cmd/src/search_jobs_cancel_test.go deleted file mode 100644 index d7e71b3a15..0000000000 --- a/cmd/src/search_jobs_cancel_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "testing" - - "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" - mockclient "github.com/sourcegraph/src-cli/internal/api/mock" - "github.com/stretchr/testify/mock" -) - -func TestSearchJobsCancel(t *testing.T) { - t.Run("running job", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", - CancelSearchJobMutation, - map[string]interface{}{"id": "test-id"}, - ).Return(mockRequest) - - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - CancelSearchJob struct { - AlwaysNil interface{} - } - }) - result.CancelSearchJob.AlwaysNil = nil - }).Return(true, nil) - - err := executeSearchJobCancel(mockClient, []string{"-id", "test-id"}) - if err != nil { - t.Errorf("expected no error, got %v", err) - } - }) - - t.Run("completed job", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, fmt.Errorf("cannot cancel completed job")) - - err := executeSearchJobCancel(mockClient, []string{"-id", "completed-id"}) - if err == nil { - t.Error("expected error for completed job, got none") - } - }) - - t.Run("non-existent job", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, fmt.Errorf("job not found")) - - err := executeSearchJobCancel(mockClient, []string{"-id", "non-existent"}) - if err == nil { - t.Error("expected error for non-existent job, got none") - } - }) - - t.Run("empty ID", func(t *testing.T) { - mockClient := new(mockclient.Client) - err := executeSearchJobCancel(mockClient, []string{"-id", ""}) - if err == nil { - t.Error("expected error for empty ID, got none") - } - }) - - t.Run("error handling", func(t *testing.T) { - testCases := []struct { - name string - id string - mockErr error - }{ - {"network error", "test-id", fmt.Errorf("network error")}, - {"server error", "test-id", fmt.Errorf("internal server error")}, - {"invalid ID", "invalid-id", fmt.Errorf("invalid ID format")}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, tc.mockErr) - - err := executeSearchJobCancel(mockClient, []string{"-id", tc.id}) - if err == nil { - t.Errorf("expected error for %s, got none", tc.name) - } - }) - } - }) -} - -func executeSearchJobCancel(client api.Client, args []string) error { - flagSet := flag.NewFlagSet("cancel", flag.ExitOnError) - var idFlag string - flagSet.StringVar(&idFlag, "id", "", "") - - if err := flagSet.Parse(args); err != nil { - return err - } - - if idFlag == "" { - return cmderrors.Usage("must provide a search job ID") - } - - var result struct { - CancelSearchJob struct { - AlwaysNil interface{} - } - } - - if ok, err := client.NewRequest(CancelSearchJobMutation, map[string]interface{}{ - "id": idFlag, - }).Do(context.Background(), &result); err != nil || !ok { - return err - } - - fmt.Printf("Search job %s canceled successfully\n", idFlag) - return nil -} diff --git a/cmd/src/search_jobs_create_test.go b/cmd/src/search_jobs_create_test.go deleted file mode 100644 index 8fc27f7e73..0000000000 --- a/cmd/src/search_jobs_create_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package main - -import ( - "context" - "flag" - "testing" - - "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" - testutil "github.com/sourcegraph/src-cli/internal/testing" - mockclient "github.com/sourcegraph/src-cli/internal/api/mock" - "github.com/stretchr/testify/mock" -) -func TestSearchJobsCreate(t *testing.T) { - t.Run("valid query", func(t *testing.T) { - mockClient := new(mockclient.Client) - - // Set up validation request - validationRequest := new(mockclient.Request) - mockClient.On("NewRequest", - ValidateSearchJobQuery, - map[string]interface{}{"query": "repo:test"}, - ).Return(validationRequest) - - validationRequest.On("Do", mock.Anything, mock.Anything).Return(true, nil) - - // Set up creation request - creationRequest := new(mockclient.Request) - mockClient.On("NewRequest", - CreateSearchJobQuery, - map[string]interface{}{"query": "repo:test"}, - ).Return(creationRequest) - - creationRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - CreateSearchJob *SearchJob `json:"createSearchJob"` - }) - result.CreateSearchJob = &SearchJob{ - ID: "test-id", - Query: "repo:test", - State: "QUEUED", - Creator: struct{ Username string }{Username: "test-user"}, - } - }).Return(true, nil) - - err := executeSearchJobCreate(mockClient, []string{"-query", "repo:test"}) - if err != nil { - t.Errorf("expected no error, got %v", err) - } - }) -} - -func executeSearchJobCreate(client api.Client, args []string) error { - flagSet := flag.NewFlagSet("create", flag.ExitOnError) - var ( - queryFlag string - formatFlag string - ) - flagSet.StringVar(&queryFlag, "query", "", "") - flagSet.StringVar(&formatFlag, "f", "{{.ID}}", "") - - if err := flagSet.Parse(args); err != nil { - return err - } - - if queryFlag == "" { - return cmderrors.Usage("must provide a query") - } - - // Define result structure before using it - var result struct { - CreateSearchJob *SearchJob `json:"createSearchJob"` - } - - if ok, err := client.NewRequest(CreateSearchJobQuery, map[string]interface{}{ - "query": queryFlag, - }).Do(context.Background(), &result); !ok { - return err - } - - // Now we can use result with proper scoping - return testutil.ExecTemplateWithParsing(formatFlag, result.CreateSearchJob) -} \ No newline at end of file diff --git a/cmd/src/search_jobs_delete_test.go b/cmd/src/search_jobs_delete_test.go deleted file mode 100644 index 86fda9ad51..0000000000 --- a/cmd/src/search_jobs_delete_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "testing" - - "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" - mockclient "github.com/sourcegraph/src-cli/internal/api/mock" - "github.com/stretchr/testify/mock" -) -func TestSearchJobsDelete(t *testing.T) { - t.Run("existing job", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", - DeleteSearchJobQuery, - map[string]interface{}{"id": "test-id"}, - ).Return(mockRequest) - - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - DeleteSearchJob struct { - AlwaysNil interface{} - } - }) - // Simulate successful deletion - result.DeleteSearchJob.AlwaysNil = nil - }).Return(true, nil) - - err := executeSearchJobDelete(mockClient, []string{"-id", "test-id"}) - if err != nil { - t.Errorf("expected no error, got %v", err) - } - }) - - t.Run("non-existent job", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, fmt.Errorf("job not found")) - - err := executeSearchJobDelete(mockClient, []string{"-id", "non-existent"}) - if err == nil { - t.Error("expected error for non-existent job, got none") - } - }) - - t.Run("empty ID", func(t *testing.T) { - mockClient := new(mockclient.Client) - err := executeSearchJobDelete(mockClient, []string{"-id", ""}) - if err == nil { - t.Error("expected error for empty ID, got none") - } - }) - - t.Run("error handling", func(t *testing.T) { - testCases := []struct { - name string - id string - mockErr error - }{ - {"network error", "test-id", fmt.Errorf("network error")}, - {"server error", "test-id", fmt.Errorf("internal server error")}, - {"invalid ID", "invalid-id", fmt.Errorf("invalid ID format")}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, tc.mockErr) - - err := executeSearchJobDelete(mockClient, []string{"-id", tc.id}) - if err == nil { - t.Errorf("expected error for %s, got none", tc.name) - } - }) - } - }) -} - -func executeSearchJobDelete(client api.Client, args []string) error { - flagSet := flag.NewFlagSet("delete", flag.ExitOnError) - var idFlag string - flagSet.StringVar(&idFlag, "id", "", "") - - if err := flagSet.Parse(args); err != nil { - return err - } - - if idFlag == "" { - return cmderrors.Usage("must provide a search job ID") - } - - var result struct { - DeleteSearchJob struct { - AlwaysNil interface{} - } - } - - if ok, err := client.NewRequest(DeleteSearchJobQuery, map[string]interface{}{ - "id": idFlag, - }).Do(context.Background(), &result); err != nil || !ok { - return err - } - - fmt.Printf("Search job %s deleted successfully\n", idFlag) - return nil -} \ No newline at end of file diff --git a/cmd/src/search_jobs_get_test.go b/cmd/src/search_jobs_get_test.go deleted file mode 100644 index 3a9144eb91..0000000000 --- a/cmd/src/search_jobs_get_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "testing" - - "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" - testutil "github.com/sourcegraph/src-cli/internal/testing" - mockclient "github.com/sourcegraph/src-cli/internal/api/mock" - "github.com/stretchr/testify/mock" -) - -func TestSearchJobsGet(t *testing.T) { - t.Run("existing job", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", - GetSearchJobQuery + SearchJobFragment, - map[string]interface{}{"id": "test-id"}, - ).Return(mockRequest) - - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - Node *SearchJob - }) - result.Node = &SearchJob{ - ID: "test-id", - Query: "repo:test", - State: "COMPLETED", - Creator: struct{ Username string }{Username: "test-user"}, - } - }).Return(true, nil) - - err := executeSearchJobGet(mockClient, []string{"-id", "test-id"}) - if err != nil { - t.Errorf("expected no error, got %v", err) - } - }) - - t.Run("non-existent job", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - Node *SearchJob - }) - result.Node = nil - }).Return(true, nil) - - err := executeSearchJobGet(mockClient, []string{"-id", "non-existent"}) - if err == nil { - t.Error("expected error for non-existent job, got none") - } - }) - - t.Run("empty ID", func(t *testing.T) { - mockClient := new(mockclient.Client) - err := executeSearchJobGet(mockClient, []string{"-id", ""}) - if err == nil { - t.Error("expected error for empty ID, got none") - } - }) - - t.Run("output formatting", func(t *testing.T) { - formats := []string{ - "{{.ID}}", - "{{.Query}}", - "{{.State}}", - "{{.Creator.Username}}", - "{{.|json}}", - } - - for _, format := range formats { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - Node *SearchJob - }) - result.Node = &SearchJob{ - ID: "test-id", - Query: "repo:test", - State: "COMPLETED", - Creator: struct{ Username string }{Username: "test-user"}, - } - }).Return(true, nil) - - err := executeSearchJobGet(mockClient, []string{"-id", "test-id", "-f", format}) - if err != nil { - t.Errorf("format %q failed: %v", format, err) - } - } - }) - - t.Run("invalid ID format", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything).Return(false, fmt.Errorf("invalid ID format")) - - err := executeSearchJobGet(mockClient, []string{"-id", "invalid-format"}) - if err == nil { - t.Error("expected error for invalid ID format, got none") - } - }) -} - -func executeSearchJobGet(client api.Client, args []string) error { - flagSet := flag.NewFlagSet("get", flag.ExitOnError) - var ( - idFlag string - formatFlag string - ) - flagSet.StringVar(&idFlag, "id", "", "") - flagSet.StringVar(&formatFlag, "f", "{{.ID}}", "") - - if err := flagSet.Parse(args); err != nil { - return err - } - - if idFlag == "" { - return cmderrors.Usage("must provide a search job ID") - } - - var result struct { - Node *SearchJob - } - - if ok, err := client.NewRequest(GetSearchJobQuery + SearchJobFragment, map[string]interface{}{ - "id": idFlag, - }).Do(context.Background(), &result); err != nil || !ok { - return err - } - - if result.Node == nil { - return fmt.Errorf("search job not found: %q", idFlag) - } - - return testutil.ExecTemplateWithParsing(formatFlag, result.Node) -} diff --git a/cmd/src/search_jobs_list_test.go b/cmd/src/search_jobs_list_test.go deleted file mode 100644 index 58fed55bb3..0000000000 --- a/cmd/src/search_jobs_list_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "testing" - - "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" - testutil "github.com/sourcegraph/src-cli/internal/testing" - mockclient "github.com/sourcegraph/src-cli/internal/api/mock" - "github.com/stretchr/testify/mock" -) -func TestSearchJobsList(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - // Use mock package for expectations - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) -} -// Helper function that mimics the actual command execution -func executeSearchJobsList(client api.Client, args []string) error { - flagSet := flag.NewFlagSet("list", flag.ExitOnError) - var ( - formatFlag = flagSet.String("f", "{{.ID}}", "") - limitFlag = flagSet.Int("limit", 10, "") - ascFlag = flagSet.Bool("asc", false, "") - orderByFlag = flagSet.String("order-by", "CREATED_AT", "") - ) - - if err := flagSet.Parse(args); err != nil { - return err - } - - if *limitFlag < 1 { - return cmderrors.Usage("limit flag must be greater than 0") - } - - validOrderBy := map[string]bool{ - "QUERY": true, - "CREATED_AT": true, - "STATE": true, - } - - if !validOrderBy[*orderByFlag] { - return cmderrors.Usage("order-by must be one of: QUERY, CREATED_AT, STATE") - } - - var result struct { - SearchJobs struct { - Nodes []SearchJob - } - } - - if ok, err := client.NewRequest(ListSearchJobsQuery, map[string]interface{}{ - "first": *limitFlag, - "descending": !*ascFlag, - "orderBy": *orderByFlag, - }).Do(context.Background(), &result); err != nil || !ok { - return err - } - - if len(result.SearchJobs.Nodes) == 0 { - return cmderrors.ExitCode(1, fmt.Errorf("no search jobs found")) - } - - return testutil.ExecTemplateWithParsing(*formatFlag, result.SearchJobs.Nodes) -} \ No newline at end of file diff --git a/cmd/src/search_jobs_logs_test.go b/cmd/src/search_jobs_logs_test.go deleted file mode 100644 index f7d0eacdff..0000000000 --- a/cmd/src/search_jobs_logs_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "io/ioutil" - "net/http" - "strings" - "testing" - - "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" - mockclient "github.com/sourcegraph/src-cli/internal/api/mock" - "github.com/stretchr/testify/mock" -) -func TestSearchJobsLogs(t *testing.T) { - t.Run("successful log retrieval", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - expectedLogs := "test log content" - mockHTTPClient := &http.Client{ - Transport: &mockTransport{ - response: &http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(expectedLogs)), - }, - }, - } - - // Inject the mock HTTP client into the test environment - originalClient := http.DefaultClient - http.DefaultClient = mockHTTPClient - defer func() { - http.DefaultClient = originalClient - }() - - mockClient.On("NewRequest", - GetSearchJobQuery + SearchJobFragment, - map[string]interface{}{"id": "test-id"}, - ).Return(mockRequest) - - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - Node *SearchJob - }) - result.Node = &SearchJob{ - ID: "test-id", - LogURL: "http://test.com/logs", - } - }).Return(true, nil) - - err := executeSearchJobLogs(mockClient, []string{"-id", "test-id"}) - if err != nil { - t.Errorf("expected no error, got %v", err) - } - }) - - t.Run("empty ID", func(t *testing.T) { - mockClient := new(mockclient.Client) - err := executeSearchJobLogs(mockClient, []string{"-id", ""}) - if err == nil { - t.Error("expected error for empty ID, got none") - } - }) - - t.Run("non-existent job", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - Node *SearchJob - }) - result.Node = nil - }).Return(true, nil) - - err := executeSearchJobLogs(mockClient, []string{"-id", "non-existent"}) - if err == nil { - t.Error("expected error for non-existent job, got none") - } - }) - - t.Run("invalid log URL", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - Node *SearchJob - }) - result.Node = &SearchJob{ - ID: "test-id", - LogURL: "", - } - }).Return(true, nil) - - err := executeSearchJobLogs(mockClient, []string{"-id", "test-id"}) - if err == nil { - t.Error("expected error for invalid log URL, got none") - } - }) -} - -func executeSearchJobLogs(client api.Client, args []string) error { - flagSet := flag.NewFlagSet("logs", flag.ExitOnError) - var ( - idFlag string - outFlag string - ) - flagSet.StringVar(&idFlag, "id", "", "") - flagSet.StringVar(&outFlag, "out", "", "") - - if err := flagSet.Parse(args); err != nil { - return err - } - - if idFlag == "" { - return cmderrors.Usage("must provide a search job ID") - } - - var result struct { - Node *SearchJob - } - - if ok, err := client.NewRequest(GetSearchJobQuery + SearchJobFragment, map[string]interface{}{ - "id": idFlag, - }).Do(context.Background(), &result); err != nil || !ok { - return err - } - - if result.Node == nil || result.Node.LogURL == "" { - return fmt.Errorf("no logs URL found for search job %s", idFlag) - } - - // Mock HTTP request handling would go here in a real implementation - return nil -} - -type mockTransport struct { - response *http.Response -} - -func (t *mockTransport) RoundTrip(*http.Request) (*http.Response, error) { - return t.response, nil -} diff --git a/cmd/src/search_jobs_results_test.go b/cmd/src/search_jobs_results_test.go deleted file mode 100644 index 17827fa5c6..0000000000 --- a/cmd/src/search_jobs_results_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package main - -import ( - "context" - "flag" - "io/ioutil" - "net/http" - "strings" - "testing" - "fmt" - - "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" - mockclient "github.com/sourcegraph/src-cli/internal/api/mock" - "github.com/stretchr/testify/mock" -) - -func TestSearchJobsResults(t *testing.T) { - t.Run("successful results retrieval", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - expectedResults := `{"result": "test search results"}` - mockHTTPClient := &http.Client{ - Transport: &mockTransport{ - response: &http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(expectedResults)), - }, - }, - } - - // Inject mock HTTP client - originalClient := http.DefaultClient - http.DefaultClient = mockHTTPClient - defer func() { - http.DefaultClient = originalClient - }() - - mockClient.On("NewRequest", - GetSearchJobQuery + SearchJobFragment, - map[string]interface{}{"id": "test-id"}, - ).Return(mockRequest) - - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - Node *SearchJob - }) - result.Node = &SearchJob{ - ID: "test-id", - URL: "http://test.com/results", - } - }).Return(true, nil) - - err := executeSearchJobResults(mockClient, []string{"-id", "test-id"}) - if err != nil { - t.Errorf("expected no error, got %v", err) - } - }) - - t.Run("empty ID", func(t *testing.T) { - mockClient := new(mockclient.Client) - err := executeSearchJobResults(mockClient, []string{"-id", ""}) - if err == nil { - t.Error("expected error for empty ID, got none") - } - }) - - t.Run("non-existent job", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - Node *SearchJob - }) - result.Node = nil - }).Return(true, nil) - - err := executeSearchJobResults(mockClient, []string{"-id", "non-existent"}) - if err == nil { - t.Error("expected error for non-existent job, got none") - } - }) - - t.Run("invalid results URL", func(t *testing.T) { - mockClient := new(mockclient.Client) - mockRequest := new(mockclient.Request) - - mockClient.On("NewRequest", mock.Anything, mock.Anything).Return(mockRequest) - mockRequest.On("Do", mock.Anything, mock.Anything). - Run(func(args mock.Arguments) { - result := args.Get(1).(*struct { - Node *SearchJob - }) - result.Node = &SearchJob{ - ID: "test-id", - URL: "", - } - }).Return(true, nil) - - err := executeSearchJobResults(mockClient, []string{"-id", "test-id"}) - if err == nil { - t.Error("expected error for invalid results URL, got none") - } - }) -} - -func executeSearchJobResults(client api.Client, args []string) error { - flagSet := flag.NewFlagSet("results", flag.ExitOnError) - var ( - idFlag string - outFlag string - ) - flagSet.StringVar(&idFlag, "id", "", "") - flagSet.StringVar(&outFlag, "out", "", "") - - if err := flagSet.Parse(args); err != nil { - return err - } - - if idFlag == "" { - return cmderrors.Usage("must provide a search job ID") - } - - var result struct { - Node *SearchJob - } - - if ok, err := client.NewRequest(GetSearchJobQuery + SearchJobFragment, map[string]interface{}{ - "id": idFlag, - }).Do(context.Background(), &result); err != nil || !ok { - return err - } - - if result.Node == nil || result.Node.URL == "" { - return fmt.Errorf("no results URL found for search job %s", idFlag) - } - - return nil -} diff --git a/cmd/src/search_jobs_test.go b/cmd/src/search_jobs_test.go deleted file mode 100644 index 400f48167e..0000000000 --- a/cmd/src/search_jobs_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "testing" -) - -func TestSearchJobsCommandRegistration(t *testing.T) { - // Command registration test - t.Run("command registration", func(t *testing.T) { - var found bool - for _, cmd := range commands { - if cmd.flagSet.Name() == "search-jobs" { - found = true - expectedAliases := []string{"search-job"} - if len(cmd.aliases) != len(expectedAliases) { - t.Errorf("got %d aliases, want %d", len(cmd.aliases), len(expectedAliases)) - } - for i, alias := range cmd.aliases { - if alias != expectedAliases[i] { - t.Errorf("got alias %s, want %s", alias, expectedAliases[i]) - } - } - break - } - } - if !found { - t.Error("search-jobs command not registered") - } - }) - - // Test subcommands registration - t.Run("subcommands", func(t *testing.T) { - expectedCommands := []string{"cancel", "create", "delete", "get", "list"} - - for _, expected := range expectedCommands { - var found bool - for _, cmd := range searchJobsCommands { - if cmd.flagSet.Name() == expected { - found = true - break - } - } - if !found { - t.Errorf("subcommand %s not registered", expected) - } - } - }) -} From dae3f65d7d5d8007ea2575f66e65a7f3bc68f718 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Wed, 5 Mar 2025 13:04:17 -0800 Subject: [PATCH 06/12] Fix linter issues. Add support for job id numbers (#1151) --- cmd/src/format.go | 14 ++++ cmd/src/search_jobs.go | 79 +++++++++++++++----- cmd/src/search_jobs_cancel.go | 15 ++-- cmd/src/search_jobs_create.go | 7 +- cmd/src/search_jobs_delete.go | 13 ++-- cmd/src/search_jobs_get.go | 127 +++++++++++++++++---------------- cmd/src/search_jobs_list.go | 125 ++++++++++++++++---------------- cmd/src/search_jobs_logs.go | 19 ++--- cmd/src/search_jobs_results.go | 14 ++-- 9 files changed, 231 insertions(+), 182 deletions(-) diff --git a/cmd/src/format.go b/cmd/src/format.go index 2c08d4fcc1..2cbe9226e9 100644 --- a/cmd/src/format.go +++ b/cmd/src/format.go @@ -80,6 +80,20 @@ func parseTemplate(text string) (*template.Template, error) { } return humanize.Time(t), nil }, + "searchJobIDNumber": func(id string) string { + sjid, err := ParseSearchJobID(id) + if err != nil { + return id + } + return fmt.Sprintf("%d", sjid.Number()) + }, + "searchJobIDCanonical": func(id string) string { + sjid, err := ParseSearchJobID(id) + if err != nil { + return id + } + return sjid.Canonical() + }, // Register search-specific template functions "searchSequentialLineNumber": searchTemplateFuncs["searchSequentialLineNumber"], diff --git a/cmd/src/search_jobs.go b/cmd/src/search_jobs.go index ca8cf03bea..efe024e8af 100644 --- a/cmd/src/search_jobs.go +++ b/cmd/src/search_jobs.go @@ -1,10 +1,13 @@ package main import ( + "encoding/base64" "flag" "fmt" - "text/template" - "os" + "regexp" + "strconv" + + "github.com/sourcegraph/src-cli/internal/cmderrors" ) // searchJobFragment is a GraphQL fragment that defines the fields to be queried @@ -51,6 +54,8 @@ The commands are: delete deletes a search job by ID get gets a search job by ID list lists search jobs + logs outputs the logs for a search job by ID + results outputs the results for a search job by ID Use "src search-jobs [command] -h" for more information about a command. ` @@ -71,22 +76,12 @@ Use "src search-jobs [command] -h" for more information about a command. }) } -// printSearchJob formats and prints a search job to stdout using the provided format template. -// Returns an error if the template parsing or execution fails. -func printSearchJob(job *SearchJob, format string) error { - tmpl, err := template.New("searchJob").Parse(format) - if err != nil { - return err - } - return tmpl.Execute(os.Stdout, job) -} - // SearchJob represents a search job with its metadata, including the search query, // execution state, creator information, timestamps, URLs, and repository statistics. type SearchJob struct { - ID string - Query string - State string + ID string + Query string + State string Creator struct { Username string } @@ -96,9 +91,55 @@ type SearchJob struct { URL string LogURL string RepoStats struct { - Total int - Completed int - Failed int - InProgress int + Total int + Completed int + Failed int + InProgress int + } +} + +type SearchJobID struct { + number uint64 +} + +func ParseSearchJobID(input string) (*SearchJobID, error) { + // accept either: + // - the numeric job id (non-negative integer) + // - the plain text SearchJob: form of the id + // - the base64-encoded "SearchJob:" string + + if input == "" { + return nil, cmderrors.Usage("must provide a search job ID") + } + + // Try to decode if it's base64 first + if decoded, err := base64.StdEncoding.DecodeString(input); err == nil { + input = string(decoded) } + + // Match either "SearchJob:" or "" + re := regexp.MustCompile(`^(?:SearchJob:)?(\d+)$`) + matches := re.FindStringSubmatch(input) + if matches == nil { + return nil, fmt.Errorf("invalid ID format: must be a non-negative integer, 'SearchJob:', or that string base64-encoded") + } + + number, err := strconv.ParseUint(matches[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid ID format: must be a 64-bit non-negative integer") + } + + return &SearchJobID{number: number}, nil +} + +func (id *SearchJobID) String() string { + return fmt.Sprintf("SearchJob:%d", id.Number()) +} + +func (id *SearchJobID) Canonical() string { + return base64.StdEncoding.EncodeToString([]byte(id.String())) +} + +func (id *SearchJobID) Number() uint64 { + return id.number } diff --git a/cmd/src/search_jobs_cancel.go b/cmd/src/search_jobs_cancel.go index df77d710ce..7ddfca3dbd 100644 --- a/cmd/src/search_jobs_cancel.go +++ b/cmd/src/search_jobs_cancel.go @@ -4,8 +4,8 @@ import ( "context" "flag" "fmt" + "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" ) const CancelSearchJobMutation = `mutation CancelSearchJob($id: ID!) { @@ -23,7 +23,7 @@ Examples: Cancel a search job: - $ src search-jobs cancel --id U2VhcmNoSm9iOjY5 + $ src search-jobs cancel -id 999 ` flagSet := flag.NewFlagSet("cancel", flag.ExitOnError) usageFunc := func() { @@ -33,8 +33,8 @@ Examples: } var ( - idFlag = flagSet.String("id", "", "ID of the search job to cancel") - apiFlags = api.NewFlags(flagSet) + idFlag = flagSet.String("id", "", "ID of the search job to cancel") + apiFlags = api.NewFlags(flagSet) ) handler := func(args []string) error { @@ -49,8 +49,9 @@ Examples: Flags: apiFlags, }) - if *idFlag == "" { - return cmderrors.Usage("must provide a search job ID") + jobID, err := ParseSearchJobID(*idFlag) + if err != nil { + return err } query := CancelSearchJobMutation @@ -62,7 +63,7 @@ Examples: } if ok, err := client.NewRequest(query, map[string]interface{}{ - "id": *idFlag, + "id": api.NullString(jobID.Canonical()), }).Do(context.Background(), &result); err != nil || !ok { return err } diff --git a/cmd/src/search_jobs_create.go b/cmd/src/search_jobs_create.go index 95d795f4f4..495c3ca884 100644 --- a/cmd/src/search_jobs_create.go +++ b/cmd/src/search_jobs_create.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" ) @@ -43,9 +44,9 @@ Examples: } var ( - queryFlag = flagSet.String("query", "", "Search query") - formatFlag = flagSet.String("f", "{{.ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) - apiFlags = api.NewFlags(flagSet) + queryFlag = flagSet.String("query", "", "Search query") + formatFlag = flagSet.String("f", "{{searchJobIDNumber .ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) + apiFlags = api.NewFlags(flagSet) ) handler := func(args []string) error { diff --git a/cmd/src/search_jobs_delete.go b/cmd/src/search_jobs_delete.go index 01b9b98b1b..239f4dbc9c 100644 --- a/cmd/src/search_jobs_delete.go +++ b/cmd/src/search_jobs_delete.go @@ -4,8 +4,8 @@ import ( "context" "flag" "fmt" + "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" ) const DeleteSearchJobQuery = `mutation DeleteSearchJob($id: ID!) { @@ -23,7 +23,7 @@ Examples: Delete a search job by ID: - $ src search-jobs delete U2VhcmNoSm9iOjY5 + $ src search-jobs delete -id 999 ` flagSet := flag.NewFlagSet("delete", flag.ExitOnError) @@ -34,7 +34,7 @@ Examples: } var ( - idFlag = flagSet.String("id", "", "ID of the search job to delete") + idFlag = flagSet.String("id", "", "ID of the search job to delete") apiFlags = api.NewFlags(flagSet) ) @@ -50,8 +50,9 @@ Examples: Flags: apiFlags, }) - if *idFlag == "" { - return cmderrors.Usage("must provide a search job ID") + jobID, err := ParseSearchJobID(*idFlag) + if err != nil { + return err } var result struct { @@ -61,7 +62,7 @@ Examples: } if ok, err := client.NewRequest(DeleteSearchJobQuery, map[string]interface{}{ - "id": *idFlag, + "id": api.NullString(jobID.Canonical()), }).Do(context.Background(), &result); err != nil || !ok { return err } diff --git a/cmd/src/search_jobs_get.go b/cmd/src/search_jobs_get.go index ffa10b68da..9f908d34c6 100644 --- a/cmd/src/search_jobs_get.go +++ b/cmd/src/search_jobs_get.go @@ -1,11 +1,11 @@ package main import ( - "context" - "flag" - "fmt" - "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" + "context" + "flag" + "fmt" + + "github.com/sourcegraph/src-cli/internal/api" ) const GetSearchJobQuery = `query SearchJob($id: ID!) { @@ -20,73 +20,76 @@ const GetSearchJobQuery = `query SearchJob($id: ID!) { // init registers the "get" subcommand for search-jobs which retrieves a search job by ID. // It supports formatting the output using Go templates and requires authentication via API flags. func init() { - usage := ` + usage := ` Examples: Get a search job by ID: - $ src search-jobs get U2VhcmNoSm9iOjY5 + $ src search-jobs get -id 999 ` - flagSet := flag.NewFlagSet("get", flag.ExitOnError) - usageFunc := func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) - } - - var ( - idFlag = flagSet.String("id", "", "ID of the search job") - formatFlag = flagSet.String("f", "{{.ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) - apiFlags = api.NewFlags(flagSet) - ) - - handler := func(args []string) error { - if err := flagSet.Parse(args); err != nil { - return err - } - - client := api.NewClient(api.ClientOpts{ - Endpoint: cfg.Endpoint, - AccessToken: cfg.AccessToken, - Out: flagSet.Output(), - Flags: apiFlags, - }) - - tmpl, err := parseTemplate(*formatFlag) - if err != nil { - return err - } - - if *idFlag == "" { - return cmderrors.Usage("must provide a search job ID") - } - - job, err := getSearchJob(client, *idFlag) - if err != nil { - return err - } - return execTemplate(tmpl, job) - } - searchJobsCommands = append(searchJobsCommands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, - }) + flagSet := flag.NewFlagSet("get", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + idFlag = flagSet.String("id", "", "ID of the search job") + formatFlag = flagSet.String("f", "{{searchJobIDNumber .ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + client := api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: flagSet.Output(), + Flags: apiFlags, + }) + + tmpl, err := parseTemplate(*formatFlag) + if err != nil { + return err + } + + job, err := getSearchJob(client, *idFlag) + if err != nil { + return err + } + + return execTemplate(tmpl, job) + } + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) } func getSearchJob(client api.Client, id string) (*SearchJob, error) { - query := GetSearchJobQuery + SearchJobFragment - var result struct { - Node *SearchJob - } + jobID, err := ParseSearchJobID(id) + if err != nil { + return nil, err + } - if ok, err := client.NewRequest(query, map[string]interface{}{ - "id": api.NullString(id), - }).Do(context.Background(), &result); err != nil || !ok { - return nil, err - } + query := GetSearchJobQuery + SearchJobFragment + + var result struct { + Node *SearchJob + } + + if ok, err := client.NewRequest(query, map[string]interface{}{ + "id": api.NullString(jobID.Canonical()), + }).Do(context.Background(), &result); err != nil || !ok { + return nil, err + } - return result.Node, nil + return result.Node, nil } diff --git a/cmd/src/search_jobs_list.go b/cmd/src/search_jobs_list.go index da9ced137f..813bf241fe 100644 --- a/cmd/src/search_jobs_list.go +++ b/cmd/src/search_jobs_list.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" ) @@ -30,94 +31,94 @@ Examples: List all search jobs in ascending order: - $ src search-jobs list --asc + $ src search-jobs list -asc Limit the number of search jobs returned: - $ src search-jobs list --limit 5 + $ src search-jobs list -limit 5 Order search jobs by a field (must be one of: QUERY, CREATED_AT, STATE): - $ src search-jobs list --order-by QUERY + $ src search-jobs list -order-by QUERY ` - flagSet := flag.NewFlagSet("list", flag.ExitOnError) - usageFunc := func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) - } - - var ( - formatFlag = flagSet.String("f", "{{.ID}}: {{.Creator.Username}} {{.State}}", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) - limitFlag = flagSet.Int("limit", 10, "Limit the number of search jobs returned") - ascFlag = flagSet.Bool("asc", false, "Sort search jobs in ascending order") - orderByFlag = flagSet.String("order-by", "CREATED_AT", "Sort search jobs by a field") - apiFlags = api.NewFlags(flagSet) - ) - - validOrderBy := map[string]bool{ - "QUERY": true, - "CREATED_AT": true, - "STATE": true, - } - - handler := func(args []string) error { - if err := flagSet.Parse(args); err != nil { - return err - } + flagSet := flag.NewFlagSet("list", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + formatFlag = flagSet.String("f", "{{searchJobIDNumber .ID}}: {{.Creator.Username}} {{.State}}", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) + limitFlag = flagSet.Int("limit", 10, "Limit the number of search jobs returned") + ascFlag = flagSet.Bool("asc", false, "Sort search jobs in ascending order") + orderByFlag = flagSet.String("order-by", "CREATED_AT", "Sort search jobs by a field") + apiFlags = api.NewFlags(flagSet) + ) + + validOrderBy := map[string]bool{ + "QUERY": true, + "CREATED_AT": true, + "STATE": true, + } + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } - client := api.NewClient(api.ClientOpts{ - Endpoint: cfg.Endpoint, - AccessToken: cfg.AccessToken, - Out: flagSet.Output(), - Flags: apiFlags, - }) + client := api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: flagSet.Output(), + Flags: apiFlags, + }) if *limitFlag < 1 { return cmderrors.Usage("limit flag must be greater than 0") } - if !validOrderBy[*orderByFlag] { - return cmderrors.Usage("order-by must be one of: QUERY, CREATED_AT, STATE") - } + if !validOrderBy[*orderByFlag] { + return cmderrors.Usage("order-by must be one of: QUERY, CREATED_AT, STATE") + } tmpl, err := parseTemplate(*formatFlag) if err != nil { return err } - query := ListSearchJobsQuery + SearchJobFragment + query := ListSearchJobsQuery + SearchJobFragment - var result struct { - SearchJobs struct { - Nodes []SearchJob - } - } + var result struct { + SearchJobs struct { + Nodes []SearchJob + } + } - if ok,err := client.NewRequest(query, map[string]interface{}{ - "first": *limitFlag, - "descending": !*ascFlag, - "orderBy": *orderByFlag, + if ok, err := client.NewRequest(query, map[string]interface{}{ + "first": *limitFlag, + "descending": !*ascFlag, + "orderBy": *orderByFlag, }).Do(context.Background(), &result); err != nil || !ok { return err } - if len(result.SearchJobs.Nodes) == 0 { - return cmderrors.ExitCode(1, fmt.Errorf("no search jobs found")) - } + if len(result.SearchJobs.Nodes) == 0 { + return cmderrors.ExitCode(1, fmt.Errorf("no search jobs found")) + } - for _, job := range result.SearchJobs.Nodes { - if err := execTemplate(tmpl, job); err != nil { - return err - } - } + for _, job := range result.SearchJobs.Nodes { + if err := execTemplate(tmpl, job); err != nil { + return err + } + } - return nil - } + return nil + } - searchJobsCommands = append(searchJobsCommands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, - }) + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) } diff --git a/cmd/src/search_jobs_logs.go b/cmd/src/search_jobs_logs.go index daa7f108ef..5c3e85b760 100644 --- a/cmd/src/search_jobs_logs.go +++ b/cmd/src/search_jobs_logs.go @@ -6,11 +6,10 @@ import ( "io" "net/http" "os" + "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" ) - // init registers the 'logs' subcommand for search jobs, which allows users to view // logs for a specific search job by its ID. The command requires a search job ID // and uses the configured API client to fetch and display the logs. @@ -19,7 +18,7 @@ func init() { Examples: View the logs of a search job: - $ src search-jobs logs U2VhcmNoSm9iOjY5 + $ src search-jobs logs -id 999 Save the logs to a file: $ src search-jobs logs U2VhcmNoSm9iOjY5 -out logs.csv @@ -33,8 +32,8 @@ Examples: } var ( - idFlag = flagSet.String("id", "", "ID of the search job to view logs for") - outFlag = flagSet.String("out", "", "File path to save the logs (optional)") + idFlag = flagSet.String("id", "", "ID of the search job to view logs for") + outFlag = flagSet.String("out", "", "File path to save the logs (optional)") apiFlags = api.NewFlags(flagSet) ) @@ -43,10 +42,6 @@ Examples: return err } - if *idFlag == "" { - return cmderrors.Usage("must provide a search job ID") - } - client := api.NewClient(api.ClientOpts{ Endpoint: cfg.Endpoint, AccessToken: cfg.AccessToken, @@ -54,10 +49,6 @@ Examples: Flags: apiFlags, }) - if *idFlag == "" { - return cmderrors.Usage("must provide a search job ID") - } - job, err := getSearchJob(client, *idFlag) if err != nil { return err @@ -105,4 +96,4 @@ Examples: handler: handler, usageFunc: usageFunc, }) -} \ No newline at end of file +} diff --git a/cmd/src/search_jobs_results.go b/cmd/src/search_jobs_results.go index 505a4606a9..a3f042b828 100644 --- a/cmd/src/search_jobs_results.go +++ b/cmd/src/search_jobs_results.go @@ -6,8 +6,8 @@ import ( "io" "net/http" "os" + "github.com/sourcegraph/src-cli/internal/api" - "github.com/sourcegraph/src-cli/internal/cmderrors" ) func init() { @@ -15,10 +15,10 @@ func init() { Examples: Get the results of a search job: - $ src search-jobs results -id U2VhcmNoSm9iOjY5 + $ src search-jobs results -id 999 Save search results to a file: - $ src search-jobs results -id U2VhcmNoSm9iOjY5 -out results.jsonl + $ src search-jobs results -id 999 -out results.jsonl ` flagSet := flag.NewFlagSet("results", flag.ExitOnError) @@ -29,8 +29,8 @@ Examples: } var ( - idFlag = flagSet.String("id", "", "ID of the search job to get results for") - outFlag = flagSet.String("out", "", "File path to save the results (optional)") + idFlag = flagSet.String("id", "", "ID of the search job to get results for") + outFlag = flagSet.String("out", "", "File path to save the results (optional)") apiFlags = api.NewFlags(flagSet) ) @@ -39,10 +39,6 @@ Examples: return err } - if *idFlag == "" { - return cmderrors.Usage("must provide a search job ID") - } - client := api.NewClient(api.ClientOpts{ Endpoint: cfg.Endpoint, AccessToken: cfg.AccessToken, From bdbe4b87c94e12c62d7f58e2f8da341e03929f67 Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Wed, 12 Mar 2025 09:42:20 -0400 Subject: [PATCH 07/12] feat: Add search jobs restart command This commit introduces the `search-jobs restart` command, enabling users to restart a search job by its ID. The command retrieves the query from the original job and creates a new search job with the same query. --- cmd/src/search_jobs.go | 1 + cmd/src/search_jobs_restart.go | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 cmd/src/search_jobs_restart.go diff --git a/cmd/src/search_jobs.go b/cmd/src/search_jobs.go index efe024e8af..785f486149 100644 --- a/cmd/src/search_jobs.go +++ b/cmd/src/search_jobs.go @@ -53,6 +53,7 @@ The commands are: create creates a search job delete deletes a search job by ID get gets a search job by ID + restart restarts a search job by ID list lists search jobs logs outputs the logs for a search job by ID results outputs the results for a search job by ID diff --git a/cmd/src/search_jobs_restart.go b/cmd/src/search_jobs_restart.go new file mode 100644 index 0000000000..549a247469 --- /dev/null +++ b/cmd/src/search_jobs_restart.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "flag" + "fmt" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +// init registers the "restart" subcommand for search jobs, which allows restarting +// a search job by its ID. It sets up command-line flags for job ID and output formatting, +// validates the search job query, and creates a new search job with the same query +// as the original job. +func init() { + usage := ` +Examples: + + Restart a search job by ID: + + $ src search-jobs restart -id 999 +` + + flagSet := flag.NewFlagSet("restart", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + idFlag = flagSet.String("id", "", "ID of the search job to restart") + formatFlag = flagSet.String("f", "{{searchJobIDNumber .ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) + apiFlags = api.NewFlags(flagSet) + ) + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + client := api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: flagSet.Output(), + Flags: apiFlags, + }) + + tmpl, err := parseTemplate(*formatFlag) + if err != nil { + return err + } + + if *idFlag == "" { + return cmderrors.Usage("must provide a job ID") + } + + originalJob, err := getSearchJob(client, *idFlag) + if err != nil { + return err + } + query := originalJob.Query + var validateResult struct { + ValidateSearchJob struct { + AlwaysNil bool `json:"alwaysNil"` + } `json:"validateSearchJob"` + } + + if ok, err := client.NewRequest(ValidateSearchJobQuery, map[string]interface{}{ + "query": query, + }).Do(context.Background(), &validateResult); err != nil || !ok { + return err + } + var result struct { + CreateSearchJob *SearchJob `json:"createSearchJob"` + } + + if ok, err := client.NewRequest(CreateSearchJobQuery, map[string]interface{}{ + "query": query, + }).Do(context.Background(), &result); !ok { + return err + } + + return execTemplate(tmpl, result.CreateSearchJob) + } + + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} From 50165f4c2073069409b6ced37cd1cdb69800158b Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Wed, 12 Mar 2025 10:01:30 -0400 Subject: [PATCH 08/12] (lint) removed unused testutil --- internal/testing/testutil.go | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 internal/testing/testutil.go diff --git a/internal/testing/testutil.go b/internal/testing/testutil.go deleted file mode 100644 index 894d649b1b..0000000000 --- a/internal/testing/testutil.go +++ /dev/null @@ -1,28 +0,0 @@ -package testing - -import ( - "encoding/json" - "os" - "text/template" -) - -// ExecTemplateWithParsing formats and prints data using the provided template format. -// It handles both template parsing and execution in a single call. -func ExecTemplateWithParsing(format string, data interface{}) error { - funcMap := template.FuncMap{ - "json": func(v interface{}) string { - b, err := json.Marshal(v) - if err != nil { - return err.Error() - } - return string(b) - }, - } - - // Use a generic template name or allow it to be passed as parameter - tmpl, err := template.New("template").Funcs(funcMap).Parse(format) - if err != nil { - return err - } - return tmpl.Execute(os.Stdout, data) -} \ No newline at end of file From 38248af00308ae25c854b6ca7bfa7b8ae80e408a Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Tue, 18 Mar 2025 12:12:29 -0400 Subject: [PATCH 09/12] refactor: Redesign search-jobs command structure with builder pattern This commit refactors the search-jobs commands to: - Implement a builder pattern for consistent command creation - Add column-based output format with customizable columns - Support JSON output format for programmatic access - Improve argument handling by using positional arguments for IDs - Separate command logic from presentation for better testability - Extract common functionality into reusable helper functions - Enhance usage documentation with better examples - Remove SearchJobID parsing functions in favor of direct ID handling --- cmd/src/format.go | 14 -- cmd/src/search_jobs.go | 298 +++++++++++++++++++++++++-------- cmd/src/search_jobs_cancel.go | 97 +++++------ cmd/src/search_jobs_create.go | 148 ++++++++-------- cmd/src/search_jobs_delete.go | 95 +++++------ cmd/src/search_jobs_get.go | 111 ++++++------ cmd/src/search_jobs_list.go | 176 +++++++++---------- cmd/src/search_jobs_logs.go | 137 ++++++++------- cmd/src/search_jobs_restart.go | 116 ++++++------- cmd/src/search_jobs_results.go | 133 ++++++++------- 10 files changed, 732 insertions(+), 593 deletions(-) diff --git a/cmd/src/format.go b/cmd/src/format.go index 2cbe9226e9..2c08d4fcc1 100644 --- a/cmd/src/format.go +++ b/cmd/src/format.go @@ -80,20 +80,6 @@ func parseTemplate(text string) (*template.Template, error) { } return humanize.Time(t), nil }, - "searchJobIDNumber": func(id string) string { - sjid, err := ParseSearchJobID(id) - if err != nil { - return id - } - return fmt.Sprintf("%d", sjid.Number()) - }, - "searchJobIDCanonical": func(id string) string { - sjid, err := ParseSearchJobID(id) - if err != nil { - return id - } - return sjid.Canonical() - }, // Register search-specific template functions "searchSequentialLineNumber": searchTemplateFuncs["searchSequentialLineNumber"], diff --git a/cmd/src/search_jobs.go b/cmd/src/search_jobs.go index 785f486149..ed85a22d52 100644 --- a/cmd/src/search_jobs.go +++ b/cmd/src/search_jobs.go @@ -1,19 +1,19 @@ package main import ( - "encoding/base64" + "encoding/json" "flag" "fmt" - "regexp" - "strconv" + "strings" + "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" ) // searchJobFragment is a GraphQL fragment that defines the fields to be queried // for a SearchJob. It includes the job's ID, query, state, creator information, // timestamps, URLs, and repository statistics. -const SearchJobFragment = ` +const searchJobFragment = ` fragment SearchJobFields on SearchJob { id query @@ -34,48 +34,12 @@ fragment SearchJobFields on SearchJob { } }` -var searchJobsCommands commander - -// init registers the 'src search-jobs' command with the CLI. It provides subcommands -// for managing search jobs, including creating, listing, getting, canceling and deleting -// jobs. The command uses a flagset for parsing options and displays usage information -// when help is requested. -func init() { - usage := `'src search-jobs' is a tool that manages search jobs on a Sourcegraph instance. - -Usage: - - src search-jobs command [command options] - -The commands are: - - cancel cancels a search job by ID - create creates a search job - delete deletes a search job by ID - get gets a search job by ID - restart restarts a search job by ID - list lists search jobs - logs outputs the logs for a search job by ID - results outputs the results for a search job by ID - -Use "src search-jobs [command] -h" for more information about a command. -` - - flagSet := flag.NewFlagSet("search-jobs", flag.ExitOnError) - handler := func(args []string) error { - searchJobsCommands.run(flagSet, "src search-jobs", usage, args) - return nil +// GraphQL query constant for validating search queries +const validateSearchJobQuery = `query ValidateSearchJob($query: String!) { + validateSearchJob(query: $query) { + errors } - - commands = append(commands, &command{ - flagSet: flagSet, - aliases: []string{"search-job"}, - handler: handler, - usageFunc: func() { - fmt.Println(usage) - }, - }) -} +}` // SearchJob represents a search job with its metadata, including the search query, // execution state, creator information, timestamps, URLs, and repository statistics. @@ -99,48 +63,238 @@ type SearchJob struct { } } -type SearchJobID struct { - number uint64 +// AvailableColumns defines the available column names for output +var AvailableColumns = map[string]bool{ + "id": true, + "query": true, + "state": true, + "username": true, + "createdat": true, + "startedat": true, + "finishedat": true, + "url": true, + "logurl": true, + "total": true, + "completed": true, + "failed": true, + "inprogress": true, } -func ParseSearchJobID(input string) (*SearchJobID, error) { - // accept either: - // - the numeric job id (non-negative integer) - // - the plain text SearchJob: form of the id - // - the base64-encoded "SearchJob:" string +// DefaultColumns defines the default columns to display +var DefaultColumns = []string{"id", "username", "state", "query"} - if input == "" { - return nil, cmderrors.Usage("must provide a search job ID") +// SearchJobCommandBuilder helps build search job commands with common flags and options +type SearchJobCommandBuilder struct { + Name string + Usage string + Flags *flag.FlagSet + ApiFlags *api.Flags +} + +// Global variables +var searchJobsCommands commander + +// NewSearchJobCommand creates a new search job command builder +func NewSearchJobCommand(name string, usage string) *SearchJobCommandBuilder { + flagSet := flag.NewFlagSet(name, flag.ExitOnError) + return &SearchJobCommandBuilder{ + Name: name, + Usage: usage, + Flags: flagSet, + ApiFlags: api.NewFlags(flagSet), } +} + +// Build creates and registers the command +func (b *SearchJobCommandBuilder) Build(handlerFunc func(*flag.FlagSet, *api.Flags, []string, bool) error) { + columnsFlag := b.Flags.String("c", strings.Join(DefaultColumns, ","), + "Comma-separated list of columns to display. Available: id,query,state,username,createdat,startedat,finishedat,url,logurl,total,completed,failed,inprogress") + jsonFlag := b.Flags.Bool("json", false, "Output results as JSON for programmatic access") - // Try to decode if it's base64 first - if decoded, err := base64.StdEncoding.DecodeString(input); err == nil { - input = string(decoded) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", b.Name) + b.Flags.PrintDefaults() + fmt.Println(b.Usage) } - // Match either "SearchJob:" or "" - re := regexp.MustCompile(`^(?:SearchJob:)?(\d+)$`) - matches := re.FindStringSubmatch(input) - if matches == nil { - return nil, fmt.Errorf("invalid ID format: must be a non-negative integer, 'SearchJob:', or that string base64-encoded") + handler := func(args []string) error { + if err := parseSearchJobsArgs(b.Flags, args); err != nil { + return err + } + + // Parse columns + columns := parseColumns(*columnsFlag) + + return handlerFunc(b.Flags, b.ApiFlags, columns, *jsonFlag) } - number, err := strconv.ParseUint(matches[1], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid ID format: must be a 64-bit non-negative integer") + searchJobsCommands = append(searchJobsCommands, &command{ + flagSet: b.Flags, + handler: handler, + usageFunc: usageFunc, + }) +} + +// parseColumns parses and validates the columns flag +func parseColumns(columnsFlag string) []string { + if columnsFlag == "" { + return DefaultColumns } - return &SearchJobID{number: number}, nil + columns := strings.Split(columnsFlag, ",") + var validColumns []string + + for _, col := range columns { + col = strings.ToLower(strings.TrimSpace(col)) + if AvailableColumns[col] { + validColumns = append(validColumns, col) + } + } + + if len(validColumns) == 0 { + return DefaultColumns + } + + return validColumns +} + +// createSearchJobsClient creates a reusable API client for search jobs commands +func createSearchJobsClient(out *flag.FlagSet, apiFlags *api.Flags) api.Client { + return api.NewClient(api.ClientOpts{ + Endpoint: cfg.Endpoint, + AccessToken: cfg.AccessToken, + Out: out.Output(), + Flags: apiFlags, + }) +} + +// parseSearchJobsArgs parses command arguments with the provided flag set +// and returns an error if parsing fails +func parseSearchJobsArgs(flagSet *flag.FlagSet, args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + return nil +} + +// validateJobID validates that a job ID was provided +func validateJobID(args []string) (string, error) { + if len(args) != 1 { + return "", cmderrors.Usage("must provide a search job ID") + } + return args[0], nil } -func (id *SearchJobID) String() string { - return fmt.Sprintf("SearchJob:%d", id.Number()) +// displaySearchJob formats and outputs a search job based on selected columns or JSON +func displaySearchJob(job *SearchJob, columns []string, asJSON bool) error { + if asJSON { + return outputAsJSON(job) + } + return outputAsColumns(job, columns) } -func (id *SearchJobID) Canonical() string { - return base64.StdEncoding.EncodeToString([]byte(id.String())) +// displaySearchJobs formats and outputs multiple search jobs +func displaySearchJobs(jobs []SearchJob, columns []string, asJSON bool) error { + if asJSON { + return outputAsJSON(jobs) + } + + for _, job := range jobs { + if err := outputAsColumns(&job, columns); err != nil { + return err + } + } + return nil +} + +// outputAsJSON outputs data as JSON +func outputAsJSON(data interface{}) error { + jsonBytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + fmt.Println(string(jsonBytes)) + return nil +} + +// outputAsColumns outputs a search job as tab-delimited columns +func outputAsColumns(job *SearchJob, columns []string) error { + values := make([]string, 0, len(columns)) + + for _, col := range columns { + switch col { + case "id": + values = append(values, job.ID) + case "query": + values = append(values, job.Query) + case "state": + values = append(values, job.State) + case "username": + values = append(values, job.Creator.Username) + case "createdat": + values = append(values, job.CreatedAt) + case "startedat": + values = append(values, job.StartedAt) + case "finishedat": + values = append(values, job.FinishedAt) + case "url": + values = append(values, job.URL) + case "logurl": + values = append(values, job.LogURL) + case "total": + values = append(values, fmt.Sprintf("%d", job.RepoStats.Total)) + case "completed": + values = append(values, fmt.Sprintf("%d", job.RepoStats.Completed)) + case "failed": + values = append(values, fmt.Sprintf("%d", job.RepoStats.Failed)) + case "inprogress": + values = append(values, fmt.Sprintf("%d", job.RepoStats.InProgress)) + } + } + + fmt.Println(strings.Join(values, "\t")) + return nil } -func (id *SearchJobID) Number() uint64 { - return id.number +// init registers the 'src search-jobs' command with the CLI. It provides subcommands +// for managing search jobs, including creating, listing, getting, canceling and deleting +// jobs. The command uses a flagset for parsing options and displays usage information +// when help is requested. +func init() { + usage := `'src search-jobs' is a tool that manages search jobs on a Sourcegraph instance. + + Usage: + + src search-jobs command [command options] + + The commands are: + + cancel cancels a search job by ID + create creates a search job + delete deletes a search job by ID + get gets a search job by ID + list lists search jobs + restart restarts a search job by ID + + Common options for all commands: + -c Select columns to display (e.g., -c id,query,state,username) + -json Output results in JSON format + + Use "src search-jobs [command] -h" for more information about a command. + ` + + flagSet := flag.NewFlagSet("search-jobs", flag.ExitOnError) + handler := func(args []string) error { + searchJobsCommands.run(flagSet, "src search-jobs", usage, args) + return nil + } + + commands = append(commands, &command{ + flagSet: flagSet, + aliases: []string{"search-job"}, + handler: handler, + usageFunc: func() { + fmt.Println(usage) + }, + }) } diff --git a/cmd/src/search_jobs_cancel.go b/cmd/src/search_jobs_cancel.go index 7ddfca3dbd..cbce6883d4 100644 --- a/cmd/src/search_jobs_cancel.go +++ b/cmd/src/search_jobs_cancel.go @@ -8,72 +8,73 @@ import ( "github.com/sourcegraph/src-cli/internal/api" ) -const CancelSearchJobMutation = `mutation CancelSearchJob($id: ID!) { +// GraphQL mutation constants +const cancelSearchJobMutation = `mutation CancelSearchJob($id: ID!) { cancelSearchJob(id: $id) { alwaysNil } }` -// init registers the 'cancel' subcommand for search jobs, which allows users to cancel -// a running search job by its ID. It sets up the command's flag parsing, usage information, -// and handles the GraphQL mutation to cancel the specified search job. -func init() { - usage := ` -Examples: - - Cancel a search job: +// cancelSearchJob cancels a search job with the given ID +func cancelSearchJob(client api.Client, jobID string) error { + var result struct { + CancelSearchJob struct { + AlwaysNil bool + } + } - $ src search-jobs cancel -id 999 -` - flagSet := flag.NewFlagSet("cancel", flag.ExitOnError) - usageFunc := func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) + if ok, err := client.NewRequest(cancelSearchJobMutation, map[string]interface{}{ + "id": jobID, + }).Do(context.Background(), &result); err != nil || !ok { + return err } - var ( - idFlag = flagSet.String("id", "", "ID of the search job to cancel") - apiFlags = api.NewFlags(flagSet) - ) + return nil +} - handler := func(args []string) error { - if err := flagSet.Parse(args); err != nil { - return err - } +// displayCancelSuccessMessage outputs a success message for the canceled job +// displayCancelSuccessMessage outputs a success message for the canceled job +func displayCancelSuccessMessage(out *flag.FlagSet, jobID string) { + fmt.Fprintf(out.Output(), "Search job %s canceled successfully\n", jobID) +} - client := api.NewClient(api.ClientOpts{ - Endpoint: cfg.Endpoint, - AccessToken: cfg.AccessToken, - Out: flagSet.Output(), - Flags: apiFlags, - }) +// init registers the 'cancel' subcommand for search jobs, which allows users to cancel +// a running search job by its ID. It sets up the command's flag parsing, usage information, +// and handles the GraphQL mutation to cancel the specified search job. +func init() { + usage := `cancels a running search job. + Examples: + + Cancel a search job by ID: + + $ src search-jobs cancel U2VhcmNoSm9iOjY5 + + Arguments: + The ID of the search job to cancel. + + The cancel command stops a running search job and outputs a confirmation message. + ` + + // Use the builder pattern for command creation + cmd := NewSearchJobCommand("cancel", usage) - jobID, err := ParseSearchJobID(*idFlag) + cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + // Validate job ID using the shared function from search_jobs_get.go + jobID, err := validateJobID(flagSet.Args()) if err != nil { return err } - query := CancelSearchJobMutation + // Get the client + client := createSearchJobsClient(flagSet, apiFlags) - var result struct { - CancelSearchJob struct { - AlwaysNil bool - } - } - - if ok, err := client.NewRequest(query, map[string]interface{}{ - "id": api.NullString(jobID.Canonical()), - }).Do(context.Background(), &result); err != nil || !ok { + // Send cancellation request + if err := cancelSearchJob(client, jobID); err != nil { return err } - fmt.Fprintf(flagSet.Output(), "Search job %s canceled successfully\n", *idFlag) - return nil - } - searchJobsCommands = append(searchJobsCommands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, + // Output success message + displayCancelSuccessMessage(flagSet, jobID) + return nil }) } diff --git a/cmd/src/search_jobs_create.go b/cmd/src/search_jobs_create.go index 495c3ca884..e9c80e3278 100644 --- a/cmd/src/search_jobs_create.go +++ b/cmd/src/search_jobs_create.go @@ -3,101 +3,97 @@ package main import ( "context" "flag" - "fmt" "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/cmderrors" ) -// ValidateSearchJobQuery defines the GraphQL query for validating search jobs -const ValidateSearchJobQuery = `query ValidateSearchJob($query: String!) { - validateSearchJob(query: $query) { - alwaysNil - } -}` - -// CreateSearchJobQuery defines the GraphQL mutation for creating search jobs -const CreateSearchJobQuery = `mutation CreateSearchJob($query: String!) { - createSearchJob(query: $query) { - ...SearchJobFields - } -}` + SearchJobFragment - -// init registers the "search-jobs create" subcommand. It allows users to create a search job -// with a specified query, validates the query before creation, and outputs the result in a -// customizable format. The command requires a search query and supports custom output formatting -// using Go templates. -func init() { - usage := ` -Examples: - - Create a search job: - - $ src search-jobs create -query "repo:^github\.com/sourcegraph/sourcegraph$ sort:indexed-desc" -` +// GraphQL query and mutation constants +const ( + // createSearchJobQuery defines the GraphQL mutation for creating search jobs + createSearchJobQuery = `mutation CreateSearchJob($query: String!) { + createSearchJob(query: $query) { + ...SearchJobFields + } + }` + searchJobFragment +) - flagSet := flag.NewFlagSet("create", flag.ExitOnError) - usageFunc := func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) +// validateSearchQuery validates a search query with the server +func validateSearchQuery(client api.Client, query string) error { + var validateResult struct { + ValidateSearchJob interface{} `json:"validateSearchJob"` } - var ( - queryFlag = flagSet.String("query", "", "Search query") - formatFlag = flagSet.String("f", "{{searchJobIDNumber .ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) - apiFlags = api.NewFlags(flagSet) - ) + if ok, err := client.NewRequest(validateSearchJobQuery, map[string]any{ + "query": query, + }).Do(context.Background(), &validateResult); err != nil || !ok { + return err + } - handler := func(args []string) error { - if err := flagSet.Parse(args); err != nil { - return err - } + return nil +} - client := api.NewClient(api.ClientOpts{ - Endpoint: cfg.Endpoint, - AccessToken: cfg.AccessToken, - Out: flagSet.Output(), - Flags: apiFlags, - }) +// createSearchJob creates a new search job with the given query +func createSearchJob(client api.Client, query string) (*SearchJob, error) { + var result struct { + CreateSearchJob *SearchJob `json:"createSearchJob"` + } - tmpl, err := parseTemplate(*formatFlag) - if err != nil { - return err - } + // Validate the query + if err := validateSearchQuery(client, query); err != nil { + return nil, err + } - if *queryFlag == "" { - return cmderrors.Usage("must provide a query") - } + if ok, err := client.NewRequest(createSearchJobQuery, map[string]any{ + "query": query, + }).Do(context.Background(), &result); !ok { + return nil, err + } - var validateResult struct { - ValidateSearchJob interface{} `json:"validateSearchJob"` - } + return result.CreateSearchJob, nil +} - if ok, err := client.NewRequest(ValidateSearchJobQuery, map[string]interface{}{ - "query": *queryFlag, - }).Do(context.Background(), &validateResult); err != nil || !ok { - return err +// init registers the "search-jobs create" subcommand. +func init() { + usage := ` + Examples: + + Create a search job: + + $ src search-jobs create "repo:^github\.com/sourcegraph/sourcegraph$ sort:indexed-desc" + + Create a search job and display specific columns: + + $ src search-jobs create "repo:sourcegraph" -c id,state,username + + Create a search job and output in JSON format: + + $ src search-jobs create "repo:sourcegraph" -json + + Available columns are: id, query, state, username, createdat, startedat, finishedat, + url, logurl, total, completed, failed, inprogress + ` + + // Use the builder pattern for command creation + cmd := NewSearchJobCommand("create", usage) + + cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + // Validate that a query was provided + if flagSet.NArg() != 1 { + return cmderrors.Usage("must provide a query") } + query := flagSet.Arg(0) - query := CreateSearchJobQuery - - var result struct { - CreateSearchJob *SearchJob `json:"createSearchJob"` - } + // Get the client + client := createSearchJobsClient(flagSet, apiFlags) - if ok, err := client.NewRequest(query, map[string]interface{}{ - "query": *queryFlag, - }).Do(context.Background(), &result); !ok { + // Create the search job + job, err := createSearchJob(client, query) + if err != nil { return err } - return execTemplate(tmpl, result.CreateSearchJob) - } - - searchJobsCommands = append(searchJobsCommands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, + // Display the created job + return displaySearchJob(job, columns, asJSON) }) } diff --git a/cmd/src/search_jobs_delete.go b/cmd/src/search_jobs_delete.go index 239f4dbc9c..4cfe844ad4 100644 --- a/cmd/src/search_jobs_delete.go +++ b/cmd/src/search_jobs_delete.go @@ -8,71 +8,72 @@ import ( "github.com/sourcegraph/src-cli/internal/api" ) -const DeleteSearchJobQuery = `mutation DeleteSearchJob($id: ID!) { +// GraphQL mutation constants +const deleteSearchJobQuery = `mutation DeleteSearchJob($id: ID!) { deleteSearchJob(id: $id) { alwaysNil } }` -// init registers the 'delete' subcommand for search-jobs which allows users to delete -// a search job by its ID. The command requires a search job ID to be provided via -// the -id flag and will make a GraphQL mutation to delete the specified job. -func init() { - usage := ` -Examples: - - Delete a search job by ID: - - $ src search-jobs delete -id 999 -` +// deleteSearchJob deletes a search job with the given ID +func deleteSearchJob(client api.Client, jobID string) error { + var result struct { + DeleteSearchJob struct { + AlwaysNil bool + } + } - flagSet := flag.NewFlagSet("delete", flag.ExitOnError) - usageFunc := func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) + if ok, err := client.NewRequest(deleteSearchJobQuery, map[string]interface{}{ + "id": jobID, + }).Do(context.Background(), &result); err != nil || !ok { + return err } - var ( - idFlag = flagSet.String("id", "", "ID of the search job to delete") - apiFlags = api.NewFlags(flagSet) - ) + return nil +} - handler := func(args []string) error { - if err := flagSet.Parse(args); err != nil { - return err - } +// displaySuccessMessage outputs a success message for the deleted job +func displaySuccessMessage(out *flag.FlagSet, jobID string) { + fmt.Fprintf(out.Output(), "Search job %s deleted successfully\n", jobID) +} + +// init registers the 'delete' subcommand for search-jobs which allows users to delete +// a search job by its ID. The command requires a search job ID to be provided via +// the -id flag and will make a GraphQL mutation to delete the specified job. +func init() { + usage := `deletes a search job. + Examples: + + Delete a search job by ID: + + $ src search-jobs delete U2VhcmNoSm9iOjY5 + + Arguments: + The ID of the search job to delete. + + The delete command permanently removes a search job and outputs a confirmation message. + ` - client := api.NewClient(api.ClientOpts{ - Endpoint: cfg.Endpoint, - AccessToken: cfg.AccessToken, - Out: flagSet.Output(), - Flags: apiFlags, - }) + // Use the builder pattern for command creation + cmd := NewSearchJobCommand("delete", usage) - jobID, err := ParseSearchJobID(*idFlag) + cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + // Validate job ID using the shared function from search_jobs_get.go + jobID, err := validateJobID(flagSet.Args()) if err != nil { return err } - var result struct { - DeleteSearchJob struct { - AlwaysNil bool - } - } + // Get the client + client := createSearchJobsClient(flagSet, apiFlags) - if ok, err := client.NewRequest(DeleteSearchJobQuery, map[string]interface{}{ - "id": api.NullString(jobID.Canonical()), - }).Do(context.Background(), &result); err != nil || !ok { + // Send deletion request + if err := deleteSearchJob(client, jobID); err != nil { return err } - fmt.Fprintf(flagSet.Output(), "Search job %s deleted successfully\n", *idFlag) - return nil - } - searchJobsCommands = append(searchJobsCommands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, + // Output success message + displaySuccessMessage(flagSet, jobID) + return nil }) } diff --git a/cmd/src/search_jobs_get.go b/cmd/src/search_jobs_get.go index 9f908d34c6..37e0cdb8c6 100644 --- a/cmd/src/search_jobs_get.go +++ b/cmd/src/search_jobs_get.go @@ -3,12 +3,12 @@ package main import ( "context" "flag" - "fmt" "github.com/sourcegraph/src-cli/internal/api" ) -const GetSearchJobQuery = `query SearchJob($id: ID!) { +// GraphQL query constants +const getSearchJobQuery = `query SearchJob($id: ID!) { node(id: $id) { ... on SearchJob { ...SearchJobFields @@ -17,79 +17,64 @@ const GetSearchJobQuery = `query SearchJob($id: ID!) { } ` -// init registers the "get" subcommand for search-jobs which retrieves a search job by ID. -// It supports formatting the output using Go templates and requires authentication via API flags. -func init() { - usage := ` -Examples: - - Get a search job by ID: - - $ src search-jobs get -id 999 -` +// getSearchJob fetches a search job by ID +func getSearchJob(client api.Client, id string) (*SearchJob, error) { + query := getSearchJobQuery + searchJobFragment - flagSet := flag.NewFlagSet("get", flag.ExitOnError) - usageFunc := func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) + var result struct { + Node *SearchJob } - var ( - idFlag = flagSet.String("id", "", "ID of the search job") - formatFlag = flagSet.String("f", "{{searchJobIDNumber .ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) - apiFlags = api.NewFlags(flagSet) - ) - - handler := func(args []string) error { - if err := flagSet.Parse(args); err != nil { - return err - } + if ok, err := client.NewRequest(query, map[string]interface{}{ + "id": api.NullString(id), + }).Do(context.Background(), &result); err != nil || !ok { + return nil, err + } - client := api.NewClient(api.ClientOpts{ - Endpoint: cfg.Endpoint, - AccessToken: cfg.AccessToken, - Out: flagSet.Output(), - Flags: apiFlags, - }) + return result.Node, nil +} - tmpl, err := parseTemplate(*formatFlag) +// init registers the "get" subcommand for search-jobs +func init() { + usage := ` + Examples: + + Get a search job by ID: + + $ src search-jobs get U2VhcmNoSm9iOjY5 + + Get a search job with specific columns: + + $ src search-jobs get U2VhcmNoSm9iOjY5 -c id,state,username + + Get a search job in JSON format: + + $ src search-jobs get U2VhcmNoSm9iOjY5 -json + + Available columns are: id, query, state, username, createdat, startedat, finishedat, + url, logurl, total, completed, failed, inprogress + ` + + // Use the builder pattern for command creation + cmd := NewSearchJobCommand("get", usage) + + cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + // Get the client using the centralized function + client := createSearchJobsClient(flagSet, apiFlags) + + // Validate that a job ID was provided + id, err := validateJobID(flagSet.Args()) if err != nil { return err } - job, err := getSearchJob(client, *idFlag) + // Get the search job + job, err := getSearchJob(client, id) if err != nil { return err } - return execTemplate(tmpl, job) - } - searchJobsCommands = append(searchJobsCommands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, + // Display the job with selected columns or as JSON + return displaySearchJob(job, columns, asJSON) }) } - -func getSearchJob(client api.Client, id string) (*SearchJob, error) { - - jobID, err := ParseSearchJobID(id) - if err != nil { - return nil, err - } - - query := GetSearchJobQuery + SearchJobFragment - - var result struct { - Node *SearchJob - } - - if ok, err := client.NewRequest(query, map[string]interface{}{ - "id": api.NullString(jobID.Canonical()), - }).Do(context.Background(), &result); err != nil || !ok { - return nil, err - } - - return result.Node, nil -} diff --git a/cmd/src/search_jobs_list.go b/cmd/src/search_jobs_list.go index 813bf241fe..e721867858 100644 --- a/cmd/src/search_jobs_list.go +++ b/cmd/src/search_jobs_list.go @@ -9,7 +9,8 @@ import ( "github.com/sourcegraph/src-cli/internal/cmderrors" ) -const ListSearchJobsQuery = `query SearchJobs($first: Int!, $descending: Boolean!, $orderBy: SearchJobsOrderBy!) { +// GraphQL query constants +const listSearchJobsQuery = `query SearchJobs($first: Int!, $descending: Boolean!, $orderBy: SearchJobsOrderBy!) { searchJobs(first: $first, orderBy: $orderBy, descending: $descending) { nodes { ...SearchJobFields @@ -18,107 +19,114 @@ const ListSearchJobsQuery = `query SearchJobs($first: Int!, $descending: Boolean } ` -// init registers the "list" subcommand for search-jobs which displays search jobs -// based on the provided filtering and formatting options. It supports pagination, -// sorting by different fields, and custom output formatting using Go templates. -func init() { - usage := ` -Examples: - - List all search jobs: - - $ src search-jobs list - - List all search jobs in ascending order: - - $ src search-jobs list -asc - - Limit the number of search jobs returned: - - $ src search-jobs list -limit 5 +// validOrderByValues defines the allowed values for the order-by flag +var validOrderByValues = map[string]bool{ + "QUERY": true, + "CREATED_AT": true, + "STATE": true, +} - Order search jobs by a field (must be one of: QUERY, CREATED_AT, STATE): +// listSearchJobs fetches search jobs based on the provided parameters +func listSearchJobs(client api.Client, limit int, descending bool, orderBy string) ([]SearchJob, error) { + query := listSearchJobsQuery + searchJobFragment - $ src search-jobs list -order-by QUERY -` - flagSet := flag.NewFlagSet("list", flag.ExitOnError) - usageFunc := func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) + var result struct { + SearchJobs struct { + Nodes []SearchJob + } } - var ( - formatFlag = flagSet.String("f", "{{searchJobIDNumber .ID}}: {{.Creator.Username}} {{.State}}", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) - limitFlag = flagSet.Int("limit", 10, "Limit the number of search jobs returned") - ascFlag = flagSet.Bool("asc", false, "Sort search jobs in ascending order") - orderByFlag = flagSet.String("order-by", "CREATED_AT", "Sort search jobs by a field") - apiFlags = api.NewFlags(flagSet) - ) - - validOrderBy := map[string]bool{ - "QUERY": true, - "CREATED_AT": true, - "STATE": true, + if ok, err := client.NewRequest(query, map[string]interface{}{ + "first": limit, + "descending": descending, + "orderBy": orderBy, + }).Do(context.Background(), &result); err != nil || !ok { + return nil, err } - handler := func(args []string) error { - if err := flagSet.Parse(args); err != nil { - return err - } + return result.SearchJobs.Nodes, nil +} - client := api.NewClient(api.ClientOpts{ - Endpoint: cfg.Endpoint, - AccessToken: cfg.AccessToken, - Out: flagSet.Output(), - Flags: apiFlags, - }) +// validateListFlags checks if the provided flags are valid +func validateListFlags(limit int, orderBy string) error { + if limit < 1 { + return cmderrors.Usage("limit flag must be greater than 0") + } - if *limitFlag < 1 { - return cmderrors.Usage("limit flag must be greater than 0") - } + if !validOrderByValues[orderBy] { + return cmderrors.Usage("order-by must be one of: QUERY, CREATED_AT, STATE") + } - if !validOrderBy[*orderByFlag] { - return cmderrors.Usage("order-by must be one of: QUERY, CREATED_AT, STATE") - } + return nil +} - tmpl, err := parseTemplate(*formatFlag) - if err != nil { +// init registers the "list" subcommand for search-jobs which displays search jobs +// based on the provided filtering and formatting options. +func init() { + usage := ` + Examples: + + List all search jobs: + + $ src search-jobs list + + List all search jobs in ascending order: + + $ src search-jobs list --asc + + Limit the number of search jobs returned: + + $ src search-jobs list --limit 5 + + Order search jobs by a field (must be one of: QUERY, CREATED_AT, STATE): + + $ src search-jobs list --order-by QUERY + + Select specific columns to display: + + $ src search-jobs list -c id,state,username,createdat + + Output results as JSON: + + $ src search-jobs list -json + + Combine options: + + $ src search-jobs list --limit 10 --order-by STATE --asc -c id,query,state + + Available columns are: id, query, state, username, createdat, startedat, finishedat, + url, logurl, total, completed, failed, inprogress + ` + + // Use the builder pattern for command creation + cmd := NewSearchJobCommand("list", usage) + + // Add list-specific flags + limitFlag := cmd.Flags.Int("limit", 10, "Limit the number of search jobs returned") + ascFlag := cmd.Flags.Bool("asc", false, "Sort search jobs in ascending order") + orderByFlag := cmd.Flags.String("order-by", "CREATED_AT", "Sort search jobs by a field") + + cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + // Get the client using the centralized function + client := createSearchJobsClient(flagSet, apiFlags) + + // Validate flags + if err := validateListFlags(*limitFlag, *orderByFlag); err != nil { return err } - query := ListSearchJobsQuery + SearchJobFragment - - var result struct { - SearchJobs struct { - Nodes []SearchJob - } - } - - if ok, err := client.NewRequest(query, map[string]interface{}{ - "first": *limitFlag, - "descending": !*ascFlag, - "orderBy": *orderByFlag, - }).Do(context.Background(), &result); err != nil || !ok { + // Fetch search jobs + jobs, err := listSearchJobs(client, *limitFlag, !*ascFlag, *orderByFlag) + if err != nil { return err } - if len(result.SearchJobs.Nodes) == 0 { + // Handle no results case + if len(jobs) == 0 { return cmderrors.ExitCode(1, fmt.Errorf("no search jobs found")) } - for _, job := range result.SearchJobs.Nodes { - if err := execTemplate(tmpl, job); err != nil { - return err - } - } - - return nil - } - - searchJobsCommands = append(searchJobsCommands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, + // Display the results with the selected columns or as JSON + return displaySearchJobs(jobs, columns, asJSON) }) } diff --git a/cmd/src/search_jobs_logs.go b/cmd/src/search_jobs_logs.go index 5c3e85b760..593bf75b2a 100644 --- a/cmd/src/search_jobs_logs.go +++ b/cmd/src/search_jobs_logs.go @@ -8,92 +8,103 @@ import ( "os" "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" ) -// init registers the 'logs' subcommand for search jobs, which allows users to view -// logs for a specific search job by its ID. The command requires a search job ID -// and uses the configured API client to fetch and display the logs. -func init() { - usage := `retrieves the logs of a search job in CSV format. -Examples: +// fetchJobLogs retrieves logs for a search job from its log URL +func fetchJobLogs(jobID string, logURL string) (io.ReadCloser, error) { + if logURL == "" { + return nil, fmt.Errorf("no logs URL found for search job %s", jobID) + } - View the logs of a search job: - $ src search-jobs logs -id 999 + // Prepare HTTP request for logs + req, err := http.NewRequest("GET", logURL, nil) + if err != nil { + return nil, err + } - Save the logs to a file: - $ src search-jobs logs U2VhcmNoSm9iOjY5 -out logs.csv + req.Header.Add("Authorization", "token "+cfg.AccessToken) -` - flagSet := flag.NewFlagSet("logs", flag.ExitOnError) - usageFunc := func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) + // Execute request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err } - var ( - idFlag = flagSet.String("id", "", "ID of the search job to view logs for") - outFlag = flagSet.String("out", "", "File path to save the logs (optional)") - apiFlags = api.NewFlags(flagSet) - ) + return resp.Body, nil +} - handler := func(args []string) error { - if err := flagSet.Parse(args); err != nil { - return err +// outputLogs writes logs to either a file or stdout +func outputLogs(logs io.Reader, outputPath string) error { + if outputPath != "" { + // Write to file + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) } + defer file.Close() - client := api.NewClient(api.ClientOpts{ - Endpoint: cfg.Endpoint, - AccessToken: cfg.AccessToken, - Out: flagSet.Output(), - Flags: apiFlags, - }) - - job, err := getSearchJob(client, *idFlag) + _, err = io.Copy(file, logs) if err != nil { - return err + return fmt.Errorf("failed to write to output file: %w", err) } + return nil + } + + // Write to stdout + _, err := io.Copy(os.Stdout, logs) + return err +} - if job == nil || job.LogURL == "" { - return fmt.Errorf("no logs URL found for search job %s", *idFlag) +// init registers the 'logs' subcommand for search jobs, which allows users to view +// logs for a specific search job by its ID. The command requires a search job ID +// and uses the configured API client to fetch and display the logs. +func init() { + usage := `retrieves the logs of a search job in CSV format. + Examples: + + View the logs of a search job: + $ src search-jobs logs U2VhcmNoSm9iOjY5 + + Save the logs to a file: + $ src search-jobs logs U2VhcmNoSm9iOjY5 -out logs.csv + + The logs command retrieves the raw log data in CSV format. The data will be + displayed on stdout or written to the file specified with -out. + ` + + // Use the builder pattern for command creation + cmd := NewSearchJobCommand("logs", usage) + + // Add logs-specific flag + outFlag := cmd.Flags.String("out", "", "File path to save the logs (optional)") + + cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + // Validate job ID + if flagSet.NArg() == 0 { + return cmderrors.Usage("must provide a search job ID") } + jobID := flagSet.Arg(0) - req, err := http.NewRequest("GET", job.LogURL, nil) + // Get the client and fetch job details + client := createSearchJobsClient(flagSet, apiFlags) + job, err := getSearchJob(client, jobID) if err != nil { return err } - req.Header.Add("Authorization", "token "+cfg.AccessToken) + if job == nil { + return fmt.Errorf("no job found with ID %s", jobID) + } - resp, err := http.DefaultClient.Do(req) + // Fetch logs + logsData, err := fetchJobLogs(jobID, job.LogURL) if err != nil { return err } - defer resp.Body.Close() - - if *outFlag != "" { - - file, err := os.Create(*outFlag) - if err != nil { - return fmt.Errorf("failed to create output file: %w", err) - } - - defer file.Close() - - _, err = io.Copy(file, resp.Body) - if err != nil { - return fmt.Errorf("failed to write to output file: %w", err) - } - return nil - } - - _, err = io.Copy(os.Stdout, resp.Body) - return err - } + defer logsData.Close() - searchJobsCommands = append(searchJobsCommands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, + // Output logs + return outputLogs(logsData, *outFlag) }) } diff --git a/cmd/src/search_jobs_restart.go b/cmd/src/search_jobs_restart.go index 549a247469..4316727d10 100644 --- a/cmd/src/search_jobs_restart.go +++ b/cmd/src/search_jobs_restart.go @@ -1,7 +1,6 @@ package main import ( - "context" "flag" "fmt" @@ -9,85 +8,66 @@ import ( "github.com/sourcegraph/src-cli/internal/cmderrors" ) +// restartSearchJob restarts a search job with the same query as the original +func restartSearchJob(client api.Client, jobID string) (*SearchJob, error) { + originalJob, err := getSearchJob(client, jobID) + if err != nil { + return nil, err + } + + if originalJob == nil { + return nil, fmt.Errorf("no job found with ID %s", jobID) + } + + query := originalJob.Query + + return createSearchJob(client, query) +} + // init registers the "restart" subcommand for search jobs, which allows restarting // a search job by its ID. It sets up command-line flags for job ID and output formatting, // validates the search job query, and creates a new search job with the same query // as the original job. func init() { usage := ` -Examples: - - Restart a search job by ID: - - $ src search-jobs restart -id 999 -` - - flagSet := flag.NewFlagSet("restart", flag.ExitOnError) - usageFunc := func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) - } - - var ( - idFlag = flagSet.String("id", "", "ID of the search job to restart") - formatFlag = flagSet.String("f", "{{searchJobIDNumber .ID}}: {{.Creator.Username}} {{.State}} ({{.Query}})", `Format for the output, using the syntax of Go package text/template. (e.g. "{{.ID}}: {{.Creator.Username}} ({{.Query}})" or "{{.|json}}")`) - apiFlags = api.NewFlags(flagSet) - ) - - handler := func(args []string) error { - if err := flagSet.Parse(args); err != nil { - return err - } - - client := api.NewClient(api.ClientOpts{ - Endpoint: cfg.Endpoint, - AccessToken: cfg.AccessToken, - Out: flagSet.Output(), - Flags: apiFlags, - }) - - tmpl, err := parseTemplate(*formatFlag) - if err != nil { - return err - } - - if *idFlag == "" { + Examples: + + Restart a search job by ID: + + $ src search-jobs restart U2VhcmNoSm9iOjY5 + + Restart a search job and display specific columns: + + $ src search-jobs restart U2VhcmNoSm9iOjY5 -c id,state,query + + Restart a search job and output in JSON format: + + $ src search-jobs restart U2VhcmNoSm9iOjY5 -json + + Available columns are: id, query, state, username, createdat, startedat, finishedat, + url, logurl, total, completed, failed, inprogress + ` + + // Use the builder pattern for command creation + cmd := NewSearchJobCommand("restart", usage) + + cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + // Validate job ID + if flagSet.NArg() != 1 { return cmderrors.Usage("must provide a job ID") } + jobID := flagSet.Arg(0) - originalJob, err := getSearchJob(client, *idFlag) - if err != nil { - return err - } - query := originalJob.Query - var validateResult struct { - ValidateSearchJob struct { - AlwaysNil bool `json:"alwaysNil"` - } `json:"validateSearchJob"` - } - - if ok, err := client.NewRequest(ValidateSearchJobQuery, map[string]interface{}{ - "query": query, - }).Do(context.Background(), &validateResult); err != nil || !ok { - return err - } - var result struct { - CreateSearchJob *SearchJob `json:"createSearchJob"` - } + // Get the client + client := createSearchJobsClient(flagSet, apiFlags) - if ok, err := client.NewRequest(CreateSearchJobQuery, map[string]interface{}{ - "query": query, - }).Do(context.Background(), &result); !ok { + // Restart the job + newJob, err := restartSearchJob(client, jobID) + if err != nil { return err } - return execTemplate(tmpl, result.CreateSearchJob) - } - - searchJobsCommands = append(searchJobsCommands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, + // Display the new job + return displaySearchJob(newJob, columns, asJSON) }) } diff --git a/cmd/src/search_jobs_results.go b/cmd/src/search_jobs_results.go index a3f042b828..c616b4d5a5 100644 --- a/cmd/src/search_jobs_results.go +++ b/cmd/src/search_jobs_results.go @@ -8,87 +8,104 @@ import ( "os" "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" ) -func init() { - usage := `retrieves the results of a search job in JSON Lines (jsonl) format. -Examples: +// fetchJobResults retrieves results for a search job from its results URL +func fetchJobResults(jobID string, resultsURL string) (io.ReadCloser, error) { + if resultsURL == "" { + return nil, fmt.Errorf("no results URL found for search job %s", jobID) + } - Get the results of a search job: - $ src search-jobs results -id 999 + // Prepare HTTP request for results + req, err := http.NewRequest("GET", resultsURL, nil) + if err != nil { + return nil, err + } - Save search results to a file: - $ src search-jobs results -id 999 -out results.jsonl -` + req.Header.Add("Authorization", "token "+cfg.AccessToken) - flagSet := flag.NewFlagSet("results", flag.ExitOnError) - usageFunc := func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) + // Execute request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err } - var ( - idFlag = flagSet.String("id", "", "ID of the search job to get results for") - outFlag = flagSet.String("out", "", "File path to save the results (optional)") - apiFlags = api.NewFlags(flagSet) - ) + return resp.Body, nil +} - handler := func(args []string) error { - if err := flagSet.Parse(args); err != nil { - return err +// outputResults writes results to either a file or stdout +func outputResults(results io.Reader, outputPath string) error { + if outputPath != "" { + // Write to file + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) } + defer file.Close() - client := api.NewClient(api.ClientOpts{ - Endpoint: cfg.Endpoint, - AccessToken: cfg.AccessToken, - Out: flagSet.Output(), - Flags: apiFlags, - }) - - job, err := getSearchJob(client, *idFlag) + _, err = io.Copy(file, results) if err != nil { - return err + return fmt.Errorf("failed to write to output file: %w", err) } + return nil + } + + // Write to stdout + _, err := io.Copy(os.Stdout, results) + return err +} - if job == nil || job.URL == "" { - return fmt.Errorf("no results URL found for search job %s", *idFlag) +// init registers the "results" subcommand for search jobs, which allows users to view +// results for a specific search job by its ID. The command requires a search job ID +// and uses the configured API client to fetch and display the results. +func init() { + usage := `retrieves the results of a search job in JSON Lines (jsonl) format. + Examples: + + Get the results of a search job: + $ src search-jobs results U2VhcmNoSm9iOjY5 + + Save search results to a file: + $ src search-jobs results U2VhcmNoSm9iOjY5 -out results.jsonl + + The results command retrieves the raw search results in JSON Lines format. + Each line contains a single JSON object representing a search result. The data + will be displayed on stdout or written to the file specified with -out. + ` + + // Use the builder pattern for command creation + cmd := NewSearchJobCommand("results", usage) + + // Add results-specific flag + outFlag := cmd.Flags.String("out", "", "File path to save the results (optional)") + + cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + // Validate job ID + if flagSet.NArg() != 1 { + return cmderrors.Usage("must provide a search job ID") } + jobID := flagSet.Arg(0) - req, err := http.NewRequest("GET", job.URL, nil) + // Get the client and fetch job details + client := createSearchJobsClient(flagSet, apiFlags) + job, err := getSearchJob(client, jobID) if err != nil { return err } - req.Header.Add("Authorization", "token "+cfg.AccessToken) + if job == nil { + return fmt.Errorf("no job found with ID %s", jobID) + } - resp, err := http.DefaultClient.Do(req) + // Fetch results + resultsData, err := fetchJobResults(jobID, job.URL) if err != nil { return err } - defer resp.Body.Close() - - if *outFlag != "" { - file, err := os.Create(*outFlag) - if err != nil { - return fmt.Errorf("failed to create output file: %w", err) - } - defer file.Close() - - _, err = io.Copy(file, resp.Body) - if err != nil { - return fmt.Errorf("failed to write to output file: %w", err) - } - return nil - } - - _, err = io.Copy(os.Stdout, resp.Body) - return err - } + defer resultsData.Close() - searchJobsCommands = append(searchJobsCommands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: usageFunc, + // Output results + return outputResults(resultsData, *outFlag) }) } From 6c66488e11909d994ce2518ea791d3583d7949a6 Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Tue, 18 Mar 2025 19:25:48 -0400 Subject: [PATCH 10/12] refactor: make search-jobs command builder methods private --- cmd/src/search_jobs.go | 24 ++++++++++++------------ cmd/src/search_jobs_cancel.go | 4 ++-- cmd/src/search_jobs_create.go | 4 ++-- cmd/src/search_jobs_delete.go | 4 ++-- cmd/src/search_jobs_get.go | 4 ++-- cmd/src/search_jobs_list.go | 4 ++-- cmd/src/search_jobs_logs.go | 4 ++-- cmd/src/search_jobs_restart.go | 4 ++-- cmd/src/search_jobs_results.go | 4 ++-- 9 files changed, 28 insertions(+), 28 deletions(-) diff --git a/cmd/src/search_jobs.go b/cmd/src/search_jobs.go index ed85a22d52..394b591c9e 100644 --- a/cmd/src/search_jobs.go +++ b/cmd/src/search_jobs.go @@ -63,8 +63,8 @@ type SearchJob struct { } } -// AvailableColumns defines the available column names for output -var AvailableColumns = map[string]bool{ +// availableColumns defines the available column names for output +var availableColumns = map[string]bool{ "id": true, "query": true, "state": true, @@ -80,8 +80,8 @@ var AvailableColumns = map[string]bool{ "inprogress": true, } -// DefaultColumns defines the default columns to display -var DefaultColumns = []string{"id", "username", "state", "query"} +// defaultColumns defines the default columns to display +var defaultColumns = []string{"id", "username", "state", "query"} // SearchJobCommandBuilder helps build search job commands with common flags and options type SearchJobCommandBuilder struct { @@ -94,8 +94,8 @@ type SearchJobCommandBuilder struct { // Global variables var searchJobsCommands commander -// NewSearchJobCommand creates a new search job command builder -func NewSearchJobCommand(name string, usage string) *SearchJobCommandBuilder { +// newSearchJobCommand creates a new search job command builder +func newSearchJobCommand(name string, usage string) *SearchJobCommandBuilder { flagSet := flag.NewFlagSet(name, flag.ExitOnError) return &SearchJobCommandBuilder{ Name: name, @@ -105,9 +105,9 @@ func NewSearchJobCommand(name string, usage string) *SearchJobCommandBuilder { } } -// Build creates and registers the command -func (b *SearchJobCommandBuilder) Build(handlerFunc func(*flag.FlagSet, *api.Flags, []string, bool) error) { - columnsFlag := b.Flags.String("c", strings.Join(DefaultColumns, ","), +// build creates and registers the command +func (b *SearchJobCommandBuilder) build(handlerFunc func(*flag.FlagSet, *api.Flags, []string, bool) error) { + columnsFlag := b.Flags.String("c", strings.Join(defaultColumns, ","), "Comma-separated list of columns to display. Available: id,query,state,username,createdat,startedat,finishedat,url,logurl,total,completed,failed,inprogress") jsonFlag := b.Flags.Bool("json", false, "Output results as JSON for programmatic access") @@ -138,7 +138,7 @@ func (b *SearchJobCommandBuilder) Build(handlerFunc func(*flag.FlagSet, *api.Fla // parseColumns parses and validates the columns flag func parseColumns(columnsFlag string) []string { if columnsFlag == "" { - return DefaultColumns + return defaultColumns } columns := strings.Split(columnsFlag, ",") @@ -146,13 +146,13 @@ func parseColumns(columnsFlag string) []string { for _, col := range columns { col = strings.ToLower(strings.TrimSpace(col)) - if AvailableColumns[col] { + if availableColumns[col] { validColumns = append(validColumns, col) } } if len(validColumns) == 0 { - return DefaultColumns + return defaultColumns } return validColumns diff --git a/cmd/src/search_jobs_cancel.go b/cmd/src/search_jobs_cancel.go index cbce6883d4..4c917785ed 100644 --- a/cmd/src/search_jobs_cancel.go +++ b/cmd/src/search_jobs_cancel.go @@ -56,9 +56,9 @@ func init() { ` // Use the builder pattern for command creation - cmd := NewSearchJobCommand("cancel", usage) + cmd := newSearchJobCommand("cancel", usage) - cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { // Validate job ID using the shared function from search_jobs_get.go jobID, err := validateJobID(flagSet.Args()) if err != nil { diff --git a/cmd/src/search_jobs_create.go b/cmd/src/search_jobs_create.go index e9c80e3278..7a9a3e709c 100644 --- a/cmd/src/search_jobs_create.go +++ b/cmd/src/search_jobs_create.go @@ -75,9 +75,9 @@ func init() { ` // Use the builder pattern for command creation - cmd := NewSearchJobCommand("create", usage) + cmd := newSearchJobCommand("create", usage) - cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { // Validate that a query was provided if flagSet.NArg() != 1 { return cmderrors.Usage("must provide a query") diff --git a/cmd/src/search_jobs_delete.go b/cmd/src/search_jobs_delete.go index 4cfe844ad4..daee31773b 100644 --- a/cmd/src/search_jobs_delete.go +++ b/cmd/src/search_jobs_delete.go @@ -55,9 +55,9 @@ func init() { ` // Use the builder pattern for command creation - cmd := NewSearchJobCommand("delete", usage) + cmd := newSearchJobCommand("delete", usage) - cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { // Validate job ID using the shared function from search_jobs_get.go jobID, err := validateJobID(flagSet.Args()) if err != nil { diff --git a/cmd/src/search_jobs_get.go b/cmd/src/search_jobs_get.go index 37e0cdb8c6..72b75d3de4 100644 --- a/cmd/src/search_jobs_get.go +++ b/cmd/src/search_jobs_get.go @@ -56,9 +56,9 @@ func init() { ` // Use the builder pattern for command creation - cmd := NewSearchJobCommand("get", usage) + cmd := newSearchJobCommand("get", usage) - cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { // Get the client using the centralized function client := createSearchJobsClient(flagSet, apiFlags) diff --git a/cmd/src/search_jobs_list.go b/cmd/src/search_jobs_list.go index e721867858..5757b07a1a 100644 --- a/cmd/src/search_jobs_list.go +++ b/cmd/src/search_jobs_list.go @@ -99,14 +99,14 @@ func init() { ` // Use the builder pattern for command creation - cmd := NewSearchJobCommand("list", usage) + cmd := newSearchJobCommand("list", usage) // Add list-specific flags limitFlag := cmd.Flags.Int("limit", 10, "Limit the number of search jobs returned") ascFlag := cmd.Flags.Bool("asc", false, "Sort search jobs in ascending order") orderByFlag := cmd.Flags.String("order-by", "CREATED_AT", "Sort search jobs by a field") - cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { // Get the client using the centralized function client := createSearchJobsClient(flagSet, apiFlags) diff --git a/cmd/src/search_jobs_logs.go b/cmd/src/search_jobs_logs.go index 593bf75b2a..aa4ff874b4 100644 --- a/cmd/src/search_jobs_logs.go +++ b/cmd/src/search_jobs_logs.go @@ -74,12 +74,12 @@ func init() { ` // Use the builder pattern for command creation - cmd := NewSearchJobCommand("logs", usage) + cmd := newSearchJobCommand("logs", usage) // Add logs-specific flag outFlag := cmd.Flags.String("out", "", "File path to save the logs (optional)") - cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { // Validate job ID if flagSet.NArg() == 0 { return cmderrors.Usage("must provide a search job ID") diff --git a/cmd/src/search_jobs_restart.go b/cmd/src/search_jobs_restart.go index 4316727d10..29bc7ab050 100644 --- a/cmd/src/search_jobs_restart.go +++ b/cmd/src/search_jobs_restart.go @@ -49,9 +49,9 @@ func init() { ` // Use the builder pattern for command creation - cmd := NewSearchJobCommand("restart", usage) + cmd := newSearchJobCommand("restart", usage) - cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { // Validate job ID if flagSet.NArg() != 1 { return cmderrors.Usage("must provide a job ID") diff --git a/cmd/src/search_jobs_results.go b/cmd/src/search_jobs_results.go index c616b4d5a5..4349778e42 100644 --- a/cmd/src/search_jobs_results.go +++ b/cmd/src/search_jobs_results.go @@ -75,12 +75,12 @@ func init() { ` // Use the builder pattern for command creation - cmd := NewSearchJobCommand("results", usage) + cmd := newSearchJobCommand("results", usage) // Add results-specific flag outFlag := cmd.Flags.String("out", "", "File path to save the results (optional)") - cmd.Build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { // Validate job ID if flagSet.NArg() != 1 { return cmderrors.Usage("must provide a search job ID") From 049e554a34ccfbcb6f6db40536d12d028f937806 Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Wed, 19 Mar 2025 10:12:25 -0400 Subject: [PATCH 11/12] (fix) do not display command success output when using -get-curl --- cmd/src/search_jobs.go | 15 ++++++--------- cmd/src/search_jobs_cancel.go | 16 +++++++--------- cmd/src/search_jobs_create.go | 24 +++++++++++++----------- cmd/src/search_jobs_delete.go | 14 ++++++-------- cmd/src/search_jobs_get.go | 11 +++++------ cmd/src/search_jobs_list.go | 16 ++++++---------- cmd/src/search_jobs_logs.go | 20 +++++++------------- cmd/src/search_jobs_restart.go | 14 ++++++-------- cmd/src/search_jobs_results.go | 19 +++++++------------ internal/api/flags.go | 7 +++++++ 10 files changed, 70 insertions(+), 86 deletions(-) diff --git a/cmd/src/search_jobs.go b/cmd/src/search_jobs.go index 394b591c9e..d821386161 100644 --- a/cmd/src/search_jobs.go +++ b/cmd/src/search_jobs.go @@ -34,13 +34,6 @@ fragment SearchJobFields on SearchJob { } }` -// GraphQL query constant for validating search queries -const validateSearchJobQuery = `query ValidateSearchJob($query: String!) { - validateSearchJob(query: $query) { - errors - } -}` - // SearchJob represents a search job with its metadata, including the search query, // execution state, creator information, timestamps, URLs, and repository statistics. type SearchJob struct { @@ -106,7 +99,7 @@ func newSearchJobCommand(name string, usage string) *SearchJobCommandBuilder { } // build creates and registers the command -func (b *SearchJobCommandBuilder) build(handlerFunc func(*flag.FlagSet, *api.Flags, []string, bool) error) { +func (b *SearchJobCommandBuilder) build(handlerFunc func(*flag.FlagSet, *api.Flags, []string, bool, api.Client) error) { columnsFlag := b.Flags.String("c", strings.Join(defaultColumns, ","), "Comma-separated list of columns to display. Available: id,query,state,username,createdat,startedat,finishedat,url,logurl,total,completed,failed,inprogress") jsonFlag := b.Flags.Bool("json", false, "Output results as JSON for programmatic access") @@ -125,7 +118,9 @@ func (b *SearchJobCommandBuilder) build(handlerFunc func(*flag.FlagSet, *api.Fla // Parse columns columns := parseColumns(*columnsFlag) - return handlerFunc(b.Flags, b.ApiFlags, columns, *jsonFlag) + client := createSearchJobsClient(b.Flags, b.ApiFlags) + + return handlerFunc(b.Flags, b.ApiFlags, columns, *jsonFlag, client) } searchJobsCommands = append(searchJobsCommands, &command{ @@ -274,7 +269,9 @@ func init() { delete deletes a search job by ID get gets a search job by ID list lists search jobs + logs fetches logs for a search job by ID restart restarts a search job by ID + results fetches results for a search job by ID Common options for all commands: -c Select columns to display (e.g., -c id,query,state,username) diff --git a/cmd/src/search_jobs_cancel.go b/cmd/src/search_jobs_cancel.go index 4c917785ed..b819bfb532 100644 --- a/cmd/src/search_jobs_cancel.go +++ b/cmd/src/search_jobs_cancel.go @@ -32,7 +32,6 @@ func cancelSearchJob(client api.Client, jobID string) error { return nil } -// displayCancelSuccessMessage outputs a success message for the canceled job // displayCancelSuccessMessage outputs a success message for the canceled job func displayCancelSuccessMessage(out *flag.FlagSet, jobID string) { fmt.Fprintf(out.Output(), "Search job %s canceled successfully\n", jobID) @@ -55,26 +54,25 @@ func init() { The cancel command stops a running search job and outputs a confirmation message. ` - // Use the builder pattern for command creation cmd := newSearchJobCommand("cancel", usage) - cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { - // Validate job ID using the shared function from search_jobs_get.go + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool, client api.Client) error { + jobID, err := validateJobID(flagSet.Args()) if err != nil { return err } - // Get the client - client := createSearchJobsClient(flagSet, apiFlags) - - // Send cancellation request if err := cancelSearchJob(client, jobID); err != nil { return err } - // Output success message + if apiFlags.GetCurl() { + return nil + } + displayCancelSuccessMessage(flagSet, jobID) + return nil }) } diff --git a/cmd/src/search_jobs_create.go b/cmd/src/search_jobs_create.go index 7a9a3e709c..14cd564e22 100644 --- a/cmd/src/search_jobs_create.go +++ b/cmd/src/search_jobs_create.go @@ -16,13 +16,16 @@ const ( ...SearchJobFields } }` + searchJobFragment + + // validateSearchJobQuery defines the GraphQL query for validating search queries + validateSearchJobQuery = `query ValidateSearchJob($query: String!) { + validateSearchJob(query: $query) { alwaysNil } + }` ) // validateSearchQuery validates a search query with the server func validateSearchQuery(client api.Client, query string) error { - var validateResult struct { - ValidateSearchJob interface{} `json:"validateSearchJob"` - } + var validateResult struct{} if ok, err := client.NewRequest(validateSearchJobQuery, map[string]any{ "query": query, @@ -39,7 +42,6 @@ func createSearchJob(client api.Client, query string) (*SearchJob, error) { CreateSearchJob *SearchJob `json:"createSearchJob"` } - // Validate the query if err := validateSearchQuery(client, query); err != nil { return nil, err } @@ -77,23 +79,23 @@ func init() { // Use the builder pattern for command creation cmd := newSearchJobCommand("create", usage) - cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { - // Validate that a query was provided + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool, client api.Client) error { + if flagSet.NArg() != 1 { return cmderrors.Usage("must provide a query") } - query := flagSet.Arg(0) - // Get the client - client := createSearchJobsClient(flagSet, apiFlags) + query := flagSet.Arg(0) - // Create the search job job, err := createSearchJob(client, query) if err != nil { return err } - // Display the created job + if apiFlags.GetCurl() { + return nil + } + return displaySearchJob(job, columns, asJSON) }) } diff --git a/cmd/src/search_jobs_delete.go b/cmd/src/search_jobs_delete.go index daee31773b..4be18e446d 100644 --- a/cmd/src/search_jobs_delete.go +++ b/cmd/src/search_jobs_delete.go @@ -54,25 +54,23 @@ func init() { The delete command permanently removes a search job and outputs a confirmation message. ` - // Use the builder pattern for command creation cmd := newSearchJobCommand("delete", usage) - cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { - // Validate job ID using the shared function from search_jobs_get.go + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool, client api.Client) error { + jobID, err := validateJobID(flagSet.Args()) if err != nil { return err } - // Get the client - client := createSearchJobsClient(flagSet, apiFlags) - - // Send deletion request if err := deleteSearchJob(client, jobID); err != nil { return err } - // Output success message + if apiFlags.GetCurl() { + return nil + } + displaySuccessMessage(flagSet, jobID) return nil }) diff --git a/cmd/src/search_jobs_get.go b/cmd/src/search_jobs_get.go index 72b75d3de4..e5c49aefc2 100644 --- a/cmd/src/search_jobs_get.go +++ b/cmd/src/search_jobs_get.go @@ -55,25 +55,24 @@ func init() { url, logurl, total, completed, failed, inprogress ` - // Use the builder pattern for command creation cmd := newSearchJobCommand("get", usage) - cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { - // Get the client using the centralized function - client := createSearchJobsClient(flagSet, apiFlags) + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool, client api.Client) error { - // Validate that a job ID was provided id, err := validateJobID(flagSet.Args()) if err != nil { return err } - // Get the search job job, err := getSearchJob(client, id) if err != nil { return err } + if apiFlags.GetCurl() { + return nil + } + // Display the job with selected columns or as JSON return displaySearchJob(job, columns, asJSON) }) diff --git a/cmd/src/search_jobs_list.go b/cmd/src/search_jobs_list.go index 5757b07a1a..608bf96ddc 100644 --- a/cmd/src/search_jobs_list.go +++ b/cmd/src/search_jobs_list.go @@ -98,35 +98,31 @@ func init() { url, logurl, total, completed, failed, inprogress ` - // Use the builder pattern for command creation cmd := newSearchJobCommand("list", usage) - // Add list-specific flags limitFlag := cmd.Flags.Int("limit", 10, "Limit the number of search jobs returned") ascFlag := cmd.Flags.Bool("asc", false, "Sort search jobs in ascending order") - orderByFlag := cmd.Flags.String("order-by", "CREATED_AT", "Sort search jobs by a field") + orderByFlag := cmd.Flags.String("order-by", "CREATED_AT", "Sort search jobs by a sortable field (QUERY, CREATED_AT, STATE)") - cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { - // Get the client using the centralized function - client := createSearchJobsClient(flagSet, apiFlags) + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool, client api.Client) error { - // Validate flags if err := validateListFlags(*limitFlag, *orderByFlag); err != nil { return err } - // Fetch search jobs jobs, err := listSearchJobs(client, *limitFlag, !*ascFlag, *orderByFlag) if err != nil { return err } - // Handle no results case if len(jobs) == 0 { return cmderrors.ExitCode(1, fmt.Errorf("no search jobs found")) } - // Display the results with the selected columns or as JSON + if apiFlags.GetCurl() { + return nil + } + return displaySearchJobs(jobs, columns, asJSON) }) } diff --git a/cmd/src/search_jobs_logs.go b/cmd/src/search_jobs_logs.go index aa4ff874b4..4fcb3f21d7 100644 --- a/cmd/src/search_jobs_logs.go +++ b/cmd/src/search_jobs_logs.go @@ -17,7 +17,6 @@ func fetchJobLogs(jobID string, logURL string) (io.ReadCloser, error) { return nil, fmt.Errorf("no logs URL found for search job %s", jobID) } - // Prepare HTTP request for logs req, err := http.NewRequest("GET", logURL, nil) if err != nil { return nil, err @@ -25,7 +24,6 @@ func fetchJobLogs(jobID string, logURL string) (io.ReadCloser, error) { req.Header.Add("Authorization", "token "+cfg.AccessToken) - // Execute request resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err @@ -34,10 +32,8 @@ func fetchJobLogs(jobID string, logURL string) (io.ReadCloser, error) { return resp.Body, nil } -// outputLogs writes logs to either a file or stdout func outputLogs(logs io.Reader, outputPath string) error { if outputPath != "" { - // Write to file file, err := os.Create(outputPath) if err != nil { return fmt.Errorf("failed to create output file: %w", err) @@ -51,7 +47,6 @@ func outputLogs(logs io.Reader, outputPath string) error { return nil } - // Write to stdout _, err := io.Copy(os.Stdout, logs) return err } @@ -73,21 +68,17 @@ func init() { displayed on stdout or written to the file specified with -out. ` - // Use the builder pattern for command creation cmd := newSearchJobCommand("logs", usage) - // Add logs-specific flag outFlag := cmd.Flags.String("out", "", "File path to save the logs (optional)") - cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { - // Validate job ID + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool, client api.Client) error { if flagSet.NArg() == 0 { return cmderrors.Usage("must provide a search job ID") } + jobID := flagSet.Arg(0) - // Get the client and fetch job details - client := createSearchJobsClient(flagSet, apiFlags) job, err := getSearchJob(client, jobID) if err != nil { return err @@ -97,14 +88,17 @@ func init() { return fmt.Errorf("no job found with ID %s", jobID) } - // Fetch logs logsData, err := fetchJobLogs(jobID, job.LogURL) if err != nil { return err } + + if apiFlags.GetCurl() { + return nil + } + defer logsData.Close() - // Output logs return outputLogs(logsData, *outFlag) }) } diff --git a/cmd/src/search_jobs_restart.go b/cmd/src/search_jobs_restart.go index 29bc7ab050..a2733e18f4 100644 --- a/cmd/src/search_jobs_restart.go +++ b/cmd/src/search_jobs_restart.go @@ -48,26 +48,24 @@ func init() { url, logurl, total, completed, failed, inprogress ` - // Use the builder pattern for command creation cmd := newSearchJobCommand("restart", usage) - cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { - // Validate job ID + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool, client api.Client) error { if flagSet.NArg() != 1 { return cmderrors.Usage("must provide a job ID") } jobID := flagSet.Arg(0) - // Get the client - client := createSearchJobsClient(flagSet, apiFlags) - - // Restart the job newJob, err := restartSearchJob(client, jobID) + if err != nil { return err } - // Display the new job + if apiFlags.GetCurl() { + return nil + } + return displaySearchJob(newJob, columns, asJSON) }) } diff --git a/cmd/src/search_jobs_results.go b/cmd/src/search_jobs_results.go index 4349778e42..e604c1df7e 100644 --- a/cmd/src/search_jobs_results.go +++ b/cmd/src/search_jobs_results.go @@ -17,7 +17,6 @@ func fetchJobResults(jobID string, resultsURL string) (io.ReadCloser, error) { return nil, fmt.Errorf("no results URL found for search job %s", jobID) } - // Prepare HTTP request for results req, err := http.NewRequest("GET", resultsURL, nil) if err != nil { return nil, err @@ -25,7 +24,6 @@ func fetchJobResults(jobID string, resultsURL string) (io.ReadCloser, error) { req.Header.Add("Authorization", "token "+cfg.AccessToken) - // Execute request resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err @@ -37,7 +35,7 @@ func fetchJobResults(jobID string, resultsURL string) (io.ReadCloser, error) { // outputResults writes results to either a file or stdout func outputResults(results io.Reader, outputPath string) error { if outputPath != "" { - // Write to file + file, err := os.Create(outputPath) if err != nil { return fmt.Errorf("failed to create output file: %w", err) @@ -51,7 +49,6 @@ func outputResults(results io.Reader, outputPath string) error { return nil } - // Write to stdout _, err := io.Copy(os.Stdout, results) return err } @@ -74,21 +71,16 @@ func init() { will be displayed on stdout or written to the file specified with -out. ` - // Use the builder pattern for command creation cmd := newSearchJobCommand("results", usage) - // Add results-specific flag outFlag := cmd.Flags.String("out", "", "File path to save the results (optional)") - cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool) error { - // Validate job ID + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool, client api.Client) error { if flagSet.NArg() != 1 { return cmderrors.Usage("must provide a search job ID") } jobID := flagSet.Arg(0) - // Get the client and fetch job details - client := createSearchJobsClient(flagSet, apiFlags) job, err := getSearchJob(client, jobID) if err != nil { return err @@ -98,14 +90,17 @@ func init() { return fmt.Errorf("no job found with ID %s", jobID) } - // Fetch results resultsData, err := fetchJobResults(jobID, job.URL) if err != nil { return err } + + if apiFlags.GetCurl() { + return nil + } + defer resultsData.Close() - // Output results return outputResults(resultsData, *outFlag) }) } diff --git a/internal/api/flags.go b/internal/api/flags.go index 5a640da86d..32a255e371 100644 --- a/internal/api/flags.go +++ b/internal/api/flags.go @@ -22,6 +22,13 @@ func (f *Flags) Trace() bool { return *(f.trace) } +func (f *Flags) GetCurl() bool { + if f.getCurl == nil { + return false + } + return *(f.getCurl) +} + func (f *Flags) UserAgentTelemetry() bool { if f.userAgentTelemetry == nil { return defaultUserAgentTelemetry() From b87df9b4fb181425503d73740f11892a5060d6bb Mon Sep 17 00:00:00 2001 From: Travis Lyons Date: Wed, 19 Mar 2025 12:45:09 -0400 Subject: [PATCH 12/12] (fix) clean up inconsistencies in usage text --- cmd/src/search_jobs_cancel.go | 2 +- cmd/src/search_jobs_delete.go | 2 +- cmd/src/search_jobs_logs.go | 2 +- cmd/src/search_jobs_results.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/src/search_jobs_cancel.go b/cmd/src/search_jobs_cancel.go index b819bfb532..507cf3481f 100644 --- a/cmd/src/search_jobs_cancel.go +++ b/cmd/src/search_jobs_cancel.go @@ -41,7 +41,7 @@ func displayCancelSuccessMessage(out *flag.FlagSet, jobID string) { // a running search job by its ID. It sets up the command's flag parsing, usage information, // and handles the GraphQL mutation to cancel the specified search job. func init() { - usage := `cancels a running search job. + usage := ` Examples: Cancel a search job by ID: diff --git a/cmd/src/search_jobs_delete.go b/cmd/src/search_jobs_delete.go index 4be18e446d..183ca2bf4e 100644 --- a/cmd/src/search_jobs_delete.go +++ b/cmd/src/search_jobs_delete.go @@ -41,7 +41,7 @@ func displaySuccessMessage(out *flag.FlagSet, jobID string) { // a search job by its ID. The command requires a search job ID to be provided via // the -id flag and will make a GraphQL mutation to delete the specified job. func init() { - usage := `deletes a search job. + usage := ` Examples: Delete a search job by ID: diff --git a/cmd/src/search_jobs_logs.go b/cmd/src/search_jobs_logs.go index 4fcb3f21d7..2fe9b6bfee 100644 --- a/cmd/src/search_jobs_logs.go +++ b/cmd/src/search_jobs_logs.go @@ -55,7 +55,7 @@ func outputLogs(logs io.Reader, outputPath string) error { // logs for a specific search job by its ID. The command requires a search job ID // and uses the configured API client to fetch and display the logs. func init() { - usage := `retrieves the logs of a search job in CSV format. + usage := ` Examples: View the logs of a search job: diff --git a/cmd/src/search_jobs_results.go b/cmd/src/search_jobs_results.go index e604c1df7e..2e45f8219f 100644 --- a/cmd/src/search_jobs_results.go +++ b/cmd/src/search_jobs_results.go @@ -57,7 +57,7 @@ func outputResults(results io.Reader, outputPath string) error { // results for a specific search job by its ID. The command requires a search job ID // and uses the configured API client to fetch and display the results. func init() { - usage := `retrieves the results of a search job in JSON Lines (jsonl) format. + usage := ` Examples: Get the results of a search job: