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..d821386161 --- /dev/null +++ b/cmd/src/search_jobs.go @@ -0,0 +1,297 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "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 = ` +fragment SearchJobFields on SearchJob { + id + query + state + creator { + username + } + createdAt + startedAt + finishedAt + URL + logURL + repoStats { + total + completed + failed + inProgress + } +}` + +// 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 + } +} + +// 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, +} + +// 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 { + 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, 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") + + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src search-jobs %s':\n", b.Name) + b.Flags.PrintDefaults() + fmt.Println(b.Usage) + } + + handler := func(args []string) error { + if err := parseSearchJobsArgs(b.Flags, args); err != nil { + return err + } + + // Parse columns + columns := parseColumns(*columnsFlag) + + client := createSearchJobsClient(b.Flags, b.ApiFlags) + + return handlerFunc(b.Flags, b.ApiFlags, columns, *jsonFlag, client) + } + + 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 + } + + 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 +} + +// 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) +} + +// 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 +} + +// 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 + 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) + -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 new file mode 100644 index 0000000000..507cf3481f --- /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" +) + +// GraphQL mutation constants +const cancelSearchJobMutation = `mutation CancelSearchJob($id: ID!) { + cancelSearchJob(id: $id) { + alwaysNil + } +}` + +// cancelSearchJob cancels a search job with the given ID +func cancelSearchJob(client api.Client, jobID string) error { + var result struct { + CancelSearchJob struct { + AlwaysNil bool + } + } + + if ok, err := client.NewRequest(cancelSearchJobMutation, map[string]interface{}{ + "id": jobID, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + + return nil +} + +// 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) +} + +// 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 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. + ` + + cmd := newSearchJobCommand("cancel", usage) + + 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 + } + + if err := cancelSearchJob(client, jobID); err != nil { + return err + } + + 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 new file mode 100644 index 0000000000..14cd564e22 --- /dev/null +++ b/cmd/src/search_jobs_create.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "flag" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +// 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 + + // 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{} + + if ok, err := client.NewRequest(validateSearchJobQuery, map[string]any{ + "query": query, + }).Do(context.Background(), &validateResult); err != nil || !ok { + return err + } + + return nil +} + +// 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"` + } + + if err := validateSearchQuery(client, query); err != nil { + return nil, err + } + + if ok, err := client.NewRequest(createSearchJobQuery, map[string]any{ + "query": query, + }).Do(context.Background(), &result); !ok { + return nil, err + } + + return result.CreateSearchJob, nil +} + +// 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, client api.Client) error { + + if flagSet.NArg() != 1 { + return cmderrors.Usage("must provide a query") + } + + query := flagSet.Arg(0) + + job, err := createSearchJob(client, query) + if err != nil { + return err + } + + 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 new file mode 100644 index 0000000000..183ca2bf4e --- /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" +) + +// GraphQL mutation constants +const deleteSearchJobQuery = `mutation DeleteSearchJob($id: ID!) { + deleteSearchJob(id: $id) { + alwaysNil + } +}` + +// deleteSearchJob deletes a search job with the given ID +func deleteSearchJob(client api.Client, jobID string) error { + var result struct { + DeleteSearchJob struct { + AlwaysNil bool + } + } + + if ok, err := client.NewRequest(deleteSearchJobQuery, map[string]interface{}{ + "id": jobID, + }).Do(context.Background(), &result); err != nil || !ok { + return err + } + + return nil +} + +// 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 := ` + 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. + ` + + cmd := newSearchJobCommand("delete", usage) + + 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 + } + + if err := deleteSearchJob(client, jobID); err != nil { + return err + } + + 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 new file mode 100644 index 0000000000..e5c49aefc2 --- /dev/null +++ b/cmd/src/search_jobs_get.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "flag" + + "github.com/sourcegraph/src-cli/internal/api" +) + +// GraphQL query constants +const getSearchJobQuery = `query SearchJob($id: ID!) { + node(id: $id) { + ... on SearchJob { + ...SearchJobFields + } + } +} +` + +// getSearchJob fetches a search job by ID +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 +} + +// 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 + ` + + cmd := newSearchJobCommand("get", usage) + + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool, client api.Client) error { + + id, err := validateJobID(flagSet.Args()) + if err != nil { + return err + } + + 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 new file mode 100644 index 0000000000..608bf96ddc --- /dev/null +++ b/cmd/src/search_jobs_list.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "flag" + "fmt" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +// GraphQL query constants +const listSearchJobsQuery = `query SearchJobs($first: Int!, $descending: Boolean!, $orderBy: SearchJobsOrderBy!) { + searchJobs(first: $first, orderBy: $orderBy, descending: $descending) { + nodes { + ...SearchJobFields + } + } +} +` + +// validOrderByValues defines the allowed values for the order-by flag +var validOrderByValues = map[string]bool{ + "QUERY": true, + "CREATED_AT": true, + "STATE": true, +} + +// 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 + + var result struct { + SearchJobs struct { + Nodes []SearchJob + } + } + + 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 + } + + return result.SearchJobs.Nodes, nil +} + +// 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 !validOrderByValues[orderBy] { + return cmderrors.Usage("order-by must be one of: QUERY, CREATED_AT, STATE") + } + + return 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 + ` + + cmd := newSearchJobCommand("list", usage) + + 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 sortable field (QUERY, CREATED_AT, STATE)") + + cmd.build(func(flagSet *flag.FlagSet, apiFlags *api.Flags, columns []string, asJSON bool, client api.Client) error { + + if err := validateListFlags(*limitFlag, *orderByFlag); err != nil { + return err + } + + jobs, err := listSearchJobs(client, *limitFlag, !*ascFlag, *orderByFlag) + if err != nil { + return err + } + + if len(jobs) == 0 { + return cmderrors.ExitCode(1, fmt.Errorf("no search jobs found")) + } + + 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 new file mode 100644 index 0000000000..2fe9b6bfee --- /dev/null +++ b/cmd/src/search_jobs_logs.go @@ -0,0 +1,104 @@ +package main + +import ( + "flag" + "fmt" + "io" + "net/http" + "os" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +// 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) + } + + req, err := http.NewRequest("GET", logURL, nil) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", "token "+cfg.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + return resp.Body, nil +} + +func outputLogs(logs io.Reader, outputPath string) error { + if outputPath != "" { + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer file.Close() + + _, err = io.Copy(file, logs) + if err != nil { + return fmt.Errorf("failed to write to output file: %w", err) + } + return nil + } + + _, err := io.Copy(os.Stdout, logs) + return err +} + +// 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 := ` + 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. + ` + + cmd := newSearchJobCommand("logs", usage) + + 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, client api.Client) error { + if flagSet.NArg() == 0 { + return cmderrors.Usage("must provide a search job ID") + } + + jobID := flagSet.Arg(0) + + job, err := getSearchJob(client, jobID) + if err != nil { + return err + } + + if job == nil { + return fmt.Errorf("no job found with ID %s", jobID) + } + + logsData, err := fetchJobLogs(jobID, job.LogURL) + if err != nil { + return err + } + + if apiFlags.GetCurl() { + return nil + } + + defer logsData.Close() + + return outputLogs(logsData, *outFlag) + }) +} diff --git a/cmd/src/search_jobs_restart.go b/cmd/src/search_jobs_restart.go new file mode 100644 index 0000000000..a2733e18f4 --- /dev/null +++ b/cmd/src/search_jobs_restart.go @@ -0,0 +1,71 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/sourcegraph/src-cli/internal/api" + "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 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 + ` + + cmd := newSearchJobCommand("restart", usage) + + 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) + + newJob, err := restartSearchJob(client, jobID) + + if err != nil { + return err + } + + 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 new file mode 100644 index 0000000000..2e45f8219f --- /dev/null +++ b/cmd/src/search_jobs_results.go @@ -0,0 +1,106 @@ +package main + +import ( + "flag" + "fmt" + "io" + "net/http" + "os" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/cmderrors" +) + +// 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) + } + + req, err := http.NewRequest("GET", resultsURL, nil) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", "token "+cfg.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + return resp.Body, nil +} + +// outputResults writes results to either a file or stdout +func outputResults(results io.Reader, outputPath string) error { + if outputPath != "" { + + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer file.Close() + + _, err = io.Copy(file, results) + if err != nil { + return fmt.Errorf("failed to write to output file: %w", err) + } + return nil + } + + _, err := io.Copy(os.Stdout, results) + return err +} + +// 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 := ` + 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. + ` + + cmd := newSearchJobCommand("results", usage) + + 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, client api.Client) error { + if flagSet.NArg() != 1 { + return cmderrors.Usage("must provide a search job ID") + } + jobID := flagSet.Arg(0) + + job, err := getSearchJob(client, jobID) + if err != nil { + return err + } + + if job == nil { + return fmt.Errorf("no job found with ID %s", jobID) + } + + resultsData, err := fetchJobResults(jobID, job.URL) + if err != nil { + return err + } + + if apiFlags.GetCurl() { + return nil + } + + defer resultsData.Close() + + 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()