Skip to content

Commit 303d860

Browse files
committed
Add deploy list command
1 parent e9f9be7 commit 303d860

12 files changed

Lines changed: 552 additions & 0 deletions

File tree

acceptance/deploy_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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 acceptance_test
24+
25+
import (
26+
"encoding/json"
27+
"testing"
28+
29+
"gotest.tools/v3/assert"
30+
"gotest.tools/v3/assert/cmp"
31+
"gotest.tools/v3/golden"
32+
33+
"github.com/CircleCI-Public/circleci-cli-v2/internal/testing/binary"
34+
testenv "github.com/CircleCI-Public/circleci-cli-v2/internal/testing/env"
35+
"github.com/CircleCI-Public/circleci-cli-v2/internal/testing/fakes"
36+
)
37+
38+
func setupDeployFake(t *testing.T) (*fakes.CircleCI, *testenv.TestEnv) {
39+
t.Helper()
40+
fake := fakes.NewCircleCI(t)
41+
42+
// Register a project so --project can resolve to IDs.
43+
fake.AddProjectInfo("gh/myorg/alpha", map[string]any{
44+
"id": "proj-uuid-1234",
45+
"slug": "gh/myorg/alpha",
46+
"name": "alpha",
47+
"organization_name": "myorg",
48+
"organization_slug": "gh/myorg",
49+
"organization_id": "org-uuid-5678",
50+
})
51+
52+
fake.AddRelease("proj-uuid-1234", map[string]any{
53+
"id": "rel-uuid-0001",
54+
"project_id": "proj-uuid-1234",
55+
"component_id": "comp-uuid-1111",
56+
"component_name": "web-frontend",
57+
"type": "DEPLOYMENT",
58+
"status": "SUCCESS",
59+
"target_version": map[string]any{"name": "1.3.0"},
60+
"plan_is_rollback": false,
61+
"pipeline_id": "pipe-uuid-aaa1",
62+
"workflow_id": "wf-uuid-aaa1",
63+
"created_at": "2026-04-28T14:30:00Z",
64+
"ended_at": "2026-04-28T14:35:00Z",
65+
})
66+
fake.AddRelease("proj-uuid-1234", map[string]any{
67+
"id": "rel-uuid-0002",
68+
"project_id": "proj-uuid-1234",
69+
"component_id": "comp-uuid-2222",
70+
"component_name": "api-server",
71+
"type": "DEPLOYMENT",
72+
"status": "FAILED",
73+
"target_version": map[string]any{"name": "2.0.1"},
74+
"failure_reason": "timeout",
75+
"plan_is_rollback": false,
76+
"pipeline_id": "pipe-uuid-bbb1",
77+
"workflow_id": "wf-uuid-bbb1",
78+
"created_at": "2026-04-27T09:15:00Z",
79+
"ended_at": "2026-04-27T09:25:00Z",
80+
})
81+
fake.AddRelease("proj-uuid-1234", map[string]any{
82+
"id": "rel-uuid-0003",
83+
"project_id": "proj-uuid-1234",
84+
"component_id": "comp-uuid-1111",
85+
"component_name": "web-frontend",
86+
"type": "ROLLBACK",
87+
"status": "SUCCESS",
88+
"target_version": map[string]any{"name": "1.2.0"},
89+
"plan_is_rollback": true,
90+
"pipeline_id": "pipe-uuid-aaa2",
91+
"workflow_id": "wf-uuid-aaa2",
92+
"created_at": "2026-04-20T10:00:00Z",
93+
"ended_at": "2026-04-20T10:05:00Z",
94+
})
95+
96+
env := testenv.New(t)
97+
env.Token = "testtoken"
98+
env.CircleCIURL = fake.URL()
99+
return fake, env
100+
}
101+
102+
func TestDeployList(t *testing.T) {
103+
_, env := setupDeployFake(t)
104+
105+
result := binary.RunCLI(t, binary.RunOpts{
106+
Binary: binaryPath,
107+
Args: []string{"deploy", "list", "--project", "gh/myorg/alpha"},
108+
Env: env.Environ(),
109+
WorkDir: t.TempDir(),
110+
})
111+
112+
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
113+
assert.Check(t, golden.String(result.Stdout, t.Name()+".txt"))
114+
}
115+
116+
func TestDeployList_JSON(t *testing.T) {
117+
_, env := setupDeployFake(t)
118+
119+
result := binary.RunCLI(t, binary.RunOpts{
120+
Binary: binaryPath,
121+
Args: []string{"deploy", "list", "--project", "gh/myorg/alpha", "--json"},
122+
Env: env.Environ(),
123+
WorkDir: t.TempDir(),
124+
})
125+
126+
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
127+
128+
var out []map[string]any
129+
err := json.Unmarshal([]byte(result.Stdout), &out)
130+
assert.NilError(t, err)
131+
assert.Check(t, cmp.Equal(len(out), 3))
132+
assert.Check(t, cmp.Equal(out[0]["component_name"], "web-frontend"))
133+
assert.Check(t, cmp.Equal(out[0]["version"], "1.3.0"))
134+
135+
assert.Check(t, golden.String(result.Stdout, t.Name()+".json"))
136+
}
137+
138+
func TestDeployList_Empty(t *testing.T) {
139+
fake := fakes.NewCircleCI(t)
140+
fake.AddProjectInfo("gh/myorg/empty", map[string]any{
141+
"id": "proj-uuid-empty",
142+
"slug": "gh/myorg/empty",
143+
"name": "empty",
144+
"organization_name": "myorg",
145+
"organization_slug": "gh/myorg",
146+
"organization_id": "org-uuid-5678",
147+
})
148+
env := testenv.New(t)
149+
env.Token = "testtoken"
150+
env.CircleCIURL = fake.URL()
151+
152+
result := binary.RunCLI(t, binary.RunOpts{
153+
Binary: binaryPath,
154+
Args: []string{"deploy", "list", "--project", "gh/myorg/empty"},
155+
Env: env.Environ(),
156+
WorkDir: t.TempDir(),
157+
})
158+
159+
assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr)
160+
assert.Check(t, golden.String(result.Stderr, t.Name()+".stderr.txt"))
161+
}
162+
163+
func TestDeployList_NoToken(t *testing.T) {
164+
env := testenv.New(t)
165+
166+
result := binary.RunCLI(t, binary.RunOpts{
167+
Binary: binaryPath,
168+
Args: []string{"deploy", "list", "--project", "gh/myorg/alpha"},
169+
Env: env.Environ(),
170+
WorkDir: t.TempDir(),
171+
})
172+
173+
assert.Equal(t, result.ExitCode, 3, "stderr: %s", result.Stderr)
174+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Releases
2+
| Component | Version | Type | Status | Created |
3+
| ------------ | ------- | ---------- | ------- | -------------------- |
4+
| web-frontend | 1.3.0 | deployment | success | 2026-04-28 14:30 UTC |
5+
| api-server | 2.0.1 | deployment | failed | 2026-04-27 09:15 UTC |
6+
| web-frontend | 1.2.0 | rollback | success | 2026-04-20 10:00 UTC |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
No releases found.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[{"id":"rel-uuid-0001","component_name":"web-frontend","version":"1.3.0","type":"DEPLOYMENT","status":"SUCCESS","is_rollback":false,"pipeline_id":"pipe-uuid-aaa1","workflow_id":"wf-uuid-aaa1","created_at":"2026-04-28 14:30 UTC","ended_at":"2026-04-28 14:35 UTC"},{"id":"rel-uuid-0002","component_name":"api-server","version":"2.0.1","type":"DEPLOYMENT","status":"FAILED","is_rollback":false,"pipeline_id":"pipe-uuid-bbb1","workflow_id":"wf-uuid-bbb1","created_at":"2026-04-27 09:15 UTC","ended_at":"2026-04-27 09:25 UTC"},{"id":"rel-uuid-0003","component_name":"web-frontend","version":"1.2.0","type":"ROLLBACK","status":"SUCCESS","is_rollback":true,"pipeline_id":"pipe-uuid-aaa2","workflow_id":"wf-uuid-aaa2","created_at":"2026-04-20 10:00 UTC","ended_at":"2026-04-20 10:05 UTC"}]

internal/apiclient/deploy.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
"context"
27+
"time"
28+
)
29+
30+
// Release represents a release returned by the CircleCI Deploy API.
31+
type Release struct {
32+
ID string `json:"id"`
33+
ProjectID string `json:"project_id"`
34+
ComponentID string `json:"component_id"`
35+
ComponentName string `json:"component_name"`
36+
Type string `json:"type"`
37+
Status string `json:"status"`
38+
TargetVersion *Version `json:"target_version"`
39+
PipelineID string `json:"pipeline_id,omitempty"`
40+
WorkflowID string `json:"workflow_id,omitempty"`
41+
PlanIsRollback bool `json:"plan_is_rollback"`
42+
IsRerelease bool `json:"is_rerelease"`
43+
FailureReason string `json:"failure_reason,omitempty"`
44+
CreatedAt time.Time `json:"created_at"`
45+
StartedAt time.Time `json:"started_at"`
46+
EndedAt time.Time `json:"ended_at"`
47+
}
48+
49+
// Version holds a version name.
50+
type Version struct {
51+
Name string `json:"name"`
52+
}
53+
54+
// ListReleases returns releases for a project. It paginates automatically.
55+
func (c *Client) ListReleases(ctx context.Context, projectID, orgID string) ([]Release, error) {
56+
var all []Release
57+
pageToken := ""
58+
59+
for {
60+
var resp struct {
61+
Items []Release `json:"items"`
62+
NextPageToken string `json:"next_page_token"`
63+
}
64+
65+
err := c.get(ctx, "/deploy/projects/%s/releases", &resp,
66+
routeParams(projectID),
67+
queryParam("org-id", orgID),
68+
queryParam("page-size", "50"),
69+
optionalQueryParam("page-token", pageToken),
70+
)
71+
if err != nil {
72+
return nil, err
73+
}
74+
75+
all = append(all, resp.Items...)
76+
77+
if resp.NextPageToken == "" {
78+
return all, nil
79+
}
80+
pageToken = resp.NextPageToken
81+
}
82+
}

internal/cmd/deploy/deploy.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 deploy
24+
25+
import (
26+
"github.com/MakeNowJust/heredoc"
27+
"github.com/spf13/cobra"
28+
29+
"github.com/CircleCI-Public/circleci-cli-v2/internal/cmdutil"
30+
clierrors "github.com/CircleCI-Public/circleci-cli-v2/internal/errors"
31+
)
32+
33+
// NewDeployCmd returns the "circleci deploy" command group.
34+
func NewDeployCmd() *cobra.Command {
35+
cmd := &cobra.Command{
36+
Use: "deploy <command>",
37+
Short: "Manage deploys",
38+
Long: heredoc.Doc(`
39+
Work with CircleCI Deploys.
40+
41+
View deployed components and their versions across environments.
42+
`),
43+
}
44+
45+
cmd.AddCommand(newListCmd())
46+
47+
return cmd
48+
}
49+
50+
func apiErr(err error, subject string) *clierrors.CLIError {
51+
return cmdutil.APIErr(err, subject,
52+
"deploy.not_found", "No deploy components found for %q.",
53+
"Check the org ID and try again")
54+
}

0 commit comments

Comments
 (0)