diff --git a/cmd/git.go b/cmd/git.go index d907d0f0..722a6eae 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -14,7 +14,7 @@ func configureGit(cmd *cobra.Command) { cmd.Flags().StringP("git-type", "", "go", `The type of git implementation to use. Available values: go: Uses go-git, a Go native implementation of git. This is compiled with the multi-gitter binary, and no extra dependencies are needed. - cmd: Calls out to the git command. This requires git to be installed and available with by calling "git". + cmd: Calls out to the git command. This requires git to be installed and available with by calling "git". This must be used when using Azure DevOps. `) _ = cmd.RegisterFlagCompletionFunc("git-type", func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"go", "cmd"}, cobra.ShellCompDirectiveDefault diff --git a/cmd/other.go b/cmd/other.go index b2f5ef08..83359d72 100644 --- a/cmd/other.go +++ b/cmd/other.go @@ -33,11 +33,13 @@ func getToken(flag *flag.FlagSet) (string, error) { token = ght } else if ght := os.Getenv("BITBUCKET_SERVER_TOKEN"); ght != "" { token = ght + } else if ght := os.Getenv("AZURE_DEVOPS_TOKEN"); ght != "" { + token = ght } } if token == "" { - return "", errors.New("either the --token flag or the GITHUB_TOKEN/GITLAB_TOKEN/GITEA_TOKEN/BITBUCKET_SERVER_TOKEN environment variable has to be set") + return "", errors.New("either the --token flag or the GITHUB_TOKEN/GITLAB_TOKEN/GITEA_TOKEN/BITBUCKET_SERVER_TOKEN/AZURE_DEVOPS_TOKEN environment variable has to be set") } return token, nil diff --git a/cmd/platform.go b/cmd/platform.go index fb64cd34..001ff430 100644 --- a/cmd/platform.go +++ b/cmd/platform.go @@ -7,6 +7,7 @@ import ( "github.com/lindell/multi-gitter/internal/http" "github.com/lindell/multi-gitter/internal/multigitter" + "github.com/lindell/multi-gitter/internal/scm/azuredevops" "github.com/lindell/multi-gitter/internal/scm/bitbucketserver" "github.com/lindell/multi-gitter/internal/scm/gitea" "github.com/lindell/multi-gitter/internal/scm/github" @@ -19,26 +20,26 @@ import ( func configurePlatform(cmd *cobra.Command) { flags := cmd.Flags() - flags.StringP("base-url", "g", "", "Base URL of the target platform, needs to be changed for GitHub enterprise, a self-hosted GitLab instance, Gitea or BitBucket.") + flags.StringP("base-url", "g", "", "Base URL of the target platform, needs to be changed for GitHub enterprise, a self-hosted GitLab instance, Gitea, BitBucket or Azure DevOps.") flags.BoolP("insecure", "", false, "Insecure controls whether a client verifies the server certificate chain and host name. Used only for Bitbucket server.") flags.StringP("username", "u", "", "The Bitbucket server username.") - flags.StringP("token", "T", "", "The personal access token for the targeting platform. Can also be set using the GITHUB_TOKEN/GITLAB_TOKEN/GITEA_TOKEN/BITBUCKET_SERVER_TOKEN environment variable.") + flags.StringP("token", "T", "", "The personal access token for the targeting platform. Can also be set using the GITHUB_TOKEN/GITLAB_TOKEN/GITEA_TOKEN/BITBUCKET_SERVER_TOKEN/AZURE_DEVOPS_TOKEN environment variable.") flags.StringSliceP("org", "O", nil, "The name of a GitHub organization. All repositories in that organization will be used.") flags.StringSliceP("group", "G", nil, "The name of a GitLab organization. All repositories in that group will be used.") flags.StringSliceP("user", "U", nil, "The name of a user. All repositories owned by that user will be used.") - flags.StringSliceP("repo", "R", nil, "The name, including owner of a GitHub repository in the format \"ownerName/repoName\".") + flags.StringSliceP("repo", "R", nil, "The repository name, including owner/group/project of the repository in the format \"ownerName/repoName\".") flags.StringP("repo-search", "", "", "Use a repository search to find repositories to target (GitHub only). Forks are NOT included by default, use `fork:true` to include them. See the GitHub documentation for full syntax: https://docs.github.com/en/search-github/searching-on-github/searching-for-repositories.") flags.StringP("code-search", "", "", "Use a code search to find a set of repositories to target (GitHub only). Repeated results from a given repository will be ignored, forks are NOT included by default (use `fork:true` to include them). See the GitHub documentation for full syntax: https://docs.github.com/en/search-github/searching-on-github/searching-code.") flags.StringSliceP("topic", "", nil, "The topic of a GitHub/GitLab/Gitea repository. All repositories having at least one matching topic are targeted.") - flags.StringSliceP("project", "P", nil, "The name, including owner of a GitLab project in the format \"ownerName/repoName\".") + flags.StringSliceP("project", "P", nil, "The name, including owner of a GitLab project in the format \"ownerName/repoName\". In Azure DevOps this is in the format \"projectName\".") flags.BoolP("include-subgroups", "", false, "Include GitLab subgroups when using the --group flag.") flags.BoolP("ssh-auth", "", false, `Use SSH cloning URL instead of HTTPS + token. This requires that a setup with ssh keys that have access to all repos and that the server is already in known_hosts.`) flags.BoolP("skip-forks", "", false, `Skip repositories which are forks.`) - flags.StringP("platform", "p", "github", "The platform that is used. Available values: github, gitlab, gitea, bitbucket_server.") + flags.StringP("platform", "p", "github", "The platform that is used. Available values: github, gitlab, gitea, bitbucket_server, azuredevops.") _ = cmd.RegisterFlagCompletionFunc("platform", func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{"github", "gitlab", "gitea", "bitbucket_server"}, cobra.ShellCompDirectiveDefault + return []string{"github", "gitlab", "gitea", "bitbucket_server, azuredevops"}, cobra.ShellCompDirectiveDefault }) // Autocompletion for organizations @@ -85,9 +86,9 @@ func configureRunPlatform(cmd *cobra.Command, prCreating bool) { } flags.BoolP("fork", "", false, forkDesc) - forkOwnerDesc := "If set, make the fork to the defined value. Default behavior is for the fork to be on the logged in user." + forkOwnerDesc := "If set, make the fork to the defined value (owner/project/group). Default behavior is for the fork to be on the logged in user." if !prCreating { - forkOwnerDesc = "If set, use forks from the defined value instead of the logged in user." + forkOwnerDesc = "If set, use forks from the defined value (owner/project/group) instead of the logged in user." } flags.StringP("fork-owner", "", "", forkOwnerDesc) @@ -114,6 +115,8 @@ func getVersionController(flag *flag.FlagSet, verifyFlags bool, readOnly bool) ( return createGiteaClient(flag, verifyFlags) case "bitbucket_server": return createBitbucketServerClient(flag, verifyFlags) + case "azuredevops": + return createAzureDevOpsClient(flag, verifyFlags) default: return nil, fmt.Errorf("unknown platform: %s", platform) } @@ -329,6 +332,46 @@ func createBitbucketServerClient(flag *flag.FlagSet, verifyFlags bool) (multigit return vc, nil } +func createAzureDevOpsClient(flag *flag.FlagSet, verifyFlags bool) (multigitter.VersionController, error) { + azureDevOpsBaseURL, _ := flag.GetString("base-url") + projects, _ := flag.GetStringSlice("project") + repos, _ := flag.GetStringSlice("repo") + sshAuth, _ := flag.GetBool("ssh-auth") + fork, _ := flag.GetBool("fork") + + if verifyFlags && len(projects) == 0 && len(repos) == 0 { + return nil, errors.New("no projects or repositories set") + } + + if azureDevOpsBaseURL == "" { + return nil, errors.New("no base-url set for azure devops") + } + + token, err := getToken(flag) + if err != nil { + return nil, err + } + + repoRefs := make([]azuredevops.RepositoryReference, len(repos)) + for i := range repos { + repoRefs[i], err = azuredevops.ParseRepositoryReference(repos[i]) + if err != nil { + return nil, err + } + } + + vc, err := azuredevops.New(token, azureDevOpsBaseURL, sshAuth, fork, azuredevops.RepositoryListing{ + Projects: projects, + Repositories: repoRefs, + }) + + if err != nil { + return nil, err + } + + return vc, nil +} + // versionControllerCompletion is a helper function to allow for easier implementation of Cobra autocompletions that depend on a version controller func versionControllerCompletion(cmd *cobra.Command, flagName string, fn func(vc multigitter.VersionController, toComplete string) ([]string, error)) { _ = cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/docs/README.template.md b/docs/README.template.md index 32b29799..ca507a94 100755 --- a/docs/README.template.md +++ b/docs/README.template.md @@ -76,8 +76,7 @@ go install github.com/lindell/multi-gitter@latest ``` ## Token - -To use multi-gitter, a token that is allowed to list repositories and create pull requests is needed. This token can either be set in the `GITHUB_TOKEN`, `GITLAB_TOKEN`, `GITEA_TOKEN` environment variable, or by using the `--token` flag. +To use multi-gitter, a token that is allowed to list repositories and create pull requests is needed. This token can either be set in the `GITHUB_TOKEN`, `GITLAB_TOKEN`, `GITEA_TOKEN`, `AZURE_DEVOPS_TOKEN` environment variable, or by using the `--token` flag. ### GitHub [How to generate a GitHub personal access token (classic)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic). Make sure to give it `repo` permissions. @@ -90,6 +89,10 @@ To use multi-gitter, a token that is allowed to list repositories and create pul In Gitea, access tokens can be generated under Settings -> Applications -> Manage Access Tokens +### Azure DevOps + +In Azure DevOps, token can be generated by going to User Settings -> Personal Access Tokens (PAT). In the scopes for the PAT, make sure to check "Full" for the Code scope, "Read" for the Identity scope, and "Read" for the Project and Team scope. + ## Config file All configuration in multi-gitter can be done through command line flags, configuration files or a mix of both. If you want to use a configuration file, simply use the `--config=./path/to/config.yaml`. Multi-gitter will also read from the file `~/.multi-gitter/config` and take and configuration from there. The priority of configs are first flags, then defined config file and lastly the static config file. diff --git a/go.mod b/go.mod index 0687a3dc..dd89c51a 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,8 @@ require ( golang.org/x/oauth2 v0.19.0 ) +require github.com/google/uuid v1.4.0 + require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect @@ -44,6 +46,7 @@ require ( github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.0 github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/go.sum b/go.sum index c3192bb8..076ce9b4 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,9 @@ github.com/google/go-github/v59 v59.0.0 h1:7h6bgpF5as0YQLLkEiVqpgtJqjimMYhBkD4jT github.com/google/go-github/v59 v59.0.0/go.mod h1:rJU4R0rQHFVFDOkqGWxfLNo6vEk4dv40oDjhV/gH6wM= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= @@ -96,6 +99,8 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.0 h1:mmJCWLe63QvybxhW1iBmQWEaCKdc4SKgALfTNZ+OphU= +github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.0/go.mod h1:mDunUZ1IUJdJIRHvFb+LPBUtxe3AYB5MI6BMXNg8194= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= diff --git a/internal/scm/azuredevops/azuredevops.go b/internal/scm/azuredevops/azuredevops.go new file mode 100644 index 00000000..0f5ed5c3 --- /dev/null +++ b/internal/scm/azuredevops/azuredevops.go @@ -0,0 +1,521 @@ +package azuredevops + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/lindell/multi-gitter/internal/scm" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/location" +) + +type AzureDevOps struct { + RepositoryListing + gitClient git.Client + identityClient identity.Client + locationClient location.Client + coreClient core.Client + SSHAuth bool + pat string + projectNameToID map[string]uuid.UUID +} + +// RepositoryListing contains information about which repositories that should be fetched +type RepositoryListing struct { + Projects []string + Repositories []RepositoryReference +} + +// RepositoryReference contains information to be able to reference a repository +type RepositoryReference struct { + ProjectName string + Name string +} + +// New creates a new AzureDevOps client +func New(pat, baseURL string, sshAuth bool, fork bool, repoListing RepositoryListing) (*AzureDevOps, error) { + connection := azuredevops.NewPatConnection(baseURL, pat) + gitClient, err := git.NewClient(context.Background(), connection) + if err != nil { + return nil, err + } + locationClient := location.NewClient(context.Background(), connection) + + coreClient, err := core.NewClient(context.Background(), connection) + if err != nil { + return nil, err + } + identityClient, err := identity.NewClient(context.Background(), connection) + if err != nil { + return nil, err + } + projectMap := make(map[string]uuid.UUID) + + if fork { + projects, err := coreClient.GetProjects(context.Background(), core.GetProjectsArgs{}) + if err != nil { + return nil, err + } + projectMap = make(map[string]uuid.UUID, len(projects.Value)) + + for _, project := range projects.Value { + projectMap[*project.Name] = *project.Id + } + } + + azureDevOps := &AzureDevOps{ + RepositoryListing: repoListing, + gitClient: gitClient, + identityClient: identityClient, + locationClient: locationClient, + coreClient: coreClient, + SSHAuth: sshAuth, + pat: pat, + projectNameToID: projectMap, + } + + return azureDevOps, nil +} + +func ParseRepositoryReference(repo string) (RepositoryReference, error) { + split := strings.Split(repo, "/") + if len(split) != 2 { + return RepositoryReference{}, fmt.Errorf("could not parse repository reference: %s", repo) + } + + return RepositoryReference{ + ProjectName: split[0], + Name: split[1], + }, nil +} + +// GetRepositories fetches repositories from all sources (groups/user/specific project) +func (a *AzureDevOps) GetRepositories(ctx context.Context) ([]scm.Repository, error) { + projects, err := a.getRepos(ctx) + + if err != nil { + return nil, err + } + repos := make([]scm.Repository, 0, len(projects)) + + for _, repo := range projects { + rCopy := repo + r, err := a.convertRepo(&rCopy) + if err == nil { + repos = append(repos, r) + } else { + return nil, err + } + } + + return repos, nil +} + +func (a *AzureDevOps) getRepos(ctx context.Context) ([]git.GitRepository, error) { + allRepos, err := a.gitClient.GetRepositories(ctx, git.GetRepositoriesArgs{}) + if err != nil { + return nil, err + } + filteredRepos := make([]git.GitRepository, 0) + + for _, repo := range *allRepos { + added := false + + // Check if the repository belongs to one of the configured projects + for _, project := range a.RepositoryListing.Projects { + if *repo.Project.Name == project { + filteredRepos = append(filteredRepos, repo) + added = true + break + } + } + + if added { + continue + } + + // Check if the repository is one of the configured repositories + for _, repository := range a.RepositoryListing.Repositories { + if *repo.Project.Name == repository.ProjectName && *repo.Name == repository.Name { + filteredRepos = append(filteredRepos, repo) + break + } + } + } + + return filteredRepos, nil +} + +func (a *AzureDevOps) CreatePullRequest(ctx context.Context, _ scm.Repository, prRepo scm.Repository, newPR scm.NewPullRequest) (scm.PullRequest, error) { + prr := prRepo.(repository) + + reviewerIDs, err := a.getUserIDs(ctx, append(newPR.Reviewers, newPR.Assignees...)) + if err != nil { + return nil, err + } + reviewers := make([]git.IdentityRefWithVote, len(reviewerIDs)) + for i, reviewerID := range reviewerIDs { + reviewers[i] = git.IdentityRefWithVote{ + Id: &reviewerID, + } + } + + prTitle := newPR.Title + if newPR.Draft { + prTitle = "Draft: " + prTitle + } + + activeLabel := true + labels := make([]core.WebApiTagDefinition, len(newPR.Labels)) + removeSourceBranch := true + + for i, label := range newPR.Labels { + lCopy := label + labels[i] = core.WebApiTagDefinition{ + Active: &activeLabel, + Name: &lCopy, + } + } + srn := PrependPrefixIfNeeded(newPR.Head) // Pass the value of newPR.Head + trn := PrependPrefixIfNeeded(newPR.Base) + + requestArgs := git.CreatePullRequestArgs{ + GitPullRequestToCreate: &git.GitPullRequest{ + Title: &prTitle, + Description: &newPR.Body, + SourceRefName: &srn, + TargetRefName: &trn, + CompletionOptions: &git.GitPullRequestCompletionOptions{ + DeleteSourceBranch: &removeSourceBranch, + }, + Labels: &labels, + Reviewers: &reviewers, + IsDraft: &newPR.Draft, + }, + RepositoryId: &prr.rid, + Project: &prr.ownerName, + } + + pr, err := a.gitClient.CreatePullRequest(ctx, requestArgs) + if err != nil { + return nil, err + } + return pullRequest{ + ownerName: prr.ownerName, + repoName: prr.name, + repoID: prr.rid, + branchName: newPR.Head, + id: *pr.PullRequestId, + }, nil +} + +func (a *AzureDevOps) UpdatePullRequest(ctx context.Context, repo scm.Repository, pullReq scm.PullRequest, updatedPR scm.NewPullRequest) (scm.PullRequest, error) { + r := repo.(repository) + pr := pullReq.(pullRequest) + + reviewerIDs, err := a.getUserIDs(ctx, append(updatedPR.Reviewers, updatedPR.Assignees...)) + if err != nil { + return nil, err + } + reviewers := make([]git.IdentityRefWithVote, len(reviewerIDs)) + for i, reviewerID := range reviewerIDs { + reviewers[i] = git.IdentityRefWithVote{ + Id: &reviewerID, + } + } + prTitle := updatedPR.Title + if updatedPR.Draft { + prTitle = "Draft: " + prTitle + } + + activeLabel := true + labels := make([]core.WebApiTagDefinition, len(updatedPR.Labels)) + for i, label := range updatedPR.Labels { + lCopy := label + labels[i] = core.WebApiTagDefinition{ + Active: &activeLabel, + Name: &lCopy, + } + } + + srn := PrependPrefixIfNeeded(updatedPR.Head) + trn := PrependPrefixIfNeeded(updatedPR.Base) + + updateResult, err := a.gitClient.UpdatePullRequest(ctx, git.UpdatePullRequestArgs{ + GitPullRequestToUpdate: &git.GitPullRequest{ + Title: &prTitle, + Description: &updatedPR.Body, + SourceRefName: &srn, + TargetRefName: &trn, + Labels: &labels, + Reviewers: &reviewers, + }, + RepositoryId: &pr.repoID, + PullRequestId: &pr.id, + }) + + if err != nil { + return nil, err + } + return pullRequest{ + ownerName: r.ownerName, + repoName: r.name, + repoID: r.rid, + branchName: updatedPR.Head, + id: *updateResult.PullRequestId, + }, nil +} + +func (a *AzureDevOps) getUserIDs(ctx context.Context, usernames []string) ([]string, error) { + userIDs := make([]string, len(usernames)) + + searchFilter := "General" + + for i, username := range usernames { + uCopy := username + users, err := a.identityClient.ReadIdentities(ctx, identity.ReadIdentitiesArgs{ + SearchFilter: &searchFilter, + FilterValue: &uCopy, + }) + + if err != nil { + return nil, err + } + if users == nil || len(*users) != 1 { + return nil, fmt.Errorf("could not find user or more than one user was found: %s", usernames[i]) + } + + userIDs[i] = (*users)[0].Id.String() + } + return userIDs, nil +} + +func (a *AzureDevOps) GetPullRequests(ctx context.Context, branchName string) ([]scm.PullRequest, error) { + repos, err := a.getRepos(ctx) + if err != nil { + return nil, err + } + bn := PrependPrefixIfNeeded(branchName) + + prs := []scm.PullRequest{} + for _, repo := range repos { + pr, err := a.getPullRequest(ctx, bn, repo.Id.String()) + if err != nil { + return nil, err + } + if pr == nil { + continue + } + + prs = append(prs, convertPullRequest(*pr)) + } + + return prs, nil +} + +func convertPullRequest(pr git.GitPullRequest) pullRequest { + return pullRequest{ + ownerName: *pr.Repository.Project.Name, + repoName: *pr.Repository.Name, + repoID: pr.Repository.Id.String(), + branchName: *pr.SourceRefName, + id: *pr.PullRequestId, + status: pullRequestStatus(pr), + lastMergeSourceCommitID: *pr.LastMergeSourceCommit.CommitId, + } +} + +func (a *AzureDevOps) getPullRequest(ctx context.Context, branchName string, rid string) (*git.GitPullRequest, error) { + status := &git.PullRequestStatusValues.Active + + prs, err := a.gitClient.GetPullRequests(ctx, git.GetPullRequestsArgs{ + RepositoryId: &rid, + SearchCriteria: &git.GitPullRequestSearchCriteria{ + SourceRefName: &branchName, + Status: status, + }, + }) + if err != nil { + return nil, err + } + if prs == nil || len(*prs) == 0 { + return nil, nil + } + + return &(*prs)[0], nil +} + +func pullRequestStatus(pr git.GitPullRequest) scm.PullRequestStatus { + switch { + case *pr.MergeStatus == git.PullRequestAsyncStatusValues.Succeeded: + return scm.PullRequestStatusSuccess + case *pr.Status == git.PullRequestStatusValues.Abandoned || *pr.Status == git.PullRequestStatusValues.Completed: + return scm.PullRequestStatusClosed + case *pr.MergeStatus == git.PullRequestAsyncStatusValues.Conflicts || + *pr.MergeStatus == git.PullRequestAsyncStatusValues.Failure || + *pr.MergeStatus == git.PullRequestAsyncStatusValues.RejectedByPolicy: + return scm.PullRequestStatusError + case *pr.Status == git.PullRequestStatusValues.Active: + return scm.PullRequestStatusPending + default: + return scm.PullRequestStatusUnknown + } +} + +// GetOpenPullRequest gets a pull request for one specific repository +func (a *AzureDevOps) GetOpenPullRequest(ctx context.Context, repo scm.Repository, branchName string) (scm.PullRequest, error) { + r := repo.(repository) + + prs, err := a.gitClient.GetPullRequests(ctx, git.GetPullRequestsArgs{ + RepositoryId: &r.rid, + SearchCriteria: &git.GitPullRequestSearchCriteria{ + Status: &git.PullRequestStatusValues.Active, + SourceRefName: &branchName, + }, + }) + if err != nil { + return nil, err + } + if prs == nil || len(*prs) == 0 { + return nil, nil + } + + return convertPullRequest((*prs)[0]), nil +} + +// MergePullRequest merges a pull request +func (a *AzureDevOps) MergePullRequest(ctx context.Context, pullReq scm.PullRequest) error { + pr := pullReq.(pullRequest) + + _, err := a.gitClient.UpdatePullRequest(ctx, git.UpdatePullRequestArgs{ + GitPullRequestToUpdate: &git.GitPullRequest{ + Status: &git.PullRequestStatusValues.Completed, + LastMergeSourceCommit: &git.GitCommitRef{ + CommitId: &pr.lastMergeSourceCommitID, + }, + }, + RepositoryId: &pr.repoID, + PullRequestId: &pr.id, + }) + if err != nil { + return err + } + return nil +} + +// ClosePullRequest closes a pull request +func (a *AzureDevOps) ClosePullRequest(ctx context.Context, pullReq scm.PullRequest) error { + pr := pullReq.(pullRequest) + + _, err := a.gitClient.UpdatePullRequest(ctx, git.UpdatePullRequestArgs{ + GitPullRequestToUpdate: &git.GitPullRequest{ + Status: &git.PullRequestStatusValues.Abandoned, + }, + RepositoryId: &pr.repoID, + PullRequestId: &pr.id, + }) + if err != nil { + return err + } + // this is the objectId needed to indicate that a branch should be deleted + newObjectID := "0000000000000000000000000000000000000000" + refUpdate := git.GitRefUpdate{ + Name: &pr.branchName, + OldObjectId: &pr.lastMergeSourceCommitID, + NewObjectId: &newObjectID, + } + + refUpdates := []git.GitRefUpdate{refUpdate} + + _, err = a.gitClient.UpdateRefs(ctx, git.UpdateRefsArgs{ + RepositoryId: &pr.repoID, + RefUpdates: &refUpdates, + }) + + if err != nil { + return err + } + return nil +} + +// ForkRepository forks a project +func (a *AzureDevOps) ForkRepository(ctx context.Context, repo scm.Repository, newProject string) (scm.Repository, error) { + r := repo.(repository) + + currentUser, err := a.getCurrentUser(ctx) + if err != nil { + return nil, err + } + np := newProject + if np == "" { + np = r.ownerName + } + + repoName := r.name + "." + strings.ReplaceAll(currentUser, " ", ".") + + // Check if the forked repo already exists + existingFork, err := a.gitClient.GetRepository(ctx, git.GetRepositoryArgs{ + Project: &np, + RepositoryId: &repoName, + }) + + var wrappedErr *azuredevops.WrappedError + if err == nil { + repo, err := a.convertRepo(existingFork) + return repo, err + } else if errors.As(err, &wrappedErr) { + if !(*wrappedErr.StatusCode == http.StatusNotFound) { + return nil, err + } + } + + repoUUID, err := uuid.Parse(r.rid) + if err != nil { + return nil, err + } + pid := a.projectNameToID[np] + parentPid := a.projectNameToID[r.ownerName] + + // Fork the repository + forkedRepo, err := a.gitClient.CreateRepository(ctx, git.CreateRepositoryArgs{ + GitRepositoryToCreate: &git.GitRepositoryCreateOptions{ + Name: &repoName, + Project: &core.TeamProjectReference{ + Id: &pid, + }, + ParentRepository: &git.GitRepositoryRef{ + Id: &repoUUID, + Project: &core.TeamProjectReference{ + Id: &parentPid, + }, + }, + }, + }) + if err != nil { + return nil, err + } + // Convert the forked repository to scm.Repository + return a.convertRepo(forkedRepo) +} + +func (a *AzureDevOps) getCurrentUser(ctx context.Context) (string, error) { + currentUser, err := a.locationClient.GetConnectionData(ctx, location.GetConnectionDataArgs{}) + if err != nil { + return "", err + } + return *currentUser.AuthenticatedUser.ProviderDisplayName, nil +} + +func PrependPrefixIfNeeded(s string) string { + if !strings.HasPrefix(s, "refs/heads/") { + s = "refs/heads/" + s + } + return s +} diff --git a/internal/scm/azuredevops/azuredevops_test.go b/internal/scm/azuredevops/azuredevops_test.go new file mode 100644 index 00000000..b08d3249 --- /dev/null +++ b/internal/scm/azuredevops/azuredevops_test.go @@ -0,0 +1,46 @@ +package azuredevops + +import ( + "reflect" + "testing" +) + +func TestParseRepositoryReference(t *testing.T) { + tests := []struct { + name string + val string + want RepositoryReference + wantErr bool + }{ + { + name: "single", + val: "my-project/my-repo", + want: RepositoryReference{ + ProjectName: "my-project", + Name: "my-repo", + }, + }, + { + name: "no-project", + val: "my-repo", + wantErr: true, + }, + { + name: "too many parts", + val: "my-project/my-repo/more-data", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseRepositoryReference(tt.val) + if (err != nil) != tt.wantErr { + t.Errorf("ParseRepositoryReference() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseRepositoryReference() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/scm/azuredevops/pullrequest.go b/internal/scm/azuredevops/pullrequest.go new file mode 100644 index 00000000..a520daa4 --- /dev/null +++ b/internal/scm/azuredevops/pullrequest.go @@ -0,0 +1,25 @@ +package azuredevops + +import ( + "fmt" + + "github.com/lindell/multi-gitter/internal/scm" +) + +type pullRequest struct { + ownerName string + repoName string + repoID string + branchName string + id int + status scm.PullRequestStatus + lastMergeSourceCommitID string +} + +func (pr pullRequest) String() string { + return fmt.Sprintf("%s/%s #%d", pr.ownerName, pr.repoName, pr.id) +} + +func (pr pullRequest) Status() scm.PullRequestStatus { + return pr.status +} diff --git a/internal/scm/azuredevops/repository.go b/internal/scm/azuredevops/repository.go new file mode 100644 index 00000000..7c6d7d01 --- /dev/null +++ b/internal/scm/azuredevops/repository.go @@ -0,0 +1,58 @@ +package azuredevops + +import ( + "fmt" + "net/url" + "path" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + "github.com/pkg/errors" +) + +func (a *AzureDevOps) convertRepo(repo *git.GitRepository) (repository, error) { + var cloneURL string + if a.SSHAuth { + cloneURL = *repo.SshUrl + } else { + u, err := url.Parse(*repo.RemoteUrl) + if err != nil { + return repository{}, errors.Wrap(err, "could not parse Azure Devops remote url") + } + // Set the token as https://ACCESSTOKEN@remote-url + u.User = url.User(a.pat) + cloneURL = u.String() + } + + defaultBranch := "" + if repo.DefaultBranch != nil { + defaultBranch = path.Base(*repo.DefaultBranch) + } + + return repository{ + url: cloneURL, + rid: repo.Id.String(), + name: *repo.Name, + ownerName: *repo.Project.Name, + defaultBranch: defaultBranch, + }, nil +} + +type repository struct { + url string + rid string + name string + ownerName string + defaultBranch string +} + +func (r repository) CloneURL() string { + return r.url +} + +func (r repository) DefaultBranch() string { + return r.defaultBranch +} + +func (r repository) FullName() string { + return fmt.Sprintf("%s/%s", r.ownerName, r.name) +} diff --git a/tools/readme-docs/main.go b/tools/readme-docs/main.go index bfb34c6c..f5ca3b86 100755 --- a/tools/readme-docs/main.go +++ b/tools/readme-docs/main.go @@ -116,8 +116,8 @@ func main() { // Replace some of the default values in the yaml example with these values var yamlExamples = map[string]string{ - "repo": "\n - my-org/js-repo\n - other-org/python-repo", - "project": "\n - group/project", + "repo": "\n - my-org/js-repo\n - other-org/python-repo\n - azure-devops-project/ado-repo", + "project": "\n - group/project\n - azureDevOpsProjectName", } var listDefaultRegex = regexp.MustCompile(`^\[(.+)\]$`)