Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/src/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions cmd/src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions cmd/src/search_jobs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package main

import (
"encoding/base64"
"flag"
"fmt"
"regexp"
"strconv"

"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
}
}`

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
}

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.
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
}
}

type SearchJobID struct {
number uint64
}

func ParseSearchJobID(input string) (*SearchJobID, error) {
// accept either:
// - the numeric job id (non-negative integer)
// - the plain text SearchJob:<integer> form of the id
// - the base64-encoded "SearchJob:<integer>" 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:<integer>" or "<integer>"
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:<integer>', 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
}
79 changes: 79 additions & 0 deletions cmd/src/search_jobs_cancel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package main

import (
"context"
"flag"
"fmt"

"github.com/sourcegraph/src-cli/internal/api"
)

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 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)
}

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,
})

jobID, err := ParseSearchJobID(*idFlag)
if err != nil {
return err
}

query := CancelSearchJobMutation

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 {
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,
})
}
103 changes: 103 additions & 0 deletions cmd/src/search_jobs_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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", "{{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 *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,
})
}
Loading
Loading