From 3063b5dba594bd6acf9d80c36aea131d78e68c08 Mon Sep 17 00:00:00 2001 From: Jeremy Bumsted Date: Fri, 5 Jun 2026 15:32:09 -0600 Subject: [PATCH 1/2] feat: Migrate `bk job retry` from GraphQL to REST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retries now go through the REST API using the same pipeline/build resolution as `bk job log` and `bk job reprioritize`: pipeline comes from the `-p` flag, configured pipeline, or the current git repository; build comes from the `-b` flag or the most recent build on the current branch. Why: the GraphQL path required a GraphQL-enabled token and was the odd one out among job commands — it predates the resolver pattern (it was copied from `job unblock` in #387). REST works with standard token scopes and keeps one consistent code path. Behavior change: `bk job retry ` no longer works with zero context from outside a configured repository — pass `-p` and `-b` explicitly, like other job commands. Without `-b`, the job is assumed to be in the latest build on the current branch. Also removes the retry GraphQL operation and its generated code; this incidentally fixes the nil-payload crash path previously guarded in retry.go (REST errors now surface cleanly). --- cmd/job/graphql/retry.graphql | 9 ---- cmd/job/retry.go | 69 ++++++++++++++++++------- internal/graphql/generated.go | 97 ----------------------------------- 3 files changed, 50 insertions(+), 125 deletions(-) delete mode 100644 cmd/job/graphql/retry.graphql diff --git a/cmd/job/graphql/retry.graphql b/cmd/job/graphql/retry.graphql deleted file mode 100644 index 93d11016..00000000 --- a/cmd/job/graphql/retry.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation RetryJob($id: ID!) { - jobTypeCommandRetry(input: {id: $id}) { - jobTypeCommand { - id - state - url - } - } -} diff --git a/cmd/job/retry.go b/cmd/job/retry.go index a8287e7e..cd36ca4d 100644 --- a/cmd/job/retry.go +++ b/cmd/job/retry.go @@ -5,26 +5,31 @@ import ( "fmt" "github.com/alecthomas/kong" + buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" + "github.com/buildkite/cli/v3/internal/build/resolver/options" "github.com/buildkite/cli/v3/internal/cli" - bkGraphQL "github.com/buildkite/cli/v3/internal/graphql" bkIO "github.com/buildkite/cli/v3/internal/io" - "github.com/buildkite/cli/v3/internal/util" + pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" + buildkite "github.com/buildkite/go-buildkite/v4" ) -const jobCommandPrefix = "JobTypeCommand---" - type RetryCmd struct { - JobID string `arg:"" help:"Job UUID to retry"` + JobID string `arg:"" help:"Job UUID to retry"` + Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}" short:"p"` + BuildNumber string `help:"The build number" short:"b"` } func (c *RetryCmd) Help() string { return `Use this command to retry build jobs. Examples: - # Retry a job by UUID - $ bk job retry 0190046e-e199-453b-a302-a21a4d649d31 + # Retry a job (requires --pipeline and --build) + $ bk job retry 0190046e-e199-453b-a302-a21a4d649d31 -p my-pipeline -b 123 + + # If inside a git repository with a configured pipeline + $ bk job retry 0190046e-e199-453b-a302-a21a4d649d31 -b 123 ` } @@ -42,24 +47,50 @@ func (c *RetryCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { return err } - // Given a job UUID argument, we need to generate the GraphQL ID matching - graphqlID := util.GenerateGraphQLID(jobCommandPrefix, c.JobID) - ctx := context.Background() - var j *bkGraphQL.RetryJobResponse - if err = bkIO.SpinWhile(f, "Retrying job", func() error { - j, err = bkGraphQL.RetryJob(ctx, f.GraphQLClient, graphqlID) - return err - }); err != nil { + pipelineRes := pipelineResolver.NewAggregateResolver( + pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), + pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), + pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), + ) + + optionsResolver := options.AggregateResolver{ + options.ResolveBranchFromRepository(f.GitRepository), + } + + args := []string{} + if c.BuildNumber != "" { + args = []string{c.BuildNumber} + } + buildRes := buildResolver.NewAggregateResolver( + buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), + buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), + ) + + bld, err := buildRes.Resolve(ctx) + if err != nil { return err } + if bld == nil { + return fmt.Errorf("no build found") + } - // Fixes segfault when error is returned, e.g. "Jobs from canceled builds cannot be retried" - if j == nil || j.JobTypeCommandRetry == nil { - return fmt.Errorf("failed to retry job") + var job buildkite.Job + if err = bkIO.SpinWhile(f, "Retrying job", func() error { + var apiErr error + job, _, apiErr = f.RestAPIClient.Jobs.RetryJob( + ctx, + bld.Organization, + bld.Pipeline, + fmt.Sprint(bld.BuildNumber), + c.JobID, + ) + return apiErr + }); err != nil { + return err } - fmt.Println("Successfully retried job: " + j.JobTypeCommandRetry.JobTypeCommand.Url) + fmt.Println("Successfully retried job: " + job.WebURL) return nil } diff --git a/internal/graphql/generated.go b/internal/graphql/generated.go index f484957f..6a0a3ba7 100644 --- a/internal/graphql/generated.go +++ b/internal/graphql/generated.go @@ -3344,57 +3344,6 @@ func (v *PipelineCreateWebhookResponse) GetPipelineCreateWebhook() *PipelineCrea return v.PipelineCreateWebhook } -// RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload includes the requested fields of the GraphQL type JobTypeCommandRetryPayload. -// The GraphQL type's documentation follows. -// -// Autogenerated return type of JobTypeCommandRetry. -type RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload struct { - JobTypeCommand RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand `json:"jobTypeCommand"` -} - -// GetJobTypeCommand returns RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload.JobTypeCommand, and is useful for accessing the field via an interface. -func (v *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload) GetJobTypeCommand() RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand { - return v.JobTypeCommand -} - -// RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand includes the requested fields of the GraphQL type JobTypeCommand. -// The GraphQL type's documentation follows. -// -// A type of job that runs a command on an agent -type RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand struct { - Id string `json:"id"` - // The state of the job - State JobStates `json:"state"` - // The URL for the job - Url string `json:"url"` -} - -// GetId returns RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand.Id, and is useful for accessing the field via an interface. -func (v *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand) GetId() string { - return v.Id -} - -// GetState returns RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand.State, and is useful for accessing the field via an interface. -func (v *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand) GetState() JobStates { - return v.State -} - -// GetUrl returns RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand.Url, and is useful for accessing the field via an interface. -func (v *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayloadJobTypeCommand) GetUrl() string { - return v.Url -} - -// RetryJobResponse is returned by RetryJob on success. -type RetryJobResponse struct { - // Retry a job. - JobTypeCommandRetry *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload `json:"jobTypeCommandRetry"` -} - -// GetJobTypeCommandRetry returns RetryJobResponse.JobTypeCommandRetry, and is useful for accessing the field via an interface. -func (v *RetryJobResponse) GetJobTypeCommandRetry() *RetryJobJobTypeCommandRetryJobTypeCommandRetryPayload { - return v.JobTypeCommandRetry -} - // UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload includes the requested fields of the GraphQL type JobTypeBlockUnblockPayload. // The GraphQL type's documentation follows. // @@ -3623,14 +3572,6 @@ type __PipelineCreateWebhookInput struct { // GetId returns __PipelineCreateWebhookInput.Id, and is useful for accessing the field via an interface. func (v *__PipelineCreateWebhookInput) GetId() string { return v.Id } -// __RetryJobInput is used internally by genqlient -type __RetryJobInput struct { - Id string `json:"id"` -} - -// GetId returns __RetryJobInput.Id, and is useful for accessing the field via an interface. -func (v *__RetryJobInput) GetId() string { return v.Id } - // __UnblockJobInput is used internally by genqlient type __UnblockJobInput struct { Id string `json:"id"` @@ -4247,44 +4188,6 @@ func PipelineCreateWebhook( return data_, err_ } -// The mutation executed by RetryJob. -const RetryJob_Operation = ` -mutation RetryJob ($id: ID!) { - jobTypeCommandRetry(input: {id:$id}) { - jobTypeCommand { - id - state - url - } - } -} -` - -func RetryJob( - ctx_ context.Context, - client_ graphql.Client, - id string, -) (data_ *RetryJobResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "RetryJob", - Query: RetryJob_Operation, - Variables: &__RetryJobInput{ - Id: id, - }, - } - - data_ = &RetryJobResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by UnblockJob. const UnblockJob_Operation = ` mutation UnblockJob ($id: ID!, $fields: JSON) { From e6ba94cc1435332937cba242af45bc473d470203 Mon Sep 17 00:00:00 2001 From: Jeremy Bumsted Date: Fri, 5 Jun 2026 16:03:54 -0600 Subject: [PATCH 2/2] fix: Retry jobs via the organization job route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use PUT /v2/organizations/:org/jobs/:job_id/retry (added in buildkite/buildkite#29824, adopted for other job commands in #833) instead of the build-scoped retry endpoint. This keeps `bk job retry ` working with only a job UUID and a selected organization — no pipeline or build context, no -p/-b flags, and no behavior change from the GraphQL version it replaces. --- cmd/job/rest.go | 14 ++++++++++++ cmd/job/retry.go | 52 +++++++++---------------------------------- cmd/job/retry_test.go | 52 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 42 deletions(-) create mode 100644 cmd/job/retry_test.go diff --git a/cmd/job/rest.go b/cmd/job/rest.go index 842e928e..383d84d7 100644 --- a/cmd/job/rest.go +++ b/cmd/job/rest.go @@ -52,6 +52,20 @@ func reprioritizeJob(ctx context.Context, client *buildkite.Client, organization return job, nil } +func retryJob(ctx context.Context, client *buildkite.Client, organization, jobID string) (buildkite.Job, error) { + req, err := client.NewRequest(ctx, "PUT", organizationJobPath(organization, jobID, "retry"), nil) + if err != nil { + return buildkite.Job{}, err + } + + var job buildkite.Job + if _, err := client.Do(req, &job); err != nil { + return buildkite.Job{}, err + } + + return job, nil +} + func unblockJob(ctx context.Context, client *buildkite.Client, organization, jobID string, fields map[string]any) (buildkite.Job, error) { req, err := client.NewRequest(ctx, "PUT", organizationJobPath(organization, jobID, "unblock"), &unblockJobOptions{ Fields: fields, diff --git a/cmd/job/retry.go b/cmd/job/retry.go index cd36ca4d..be985c84 100644 --- a/cmd/job/retry.go +++ b/cmd/job/retry.go @@ -5,31 +5,23 @@ import ( "fmt" "github.com/alecthomas/kong" - buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" - "github.com/buildkite/cli/v3/internal/build/resolver/options" "github.com/buildkite/cli/v3/internal/cli" bkIO "github.com/buildkite/cli/v3/internal/io" - pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/cli/v3/pkg/cmd/validation" buildkite "github.com/buildkite/go-buildkite/v4" ) type RetryCmd struct { - JobID string `arg:"" help:"Job UUID to retry"` - Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}" short:"p"` - BuildNumber string `help:"The build number" short:"b"` + JobID string `arg:"" help:"Job UUID to retry"` } func (c *RetryCmd) Help() string { return `Use this command to retry build jobs. Examples: - # Retry a job (requires --pipeline and --build) - $ bk job retry 0190046e-e199-453b-a302-a21a4d649d31 -p my-pipeline -b 123 - - # If inside a git repository with a configured pipeline - $ bk job retry 0190046e-e199-453b-a302-a21a4d649d31 -b 123 + # Retry a job by UUID + $ bk job retry 0190046e-e199-453b-a302-a21a4d649d31 ` } @@ -43,47 +35,23 @@ func (c *RetryCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f.NoInput = globals.DisableInput() f.Quiet = globals.IsQuiet() + organization, err := configuredOrganization(f.Config.OrganizationSlug()) + if err != nil { + return err + } if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { return err } ctx := context.Background() - pipelineRes := pipelineResolver.NewAggregateResolver( - pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), - pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), - pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOneWithFactory(f))), - ) - - optionsResolver := options.AggregateResolver{ - options.ResolveBranchFromRepository(f.GitRepository), - } - - args := []string{} - if c.BuildNumber != "" { - args = []string{c.BuildNumber} - } - buildRes := buildResolver.NewAggregateResolver( - buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), - buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), - ) - - bld, err := buildRes.Resolve(ctx) - if err != nil { - return err - } - if bld == nil { - return fmt.Errorf("no build found") - } - var job buildkite.Job if err = bkIO.SpinWhile(f, "Retrying job", func() error { var apiErr error - job, _, apiErr = f.RestAPIClient.Jobs.RetryJob( + job, apiErr = retryJob( ctx, - bld.Organization, - bld.Pipeline, - fmt.Sprint(bld.BuildNumber), + f.RestAPIClient, + organization, c.JobID, ) return apiErr diff --git a/cmd/job/retry_test.go b/cmd/job/retry_test.go new file mode 100644 index 00000000..953e3822 --- /dev/null +++ b/cmd/job/retry_test.go @@ -0,0 +1,52 @@ +package job + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + buildkite "github.com/buildkite/go-buildkite/v4" +) + +func TestRetryJobUsesOrganizationEndpoint(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Fatalf("method = %s, want PUT", r.Method) + } + if r.URL.Path != "/v2/organizations/buildkite/jobs/job-1/retry" { + t.Fatalf("path = %s", r.URL.Path) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if len(body) != 0 { + t.Fatalf("body = %q, want empty", body) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"job-2","state":"scheduled","retried_in_job_id":"job-2","web_url":"https://buildkite.com/buildkite/cli/builds/42#job-2"}`)) + })) + defer server.Close() + + client, err := buildkite.NewOpts( + buildkite.WithBaseURL(server.URL), + buildkite.WithTokenAuth("test-token"), + ) + if err != nil { + t.Fatalf("new client: %v", err) + } + + job, err := retryJob(context.Background(), client, "buildkite", "job-1") + if err != nil { + t.Fatalf("retryJob() error = %v", err) + } + if job.ID != "job-2" { + t.Fatalf("job = %#v", job) + } +}