diff --git a/Dockerfile.dev b/Dockerfile.dev index 8d26eb9..aef1d89 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,5 +1,6 @@ -FROM golang:1.20 +FROM golang:1.21 RUN go install gotest.tools/gotestsum@v1.12.2 +RUN git config --global --add safe.directory /app WORKDIR /app diff --git a/cmd/get.go b/cmd/get.go index b984c8f..240d82d 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "fmt" "log" "os" @@ -283,6 +284,36 @@ func getAllAgents(client client.AgentApiV1AlphaApi, agentType string) ([]models. return agents, nil } +var GetCurrentProjectCmd = &cobra.Command{ + Use: "current_project", + Short: "Get project for current directory", + Aliases: []string{"cur"}, +Long: `Determine the Semaphore project associated with this repository. + + Resolution order: + 1. A remote selected by 'gh repo set-default', if present. + 2. The 'origin' remote, when configured. + 3. The 'upstream' remote, when configured. + 4. Any remaining remote whose URL is shared by all candidates. + 5. An explicit error when multiple distinct URLs remain.`, + + Run: func(cmd *cobra.Command, args []string) { + project, err := utils.InferProject() + utils.Check(err) + + doJSON, err := cmd.Flags().GetBool("json") + utils.Check(err) + if doJSON { + jsonBody, err := json.MarshalIndent(project, "", " ") + utils.Check(err) + fmt.Println(string(jsonBody)) + } else { + fmt.Println(project.Metadata.Id, project.Metadata.Name, project.Spec.Repository.Url) + } + + }, +} + var GetProjectCmd = &cobra.Command{ Use: "projects [name]", Short: "Get projects.", @@ -531,6 +562,9 @@ func init() { getCmd.AddCommand(GetProjectCmd) getCmd.AddCommand(GetAgentTypeCmd) + getCmd.AddCommand(GetCurrentProjectCmd) + GetCurrentProjectCmd.Flags().Bool("json", false, "print project information as json") + GetAgentsCmd.Flags().StringP("agent-type", "t", "", "agent type; if specified, returns only agents for this agent type") getCmd.AddCommand(GetAgentsCmd) diff --git a/cmd/utils/project.go b/cmd/utils/project.go index 9994cdd..4580234 100644 --- a/cmd/utils/project.go +++ b/cmd/utils/project.go @@ -2,14 +2,50 @@ package utils import ( "fmt" + "slices" "log" "os/exec" "strings" "github.com/semaphoreci/cli/api/client" + "github.com/semaphoreci/cli/api/models" ) +type GitRemote struct { + Name string + URL string + Project models.ProjectV1Alpha +} + +type GitRemoteList []GitRemote + +func (grl GitRemoteList) Contains(remoteNameOrUrl string) bool { + for _, gitRemote := range grl { + if gitRemote.Name == remoteNameOrUrl || gitRemote.URL == remoteNameOrUrl { + return true + } + } + return false +} + +func (grl GitRemoteList) Get(remoteNameOrUrl string) (*GitRemote, error) { + for _, gitRemote := range grl { + if gitRemote.Name == remoteNameOrUrl || gitRemote.URL == remoteNameOrUrl { + return &gitRemote, nil + } + } + return &GitRemote{}, fmt.Errorf("no remote matching %s found in remote list") +} + +func (grl GitRemoteList) URLs() []string { + urls := []string{} + for _, gitRemote := range grl { + urls = append(urls, gitRemote.URL) + } + return urls +} + func GetProjectId(name string) string { projectClient := client.NewProjectV1AlphaApi() project, err := projectClient.GetProject(name) @@ -20,47 +56,79 @@ func GetProjectId(name string) string { } func InferProjectName() (string, error) { - originUrl, err := getGitOriginUrl() + project, err := InferProject() if err != nil { return "", err } + return project.Metadata.Name, nil +} - log.Printf("Origin url: '%s'\n", originUrl) - - projectName, err := getProjectIdFromUrl(originUrl) +func InferProject() (models.ProjectV1Alpha, error) { + // Note that getAllGitRemotesAndProjects will only return remotes + // where the URL of that remote is configured in a project we got + // from the API, so this list is a list of remotes valid projects + // configured. All we have to do now is pick one. + gitRemotes, err := getAllGitRemotesAndProjects() if err != nil { - return "", err + return models.ProjectV1Alpha{}, err } - return projectName, nil -} + // If the user is using GitHub and has run `gh repo set-default`, then + // we can get that 'base' remote name and see if we have a project for + // it. + ghBaseRemoteName, err := getGitHubBaseRemoteName() + if err != nil { + log.Printf("tried looking for a `gh` base repo configuration, but found none.") + } else { + gitRemote, err := gitRemotes.Get(ghBaseRemoteName) + if err == nil { + return gitRemote.Project, nil + } + } -func getProjectIdFromUrl(url string) (string, error) { - projectClient := client.NewProjectV1AlphaApi() - projects, err := projectClient.ListProjects() + // If we only got one remote with a configured project, return it; + // alternately, if we got multiple, return the alphabetically first + // one. + if len(gitRemotes) == 1 { + return gitRemotes[0].Project, nil + } - if err != nil { - return "", fmt.Errorf("getting project list failed '%s'", err) + // If we got an "origin" remote or an "upstream" remote, return that (in + // that order of preference) + for _, remoteName := range []string{"origin", "upstream"} { + remote, err := gitRemotes.Get(remoteName) + if err == nil { + return remote.Project, nil + } } - projectName := "" - for _, p := range projects.Projects { - if p.Spec.Repository.Url == url { - projectName = p.Metadata.Name - break + // At this point, we have multiple remotes configured, all of which have + // a project configured in Semaphore, none of which are named "origin" or + // "upstream", and none of which are set as the gh base repo. The *most likely* + // explanation here is that the user has the same repo URL configured multiple + // times, or they're doing something extremely unusual. I'm not sure we can + // make the correct decision here. + allUrls := []string{} + for _, url := range gitRemotes.URLs() { + if !slices.Contains(allUrls, url) { + allUrls = append(allUrls, url) } } - if projectName == "" { - return "", fmt.Errorf("project with url '%s' not found in this org", url) + // Okay, there's only one URL so we can just pick the relevant project. + if len(allUrls) == 1 { + return gitRemotes[0].Project, nil } - return projectName, nil + // At this point we'd just be guessing, so let's give up + return models.ProjectV1Alpha{}, fmt.Errorf("found %d remotes with %d different URLs but cannot determine the correct one", len(gitRemotes), len(allUrls)) } -func getGitOriginUrl() (string, error) { - args := []string{"config", "remote.origin.url"} - +// getGitHubBaseRemoteName checks to see if the `gh` cli tool has set a default +// remote for this repository. If not, or if we're not using Github at all, we +// can just ignore the error. +func getGitHubBaseRemoteName() (string, error) { + args := []string{"config", "--local", "--get-regexp", "gh-resolved"} cmd := exec.Command("git", args...) out, err := cmd.CombinedOutput() if err != nil { @@ -69,5 +137,50 @@ func getGitOriginUrl() (string, error) { return "", fmt.Errorf("%s failed with message: '%s'\n%s", cmd_string, err, user_msg) } - return strings.TrimSpace(string(out)), nil + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + + if len(lines) == 0 { + return "", fmt.Errorf("no GitHub base remote configured for this repository") + } + if len(lines) > 1 { + return "", fmt.Errorf("got multiple lines when looking for GitHub base remote") + } + + fields := strings.Fields(lines[0]) + remoteName := strings.Split(fields[0], ".")[1] + return remoteName, nil +} + +func getAllGitRemotesAndProjects() (GitRemoteList, error) { + args := []string{"config", "--local", "--get-regexp", "remote\\..*\\.url"} + cmd := exec.Command("git", args...) + out, err := cmd.CombinedOutput() + if err != nil { + cmd_string := fmt.Sprintf("'%s %s'", "git", strings.Join(args, " ")) + user_msg := "You are probably not in a git directory?" + return GitRemoteList{}, fmt.Errorf("%s failed with message: '%s'\n%s", cmd_string, err, user_msg) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + + projectClient := client.NewProjectV1AlphaApi() + projects, err := projectClient.ListProjects() + + remotes := GitRemoteList{} + + for _, line := range lines { + fields := strings.Fields(line) + keyFields := strings.Split(line, ".") + remoteName := keyFields[1] + url := fields[1] + + for _, proj := range projects.Projects { + if proj.Spec.Repository.Url == url { + remotes = append(remotes, GitRemote{Name: remoteName, URL: url, Project: proj}) + break + } + } + + } + + return remotes, nil } diff --git a/go.mod b/go.mod index d844f4c..e661669 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/semaphoreci/cli -go 1.20 +go 1.21 require ( github.com/ghodss/yaml v1.0.0