Skip to content

Commit 915a025

Browse files
schurchleycciclaude
andcommitted
feat(pipeline): add search command
Wraps POST /pipeline/search with structured shorthand flags (--branch, --actor, --state) and a --filter escape hatch for raw expressions. Adds FlexTime to handle empty timestamp strings the search endpoint returns on early-lifecycle pipelines, scoped to SearchPipeline only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e9f9be7 commit 915a025

10 files changed

Lines changed: 640 additions & 0 deletions

File tree

acceptance/pipeline_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,3 +714,176 @@ func TestPipelineCancel_NoToken(t *testing.T) {
714714

715715
assert.Equal(t, result.ExitCode, 3, "stderr: %s", result.Stderr)
716716
}
717+
718+
// --- pipeline search tests ---
719+
720+
const searchProjectID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
721+
722+
func fakeProjectInfo(slug, id string) map[string]any {
723+
return map[string]any{
724+
"id": id,
725+
"slug": slug,
726+
"name": "testrepo",
727+
}
728+
}
729+
730+
func fakeSearchPipeline(id string, number int, status, branch string) map[string]any {
731+
now := time.Date(2020, 1, 1, 12, 0, 0, 0, time.UTC)
732+
return map[string]any{
733+
"id": id,
734+
"number": number,
735+
"state": "created",
736+
"status": status,
737+
"created_at": now.Format(time.RFC3339),
738+
"updated_at": now.Format(time.RFC3339),
739+
"trigger": map[string]any{
740+
"type": "webhook",
741+
"received_at": now.Format(time.RFC3339),
742+
"actor": map[string]any{"id": "actor-uuid-1"},
743+
},
744+
"vcs": map[string]any{
745+
"branch": branch,
746+
"revision": "abc1234def5678",
747+
},
748+
"project": map[string]any{
749+
"id": searchProjectID,
750+
},
751+
"workflows_summary": map[string]any{
752+
"count_by_status": map[string]any{
753+
"success": 2,
754+
"failed": 1,
755+
},
756+
},
757+
}
758+
}
759+
760+
func TestPipelineSearch(t *testing.T) {
761+
fake := fakes.NewCircleCI(t)
762+
fake.AddProjectInfo(watchSlug, fakeProjectInfo(watchSlug, searchProjectID))
763+
fake.SetSearchResponse(map[string]any{
764+
"items": []any{
765+
fakeSearchPipeline("pid-search-1", 10, "success", "main"),
766+
fakeSearchPipeline("pid-search-2", 9, "failed", "feature"),
767+
},
768+
"next_page_token": nil,
769+
"total_size": 2,
770+
})
771+
772+
env := testenv.New(t)
773+
env.Token = testToken
774+
env.CircleCIURL = fake.URL()
775+
776+
result := binary.RunCLI(t, binary.RunOpts{
777+
Binary: binaryPath,
778+
Args: []string{"pipeline", "search", "--project", watchSlug},
779+
Env: env.Environ(),
780+
WorkDir: t.TempDir(),
781+
})
782+
783+
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
784+
assert.Check(t, golden.String(result.Stdout, t.Name()+".txt"))
785+
}
786+
787+
func TestPipelineSearch_JSON(t *testing.T) {
788+
fake := fakes.NewCircleCI(t)
789+
fake.AddProjectInfo(watchSlug, fakeProjectInfo(watchSlug, searchProjectID))
790+
fake.SetSearchResponse(map[string]any{
791+
"items": []any{
792+
fakeSearchPipeline("pid-search-1", 10, "success", "main"),
793+
},
794+
"next_page_token": nil,
795+
"total_size": 1,
796+
})
797+
798+
env := testenv.New(t)
799+
env.Token = testToken
800+
env.CircleCIURL = fake.URL()
801+
802+
result := binary.RunCLI(t, binary.RunOpts{
803+
Binary: binaryPath,
804+
Args: []string{"pipeline", "search", "--project", watchSlug, "--json"},
805+
Env: env.Environ(),
806+
WorkDir: t.TempDir(),
807+
})
808+
809+
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
810+
assert.Check(t, golden.String(result.Stdout, t.Name()+".json"))
811+
}
812+
813+
func TestPipelineSearch_WithFilter(t *testing.T) {
814+
fake := fakes.NewCircleCI(t)
815+
fake.AddProjectInfo(watchSlug, fakeProjectInfo(watchSlug, searchProjectID))
816+
fake.SetSearchResponse(map[string]any{
817+
"items": []any{fakeSearchPipeline("pid-search-1", 10, "failed", "main")},
818+
"next_page_token": nil,
819+
"total_size": 1,
820+
})
821+
822+
env := testenv.New(t)
823+
env.Token = testToken
824+
env.CircleCIURL = fake.URL()
825+
826+
rawFilter := `pipeline.git.branch == "main" and pipeline.state == "errored"`
827+
result := binary.RunCLI(t, binary.RunOpts{
828+
Binary: binaryPath,
829+
Args: []string{"pipeline", "search", "--project", watchSlug, "--filter", rawFilter},
830+
Env: env.Environ(),
831+
WorkDir: t.TempDir(),
832+
})
833+
834+
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
835+
assert.Check(t, cmp.Contains(result.Stdout, "pid-search-1"))
836+
837+
req := fake.LastSearchRequest()
838+
assert.Assert(t, req != nil, "search endpoint was not called")
839+
assert.Equal(t, req["filter"], rawFilter)
840+
}
841+
842+
// The /pipeline/search API returns "" for timestamps on some pipelines.
843+
// This must not crash the JSON decoder.
844+
func TestPipelineSearch_EmptyTimestamp(t *testing.T) {
845+
fake := fakes.NewCircleCI(t)
846+
fake.AddProjectInfo(watchSlug, fakeProjectInfo(watchSlug, searchProjectID))
847+
pip := fakeSearchPipeline("pid-search-1", 10, "success", "main")
848+
pip["created_at"] = ""
849+
pip["updated_at"] = ""
850+
fake.SetSearchResponse(map[string]any{
851+
"items": []any{pip},
852+
"next_page_token": nil,
853+
"total_size": 1,
854+
})
855+
856+
env := testenv.New(t)
857+
env.Token = testToken
858+
env.CircleCIURL = fake.URL()
859+
860+
result := binary.RunCLI(t, binary.RunOpts{
861+
Binary: binaryPath,
862+
Args: []string{"pipeline", "search", "--project", watchSlug},
863+
Env: env.Environ(),
864+
WorkDir: t.TempDir(),
865+
})
866+
867+
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
868+
assert.Check(t, cmp.Contains(result.Stdout, "pid-search-1"))
869+
}
870+
871+
func TestPipelineSearch_EmptyResults(t *testing.T) {
872+
fake := fakes.NewCircleCI(t)
873+
fake.AddProjectInfo(watchSlug, fakeProjectInfo(watchSlug, searchProjectID))
874+
// No SetSearchResponse → handler returns empty items list.
875+
876+
env := testenv.New(t)
877+
env.Token = testToken
878+
env.CircleCIURL = fake.URL()
879+
880+
result := binary.RunCLI(t, binary.RunOpts{
881+
Binary: binaryPath,
882+
Args: []string{"pipeline", "search", "--project", watchSlug},
883+
Env: env.Environ(),
884+
WorkDir: t.TempDir(),
885+
})
886+
887+
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
888+
assert.Check(t, cmp.Contains(result.Stderr, "No pipelines found."))
889+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Pipeline Search Results
2+
| # | Branch | Revision | Status | Workflows | Created | Pipeline |
3+
| -- | ------- | -------- | ------- | ------------------- | -------------------- | -------------- |
4+
| 10 | main | abc1234 | success | 1 failed, 2 success | 2020-01-01 12:00 UTC | `pid-search-1` |
5+
| 9 | feature | abc1234 | failed | 1 failed, 2 success | 2020-01-01 12:00 UTC | `pid-search-2` |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}]

internal/apiclient/pipeline.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,74 @@ func (c *Client) TriggerPipeline(ctx context.Context, projectSlug, branch string
187187
return &resp, nil
188188
}
189189

190+
// SearchPipeline is a pipeline item returned by the search API. It has a
191+
// richer shape than Pipeline: a separate Status field, a WorkflowsSummary,
192+
// and a Project struct instead of a flat project_slug.
193+
type SearchPipeline struct {
194+
ID string `json:"id"`
195+
Number int64 `json:"number"`
196+
State string `json:"state"`
197+
Status string `json:"status"`
198+
CreatedAt FlexTime `json:"created_at"`
199+
UpdatedAt FlexTime `json:"updated_at"`
200+
Trigger PipelineTrigger `json:"trigger"`
201+
VCS *PipelineVCS `json:"vcs,omitempty"`
202+
Errors []PipelineError `json:"errors,omitempty"`
203+
Project *SearchProject `json:"project,omitempty"`
204+
WorkflowsSummary *WorkflowsSummary `json:"workflows_summary,omitempty"`
205+
}
206+
207+
// SearchProject holds the project UUID returned by the search API.
208+
type SearchProject struct {
209+
ID string `json:"id"`
210+
}
211+
212+
// WorkflowsSummary holds aggregated workflow status counts for a pipeline.
213+
type WorkflowsSummary struct {
214+
CountByStatus map[string]int `json:"count_by_status"`
215+
}
216+
217+
// SearchPipelinesRequest is the request body for POST /api/v2/pipeline/search.
218+
type SearchPipelinesRequest struct {
219+
Scope *SearchScope `json:"scope,omitempty"`
220+
Filter string `json:"filter,omitempty"`
221+
OrderBy string `json:"order_by,omitempty"`
222+
PageToken string `json:"page_token,omitempty"`
223+
}
224+
225+
// SearchScope narrows a pipeline search to specific projects and/or a date range.
226+
type SearchScope struct {
227+
ProjectIDs []string `json:"project_ids,omitempty"`
228+
CreatedAfter *time.Time `json:"created_after,omitempty"`
229+
CreatedBefore *time.Time `json:"created_before,omitempty"`
230+
}
231+
232+
type searchPipelinesResponse struct {
233+
Items []SearchPipeline `json:"items"`
234+
NextPageToken string `json:"next_page_token"`
235+
TotalSize int `json:"total_size"`
236+
}
237+
238+
// SearchPipelines calls POST /api/v2/pipeline/search and paginates up to limit
239+
// results.
240+
func (c *Client) SearchPipelines(ctx context.Context, req SearchPipelinesRequest, limit int) ([]SearchPipeline, error) {
241+
var all []SearchPipeline
242+
for {
243+
var resp searchPipelinesResponse
244+
if err := c.post(ctx, "/pipeline/search", req, &resp); err != nil {
245+
return nil, err
246+
}
247+
all = append(all, resp.Items...)
248+
if len(all) >= limit {
249+
return all[:limit], nil
250+
}
251+
if resp.NextPageToken == "" {
252+
return all, nil
253+
}
254+
req.PageToken = resp.NextPageToken
255+
}
256+
}
257+
190258
// PipelineWorkflowSummary holds brief workflow status for a pipeline.
191259
type PipelineWorkflowSummary struct {
192260
ID string `json:"id"`

internal/apiclient/time.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2026 Circle Internet Services, Inc.
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in
11+
// all copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
//
21+
// SPDX-License-Identifier: MIT
22+
23+
package apiclient
24+
25+
import (
26+
"strings"
27+
"time"
28+
)
29+
30+
// FlexTime is a time.Time that unmarshals an empty JSON string as the zero
31+
// time rather than returning a parse error. The /pipeline/search endpoint
32+
// returns "" for timestamps on some pipelines, which time.Time's UnmarshalJSON
33+
// rejects.
34+
type FlexTime struct {
35+
time.Time
36+
}
37+
38+
func (t *FlexTime) UnmarshalJSON(b []byte) error {
39+
s := strings.Trim(string(b), `"`)
40+
if s == "" || s == "null" {
41+
t.Time = time.Time{}
42+
return nil
43+
}
44+
parsed, err := time.Parse(time.RFC3339Nano, s)
45+
if err != nil {
46+
return err
47+
}
48+
t.Time = parsed
49+
return nil
50+
}

internal/cmd/pipeline/pipeline.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func NewPipelineCmd() *cobra.Command {
4646
cmd.AddCommand(newCancelCmd())
4747
cmd.AddCommand(newGetCmd())
4848
cmd.AddCommand(newListCmd())
49+
cmd.AddCommand(newSearchCmd())
4950
cmd.AddCommand(newTriggerCmd())
5051
cmd.AddCommand(newWatchCmd())
5152

0 commit comments

Comments
 (0)