Skip to content

Commit

Permalink
Merge pull request #366 from CircleCI-Public/git-inference
Browse files Browse the repository at this point in the history
Add command to open current project
  • Loading branch information
marcomorain authored Feb 25, 2020
2 parents 664c4ea + 3e039e2 commit 9f3c4a5
Show file tree
Hide file tree
Showing 9 changed files with 280 additions and 1 deletion.
47 changes: 47 additions & 0 deletions cmd/open.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package cmd

import (
"fmt"
"net/url"
"strings"

"github.com/CircleCI-Public/circleci-cli/git"
"github.com/pkg/browser"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)

func projectUrl(remote *git.Remote) string {
return fmt.Sprintf("https://app.circleci.com/%s/%s/%s/pipelines",
url.PathEscape(strings.ToLower(string(remote.VcsType))),
url.PathEscape(remote.Organization),
url.PathEscape(remote.Project))
}

var errorMessage = `
Unable detect which URL should be opened. This command is intended to be run from
a git repository with a remote named 'origin' that is hosted on Github or Bitbucket
Error`

func openProjectInBrowser() error {

remote, err := git.InferProjectFromGitRemotes()

if err != nil {
return errors.Wrap(err, errorMessage)
}

return browser.OpenURL(projectUrl(remote))
}

func newOpenCommand() *cobra.Command {

openCommand := &cobra.Command{
Use: "open",
Short: "Open the current project in the browser.",
RunE: func(_ *cobra.Command, _ []string) error {
return openProjectInBrowser()
},
}
return openCommand
}
25 changes: 25 additions & 0 deletions cmd/open_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package cmd

import (
"github.com/CircleCI-Public/circleci-cli/git"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)

var _ = Describe("open", func() {
It("can build urls", func() {
Expect(projectUrl(&git.Remote{
Project: "foo",
Organization: "bar",
VcsType: git.GitHub,
})).To(Equal("https://app.circleci.com/github/bar/foo/pipelines"))
})

It("escapes garbage", func() {
Expect(projectUrl(&git.Remote{
Project: "/one/two",
Organization: "%^&*()[]",
VcsType: git.Bitbucket,
})).To(Equal("https://app.circleci.com/bitbucket/%25%5E&%2A%28%29%5B%5D/%2Fone%2Ftwo/pipelines"))
})
})
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func MakeCommands() *cobra.Command {
rootCmd.SetUsageTemplate(usageTemplate)
rootCmd.DisableAutoGenTag = true

rootCmd.AddCommand(newOpenCommand())
rootCmd.AddCommand(newTestsCommand())
rootCmd.AddCommand(newQueryCommand(rootOptions))
rootCmd.AddCommand(newConfigCommand(rootOptions))
Expand Down
2 changes: 1 addition & 1 deletion cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ var _ = Describe("Root", func() {
Describe("subcommands", func() {
It("can create commands", func() {
commands := cmd.MakeCommands()
Expect(len(commands.Commands())).To(Equal(14))
Expect(len(commands.Commands())).To(Equal(15))
})
})

Expand Down
105 changes: 105 additions & 0 deletions git/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package git

import (
"errors"
"fmt"
"os/exec"
"regexp"
"strings"
)

type VcsType string

const (
GitHub VcsType = "GITHUB"
Bitbucket VcsType = "BITBUCKET"
)

type Remote struct {
VcsType VcsType
Organization string
Project string
}

// Parse the output of `git remote` to infer what VCS provider is being used by
// in the current working directory. The assumption is that the 'origin' remote
// will be a Bitbucket or GitHub project. This matching is a best effort approach,
// and pull requests are welcome to make it more robust.
func InferProjectFromGitRemotes() (*Remote, error) {

remoteUrl, err := getRemoteUrl("origin")

if err != nil {
return nil, err
}

return findRemote(remoteUrl)
}

func findRemote(url string) (*Remote, error) {
vcsType, slug, err := findProviderAndSlug(url)
if err != nil {
return nil, err
}

matches := strings.Split(slug, "/")

if len(matches) != 2 {
return nil, fmt.Errorf("Splitting '%s' into organization and project failed", slug)
}

return &Remote{
VcsType: vcsType,
Organization: matches[0],
Project: strings.TrimSuffix(matches[1], ".git"),
}, nil
}

func findProviderAndSlug(url string) (VcsType, string, error) {

var vcsParsers = map[VcsType][]*regexp.Regexp{
GitHub: {
regexp.MustCompile(`^git@github\.com:(.*)`),
regexp.MustCompile(`https://(?:.*@)?github\.com/(.*)`),
},
Bitbucket: {
regexp.MustCompile(`^git@bitbucket\.org:(.*)`),
regexp.MustCompile(`https://(?:.*@)?bitbucket\.org/(.*)`),
},
}

for provider, regexes := range vcsParsers {
for _, regex := range regexes {
if matches := regex.FindStringSubmatch(url); matches != nil {
return provider, matches[1], nil
}
}
}

return "", "", fmt.Errorf("Unknown git remote: %s", url)
}

func getRemoteUrl(remoteName string) (string, error) {

// Ensure that git is on the path
if _, err := exec.LookPath("git"); err != nil {
return "", errors.New("Could not find 'git' on the path; this command requires git to be installed.")
}

// Ensure that we are in a git repository
if output, err := exec.Command("git", "status").CombinedOutput(); err != nil {
if strings.Contains(string(output), "not a git repository") {
return "", errors.New("This command must be run from inside a git repository")
}
// If `git status` fails for any other reason, let's optimisticly continue
// execution and allow the call to `git remote` to fail.
}

out, err := exec.Command("git", "remote", "get-url", remoteName).CombinedOutput()
if err != nil {
return "", fmt.Errorf("Error finding the %s git remote: %s",
remoteName,
strings.TrimSpace(string(out)))
}
return string(out), nil
}
13 changes: 13 additions & 0 deletions git/git_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package git_test

import (
"testing"

. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)

func TestGit(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Git Suite")
}
85 changes: 85 additions & 0 deletions git/git_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package git

import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)

var _ = Describe("Dealing with git", func() {

Context("remotes", func() {

Describe("integration tests", func() {

It("fails gracefully when the remote can't be found", func() {
// This test will fail if the current working directory has git remote
// named 'peristeronic'.
_, err := getRemoteUrl("peristeronic")
Expect(err).To(MatchError("Error finding the peristeronic git remote: fatal: No such remote 'peristeronic'"))
})

It("can read git output", func() {
Expect(getRemoteUrl("origin")).To(MatchRegexp(`github`))
})

})

It("should parse these", func() {

cases := map[string]*Remote{
"[email protected]:foobar/foo-service.git": &Remote{
VcsType: GitHub,
Organization: "foobar",
Project: "foo-service",
},

"[email protected]:example/makefile_sh.git": &Remote{
VcsType: Bitbucket,
Organization: "example",
Project: "makefile_sh",
},

"https://github.com/apple/pear.git": &Remote{
VcsType: GitHub,
Organization: "apple",
Project: "pear",
},

"[email protected]:example/makefile_sh": &Remote{
VcsType: Bitbucket,
Organization: "example",
Project: "makefile_sh",
},

"https://[email protected]/kiwi/fruit.git": &Remote{
VcsType: Bitbucket,
Organization: "kiwi",
Project: "fruit",
},

"https://[email protected]/kiwi/fruit": &Remote{
VcsType: Bitbucket,
Organization: "kiwi",
Project: "fruit",
},
}

for url, remote := range cases {
Expect(findRemote(url)).To(Equal(remote))
}

})

It("should not parse these", func() {

cases := map[string]string{
"asd/asd/asd": "Unknown git remote: asd/asd/asd",
"[email protected]:foo/bar/baz": "Splitting 'foo/bar/baz' into organization and project failed",
}
for url, message := range cases {
_, err := findRemote(url)
Expect(err).To(MatchError(message))
}
})
})
})
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
github.com/mitchellh/mapstructure v1.1.2
github.com/onsi/ginkgo v1.7.0
github.com/onsi/gomega v1.4.3
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4
github.com/pkg/errors v0.8.0
github.com/rhysd/go-github-selfupdate v0.0.0-20180520142321-41c1bbb0804a
github.com/spf13/cobra v0.0.3
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down

0 comments on commit 9f3c4a5

Please sign in to comment.