From b1df470ad14a47d8eeb8048e731159752c6f2d57 Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:08:03 +1000 Subject: [PATCH 01/11] Update reference to go-buildkite v5.10.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4aa89cbe..a35e1de1 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/alecthomas/kong v1.16.0 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be - github.com/buildkite/go-buildkite/v5 v5.9.0 + github.com/buildkite/go-buildkite/v5 v5.10.0 github.com/buildkite/termoji v0.0.0-20260330080310-c0aa4ebee0d1 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 diff --git a/go.sum b/go.sum index f92142f5..ee3c5e1f 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,8 @@ github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwN github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradleyjkemp/cupaloy/v2 v2.6.0 h1:knToPYa2xtfg42U3I6punFEjaGFKWQRXJwj0JTv4mTs= github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= -github.com/buildkite/go-buildkite/v5 v5.9.0 h1:R3T4zZPWdV0uZXPY62eUDKWKCxB8aP57flbOnVT0P9g= -github.com/buildkite/go-buildkite/v5 v5.9.0/go.mod h1:a5uCFNQjMFxT7g4H4NDId+DRkfYBo+CqvryoDZRppPk= +github.com/buildkite/go-buildkite/v5 v5.10.0 h1:XYSUuFJhVJZrZJNEc+Rvd7jJxyJMy21iMuCcvz5jFMM= +github.com/buildkite/go-buildkite/v5 v5.10.0/go.mod h1:a5uCFNQjMFxT7g4H4NDId+DRkfYBo+CqvryoDZRppPk= github.com/buildkite/roko v1.4.0 h1:DxixoCdpNqxu4/1lXrXbfsKbJSd7r1qoxtef/TT2J80= github.com/buildkite/roko v1.4.0/go.mod h1:0vbODqUFEcVf4v2xVXRfZZRsqJVsCCHTG/TBRByGK4E= github.com/buildkite/termoji v0.0.0-20260330080310-c0aa4ebee0d1 h1:aaEl0QZURcwC+KOfFTzSp66xknw5eTmFZ1NgB87s2xk= From 7e45802f4d10ec93e0c11a1bcb157f583faeb4c7 Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:24:59 +1000 Subject: [PATCH 02/11] Support artifacts list / download by path or state filter --- cmd/artifacts/download.go | 15 ++++++++++++--- cmd/artifacts/list.go | 8 +++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/cmd/artifacts/download.go b/cmd/artifacts/download.go index 3bdca5d9..3440c4d6 100644 --- a/cmd/artifacts/download.go +++ b/cmd/artifacts/download.go @@ -24,6 +24,8 @@ type DownloadCmd struct { BuildNumber string `help:"Build number containing the artifact. If omitted, the most recent build on the current branch will be used." short:"b" name:"build"` Pipeline string `help:"The pipeline containing the artifact. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"The job UUID containing the artifact." short:"j" name:"job-uuid"` + Path string `help:"Filter artifacts to download by path."` + State string `help:"Filter artifacts to download by state (e.g. new, finished, error, deleted, expired)."` } func (c *DownloadCmd) Help() string { @@ -48,6 +50,10 @@ Examples: # Specify the pipeline explicitly $ bk artifacts download --build 429 -p monolith + + # Filter artifacts to download by path or state + $ bk artifacts download --build 429 --path "coverage/**" + $ bk artifacts download --build 429 --state finished ` } @@ -126,7 +132,7 @@ func (c *DownloadCmd) downloadAll(ctx context.Context, f *factory.Factory, org, if err := bkIO.SpinWhile(f, "Loading artifacts", func() error { var err error - artifacts, err = listArtifacts(ctx, f, org, pipeline, build, c.JobUUID) + artifacts, err = listArtifacts(ctx, f, org, pipeline, build, c.JobUUID, c.Path, c.State) return err }); err != nil { return err @@ -165,7 +171,7 @@ func findArtifact(ctx context.Context, f *factory.Factory, org, pipeline, build, return &artifact, nil } - artifacts, err := listArtifacts(ctx, f, org, pipeline, build, "") + artifacts, err := listArtifacts(ctx, f, org, pipeline, build, "", "", "") if err != nil { return nil, err } @@ -180,9 +186,12 @@ func findArtifact(ctx context.Context, f *factory.Factory, org, pipeline, build, } // listArtifacts fetches all artifacts for a build or job, paginating through all results. -func listArtifacts(ctx context.Context, f *factory.Factory, org, pipeline, build, jobUUID string) ([]buildkite.Artifact, error) { +// path and state are optional filters passed through to the Buildkite API. +func listArtifacts(ctx context.Context, f *factory.Factory, org, pipeline, build, jobUUID, path, state string) ([]buildkite.Artifact, error) { var all []buildkite.Artifact opts := &buildkite.ArtifactListOptions{ + Path: path, + State: state, ListOptions: buildkite.ListOptions{PerPage: 100}, } diff --git a/cmd/artifacts/list.go b/cmd/artifacts/list.go index 8f4c76f1..c67985a6 100644 --- a/cmd/artifacts/list.go +++ b/cmd/artifacts/list.go @@ -23,6 +23,8 @@ type ListCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to list artifacts for"` Pipeline string `help:"The pipeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"List artifacts for a specific job on the given build." short:"j" name:"job-uuid"` + Path string `help:"Filter artifacts by path."` + State string `help:"Filter artifacts by state (e.g. new, finished, error, deleted, expired)."` output.OutputFlags } @@ -44,6 +46,10 @@ Examples: # If not inside a repository or to use a specific pipeline, pass -p $ bk artifacts list 429 -p monolith + + # Filter artifacts by path or state + $ bk artifacts list 429 --path "coverage/**" + $ bk artifacts list 429 --state finished ` } @@ -99,7 +105,7 @@ func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { var buildArtifacts []buildkite.Artifact if err = bkIO.SpinWhile(f, "Loading artifacts information", func() error { - buildArtifacts, err = listArtifacts(ctx, f, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.JobUUID) + buildArtifacts, err = listArtifacts(ctx, f, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.JobUUID, c.Path, c.State) return err }); err != nil { return err From 58f5d8a8cd5cff4b8c792d71d1c6c2e8f695a699 Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:50:53 +1000 Subject: [PATCH 03/11] Update help text for artifacts path filter to include wildcard supports and make sure that if ArtifactID is specified, path and state filter can't be specified --- cmd/artifacts/download.go | 5 ++++- cmd/artifacts/list.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/artifacts/download.go b/cmd/artifacts/download.go index 3440c4d6..846836ff 100644 --- a/cmd/artifacts/download.go +++ b/cmd/artifacts/download.go @@ -24,7 +24,7 @@ type DownloadCmd struct { BuildNumber string `help:"Build number containing the artifact. If omitted, the most recent build on the current branch will be used." short:"b" name:"build"` Pipeline string `help:"The pipeline containing the artifact. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"The job UUID containing the artifact." short:"j" name:"job-uuid"` - Path string `help:"Filter artifacts to download by path."` + Path string `help:"Filter artifacts by path. Supports exact matches and glob patterns using * as a wildcard."` State string `help:"Filter artifacts to download by state (e.g. new, finished, error, deleted, expired)."` } @@ -102,6 +102,9 @@ func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error build := strconv.Itoa(bld.BuildNumber) if c.ArtifactID != "" { + if c.Path != "" || c.State != "" { + return bkErrors.NewValidationError(nil, "--path and --state cannot be combined with an artifact ID") + } return c.downloadOne(ctx, f, bld.Organization, bld.Pipeline, build) } diff --git a/cmd/artifacts/list.go b/cmd/artifacts/list.go index c67985a6..7d0a580d 100644 --- a/cmd/artifacts/list.go +++ b/cmd/artifacts/list.go @@ -23,7 +23,7 @@ type ListCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to list artifacts for"` Pipeline string `help:"The pipeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"List artifacts for a specific job on the given build." short:"j" name:"job-uuid"` - Path string `help:"Filter artifacts by path."` + Path string `help:"Filter artifacts by path. Supports glob patterns, e.g. --path \"coverage/**\"."` State string `help:"Filter artifacts by state (e.g. new, finished, error, deleted, expired)."` output.OutputFlags } From 8258984138772f4c9f0001039c7bedac23471c48 Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:10:43 +1000 Subject: [PATCH 04/11] Fix validation error message --- cmd/artifacts/download.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/cmd/artifacts/download.go b/cmd/artifacts/download.go index 846836ff..53361e49 100644 --- a/cmd/artifacts/download.go +++ b/cmd/artifacts/download.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "github.com/alecthomas/kong" buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" @@ -24,7 +25,7 @@ type DownloadCmd struct { BuildNumber string `help:"Build number containing the artifact. If omitted, the most recent build on the current branch will be used." short:"b" name:"build"` Pipeline string `help:"The pipeline containing the artifact. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"The job UUID containing the artifact." short:"j" name:"job-uuid"` - Path string `help:"Filter artifacts by path. Supports exact matches and glob patterns using * as a wildcard."` + Path string `help:"Filter artifacts by path. Supports glob patterns, e.g. --path \"coverage/**\"."` State string `help:"Filter artifacts to download by state (e.g. new, finished, error, deleted, expired)."` } @@ -71,6 +72,14 @@ func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error return err } + if c.ArtifactID != "" && (c.Path != "" || c.State != "") { + return bkErrors.NewValidationError( + nil, + "--path and --state cannot be used when downloading a specific artifact by ID", + "Omit the artifact ID to filter, or remove --path/--state to download by ID.", + ) + } + pipelineRes := pipelineResolver.NewAggregateResolver( pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOneWithFactory(f)), @@ -102,9 +111,6 @@ func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error build := strconv.Itoa(bld.BuildNumber) if c.ArtifactID != "" { - if c.Path != "" || c.State != "" { - return bkErrors.NewValidationError(nil, "--path and --state cannot be combined with an artifact ID") - } return c.downloadOne(ctx, f, bld.Organization, bld.Pipeline, build) } @@ -135,7 +141,7 @@ func (c *DownloadCmd) downloadAll(ctx context.Context, f *factory.Factory, org, if err := bkIO.SpinWhile(f, "Loading artifacts", func() error { var err error - artifacts, err = listArtifacts(ctx, f, org, pipeline, build, c.JobUUID, c.Path, c.State) + artifacts, err = listArtifacts(ctx, f, org, pipeline, build, c.JobUUID, c.Path, strings.ToLower(c.State)) return err }); err != nil { return err From 31928420443d4ad2de811096d5a1dcba447a85e1 Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:19:40 +1000 Subject: [PATCH 05/11] Minor help text update and lowercase string validation --- cmd/artifacts/download.go | 2 +- cmd/artifacts/list.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/artifacts/download.go b/cmd/artifacts/download.go index 53361e49..884adf23 100644 --- a/cmd/artifacts/download.go +++ b/cmd/artifacts/download.go @@ -25,7 +25,7 @@ type DownloadCmd struct { BuildNumber string `help:"Build number containing the artifact. If omitted, the most recent build on the current branch will be used." short:"b" name:"build"` Pipeline string `help:"The pipeline containing the artifact. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"The job UUID containing the artifact." short:"j" name:"job-uuid"` - Path string `help:"Filter artifacts by path. Supports glob patterns, e.g. --path \"coverage/**\"."` + Path string `help:"Filter artifacts by path. Supports exact matches and glob patterns using * as a wildcard, e.g. --path \"log/rspec*.json\"."` State string `help:"Filter artifacts to download by state (e.g. new, finished, error, deleted, expired)."` } diff --git a/cmd/artifacts/list.go b/cmd/artifacts/list.go index 7d0a580d..30ffb40d 100644 --- a/cmd/artifacts/list.go +++ b/cmd/artifacts/list.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/alecthomas/kong" "github.com/buildkite/cli/v3/internal/artifact" @@ -23,7 +24,7 @@ type ListCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to list artifacts for"` Pipeline string `help:"The pipeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"List artifacts for a specific job on the given build." short:"j" name:"job-uuid"` - Path string `help:"Filter artifacts by path. Supports glob patterns, e.g. --path \"coverage/**\"."` + Path string `help:"Filter artifacts by path. Supports exact matches and glob patterns using * as a wildcard, e.g. --path \"log/rspec*.json\"."` State string `help:"Filter artifacts by state (e.g. new, finished, error, deleted, expired)."` output.OutputFlags } @@ -105,7 +106,7 @@ func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { var buildArtifacts []buildkite.Artifact if err = bkIO.SpinWhile(f, "Loading artifacts information", func() error { - buildArtifacts, err = listArtifacts(ctx, f, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.JobUUID, c.Path, c.State) + buildArtifacts, err = listArtifacts(ctx, f, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.JobUUID, c.Path, strings.ToLower(c.State)) return err }); err != nil { return err From 9df03c822961e708b451950cc5391c88a7f78fca Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:47:38 +1000 Subject: [PATCH 06/11] Improve no artifacts found messaging --- cmd/artifacts/download.go | 21 +++++++++++++++++++-- cmd/artifacts/list.go | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cmd/artifacts/download.go b/cmd/artifacts/download.go index 884adf23..36b58098 100644 --- a/cmd/artifacts/download.go +++ b/cmd/artifacts/download.go @@ -3,6 +3,7 @@ package artifacts import ( "context" "fmt" + "io" "os" "path/filepath" "strconv" @@ -21,7 +22,7 @@ import ( ) type DownloadCmd struct { - ArtifactID string `arg:"" optional:"" help:"Artifact ID to download. If omitted, all artifacts are downloaded. Use 'bk artifacts list' to find IDs."` + ArtifactID string `arg:"" optional:"" help:"Artifact ID to download. If omitted, all matching artifacts are downloaded (see --path/--state). Use 'bk artifacts list' to find IDs."` BuildNumber string `help:"Build number containing the artifact. If omitted, the most recent build on the current branch will be used." short:"b" name:"build"` Pipeline string `help:"The pipeline containing the artifact. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"The job UUID containing the artifact." short:"j" name:"job-uuid"` @@ -148,7 +149,7 @@ func (c *DownloadCmd) downloadAll(ctx context.Context, f *factory.Factory, org, } if len(artifacts) == 0 { - fmt.Println("No artifacts found.") + writeNoArtifactsMessage(os.Stdout, c.Path, strings.ToLower(c.State)) return nil } @@ -251,3 +252,19 @@ func downloadToFile(ctx context.Context, f *factory.Factory, url, destPath strin _, err = f.RestAPIClient.Artifacts.DownloadArtifactByURL(ctx, url, out) return err } + +// writeNoArtifactsMessage prints a "no artifacts" message tailored to the +// active --path / --state filters, so users see what constraint returned +// nothing. +func writeNoArtifactsMessage(w io.Writer, path, state string) { + switch { + case path != "" && state != "": + fmt.Fprintf(w, "No artifacts found matching path '%s' and state '%s'.\n", path, state) + case path != "": + fmt.Fprintf(w, "No artifacts found matching path '%s'.\n", path) + case state != "": + fmt.Fprintf(w, "No artifacts found matching state '%s'.\n", state) + default: + fmt.Fprintln(w, "No artifacts found.") + } +} diff --git a/cmd/artifacts/list.go b/cmd/artifacts/list.go index 30ffb40d..51eef195 100644 --- a/cmd/artifacts/list.go +++ b/cmd/artifacts/list.go @@ -120,7 +120,7 @@ func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { defer func() { _ = cleanup() }() if len(buildArtifacts) == 0 { - fmt.Fprintln(writer, "No artifacts found.") + writeNoArtifactsMessage(writer, c.Path, strings.ToLower(c.State)) return nil } From 7a8d9ab244b22bfb62cc76075de5762182bdaf4f Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:49:41 +1000 Subject: [PATCH 07/11] Update examples for --path filter --- cmd/artifacts/download.go | 2 +- cmd/artifacts/list.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/artifacts/download.go b/cmd/artifacts/download.go index 36b58098..c7e70d86 100644 --- a/cmd/artifacts/download.go +++ b/cmd/artifacts/download.go @@ -54,7 +54,7 @@ Examples: $ bk artifacts download --build 429 -p monolith # Filter artifacts to download by path or state - $ bk artifacts download --build 429 --path "coverage/**" + $ bk artifacts download --build 429 --path "log/rspec*.json" $ bk artifacts download --build 429 --state finished ` } diff --git a/cmd/artifacts/list.go b/cmd/artifacts/list.go index 51eef195..eb1f6b80 100644 --- a/cmd/artifacts/list.go +++ b/cmd/artifacts/list.go @@ -49,7 +49,7 @@ Examples: $ bk artifacts list 429 -p monolith # Filter artifacts by path or state - $ bk artifacts list 429 --path "coverage/**" + $ bk artifacts list 429 --path "log/rspec*.json" $ bk artifacts list 429 --state finished ` } From 5a15f306248a5cfb301e32befa4acda99260385b Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:55:57 +1000 Subject: [PATCH 08/11] Add unit tests for artifact list/download --- cmd/artifacts/download_test.go | 370 +++++++++++++++++++++++++++++++++ cmd/artifacts/list_test.go | 159 ++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 cmd/artifacts/download_test.go create mode 100644 cmd/artifacts/list_test.go diff --git a/cmd/artifacts/download_test.go b/cmd/artifacts/download_test.go new file mode 100644 index 00000000..e4211b35 --- /dev/null +++ b/cmd/artifacts/download_test.go @@ -0,0 +1,370 @@ +package artifacts + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/alecthomas/kong" + bkErrors "github.com/buildkite/cli/v3/internal/errors" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + buildkite "github.com/buildkite/go-buildkite/v5" +) + +func newArtifactsTestFactory(t *testing.T, serverURL string) *factory.Factory { + t.Helper() + client, err := buildkite.NewOpts(buildkite.WithBaseURL(serverURL)) + if err != nil { + t.Fatalf("new buildkite client: %v", err) + } + return &factory.Factory{RestAPIClient: client, Quiet: true, NoInput: true} +} + +func writeArtifactsPage(t *testing.T, w http.ResponseWriter, artifacts []buildkite.Artifact, nextPageURL string) { + t.Helper() + if nextPageURL != "" { + w.Header().Set("Link", fmt.Sprintf(`<%s>; rel="next"`, nextPageURL)) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(artifacts); err != nil { + t.Fatalf("encode artifacts: %v", err) + } +} + +func TestWriteNoArtifactsMessage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + state string + want string + }{ + {"no filters", "", "", "No artifacts found.\n"}, + {"path only", "coverage/**", "", "No artifacts found matching path 'coverage/**'.\n"}, + {"state only", "", "finished", "No artifacts found matching state 'finished'.\n"}, + {"both", "coverage/**", "finished", "No artifacts found matching path 'coverage/**' and state 'finished'.\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeNoArtifactsMessage(&buf, tt.path, tt.state) + if got := buf.String(); got != tt.want { + t.Fatalf("writeNoArtifactsMessage() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestListArtifactsHitsBuildEndpoint(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := "/v2/organizations/acme/pipelines/monolith/builds/429/artifacts" + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "a1", Path: "coverage.xml"}}, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + got, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", "", "", "") + if err != nil { + t.Fatalf("listArtifacts() error = %v", err) + } + if len(got) != 1 || got[0].ID != "a1" { + t.Fatalf("listArtifacts() = %+v, want single artifact a1", got) + } +} + +func TestListArtifactsHitsJobEndpoint(t *testing.T) { + t.Parallel() + + const jobUUID = "0193903e-ecd9-4c51-9156-0738da987e87" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := fmt.Sprintf("/v2/organizations/acme/pipelines/monolith/builds/429/jobs/%s/artifacts", jobUUID) + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "a1"}}, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + if _, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", jobUUID, "", ""); err != nil { + t.Fatalf("listArtifacts() error = %v", err) + } +} + +func TestListArtifactsPassesFilters(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if got := q.Get("path"); got != "coverage/**" { + t.Fatalf("path = %q, want coverage/**", got) + } + if got := q.Get("state"); got != "finished" { + t.Fatalf("state = %q, want finished", got) + } + if got := q.Get("per_page"); got != "100" { + t.Fatalf("per_page = %q, want 100", got) + } + writeArtifactsPage(t, w, []buildkite.Artifact{}, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + if _, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", "", "coverage/**", "finished"); err != nil { + t.Fatalf("listArtifacts() error = %v", err) + } +} + +func TestListArtifactsPaginates(t *testing.T) { + t.Parallel() + + var server *httptest.Server + var calls int + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + page := r.URL.Query().Get("page") + switch page { + case "", "1": + next := server.URL + r.URL.Path + "?page=2&per_page=100" + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "a1"}, {ID: "a2"}}, next) + case "2": + next := server.URL + r.URL.Path + "?page=3&per_page=100" + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "a3"}}, next) + case "3": + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "a4"}}, "") + default: + t.Fatalf("unexpected page = %q", page) + } + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + got, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", "", "", "") + if err != nil { + t.Fatalf("listArtifacts() error = %v", err) + } + + wantIDs := []string{"a1", "a2", "a3", "a4"} + if len(got) != len(wantIDs) { + t.Fatalf("got %d artifacts, want %d", len(got), len(wantIDs)) + } + for i, id := range wantIDs { + if got[i].ID != id { + t.Fatalf("artifact %d ID = %q, want %q", i, got[i].ID, id) + } + } + if calls != 3 { + t.Fatalf("calls = %d, want 3", calls) + } +} + +func TestListArtifactsPropagatesError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"boom"}`, http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + if _, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", "", "", ""); err == nil { + t.Fatal("listArtifacts() expected error, got nil") + } +} + +func TestFindArtifactWithJobUUIDUsesGetEndpoint(t *testing.T) { + t.Parallel() + + const ( + jobUUID = "0193903e-ecd9-4c51-9156-0738da987e87" + artID = "0191727d-b5ce-4576-b37d-477ae0ca830c" + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := fmt.Sprintf("/v2/organizations/acme/pipelines/monolith/builds/429/jobs/%s/artifacts/%s", jobUUID, artID) + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(buildkite.Artifact{ID: artID, Path: "coverage.xml"}) + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + got, err := findArtifact(context.Background(), f, "acme", "monolith", "429", artID, jobUUID) + if err != nil { + t.Fatalf("findArtifact() error = %v", err) + } + if got == nil || got.ID != artID { + t.Fatalf("findArtifact() = %+v, want ID %q", got, artID) + } +} + +func TestFindArtifactWithoutJobUUIDScansList(t *testing.T) { + t.Parallel() + + const artID = "wanted" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := "/v2/organizations/acme/pipelines/monolith/builds/429/artifacts" + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + writeArtifactsPage(t, w, []buildkite.Artifact{ + {ID: "other"}, + {ID: artID, Path: "the-one.txt"}, + }, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + got, err := findArtifact(context.Background(), f, "acme", "monolith", "429", artID, "") + if err != nil { + t.Fatalf("findArtifact() error = %v", err) + } + if got == nil || got.Path != "the-one.txt" { + t.Fatalf("findArtifact() = %+v, want the-one.txt", got) + } +} + +func TestFindArtifactNotFoundReturnsResourceError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeArtifactsPage(t, w, []buildkite.Artifact{{ID: "other"}}, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + _, err := findArtifact(context.Background(), f, "acme", "monolith", "429", "missing", "") + if err == nil { + t.Fatal("findArtifact() expected error, got nil") + } + if !errors.Is(err, bkErrors.ErrResourceNotFound) { + t.Fatalf("findArtifact() error = %v, want ErrResourceNotFound", err) + } + if !strings.Contains(err.Error(), "missing") { + t.Fatalf("findArtifact() error = %v, want to mention artifact ID", err) + } +} + +func TestDownloadToFileCreatesParentDirAndWritesBody(t *testing.T) { + t.Parallel() + + const body = "artifact-bytes" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + destPath := filepath.Join(t.TempDir(), "nested", "dir", "file.bin") + + if err := downloadToFile(context.Background(), f, server.URL, destPath); err != nil { + t.Fatalf("downloadToFile() error = %v", err) + } + + got, err := os.ReadFile(destPath) + if err != nil { + t.Fatalf("read written file: %v", err) + } + if string(got) != body { + t.Fatalf("file contents = %q, want %q", got, body) + } +} + +func TestDownloadArtifactUsesArtifactPathAsDest(t *testing.T) { + // No t.Parallel(): t.Chdir is incompatible with parallel tests. + const body = "hello" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + + // Run from a temp cwd so the relative destPath lands somewhere isolated. + t.Chdir(t.TempDir()) + + f := newArtifactsTestFactory(t, server.URL) + art := &buildkite.Artifact{Path: "logs/rspec.json", DownloadURL: server.URL} + + dest, err := downloadArtifact(context.Background(), f, art) + if err != nil { + t.Fatalf("downloadArtifact() error = %v", err) + } + if dest != filepath.FromSlash("logs/rspec.json") { + t.Fatalf("dest = %q, want logs/rspec.json (OS-adjusted)", dest) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read written file: %v", err) + } + if string(got) != body { + t.Fatalf("file contents = %q, want %q", got, body) + } +} + +func TestDownloadCmdFlagParsing(t *testing.T) { + t.Parallel() + + var cmd DownloadCmd + parser, err := kong.New(&cmd) + if err != nil { + t.Fatalf("kong.New() error = %v", err) + } + if _, err := parser.Parse([]string{ + "art-123", + "--build", "429", + "-p", "monolith", + "--job-uuid", "job-uuid-1", + "--path", "coverage/**", + "--state", "Finished", + }); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if cmd.ArtifactID != "art-123" { + t.Errorf("ArtifactID = %q, want art-123", cmd.ArtifactID) + } + if cmd.BuildNumber != "429" { + t.Errorf("BuildNumber = %q, want 429", cmd.BuildNumber) + } + if cmd.Pipeline != "monolith" { + t.Errorf("Pipeline = %q, want monolith", cmd.Pipeline) + } + if cmd.JobUUID != "job-uuid-1" { + t.Errorf("JobUUID = %q, want job-uuid-1", cmd.JobUUID) + } + if cmd.Path != "coverage/**" { + t.Errorf("Path = %q, want coverage/**", cmd.Path) + } + if cmd.State != "Finished" { + t.Errorf("State = %q, want Finished (parser preserves casing)", cmd.State) + } +} + +func TestDownloadCmdHelpMentionsFilters(t *testing.T) { + t.Parallel() + + var cmd DownloadCmd + help := cmd.Help() + for _, want := range []string{"--path", "--state", "log/rspec*.json", "bk artifacts list"} { + if !strings.Contains(help, want) { + t.Errorf("Help() missing %q", want) + } + } +} diff --git a/cmd/artifacts/list_test.go b/cmd/artifacts/list_test.go new file mode 100644 index 00000000..7f621e9d --- /dev/null +++ b/cmd/artifacts/list_test.go @@ -0,0 +1,159 @@ +package artifacts + +import ( + "bytes" + "strings" + "testing" + + "github.com/alecthomas/kong" + buildkite "github.com/buildkite/go-buildkite/v5" +) + +func TestDisplayArtifactsRendersJobIDAsBuildkiteURL(t *testing.T) { + // Wide enough that the URL column doesn't get truncated. + t.Setenv("BUILDKITE_TABLE_MAX_WIDTH", "300") + + artifacts := []buildkite.Artifact{ + {ID: "art-1", Path: "logs/rspec.json", FileSize: 1024, JobID: "job-1"}, + } + const baseURL = "https://buildkite.com/organizations/acme/pipelines/monolith/builds/429" + + var buf bytes.Buffer + if err := displayArtifacts(artifacts, &buf, baseURL); err != nil { + t.Fatalf("displayArtifacts() error = %v", err) + } + + out := buf.String() + for _, want := range []string{ + "ID", "PATH", "SIZE", "URL", + "art-1", "logs/rspec.json", "1.0KB", + baseURL + "/jobs/job-1/artifacts/art-1", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\n---\n%s", want, out) + } + } +} + +func TestDisplayArtifactsFallsBackToArtifactURL(t *testing.T) { + t.Parallel() + + artifacts := []buildkite.Artifact{ + {ID: "art-2", Path: "output.zip", URL: "https://api.example.com/artifacts/art-2"}, + } + + var buf bytes.Buffer + if err := displayArtifacts(artifacts, &buf, "https://buildkite.com/x"); err != nil { + t.Fatalf("displayArtifacts() error = %v", err) + } + if !strings.Contains(buf.String(), "https://api.example.com/artifacts/art-2") { + t.Errorf("expected artifact URL to be used when JobID is empty:\n%s", buf.String()) + } +} + +func TestDisplayArtifactsRendersDashWhenNoURL(t *testing.T) { + t.Parallel() + + artifacts := []buildkite.Artifact{{ID: "art-3", Path: "orphan.txt"}} + + var buf bytes.Buffer + if err := displayArtifacts(artifacts, &buf, "https://buildkite.com/x"); err != nil { + t.Fatalf("displayArtifacts() error = %v", err) + } + // Table columns render "-" for artifacts with no JobID and no URL. + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + var dataLine string + for _, l := range lines { + if strings.Contains(l, "orphan.txt") { + dataLine = l + break + } + } + if dataLine == "" { + t.Fatalf("no data line for orphan.txt in:\n%s", buf.String()) + } + if !strings.Contains(dataLine, "-") { + t.Errorf("expected '-' placeholder for empty URL in row: %q", dataLine) + } +} + +func TestDisplayArtifactsEmpty(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := displayArtifacts(nil, &buf, "https://buildkite.com/x"); err != nil { + t.Fatalf("displayArtifacts() error = %v", err) + } + // Headers should still render even when there are no rows. + for _, want := range []string{"ID", "PATH", "SIZE", "URL"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("output missing header %q\n%s", want, buf.String()) + } + } +} + +func TestListCmdFlagParsing(t *testing.T) { + t.Parallel() + + var cmd ListCmd + parser, err := kong.New(&cmd, kong.Vars{"output_default_format": ""}) + if err != nil { + t.Fatalf("kong.New() error = %v", err) + } + if _, err := parser.Parse([]string{ + "429", + "-p", "monolith", + "--job-uuid", "job-uuid-1", + "--path", "log/rspec*.json", + "--state", "Finished", + }); err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if cmd.BuildNumber != "429" { + t.Errorf("BuildNumber = %q, want 429", cmd.BuildNumber) + } + if cmd.Pipeline != "monolith" { + t.Errorf("Pipeline = %q, want monolith", cmd.Pipeline) + } + if cmd.JobUUID != "job-uuid-1" { + t.Errorf("JobUUID = %q, want job-uuid-1", cmd.JobUUID) + } + if cmd.Path != "log/rspec*.json" { + t.Errorf("Path = %q, want log/rspec*.json", cmd.Path) + } + if cmd.State != "Finished" { + t.Errorf("State = %q, want Finished (parser preserves casing)", cmd.State) + } +} + +func TestListCmdBuildNumberOptional(t *testing.T) { + t.Parallel() + + var cmd ListCmd + parser, err := kong.New(&cmd, kong.Vars{"output_default_format": ""}) + if err != nil { + t.Fatalf("kong.New() error = %v", err) + } + if _, err := parser.Parse([]string{"--state", "finished"}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + if cmd.BuildNumber != "" { + t.Errorf("BuildNumber = %q, want empty", cmd.BuildNumber) + } + if cmd.State != "finished" { + t.Errorf("State = %q, want finished", cmd.State) + } +} + +func TestListCmdHelpMentionsFilters(t *testing.T) { + t.Parallel() + + var cmd ListCmd + help := cmd.Help() + for _, want := range []string{"--path", "--state", "log/rspec*.json", "bk artifacts list"} { + if !strings.Contains(help, want) { + t.Errorf("Help() missing %q", want) + } + } +} From 039177e8f507ec9fed0c4b45208843cae9bfa742 Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:16:44 +1000 Subject: [PATCH 09/11] Minor improvements --- cmd/artifacts/download.go | 5 ++++- cmd/artifacts/list.go | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cmd/artifacts/download.go b/cmd/artifacts/download.go index c7e70d86..8fd19fd8 100644 --- a/cmd/artifacts/download.go +++ b/cmd/artifacts/download.go @@ -149,7 +149,7 @@ func (c *DownloadCmd) downloadAll(ctx context.Context, f *factory.Factory, org, } if len(artifacts) == 0 { - writeNoArtifactsMessage(os.Stdout, c.Path, strings.ToLower(c.State)) + writeNoArtifactsMessage(os.Stdout, c.Path, c.State) return nil } @@ -210,6 +210,9 @@ func listArtifacts(ctx context.Context, f *factory.Factory, org, pipeline, build var resp *buildkite.Response var err error + // ListByJob and ListByBuild both take *ArtifactListOptions, which carries + // Path and State — so the same filters flow into either endpoint when + // --job-uuid is combined with --path / --state. if jobUUID != "" { artifacts, resp, err = f.RestAPIClient.Artifacts.ListByJob(ctx, org, pipeline, build, jobUUID, opts) } else { diff --git a/cmd/artifacts/list.go b/cmd/artifacts/list.go index eb1f6b80..3a8ab307 100644 --- a/cmd/artifacts/list.go +++ b/cmd/artifacts/list.go @@ -22,7 +22,7 @@ import ( type ListCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to list artifacts for"` - Pipeline string `help:"The pipeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` + Pipeline string `help:"The pipgeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"List artifacts for a specific job on the given build." short:"j" name:"job-uuid"` Path string `help:"Filter artifacts by path. Supports exact matches and glob patterns using * as a wildcard, e.g. --path \"log/rspec*.json\"."` State string `help:"Filter artifacts by state (e.g. new, finished, error, deleted, expired)."` @@ -120,7 +120,7 @@ func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { defer func() { _ = cleanup() }() if len(buildArtifacts) == 0 { - writeNoArtifactsMessage(writer, c.Path, strings.ToLower(c.State)) + writeNoArtifactsMessage(writer, c.Path, c.State) return nil } From 25d01e5bc31ced801bb134bc49dd65e542028a88 Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:23:34 +1000 Subject: [PATCH 10/11] Fix type and add code comments to the validation implemented --- cmd/artifacts/download.go | 4 ++++ cmd/artifacts/list.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/artifacts/download.go b/cmd/artifacts/download.go index 8fd19fd8..9b0a8e5d 100644 --- a/cmd/artifacts/download.go +++ b/cmd/artifacts/download.go @@ -73,6 +73,10 @@ func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error return err } + // --job-uuid is deliberately allowed alongside an ArtifactID: findArtifact + // uses it as the fast path (Artifacts.Get) instead of listing and scanning. + // --path / --state have no meaning when targeting a single ID, so reject + // those combinations up front. if c.ArtifactID != "" && (c.Path != "" || c.State != "") { return bkErrors.NewValidationError( nil, diff --git a/cmd/artifacts/list.go b/cmd/artifacts/list.go index 3a8ab307..c477b45f 100644 --- a/cmd/artifacts/list.go +++ b/cmd/artifacts/list.go @@ -22,7 +22,7 @@ import ( type ListCmd struct { BuildNumber string `arg:"" optional:"" help:"Build number to list artifacts for"` - Pipeline string `help:"The pipgeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` + Pipeline string `help:"The pipeline to view. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}. If omitted, it will be resolved using the current directory." short:"p"` JobUUID string `help:"List artifacts for a specific job on the given build." short:"j" name:"job-uuid"` Path string `help:"Filter artifacts by path. Supports exact matches and glob patterns using * as a wildcard, e.g. --path \"log/rspec*.json\"."` State string `help:"Filter artifacts by state (e.g. new, finished, error, deleted, expired)."` From d8ca06552b1c9f3b682901770c721725e8b85de9 Mon Sep 17 00:00:00 2001 From: lizrabuya <115472349+lizrabuya@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:38:53 +1000 Subject: [PATCH 11/11] Add more validation tests --- cmd/artifacts/download.go | 28 +++++++++----- cmd/artifacts/download_test.go | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/cmd/artifacts/download.go b/cmd/artifacts/download.go index 9b0a8e5d..98270f8c 100644 --- a/cmd/artifacts/download.go +++ b/cmd/artifacts/download.go @@ -59,6 +59,22 @@ Examples: ` } +// validate checks flag combinations that can be rejected without any API +// calls. --job-uuid is deliberately allowed alongside an ArtifactID: +// findArtifact uses it as the fast path (Artifacts.Get) instead of listing +// and scanning. --path / --state have no meaning when targeting a single ID, +// so reject those combinations up front. +func (c *DownloadCmd) validate() error { + if c.ArtifactID != "" && (c.Path != "" || c.State != "") { + return bkErrors.NewValidationError( + nil, + "--path and --state cannot be used when downloading a specific artifact by ID", + "Omit the artifact ID to filter, or remove --path/--state to download by ID.", + ) + } + return nil +} + func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { f, err := factory.New(factory.WithDebug(globals.EnableDebug())) if err != nil { @@ -73,16 +89,8 @@ func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error return err } - // --job-uuid is deliberately allowed alongside an ArtifactID: findArtifact - // uses it as the fast path (Artifacts.Get) instead of listing and scanning. - // --path / --state have no meaning when targeting a single ID, so reject - // those combinations up front. - if c.ArtifactID != "" && (c.Path != "" || c.State != "") { - return bkErrors.NewValidationError( - nil, - "--path and --state cannot be used when downloading a specific artifact by ID", - "Omit the artifact ID to filter, or remove --path/--state to download by ID.", - ) + if err := c.validate(); err != nil { + return err } pipelineRes := pipelineResolver.NewAggregateResolver( diff --git a/cmd/artifacts/download_test.go b/cmd/artifacts/download_test.go index e4211b35..c67d0658 100644 --- a/cmd/artifacts/download_test.go +++ b/cmd/artifacts/download_test.go @@ -132,6 +132,32 @@ func TestListArtifactsPassesFilters(t *testing.T) { } } +func TestListArtifactsPassesFiltersOnJobEndpoint(t *testing.T) { + t.Parallel() + + const jobUUID = "0193903e-ecd9-4c51-9156-0738da987e87" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := fmt.Sprintf("/v2/organizations/acme/pipelines/monolith/builds/429/jobs/%s/artifacts", jobUUID) + if r.URL.Path != wantPath { + t.Fatalf("path = %q, want %q", r.URL.Path, wantPath) + } + q := r.URL.Query() + if got := q.Get("path"); got != "log/rspec*.json" { + t.Fatalf("path = %q, want log/rspec*.json", got) + } + if got := q.Get("state"); got != "finished" { + t.Fatalf("state = %q, want finished", got) + } + writeArtifactsPage(t, w, []buildkite.Artifact{}, "") + })) + t.Cleanup(server.Close) + + f := newArtifactsTestFactory(t, server.URL) + if _, err := listArtifacts(context.Background(), f, "acme", "monolith", "429", jobUUID, "log/rspec*.json", "finished"); err != nil { + t.Fatalf("listArtifacts() error = %v", err) + } +} + func TestListArtifactsPaginates(t *testing.T) { t.Parallel() @@ -318,6 +344,47 @@ func TestDownloadArtifactUsesArtifactPathAsDest(t *testing.T) { } } +func TestDownloadCmdValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmd DownloadCmd + wantErr bool + }{ + {"no artifact ID, no filters", DownloadCmd{}, false}, + {"filters without artifact ID", DownloadCmd{Path: "coverage/**", State: "finished"}, false}, + {"artifact ID alone", DownloadCmd{ArtifactID: "art-1"}, false}, + {"artifact ID with job UUID (fast path)", DownloadCmd{ArtifactID: "art-1", JobUUID: "job-1"}, false}, + {"artifact ID with path rejected", DownloadCmd{ArtifactID: "art-1", Path: "coverage/**"}, true}, + {"artifact ID with state rejected", DownloadCmd{ArtifactID: "art-1", State: "finished"}, true}, + {"artifact ID with both rejected", DownloadCmd{ArtifactID: "art-1", Path: "coverage/**", State: "finished"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.cmd.validate() + if tt.wantErr { + if err == nil { + t.Fatal("validate() = nil, want error") + } + if !errors.Is(err, bkErrors.ErrValidation) { + t.Fatalf("validate() error = %v, want ErrValidation", err) + } + if !strings.Contains(err.Error(), "--path and --state") { + t.Errorf("validate() error = %v, want to mention --path and --state", err) + } + return + } + if err != nil { + t.Fatalf("validate() = %v, want nil", err) + } + }) + } +} + func TestDownloadCmdFlagParsing(t *testing.T) { t.Parallel()