Skip to content

Commit a4bb215

Browse files
authored
Merge pull request #1229 from CircleCI-Public/parker/next/open-command
Add `circleci open` command to `next`
2 parents f46168b + c0c6632 commit a4bb215

7 files changed

Lines changed: 185 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ internal/
7676
│ ├── config/ circleci config validate/process/pack/generate
7777
│ ├── context/ circleci context + circleci context secret
7878
│ ├── job/ circleci job artifacts (deep path; wraps internal/artifacts)
79+
│ ├── open/ circleci open (opens current project in the CircleCI web UI)
7980
│ ├── pipeline/ circleci pipeline list/get/trigger
8081
│ ├── workflow/ circleci workflow list/get/cancel/rerun
8182
│ ├── orb/ circleci orb list/info/validate/publish/...

internal/cmd/open/open.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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 open implements the "circleci open" command.
24+
package open
25+
26+
import (
27+
"fmt"
28+
"net/url"
29+
"strings"
30+
31+
"github.com/MakeNowJust/heredoc"
32+
"github.com/pkg/browser"
33+
"github.com/spf13/cobra"
34+
35+
"github.com/CircleCI-Public/circleci-cli-v2/internal/cmdutil"
36+
clierrors "github.com/CircleCI-Public/circleci-cli-v2/internal/errors"
37+
"github.com/CircleCI-Public/circleci-cli-v2/internal/gitremote"
38+
)
39+
40+
// projectURL builds the CircleCI pipelines URL for the given project slug.
41+
func projectURL(appURL, slug string) (string, error) {
42+
parts := strings.SplitN(slug, "/", 3)
43+
if len(parts) != 3 {
44+
return "", fmt.Errorf("invalid slug: %q", slug)
45+
}
46+
return fmt.Sprintf("%s/pipelines/%s/%s/%s",
47+
appURL,
48+
url.PathEscape(parts[0]),
49+
url.PathEscape(parts[1]),
50+
url.PathEscape(parts[2]),
51+
), nil
52+
}
53+
54+
// NewOpenCmd returns the "circleci open" command.
55+
func NewOpenCmd() *cobra.Command {
56+
return &cobra.Command{
57+
Use: "open",
58+
Short: "Open the current project in the browser",
59+
Long: heredoc.Doc(`
60+
Open the CircleCI pipelines page for the current project in your
61+
default web browser.
62+
63+
The project is inferred from the current git repository's remote.
64+
Supports GitHub, Bitbucket, and GitLab remotes.
65+
`),
66+
Example: heredoc.Doc(`
67+
# Open pipelines for the current repo
68+
$ circleci open
69+
`),
70+
RunE: func(cmd *cobra.Command, _ []string) error {
71+
ctx := cmd.Context()
72+
73+
info, err := gitremote.Detect()
74+
if err != nil {
75+
return clierrors.New("git.detect_failed",
76+
"Could not detect project from git remote", err.Error()).
77+
WithSuggestions(
78+
"Run from inside a git repository with a GitHub, Bitbucket, or GitLab remote",
79+
).
80+
WithExitCode(clierrors.ExitBadArguments)
81+
}
82+
83+
appURL, err := cmdutil.AppURL(ctx, cmd)
84+
if err != nil {
85+
return err
86+
}
87+
88+
u, err := projectURL(appURL, info.Slug)
89+
if err != nil {
90+
return err
91+
}
92+
93+
return browser.OpenURL(u)
94+
},
95+
}
96+
}

internal/cmd/open/open_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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 open
24+
25+
import (
26+
"testing"
27+
28+
"gotest.tools/v3/assert"
29+
"gotest.tools/v3/assert/cmp"
30+
)
31+
32+
func TestProjectURL(t *testing.T) {
33+
const appURL = "https://app.circleci.com"
34+
t.Run("github project", func(t *testing.T) {
35+
got, err := projectURL(appURL, "gh/bar/foo")
36+
assert.NilError(t, err)
37+
assert.Check(t, cmp.Equal(got, "https://app.circleci.com/pipelines/gh/bar/foo"))
38+
})
39+
40+
t.Run("bitbucket project", func(t *testing.T) {
41+
got, err := projectURL(appURL, "bb/myorg/myrepo")
42+
assert.NilError(t, err)
43+
assert.Check(t, cmp.Equal(got, "https://app.circleci.com/pipelines/bb/myorg/myrepo"))
44+
})
45+
46+
t.Run("gitlab project", func(t *testing.T) {
47+
got, err := projectURL(appURL, "gl/my-group/my-project")
48+
assert.NilError(t, err)
49+
assert.Check(t, cmp.Equal(got, "https://app.circleci.com/pipelines/gl/my-group/my-project"))
50+
})
51+
52+
t.Run("invalid slug", func(t *testing.T) {
53+
_, err := projectURL(appURL, "invalid")
54+
assert.Check(t, err != nil, "expected error for invalid slug")
55+
})
56+
}

internal/cmd/root/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
"github.com/CircleCI-Public/circleci-cli-v2/internal/cmd/envvar"
3737
"github.com/CircleCI-Public/circleci-cli-v2/internal/cmd/job"
3838
cmdlogs "github.com/CircleCI-Public/circleci-cli-v2/internal/cmd/logs"
39+
cmdopen "github.com/CircleCI-Public/circleci-cli-v2/internal/cmd/open"
3940
"github.com/CircleCI-Public/circleci-cli-v2/internal/cmd/pipeline"
4041
"github.com/CircleCI-Public/circleci-cli-v2/internal/cmd/project"
4142
"github.com/CircleCI-Public/circleci-cli-v2/internal/cmd/runner"
@@ -98,6 +99,7 @@ func NewRootCmd(version string) *cobra.Command {
9899
cmd.AddCommand(envvar.NewEnvVarCmd())
99100
cmd.AddCommand(job.NewJobCmd())
100101
cmd.AddCommand(cmdlogs.NewLogsCmd())
102+
cmd.AddCommand(cmdopen.NewOpenCmd())
101103
cmd.AddCommand(pipeline.NewPipelineCmd())
102104
cmd.AddCommand(project.NewProjectCmd())
103105
cmd.AddCommand(runner.NewRunnerCmd())

internal/cmd/root/testdata/usage/circleci.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Available Commands:
1111
job Manage jobs
1212
logs Fetch job logs
1313
mcp MCP server management
14+
open Open the current project in the browser
1415
pipeline Manage pipelines
1516
project Manage CircleCI projects
1617
runner Manage self-hosted runners
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Usage:
2+
circleci open [flags]
3+
4+
Examples:
5+
# Open pipelines for the current repo
6+
$ circleci open
7+
8+
9+
Global Flags:
10+
-c, --config string path to config file (default: ~/.config/circleci/config.yml)
11+
--debug enable debug logging
12+
-q, --quiet suppress informational output; data on stdout is unaffected

internal/cmdutil/client.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"errors"
3131
"fmt"
3232
"net/http"
33+
"net/url"
3334

3435
"github.com/spf13/cobra"
3536

@@ -79,6 +80,22 @@ func LoadClient(ctx context.Context, cmd *cobra.Command) (*apiclient.Client, err
7980
return apiclient.New(cfg.EffectiveHost(), token, nil), nil
8081
}
8182

83+
func AppURL(ctx context.Context, cmd *cobra.Command) (string, error) {
84+
configPath, _ := ctx.Value(configPathKey{}).(string)
85+
cfg, err := config.LoadFrom(ctx, configPath, IsSecureStorage(cmd))
86+
if err != nil {
87+
return "", clierrors.New("config.load_failed", "Failed to load config", err.Error()).
88+
WithExitCode(clierrors.ExitGeneralError)
89+
}
90+
u, err := url.Parse(cfg.EffectiveHost())
91+
if err != nil {
92+
return "", err
93+
}
94+
95+
u.Host = "app." + u.Host
96+
return u.String(), nil
97+
}
98+
8299
// APIErr converts an apiclient error into a structured CLIError.
83100
//
84101
// notFoundCode and notFoundMsg customise the 404 case for the calling resource

0 commit comments

Comments
 (0)