diff --git a/acceptance/pipeline_test.go b/acceptance/pipeline_test.go index b8f3e094..c8fadb97 100644 --- a/acceptance/pipeline_test.go +++ b/acceptance/pipeline_test.go @@ -714,3 +714,176 @@ func TestPipelineCancel_NoToken(t *testing.T) { assert.Equal(t, result.ExitCode, 3, "stderr: %s", result.Stderr) } + +// --- pipeline search tests --- + +const searchProjectID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + +func fakeProjectInfo(slug, id string) map[string]any { + return map[string]any{ + "id": id, + "slug": slug, + "name": "testrepo", + } +} + +func fakeSearchPipeline(id string, number int, status, branch string) map[string]any { + now := time.Date(2020, 1, 1, 12, 0, 0, 0, time.UTC) + return map[string]any{ + "id": id, + "number": number, + "state": "created", + "status": status, + "created_at": now.Format(time.RFC3339), + "updated_at": now.Format(time.RFC3339), + "trigger": map[string]any{ + "type": "webhook", + "received_at": now.Format(time.RFC3339), + "actor": map[string]any{"id": "actor-uuid-1"}, + }, + "vcs": map[string]any{ + "branch": branch, + "revision": "abc1234def5678", + }, + "project": map[string]any{ + "id": searchProjectID, + }, + "workflows_summary": map[string]any{ + "count_by_status": map[string]any{ + "success": 2, + "failed": 1, + }, + }, + } +} + +func TestPipelineSearch(t *testing.T) { + fake := fakes.NewCircleCI(t) + fake.AddProjectInfo(watchSlug, fakeProjectInfo(watchSlug, searchProjectID)) + fake.SetSearchResponse(map[string]any{ + "items": []any{ + fakeSearchPipeline("pid-search-1", 10, "success", "main"), + fakeSearchPipeline("pid-search-2", 9, "failed", "feature"), + }, + "next_page_token": nil, + "total_size": 2, + }) + + env := testenv.New(t) + env.Token = testToken + env.CircleCIURL = fake.URL() + + result := binary.RunCLI(t, binary.RunOpts{ + Binary: binaryPath, + Args: []string{"pipeline", "search", "--project", watchSlug}, + Env: env.Environ(), + WorkDir: t.TempDir(), + }) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Check(t, golden.String(result.Stdout, t.Name()+".txt")) +} + +func TestPipelineSearch_JSON(t *testing.T) { + fake := fakes.NewCircleCI(t) + fake.AddProjectInfo(watchSlug, fakeProjectInfo(watchSlug, searchProjectID)) + fake.SetSearchResponse(map[string]any{ + "items": []any{ + fakeSearchPipeline("pid-search-1", 10, "success", "main"), + }, + "next_page_token": nil, + "total_size": 1, + }) + + env := testenv.New(t) + env.Token = testToken + env.CircleCIURL = fake.URL() + + result := binary.RunCLI(t, binary.RunOpts{ + Binary: binaryPath, + Args: []string{"pipeline", "search", "--project", watchSlug, "--json"}, + Env: env.Environ(), + WorkDir: t.TempDir(), + }) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Check(t, golden.String(result.Stdout, t.Name()+".json")) +} + +func TestPipelineSearch_WithFilter(t *testing.T) { + fake := fakes.NewCircleCI(t) + fake.AddProjectInfo(watchSlug, fakeProjectInfo(watchSlug, searchProjectID)) + fake.SetSearchResponse(map[string]any{ + "items": []any{fakeSearchPipeline("pid-search-1", 10, "failed", "main")}, + "next_page_token": nil, + "total_size": 1, + }) + + env := testenv.New(t) + env.Token = testToken + env.CircleCIURL = fake.URL() + + rawFilter := `pipeline.git.branch == "main" and pipeline.state == "errored"` + result := binary.RunCLI(t, binary.RunOpts{ + Binary: binaryPath, + Args: []string{"pipeline", "search", "--project", watchSlug, "--filter", rawFilter}, + Env: env.Environ(), + WorkDir: t.TempDir(), + }) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Check(t, cmp.Contains(result.Stdout, "pid-search-1")) + + req := fake.LastSearchRequest() + assert.Assert(t, req != nil, "search endpoint was not called") + assert.Equal(t, req["filter"], rawFilter) +} + +// The /pipeline/search API returns "" for timestamps on some pipelines. +// This must not crash the JSON decoder. +func TestPipelineSearch_EmptyTimestamp(t *testing.T) { + fake := fakes.NewCircleCI(t) + fake.AddProjectInfo(watchSlug, fakeProjectInfo(watchSlug, searchProjectID)) + pip := fakeSearchPipeline("pid-search-1", 10, "success", "main") + pip["created_at"] = "" + pip["updated_at"] = "" + fake.SetSearchResponse(map[string]any{ + "items": []any{pip}, + "next_page_token": nil, + "total_size": 1, + }) + + env := testenv.New(t) + env.Token = testToken + env.CircleCIURL = fake.URL() + + result := binary.RunCLI(t, binary.RunOpts{ + Binary: binaryPath, + Args: []string{"pipeline", "search", "--project", watchSlug}, + Env: env.Environ(), + WorkDir: t.TempDir(), + }) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Check(t, cmp.Contains(result.Stdout, "pid-search-1")) +} + +func TestPipelineSearch_EmptyResults(t *testing.T) { + fake := fakes.NewCircleCI(t) + fake.AddProjectInfo(watchSlug, fakeProjectInfo(watchSlug, searchProjectID)) + // No SetSearchResponse → handler returns empty items list. + + env := testenv.New(t) + env.Token = testToken + env.CircleCIURL = fake.URL() + + result := binary.RunCLI(t, binary.RunOpts{ + Binary: binaryPath, + Args: []string{"pipeline", "search", "--project", watchSlug}, + Env: env.Environ(), + WorkDir: t.TempDir(), + }) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Check(t, cmp.Contains(result.Stderr, "No pipelines found.")) +} diff --git a/acceptance/testdata/TestPipelineSearch.txt b/acceptance/testdata/TestPipelineSearch.txt new file mode 100644 index 00000000..5186ee80 --- /dev/null +++ b/acceptance/testdata/TestPipelineSearch.txt @@ -0,0 +1,5 @@ +# Pipeline Search Results +| # | Branch | Revision | Status | Workflows | Created | Pipeline | +| -- | ------- | -------- | ------- | ------------------- | -------------------- | -------------- | +| 10 | main | abc1234 | success | 1 failed, 2 success | 2020-01-01 12:00 UTC | `pid-search-1` | +| 9 | feature | abc1234 | failed | 1 failed, 2 success | 2020-01-01 12:00 UTC | `pid-search-2` | diff --git a/acceptance/testdata/TestPipelineSearch_JSON.json b/acceptance/testdata/TestPipelineSearch_JSON.json new file mode 100644 index 00000000..83b0b2b5 --- /dev/null +++ b/acceptance/testdata/TestPipelineSearch_JSON.json @@ -0,0 +1 @@ +[{"id":"pid-search-1","number":10,"state":"created","status":"success","branch":"main","revision":"abc1234def5678","workflows_summary":{"failed":1,"success":2},"created_at":"2020-01-01T12:00:00Z"}] diff --git a/internal/apiclient/pipeline.go b/internal/apiclient/pipeline.go index f46f14c1..13b05f57 100644 --- a/internal/apiclient/pipeline.go +++ b/internal/apiclient/pipeline.go @@ -187,6 +187,74 @@ func (c *Client) TriggerPipeline(ctx context.Context, projectSlug, branch string return &resp, nil } +// SearchPipeline is a pipeline item returned by the search API. It has a +// richer shape than Pipeline: a separate Status field, a WorkflowsSummary, +// and a Project struct instead of a flat project_slug. +type SearchPipeline struct { + ID string `json:"id"` + Number int64 `json:"number"` + State string `json:"state"` + Status string `json:"status"` + CreatedAt FlexTime `json:"created_at"` + UpdatedAt FlexTime `json:"updated_at"` + Trigger PipelineTrigger `json:"trigger"` + VCS *PipelineVCS `json:"vcs,omitempty"` + Errors []PipelineError `json:"errors,omitempty"` + Project *SearchProject `json:"project,omitempty"` + WorkflowsSummary *WorkflowsSummary `json:"workflows_summary,omitempty"` +} + +// SearchProject holds the project UUID returned by the search API. +type SearchProject struct { + ID string `json:"id"` +} + +// WorkflowsSummary holds aggregated workflow status counts for a pipeline. +type WorkflowsSummary struct { + CountByStatus map[string]int `json:"count_by_status"` +} + +// SearchPipelinesRequest is the request body for POST /api/v2/pipeline/search. +type SearchPipelinesRequest struct { + Scope *SearchScope `json:"scope,omitempty"` + Filter string `json:"filter,omitempty"` + OrderBy string `json:"order_by,omitempty"` + PageToken string `json:"page_token,omitempty"` +} + +// SearchScope narrows a pipeline search to specific projects and/or a date range. +type SearchScope struct { + ProjectIDs []string `json:"project_ids,omitempty"` + CreatedAfter *time.Time `json:"created_after,omitempty"` + CreatedBefore *time.Time `json:"created_before,omitempty"` +} + +type searchPipelinesResponse struct { + Items []SearchPipeline `json:"items"` + NextPageToken string `json:"next_page_token"` + TotalSize int `json:"total_size"` +} + +// SearchPipelines calls POST /api/v2/pipeline/search and paginates up to limit +// results. +func (c *Client) SearchPipelines(ctx context.Context, req SearchPipelinesRequest, limit int) ([]SearchPipeline, error) { + var all []SearchPipeline + for { + var resp searchPipelinesResponse + if err := c.post(ctx, "/pipeline/search", req, &resp); err != nil { + return nil, err + } + all = append(all, resp.Items...) + if limit > 0 && len(all) >= limit { + return all[:limit], nil + } + if resp.NextPageToken == "" { + return all, nil + } + req.PageToken = resp.NextPageToken + } +} + // PipelineWorkflowSummary holds brief workflow status for a pipeline. type PipelineWorkflowSummary struct { ID string `json:"id"` diff --git a/internal/apiclient/time.go b/internal/apiclient/time.go new file mode 100644 index 00000000..fad7ba53 --- /dev/null +++ b/internal/apiclient/time.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Circle Internet Services, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// SPDX-License-Identifier: MIT + +package apiclient + +import ( + "strings" + "time" +) + +// FlexTime is a time.Time that unmarshals an empty JSON string as the zero +// time rather than returning a parse error. The /pipeline/search endpoint +// returns "" for timestamps on some pipelines, which time.Time's UnmarshalJSON +// rejects. +type FlexTime struct { + time.Time +} + +func (t *FlexTime) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), `"`) + if s == "" || s == "null" { + t.Time = time.Time{} + return nil + } + parsed, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return err + } + t.Time = parsed + return nil +} diff --git a/internal/cmd/pipeline/pipeline.go b/internal/cmd/pipeline/pipeline.go index 4acbd279..54eb2cb2 100644 --- a/internal/cmd/pipeline/pipeline.go +++ b/internal/cmd/pipeline/pipeline.go @@ -46,6 +46,7 @@ func NewPipelineCmd() *cobra.Command { cmd.AddCommand(newCancelCmd()) cmd.AddCommand(newGetCmd()) cmd.AddCommand(newListCmd()) + cmd.AddCommand(newSearchCmd()) cmd.AddCommand(newTriggerCmd()) cmd.AddCommand(newWatchCmd()) diff --git a/internal/cmd/pipeline/search.go b/internal/cmd/pipeline/search.go new file mode 100644 index 00000000..676eae91 --- /dev/null +++ b/internal/cmd/pipeline/search.go @@ -0,0 +1,275 @@ +// Copyright (c) 2026 Circle Internet Services, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// SPDX-License-Identifier: MIT + +package pipeline + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/CircleCI-Public/circleci-cli-v2/internal/apiclient" + "github.com/CircleCI-Public/circleci-cli-v2/internal/cmdutil" + clierrors "github.com/CircleCI-Public/circleci-cli-v2/internal/errors" + "github.com/CircleCI-Public/circleci-cli-v2/internal/gitremote" + "github.com/CircleCI-Public/circleci-cli-v2/internal/iostream" + "github.com/CircleCI-Public/circleci-cli-v2/internal/mdtable" +) + +func newSearchCmd() *cobra.Command { + var ( + projectSlug string + filter string + after string + before string + limit int + jsonOut bool + ) + + cmd := &cobra.Command{ + Use: "search", + Short: "Search pipelines using filter expressions", + Long: heredoc.Doc(` + Search pipelines using the CircleCI pipeline search API. + + The project is inferred from the current git repository's remote unless + overridden with --project. For simple branch filtering use "circleci + pipeline list"; this command is for the filter-expression API. + + By default, results are limited to pipelines created in the last + two weeks. Use --after and --before to broaden or narrow the window. + + Filter expressions use field names like pipeline.git.branch, + pipeline.git.tag, pipeline.git.revision, pipeline.number, and + actor.id. See the CircleCI API docs for the full list of fields. + + Operators: ==, != (equality); starts-with (string prefix); + <, <=, >, >= (numeric); and, or, not (logical) + + actor.id takes a user UUID, not a login. Find a user's UUID via + "circleci api /me". + + JSON fields: id, number, state, status, branch, revision, + workflows_summary (map of status → count), + created_at (RFC3339) + `), + Example: heredoc.Doc(` + # Search pipelines on the main branch + $ circleci pipeline search --filter 'pipeline.git.branch == "main"' + + # Search pipelines on branches starting with "feature/" + $ circleci pipeline search --filter 'pipeline.git.branch starts-with "feature/"' + + # Combine fields with "and" + $ circleci pipeline search --filter 'pipeline.git.branch == "main" and actor.id == "104c584e-50cb-4f72-a43a-a38a7b0b6a7b"' + + # Search within a date range and output as JSON + $ circleci pipeline search --after 2024-01-01T00:00:00Z --before 2024-02-01T00:00:00Z --json + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if limit < 1 { + return clierrors.New("search.invalid_limit", "Invalid limit", + fmt.Sprintf("--limit must be a positive integer, got %d", limit), + ).WithExitCode(clierrors.ExitBadArguments) + } + + var afterTime, beforeTime *time.Time + if after != "" { + t, err := time.Parse(time.RFC3339, after) + if err != nil { + return clierrors.New("search.invalid_date", "Invalid date", + fmt.Sprintf("--after %q is not a valid RFC3339 timestamp", after), + ).WithExitCode(clierrors.ExitBadArguments) + } + afterTime = &t + } + if before != "" { + t, err := time.Parse(time.RFC3339, before) + if err != nil { + return clierrors.New("search.invalid_date", "Invalid date", + fmt.Sprintf("--before %q is not a valid RFC3339 timestamp", before), + ).WithExitCode(clierrors.ExitBadArguments) + } + beforeTime = &t + } + + ctx := iostream.FromCmd(cmd.Context(), cmd) + client, err := cmdutil.LoadClient(ctx, cmd) + if err != nil { + return err + } + + return runSearch(ctx, client, searchParams{ + projectSlug: projectSlug, + filter: filter, + afterTime: afterTime, + beforeTime: beforeTime, + limit: limit, + jsonOut: jsonOut, + }) + }, + } + + cmd.Flags().StringVar(&projectSlug, "project", "", "Project slug (e.g. gh/org/repo); defaults to git remote") + cmd.Flags().StringVar(&filter, "filter", "", `Filter expression (e.g. 'pipeline.git.branch == "main"')`) + cmd.Flags().StringVar(&after, "after", "", "Only pipelines created after this time (RFC3339)") + cmd.Flags().StringVar(&before, "before", "", "Only pipelines created before this time (RFC3339)") + cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of results") + cmdutil.AddJSONFlag(cmd, &jsonOut) + cmdutil.AddJQFlag(cmd) + + return cmd +} + +type searchParams struct { + projectSlug string + filter string + afterTime *time.Time + beforeTime *time.Time + limit int + jsonOut bool +} + +func runSearch(ctx context.Context, client *apiclient.Client, p searchParams) error { + slug := p.projectSlug + if slug == "" { + info, err := gitremote.Detect() + if err != nil { + return cmdutil.GitDetectErr(err, "Or specify the project: circleci pipeline search --project gh/org/repo") + } + slug = info.Slug + } + + sp := iostream.Spinner(ctx, !p.jsonOut, "Searching pipelines...") + + projectInfo, err := client.GetProjectInfo(ctx, slug) + if err != nil { + sp.Stop() + return cmdutil.APIErr(err, slug, "project.not_found", "No project found for %q.", + "Check the project slug and try again") + } + + scope := &apiclient.SearchScope{ + ProjectIDs: []string{projectInfo.ID}, + CreatedAfter: p.afterTime, + CreatedBefore: p.beforeTime, + } + + req := apiclient.SearchPipelinesRequest{ + Scope: scope, + Filter: p.filter, + } + + pipelines, err := client.SearchPipelines(ctx, req, p.limit) + sp.Stop() + if err != nil { + return apiErr(err, slug) + } + + entries := make([]searchResultEntry, len(pipelines)) + for i, pip := range pipelines { + entries[i] = toSearchResultEntry(&pip) + } + + if p.jsonOut { + return iostream.PrintJSON(ctx, entries) + } + + if len(entries) == 0 { + iostream.ErrPrintln(ctx, "No pipelines found.") + return nil + } + + printSearchResults(ctx, entries) + return nil +} + +type searchResultEntry struct { + ID string `json:"id"` + Number int64 `json:"number"` + State string `json:"state,omitempty"` + Status string `json:"status,omitempty"` + Branch string `json:"branch,omitempty"` + Revision string `json:"revision,omitempty"` + WorkflowsSummary map[string]int `json:"workflows_summary,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func toSearchResultEntry(p *apiclient.SearchPipeline) searchResultEntry { + e := searchResultEntry{ + ID: p.ID, + Number: p.Number, + State: p.State, + Status: p.Status, + CreatedAt: p.CreatedAt.UTC(), + } + if p.VCS != nil { + e.Branch = p.VCS.Branch + e.Revision = p.VCS.Revision + } + if p.WorkflowsSummary != nil { + e.WorkflowsSummary = p.WorkflowsSummary.CountByStatus + } + return e +} + +func formatWorkflowCounts(counts map[string]int) string { + keys := make([]string, 0, len(counts)) + for k, n := range counts { + if n > 0 { + keys = append(keys, k) + } + } + sort.Strings(keys) + parts := make([]string, len(keys)) + for i, k := range keys { + parts[i] = fmt.Sprintf("%d %s", counts[k], k) + } + return strings.Join(parts, ", ") +} + +func printSearchResults(ctx context.Context, entries []searchResultEntry) { + table := mdtable.New("#", "Branch", "Revision", "Status", "Workflows", "Created", "Pipeline") + for _, e := range entries { + rev := e.Revision + if len(rev) > 7 { + rev = rev[:7] + } + table.Row( + strconv.Itoa(int(e.Number)), + e.Branch, + rev, + e.Status, + formatWorkflowCounts(e.WorkflowsSummary), + e.CreatedAt.Format("2006-01-02 15:04 UTC"), + "`"+e.ID+"`", + ) + } + iostream.PrintMarkdown(ctx, "# Pipeline Search Results\n"+table.Render()) +} diff --git a/internal/cmd/root/testdata/usage/circleci/pipeline.txt b/internal/cmd/root/testdata/usage/circleci/pipeline.txt index d699ab11..2efd5d6a 100644 --- a/internal/cmd/root/testdata/usage/circleci/pipeline.txt +++ b/internal/cmd/root/testdata/usage/circleci/pipeline.txt @@ -5,6 +5,7 @@ Available Commands: cancel Cancel a pipeline get Get a pipeline's status list List recent pipelines for a project + search Search pipelines using filter expressions trigger Trigger a new pipeline watch Watch a pipeline until it completes diff --git a/internal/cmd/root/testdata/usage/circleci/pipeline/search.txt b/internal/cmd/root/testdata/usage/circleci/pipeline/search.txt new file mode 100644 index 00000000..69fa380f --- /dev/null +++ b/internal/cmd/root/testdata/usage/circleci/pipeline/search.txt @@ -0,0 +1,30 @@ +Usage: + circleci pipeline search [flags] + +Examples: +# Search pipelines on the main branch +$ circleci pipeline search --filter 'pipeline.git.branch == "main"' + +# Search pipelines on branches starting with "feature/" +$ circleci pipeline search --filter 'pipeline.git.branch starts-with "feature/"' + +# Combine fields with "and" +$ circleci pipeline search --filter 'pipeline.git.branch == "main" and actor.id == "104c584e-50cb-4f72-a43a-a38a7b0b6a7b"' + +# Search within a date range and output as JSON +$ circleci pipeline search --after 2024-01-01T00:00:00Z --before 2024-02-01T00:00:00Z --json + + +Flags: + --after string Only pipelines created after this time (RFC3339) + --before string Only pipelines created before this time (RFC3339) + --filter string Filter expression (e.g. 'pipeline.git.branch == "main"') + --jq string Process values from the response using jq syntax + --json Output as JSON + --limit int Maximum number of results (default 20) + --project string Project slug (e.g. gh/org/repo); defaults to git remote + +Global Flags: + -c, --config string path to config file (default: ~/.config/circleci/config.yml) + --debug enable debug logging + -q, --quiet suppress informational output; data on stdout is unaffected diff --git a/internal/testing/fakes/circleci.go b/internal/testing/fakes/circleci.go index f58f3ad2..9b11fdeb 100644 --- a/internal/testing/fakes/circleci.go +++ b/internal/testing/fakes/circleci.go @@ -81,6 +81,10 @@ type CircleCI struct { deletedContextVars map[string]bool // "contextID/name" → deleted deletedContextRestrictions map[string]bool // "contextID/restrictionID" → deleted + // Pipeline search state. + searchResponse any // response body for POST /api/v2/pipeline/search + lastSearchRequest map[string]any // most recent decoded request body, nil if not yet called + // Auth state. me any // response for GET /api/v2/me oauthTokenResponse any // response body for POST /oauth/token @@ -137,6 +141,7 @@ func NewCircleCI(t *testing.T) *CircleCI { r.Get("/api/v2/project/{vcs}/{org}/{repo}/{jobNumber}/artifacts", f.handleGetJobArtifacts) r.Get("/api/v2/project/{vcs}/{org}/{repo}/job/{jobNumber}", f.handleGetJob) r.Post("/api/v2/project/{vcs}/{org}/{repo}/pipeline", f.handleTriggerPipeline) + r.Post("/api/v2/pipeline/search", f.handleSearchPipelines) r.Get("/api/v1.1/project/{vcs}/{org}/{repo}/{jobNumber}", f.handleGetJobV1) // Project / env-var routes. These API calls do not URL-encode slashes in the // project slug, so we match three separate path segments rather than {slug}. @@ -259,6 +264,23 @@ func (f *CircleCI) AddJobV1(slug string, jobNumber int64, job any) { f.jobsV1[key] = job } +// SetSearchResponse registers the response body returned when POST +// /api/v2/pipeline/search is called. If never called the handler returns an +// empty items list. +func (f *CircleCI) SetSearchResponse(resp any) { + f.mu.Lock() + defer f.mu.Unlock() + f.searchResponse = resp +} + +// LastSearchRequest returns the decoded JSON body of the most recent POST +// /api/v2/pipeline/search request, or nil if the endpoint has not been called. +func (f *CircleCI) LastSearchRequest() map[string]any { + f.mu.RLock() + defer f.mu.RUnlock() + return f.lastSearchRequest +} + // SetTriggerResponse registers the response body returned when POST // /api/v2/project//pipeline is called. slug should be in "vcs/org/repo" form. func (f *CircleCI) SetTriggerResponse(slug string, resp any) { @@ -456,6 +478,26 @@ func (f *CircleCI) handleTriggerPipeline(w http.ResponseWriter, r *http.Request) render.JSON(w, r, resp) } +func (f *CircleCI) handleSearchPipelines(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + render.JSON(w, r, map[string]any{"message": "invalid body"}) + return + } + + f.mu.Lock() + f.lastSearchRequest = body + resp := f.searchResponse + f.mu.Unlock() + + if resp == nil { + render.JSON(w, r, map[string]any{"items": []any{}, "next_page_token": nil, "total_size": 0}) + return + } + render.JSON(w, r, resp) +} + func (f *CircleCI) handleGetJob(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "vcs") + "/" + chi.URLParam(r, "org") + "/" + chi.URLParam(r, "repo") key := slug + "/" + chi.URLParam(r, "jobNumber")