From 7c8392368a2a02f89ec95c6c0b2b3b387f927544 Mon Sep 17 00:00:00 2001 From: Soule BA Date: Mon, 11 Oct 2021 16:23:06 +0200 Subject: [PATCH] Add the capabilities to create org and user repositories. Add client to create keys, commit, create branches and pull requests for the repositories. Add capabilities for reconciling repositories, deploy keys and teams access. Signed-off-by: Soule BA --- stash/client_repositories_org.go | 281 ++++++++++++- stash/client_repositories_user.go | 146 ++++++- stash/client_repository_branch.go | 109 +++++ stash/client_repository_commit.go | 131 ++++++ stash/client_repository_deploykey.go | 254 ++++++++++++ stash/client_repository_pullrequest.go | 83 ++++ stash/client_repository_teamaccess.go | 267 ++++++++++++ stash/integration_repositories_org_test.go | 429 ++++++++++++++++++++ stash/integration_repositories_user_test.go | 233 +++++++++++ stash/resource_commit.go | 53 +++ stash/resource_deploykey.go | 136 +++++++ stash/resource_pullrequest.go | 54 +++ stash/resource_repository.go | 246 +++++++++++ stash/resource_teamaccess.go | 153 +++++++ stash/resource_teamaccess_test.go | 95 +++++ 15 files changed, 2652 insertions(+), 18 deletions(-) create mode 100644 stash/client_repository_branch.go create mode 100644 stash/client_repository_commit.go create mode 100644 stash/client_repository_deploykey.go create mode 100644 stash/client_repository_pullrequest.go create mode 100644 stash/client_repository_teamaccess.go create mode 100644 stash/integration_repositories_org_test.go create mode 100644 stash/integration_repositories_user_test.go create mode 100644 stash/resource_commit.go create mode 100644 stash/resource_deploykey.go create mode 100644 stash/resource_pullrequest.go create mode 100644 stash/resource_repository.go create mode 100644 stash/resource_teamaccess.go create mode 100644 stash/resource_teamaccess_test.go diff --git a/stash/client_repositories_org.go b/stash/client_repositories_org.go index 196ec0be..c5e99222 100644 --- a/stash/client_repositories_org.go +++ b/stash/client_repositories_org.go @@ -19,8 +19,10 @@ package stash import ( "context" "errors" + "fmt" "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/fluxcd/go-git-providers/validation" ) // OrgRepositoriesClient implements the gitprovider.OrgRepositoriesClient interface. @@ -32,31 +34,292 @@ type OrgRepositoriesClient struct { } // Get returns the repository at the given path. -// // ErrNotFound is returned if the resource does not exist. func (c *OrgRepositoriesClient) Get(ctx context.Context, ref gitprovider.OrgRepositoryRef) (gitprovider.OrgRepository, error) { - return nil, errors.New("not implemented") + // Make sure the OrgRepositoryRef is valid + if err := validateOrgRepositoryRef(ref, c.host); err != nil { + return nil, err + } + + slug := ref.GetSlug() + if slug == "" { + // try with name + slug = ref.GetRepository() + } + + apiObj, err := c.client.Repositories.Get(ctx, ref.GetKey(), slug) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, gitprovider.ErrNotFound + } + return nil, fmt.Errorf("failed to get repository %s/%s: %w", ref.GetKey(), slug, err) + } + + // Validate the API objects + if err := validateRepositoryAPI(apiObj); err != nil { + return nil, err + } + + ref.RepositorySlug = apiObj.Slug + + return newOrgRepository(c.clientContext, apiObj, ref), nil } // List all repositories in the given organization. -// // List returns all available repositories, using multiple paginated requests if needed. func (c *OrgRepositoriesClient) List(ctx context.Context, ref gitprovider.OrganizationRef) ([]gitprovider.OrgRepository, error) { - return nil, errors.New("not implemented") + // Make sure the OrganizationRef is valid + if err := validateOrganizationRef(ref, c.host); err != nil { + return nil, err + } + + apiObjs, err := c.client.Repositories.All(ctx, ref.GetKey(), c.maxPages) + if err != nil { + return nil, fmt.Errorf("failed to list repositories: %w", err) + } + + // Traverse the list, and return a list of OrgRepository objects + repos := make([]gitprovider.OrgRepository, 0, len(apiObjs)) + for _, apiObj := range apiObjs { + if err := validateRepositoryAPI(apiObj); err != nil { + return nil, err + } + repos = append(repos, newOrgRepository(c.clientContext, apiObj, gitprovider.OrgRepositoryRef{ + OrganizationRef: ref, + RepositoryName: apiObj.Name, + RepositorySlug: apiObj.Slug, + })) + } + return repos, nil } // Create creates a repository for the given organization, with the data and options. -// // ErrAlreadyExists will be returned if the resource already exists. -func (c *OrgRepositoriesClient) Create(ctx context.Context, ref gitprovider.OrgRepositoryRef, req gitprovider.RepositoryInfo, opts ...gitprovider.RepositoryCreateOption) (gitprovider.OrgRepository, error) { - return nil, errors.New("not implemented") +func (c *OrgRepositoriesClient) Create(ctx context.Context, + ref gitprovider.OrgRepositoryRef, + req gitprovider.RepositoryInfo, + opts ...gitprovider.RepositoryCreateOption) (gitprovider.OrgRepository, error) { + // Make sure the RepositoryRef is valid + if err := validateOrgRepositoryRef(ref, c.host); err != nil { + return nil, err + } + + apiObj, err := createRepository(ctx, c.client, ref.GetKey(), ref, req, opts...) + if err != nil { + if errors.Is(err, ErrAlreadyExists) { + return nil, gitprovider.ErrAlreadyExists + } + return nil, fmt.Errorf("failed to create repository %s/%s: %w", ref.GetKey(), ref.GetSlug(), err) + } + + ref.RepositorySlug = apiObj.Slug + + return newOrgRepository(c.clientContext, apiObj, ref), nil } // Reconcile makes sure the given desired state (req) becomes the actual state in the backing Git provider. -// // If req doesn't exist under the hood, it is created (actionTaken == true). // If req doesn't equal the actual state, the resource will be updated (actionTaken == true). // If req is already the actual state, this is a no-op (actionTaken == false). func (c *OrgRepositoriesClient) Reconcile(ctx context.Context, ref gitprovider.OrgRepositoryRef, req gitprovider.RepositoryInfo, opts ...gitprovider.RepositoryReconcileOption) (gitprovider.OrgRepository, bool, error) { - return nil, false, errors.New("not implemented") + actual, err := c.Get(ctx, ref) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + resp, err := c.Create(ctx, ref, req, toCreateOpts(opts...)...) + return resp, true, err + } + + // Unexpected path, Get should succeed or return NotFound + return nil, false, fmt.Errorf("unexpected error when reconciling repository: %w", err) + } + + actionTaken, err := c.reconcileRepository(ctx, actual, req) + + return actual, actionTaken, err +} + +// update will apply the desired state in this object to the server. +// ErrNotFound is returned if the resource does not exist. +func update(ctx context.Context, c *Client, orgKey, repoSlug string, repository *Repository) (*Repository, error) { + apiObj, err := c.Repositories.Update(ctx, orgKey, repoSlug, repository) + if err != nil { + return nil, fmt.Errorf("failed to update repository: %w", err) + } + + return apiObj, nil +} + +func delete(ctx context.Context, c *Client, orgKey, repoSlug string) error { + if err := c.Repositories.Delete(ctx, orgKey, repoSlug); err != nil { + return fmt.Errorf("failed to delete repository: %w", err) + } + + return nil +} + +func createRepository(ctx context.Context, c *Client, orgKey string, ref gitprovider.RepositoryRef, req gitprovider.RepositoryInfo, opts ...gitprovider.RepositoryCreateOption) (*Repository, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, err + } + + // Assemble the options struct based on the given options + opt, err := gitprovider.MakeRepositoryCreateOptions(opts...) + if err != nil { + return nil, err + } + + // Convert to the API object and apply the options + data := repositoryToAPI(&req, ref) + if err != nil { + return nil, err + } + + repo, err := c.Repositories.Create(ctx, orgKey, data) + if err != nil { + return nil, fmt.Errorf("failed to create repository: %w", err) + } + + if opt.AutoInit != nil && *(opt.AutoInit) { + user, err := c.Users.Get(ctx, repo.Session.UserName) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + readmeContents := fmt.Sprintf("# %s\n%s", repo.Name, repo.Description) + readmePath, licensePath := "README.md", "LICENSE.md" + files := []CommitFile{ + { + Path: &readmePath, + Content: &readmeContents, + }, + } + var licenseContent string + if opt.LicenseTemplate != nil { + licenseContent, err = getLicense(*opt.LicenseTemplate) + // If the license template is invalid, we'll just skip the license + if err == nil { + files = append(files, CommitFile{ + Path: &licensePath, + Content: &licenseContent, + }) + } + } + + initCommit, err := NewCommit( + WithAuthor(&CommitAuthor{ + Name: user.Name, + Email: user.EmailAddress, + }), + WithMessage("initial commit"), + WithURL(getRepoHTTPref(repo.Links.Clone)), + WithFiles(files)) + + r, dir, err := c.Git.InitRepository(ctx, initCommit, true) + if err != nil { + if err := c.Repositories.Delete(ctx, repo.Project.Key, repo.Slug); err != nil { + return nil, fmt.Errorf("failed to delete repository: %w", err) + } + return nil, fmt.Errorf("failed to init repository: %w", err) + } + + err = c.Git.Push(ctx, r) + if err != nil { + return nil, fmt.Errorf("failed to push initial commit: %w", err) + } + + err = c.Git.Cleanup(dir) + if err != nil { + return nil, fmt.Errorf("failed to cleanup repository: %w", err) + } + } + + return repo, nil +} + +func getRepoHTTPref(clones []Clone) string { + for _, clone := range clones { + if clone.Name == "http" { + return clone.Href + } + } + return "no http ref found" +} + +func (c *OrgRepositoriesClient) reconcileRepository(ctx context.Context, actual gitprovider.UserRepository, req gitprovider.RepositoryInfo) (bool, error) { + // If the desired matches the actual state, just return the actual state + new := actual.Get() + if req.Equals(new) { + return false, nil + } + // Populate the desired state to the current-actual object + if err := actual.Set(req); err != nil { + return false, err + } + + projectKey, repoSlug := getStashRefs(actual.Repository()) + + // Apply the desired state by running Update + repo := actual.APIObject().(*Repository) + _, err := update(ctx, c.client, projectKey, repoSlug, repo) + + if err != nil { + return false, err + } + + return true, nil +} + +func toCreateOpts(opts ...gitprovider.RepositoryReconcileOption) []gitprovider.RepositoryCreateOption { + // Convert RepositoryReconcileOption => RepositoryCreateOption + createOpts := make([]gitprovider.RepositoryCreateOption, 0, len(opts)) + for _, opt := range opts { + createOpts = append(createOpts, opt) + } + return createOpts +} + +// validateOrgRepositoryRef makes sure the OrgRepositoryRef is valid for GitHub's usage. +func validateOrgRepositoryRef(ref gitprovider.OrgRepositoryRef, expectedDomain string) error { + // Make sure the RepositoryRef fields are valid + if err := validation.ValidateTargets("OrgRepositoryRef", ref); err != nil { + return err + } + // Make sure the type is valid, and domain is expected + return validateIdentityFields(ref, expectedDomain) +} + +func getStashRefs(ref gitprovider.RepositoryRef) (string, string) { + var repoSlug string + if slugger, ok := ref.(gitprovider.Slugger); ok { + repoSlug = slugger.GetSlug() + } else { + repoSlug = ref.GetRepository() + } + + var projectKey string + if keyer, ok := ref.(gitprovider.Keyer); ok { + projectKey = keyer.GetKey() + } else { + projectKey = ref.GetIdentity() + } + + return projectKey, repoSlug +} + +// validateRepositoryAPI validates the apiObj received from the server, to make sure that it is +// valid for our use. +func validateRepositoryAPI(apiObj *Repository) error { + return validateAPIObject("Stash.Repository", func(validator validation.Validator) { + // Make sure name is set + if apiObj.Name == "" { + validator.Required("Name") + } + // Make sure slug is set + if apiObj.Slug == "" { + validator.Required("Slug") + } + }) } diff --git a/stash/client_repositories_user.go b/stash/client_repositories_user.go index 7eaecfd1..5b625954 100644 --- a/stash/client_repositories_user.go +++ b/stash/client_repositories_user.go @@ -19,6 +19,7 @@ package stash import ( "context" "errors" + "fmt" "github.com/fluxcd/go-git-providers/gitprovider" "github.com/fluxcd/go-git-providers/validation" @@ -33,28 +34,92 @@ type UserRepositoriesClient struct { } // Get returns the repository at the given path. -// // ErrNotFound is returned if the resource does not exist. func (c *UserRepositoriesClient) Get(ctx context.Context, ref gitprovider.UserRepositoryRef) (gitprovider.UserRepository, error) { - return nil, errors.New("not implemented") + // Make sure the UserRepositoryRef is valid + if err := validateUserRepositoryRef(ref, c.host); err != nil { + return nil, err + } + + // Make sure the UserRef is valid + if err := validateUserRef(ref.UserRef, c.host); err != nil { + return nil, err + } + + slug := ref.GetSlug() + if slug == "" { + // try with name + slug = ref.GetRepository() + } + + apiObj, err := c.client.Repositories.Get(ctx, addTilde(ref.UserLogin), slug) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, gitprovider.ErrNotFound + } + return nil, fmt.Errorf("failed to get repository %s/%s: %w", addTilde(ref.UserLogin), slug, err) + } + + // Validate the API objects + if err := validateRepositoryAPI(apiObj); err != nil { + return nil, err + } + + ref.RepositorySlug = apiObj.Slug + + return newUserRepository(c.clientContext, apiObj, ref), nil } // List all repositories for the given user. -// // List returns all available repositories, using multiple paginated requests if needed. func (c *UserRepositoriesClient) List(ctx context.Context, ref gitprovider.UserRef) ([]gitprovider.UserRepository, error) { - return nil, errors.New("not implemented") + // Make sure the UserRef is valid + if err := validateUserRef(ref, c.host); err != nil { + return nil, err + } + + apiObjs, err := c.client.Repositories.All(ctx, addTilde(ref.UserLogin), c.maxPages) + if err != nil { + return nil, fmt.Errorf("failed to list repositories for %s: %w", addTilde(ref.UserLogin), err) + } + + // Traverse the list, and return a list of UserRepository objects + repos := make([]gitprovider.UserRepository, 0, len(apiObjs)) + for _, apiObj := range apiObjs { + if err := validateRepositoryAPI(apiObj); err != nil { + return nil, err + } + repos = append(repos, newUserRepository(c.clientContext, apiObj, gitprovider.UserRepositoryRef{ + UserRef: ref, + RepositoryName: apiObj.Name, + RepositorySlug: apiObj.Slug, + })) + } + return repos, nil } // Create creates a repository for the given organization, with the data and options -// // ErrAlreadyExists will be returned if the resource already exists. func (c *UserRepositoriesClient) Create(ctx context.Context, ref gitprovider.UserRepositoryRef, req gitprovider.RepositoryInfo, - opts ...gitprovider.RepositoryCreateOption, -) (gitprovider.UserRepository, error) { - return nil, errors.New("not implemented") + opts ...gitprovider.RepositoryCreateOption) (gitprovider.UserRepository, error) { + // Make sure the RepositoryRef is valid + if err := validateUserRepositoryRef(ref, c.host); err != nil { + return nil, err + } + + apiObj, err := createRepository(ctx, c.client, addTilde(ref.UserLogin), ref, req, opts...) + if err != nil { + if errors.Is(err, ErrAlreadyExists) { + return nil, gitprovider.ErrAlreadyExists + } + return nil, fmt.Errorf("failed to create repository %s/%s: %w", addTilde(ref.UserLogin), ref.RepositoryName, err) + } + + ref.RepositorySlug = apiObj.Slug + + return newUserRepository(c.clientContext, apiObj, ref), nil } // Reconcile makes sure the given desired state (req) becomes the actual state in the backing Git provider. @@ -63,7 +128,43 @@ func (c *UserRepositoriesClient) Create(ctx context.Context, // If req doesn't equal the actual state, the resource will be updated (actionTaken == true). // If req is already the actual state, this is a no-op (actionTaken == false). func (c *UserRepositoriesClient) Reconcile(ctx context.Context, ref gitprovider.UserRepositoryRef, req gitprovider.RepositoryInfo, opts ...gitprovider.RepositoryReconcileOption) (gitprovider.UserRepository, bool, error) { - return nil, false, errors.New("not implemented") + actual, err := c.Get(ctx, ref) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + resp, err := c.Create(ctx, ref, req, toCreateOpts(opts...)...) + return resp, true, err + } + + // Unexpected path, Get should succeed or return NotFound + return nil, false, fmt.Errorf("failed to reconcile repository %s/%s: %w", addTilde(ref.UserLogin), ref.RepositoryName, err) + } + + actionTaken, err := c.reconcileRepository(ctx, actual, req) + + return actual, actionTaken, err +} + +func (c *UserRepositoriesClient) reconcileRepository(ctx context.Context, actual gitprovider.UserRepository, req gitprovider.RepositoryInfo) (bool, error) { + // If the desired matches the actual state, just return the actual state + new := actual.Get() + if req.Equals(new) { + return false, nil + } + // Populate the desired state to the current-actual object + if err := actual.Set(req); err != nil { + return false, err + } + // Apply the desired state by running Update + repo := actual.APIObject().(*Repository) + ref := actual.Repository().(gitprovider.UserRepositoryRef) + _, err := update(ctx, c.client, addTilde(ref.UserLogin), ref.GetSlug(), repo) + + if err != nil { + return false, err + } + + return true, nil } func validateUserAPI(apiObj *User) error { @@ -73,3 +174,30 @@ func validateUserAPI(apiObj *User) error { } }) } + +// validateUserRepositoryRef makes sure the UserRepositoryRef is valid for GitHub's usage. +func validateUserRepositoryRef(ref gitprovider.UserRepositoryRef, expectedDomain string) error { + // Make sure the RepositoryRef fields are valid + if err := validation.ValidateTargets("UserRepositoryRef", ref); err != nil { + return err + } + // Make sure the type is valid, and domain is expected + return validateIdentityFields(ref, expectedDomain) +} + +// validateUserRef makes sure the UserRef is valid for GitHub's usage. +func validateUserRef(ref gitprovider.UserRef, expectedDomain string) error { + // Make sure the OrganizationRef fields are valid + if err := validation.ValidateTargets("UserRef", ref); err != nil { + return err + } + // Make sure the type is valid, and domain is expected + return validateIdentityFields(ref, expectedDomain) +} + +func addTilde(userName string) string { + if len(userName) > 0 && userName[0] == '~' { + return userName + } + return fmt.Sprintf("~%s", userName) +} diff --git a/stash/client_repository_branch.go b/stash/client_repository_branch.go new file mode 100644 index 00000000..04ca4e15 --- /dev/null +++ b/stash/client_repository_branch.go @@ -0,0 +1,109 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "context" + "fmt" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +// BranchClient implements the gitprovider.BranchClient interface. +var _ gitprovider.BranchClient = &BranchClient{} + +// BranchClient operates on the branch for a specific repository. +type BranchClient struct { + *clientContext + ref gitprovider.RepositoryRef +} + +// Create creates a branch with the given specifications. +func (c *BranchClient) Create(ctx context.Context, branch, sha string) error { + projectKey, repoSlug := getStashRefs(c.ref) + + // check if it is a user repository + // if yes, we need to add a tilde to the user login and use it as the project key + if r, ok := c.ref.(gitprovider.UserRepositoryRef); ok { + projectKey = addTilde(r.UserLogin) + } + + repo, err := c.client.Repositories.Get(ctx, projectKey, repoSlug) + if err != nil { + return fmt.Errorf("failed to get repository %s/%s: %w", projectKey, repoSlug, err) + } + + user, err := c.client.Users.Get(ctx, repo.Session.UserName) + if err != nil { + return fmt.Errorf("failed to get user %s: %w", repo.Session.UserName, err) + } + + url := getRepoHTTPref(repo.Links.Clone) + + r, dir, err := c.client.Git.CloneRepository(ctx, url) + if err != nil { + return fmt.Errorf("failed to clone repository: %w", err) + } + + err = c.client.Git.CreateBranch(branch, r, sha) + if err != nil { + return fmt.Errorf("failed to create branch: %w", err) + } + + commit, err := NewCommit( + WithAuthor(&CommitAuthor{ + Name: user.Name, + Email: user.EmailAddress, + }), + WithMessage("Create branch"), + WithURL(url)) + + _, err = c.client.Git.CreateCommit(ctx, dir, r, "", commit) + if err != nil { + return fmt.Errorf("failed to create commit: %w", err) + } + + err = c.client.Git.Push(ctx, r) + if err != nil { + return fmt.Errorf("failed to push commit: %w", err) + } + + err = c.client.Git.Cleanup(dir) + if err != nil { + return fmt.Errorf("failed to cleanup: %w", err) + } + + return nil +} + +func (c *BranchClient) getDefault(ctx context.Context) (string, error) { + projectKey, repoSlug := getStashRefs(c.ref) + + // check if it is a user repository + // if yes, we need to add a tilde to the user login and use it as the project key + if r, ok := c.ref.(gitprovider.UserRepositoryRef); ok { + projectKey = addTilde(r.UserLogin) + } + + b, err := c.client.Branches.Default(ctx, projectKey, repoSlug) + if err != nil { + return "", fmt.Errorf("failed to get default branch: %w", err) + } + + return b.DisplayID, nil + +} diff --git a/stash/client_repository_commit.go b/stash/client_repository_commit.go new file mode 100644 index 00000000..888eac51 --- /dev/null +++ b/stash/client_repository_commit.go @@ -0,0 +1,131 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "context" + "fmt" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +// CommitClient implements the gitprovider.CommitClient interface. +var _ gitprovider.CommitClient = &CommitClient{} + +// CommitClient operates on the commits for a specific repository. +type CommitClient struct { + *clientContext + ref gitprovider.RepositoryRef +} + +// ListPage lists repository commits of the given page and page size. +func (c *CommitClient) ListPage(ctx context.Context, branch string, perPage, page int) ([]gitprovider.Commit, error) { + commitList, err := c.listPage(ctx, branch, perPage, page) + if err != nil { + return nil, fmt.Errorf("failed to list commits: %w", err) + } + // Cast to the generic []gitprovider.Commit + commits := make([]gitprovider.Commit, 0, len(commitList)) + for _, commit := range commitList { + commits = append(commits, commit) + } + return commits, nil +} + +func (c *CommitClient) listPage(ctx context.Context, branch string, perPage, page int) ([]*commitType, error) { + projectKey, repoSlug := getStashRefs(c.ref) + + // check if it is a user repository + // if yes, we need to add a tilde to the user login and use it as the project key + if r, ok := c.ref.(gitprovider.UserRepositoryRef); ok { + projectKey = addTilde(r.UserLogin) + } + + apiObjs, err := c.client.Commits.ListPage(ctx, projectKey, repoSlug, branch, perPage, page) + if err != nil { + return nil, err + } + + // Map the api object to our CommitType type + commits := make([]*commitType, 0, len(apiObjs)) + for _, apiObj := range apiObjs { + commits = append(commits, newCommit(apiObj)) + } + return commits, nil +} + +// Create creates a commit with the given specifications. +func (c *CommitClient) Create(ctx context.Context, branch string, message string, files []gitprovider.CommitFile) (gitprovider.Commit, error) { + projectKey, repoSlug := getStashRefs(c.ref) + + // check if it is a user repository + // if yes, we need to add a tilde to the user login and use it as the project key + if r, ok := c.ref.(gitprovider.UserRepositoryRef); ok { + projectKey = addTilde(r.UserLogin) + } + + repo, err := c.client.Repositories.Get(ctx, projectKey, repoSlug) + if err != nil { + return nil, fmt.Errorf("failed to get repository %s/%s: %w", projectKey, repoSlug, err) + } + + user, err := c.client.Users.Get(ctx, repo.Session.UserName) + if err != nil { + return nil, fmt.Errorf("failed to get user %s: %w", repo.Session.UserName, err) + } + + url := getRepoHTTPref(repo.Links.Clone) + r, dir, err := c.client.Git.CloneRepository(ctx, url) + if err != nil { + return nil, fmt.Errorf("failed to clone repository %s: %w", url, err) + } + + f := make([]CommitFile, 0, len(files)) + for _, file := range files { + f = append(f, CommitFile{Path: file.Path, Content: file.Content}) + } + commit, err := NewCommit( + WithAuthor(&CommitAuthor{ + Name: user.Name, + Email: user.EmailAddress, + }), + WithMessage(message), + WithURL(url), + WithFiles(f)) + + result, err := c.client.Git.CreateCommit(ctx, dir, r, branch, commit) + if err != nil { + return nil, fmt.Errorf("failed to create commit: %w", err) + } + + err = c.client.Git.Push(ctx, r) + if err != nil { + return nil, fmt.Errorf("failed to push commit: %w", err) + } + + sha, err := c.client.Commits.Get(ctx, projectKey, repoSlug, result.SHA) + if err != nil { + return nil, fmt.Errorf("failed to get commit %s: %w", result.SHA, err) + } + + err = c.client.Git.Cleanup(dir) + if err != nil { + return nil, fmt.Errorf("failed to cleanup repository: %w", err) + } + + return newCommit(sha), nil +} diff --git a/stash/client_repository_deploykey.go b/stash/client_repository_deploykey.go new file mode 100644 index 00000000..66611442 --- /dev/null +++ b/stash/client_repository_deploykey.go @@ -0,0 +1,254 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "context" + "errors" + "fmt" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +// DeployKeyClient implements the gitprovider.DeployKeyClient interface. +var _ gitprovider.DeployKeyClient = &DeployKeyClient{} + +// DeployKeyClient operates on the access deploy key list for a specific repository. +type DeployKeyClient struct { + *clientContext + ref gitprovider.RepositoryRef +} + +// Get returns the key with the given name. +// name is internally converted to a label. +// ErrNotFound is returned if the resource does not exist. +func (c *DeployKeyClient) Get(ctx context.Context, name string) (gitprovider.DeployKey, error) { + key, err := c.get(ctx, name) + if err != nil { + return nil, fmt.Errorf("failed to get deploy key %q: %w", name, err) + } + return newDeployKey(c, key), nil +} + +func (c *DeployKeyClient) get(ctx context.Context, name string) (*DeployKey, error) { + deployKeys, err := c.list(ctx) + if err != nil { + return nil, err + } + // Loop through deploy keys once we find one with the right name + for _, dk := range deployKeys { + if dk.Label == name { + return dk, nil + } + } + return nil, gitprovider.ErrNotFound +} + +// List lists all repository deploy keys. +// List returns all available repository deploy keys for the given type, +// using multiple paginated requests if needed. +func (c *DeployKeyClient) List(ctx context.Context) ([]gitprovider.DeployKey, error) { + apiObjs, err := c.list(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list deploy keys: %w", err) + } + // Cast to the generic []gitprovider.DeployKey + keys := make([]gitprovider.DeployKey, 0, len(apiObjs)) + for _, apiObj := range apiObjs { + keys = append(keys, newDeployKey(c, apiObj)) + } + return keys, nil +} + +func (c *DeployKeyClient) list(ctx context.Context) ([]*DeployKey, error) { + projectKey, repoSlug := getStashRefs(c.ref) + + // check if it is a user repository + if r, ok := c.ref.(gitprovider.UserRepositoryRef); ok { + projectKey = addTilde(r.UserLogin) + } + + apiObjs, err := c.client.DeployKeys.All(ctx, projectKey, repoSlug, c.maxPages) + if err != nil { + return nil, err + } + + for _, apiObj := range apiObjs { + if err := validateDeployKeyAPI(apiObj); err != nil { + return nil, err + } + } + return apiObjs, nil +} + +// Create creates a deploy key with the given specifications. +// +// ErrAlreadyExists will be returned if the resource already exists. +func (c *DeployKeyClient) Create(ctx context.Context, req gitprovider.DeployKeyInfo) (gitprovider.DeployKey, error) { + apiObj, err := createDeployKey(ctx, c, req) + if err != nil { + return nil, fmt.Errorf("failed to create deploy key: %w", err) + } + + return newDeployKey(c, apiObj), nil +} + +func createDeployKey(ctx context.Context, c *DeployKeyClient, req gitprovider.DeployKeyInfo) (*DeployKey, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, err + } + + projectKey, repoSlug := getStashRefs(c.ref) + + // check if it is a user repository + if r, ok := c.ref.(gitprovider.UserRepositoryRef); ok { + projectKey = addTilde(r.UserLogin) + } + + apiObj, err := c.client.DeployKeys.Create(ctx, deployKeyToAPI(projectKey, repoSlug, &req)) + if err != nil { + return nil, err + } + + return apiObj, nil +} + +// Reconcile makes sure the given desired state (req) becomes the actual state in the backing Git provider. +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be deleted and recreated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +func (c *DeployKeyClient) Reconcile(ctx context.Context, req gitprovider.DeployKeyInfo) (gitprovider.DeployKey, bool, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, false, err + } + + // Get the key with the desired name + actual, err := c.Get(ctx, req.Name) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + resp, err := c.Create(ctx, req) + return resp, true, err + } + + // Unexpected path, Get should succeed or return NotFound + return nil, false, fmt.Errorf("failed to reconcile deploy key %q: %w", req.Name, err) + } + + // If the desired matches the actual state, just return the actual state + if req.Equals(actual.Get()) { + return actual, false, nil + } + + // Populate the desired state to the current-actual object + if err := actual.Set(req); err != nil { + return actual, false, err + } + // Apply the desired state by running Update + _, err = c.update(ctx, actual.Repository(), actual.Get()) + if err != nil { + return actual, false, fmt.Errorf("failed to update deploy key %q: %w", req.Name, err) + } + return actual, true, nil +} + +// update will apply the desired state in this object to the server. +// ErrNotFound is returned if the resource does not exist. +func (c *DeployKeyClient) update(ctx context.Context, ref gitprovider.RepositoryRef, req gitprovider.DeployKeyInfo) (*DeployKey, error) { + // Delete the old key and recreate + if err := c.delete(ctx, ref, req); err != nil { + return nil, err + } + + projectKey, repoSlug := getStashRefs(c.ref) + + // check if it is a user repository + // if yes, we need to add a tilde to the user login and use it as the project key + if r, ok := c.ref.(gitprovider.UserRepositoryRef); ok { + projectKey = addTilde(r.UserLogin) + } + + apiObj, err := c.client.DeployKeys.Create(ctx, deployKeyToAPI(projectKey, repoSlug, &req)) + if err != nil { + return nil, err + } + + return apiObj, nil +} + +func (c *DeployKeyClient) delete(ctx context.Context, ref gitprovider.RepositoryRef, req gitprovider.DeployKeyInfo) error { + projectKey, repoSlug := getStashRefs(c.ref) + + // check if it is a user repository + if r, ok := c.ref.(gitprovider.UserRepositoryRef); ok { + projectKey = addTilde(r.UserLogin) + } + + key := deployKeyToAPI(projectKey, repoSlug, &req) + // Delete the old key + if err := c.client.DeployKeys.Delete(ctx, key.Project.Key, key.Repository.Slug, key.Key.ID); err != nil { + return fmt.Errorf("failed to delete deploy key %q: %w", req.Name, err) + } + + return nil +} + +func deployKeyInfoToAPIObj(info *gitprovider.DeployKeyInfo, apiObj *DeployKey) { + if info.ReadOnly != nil { + if *info.ReadOnly { + apiObj.Permission = stashPermissionRead + } else { + apiObj.Permission = stashPermissionWrite + } + } + apiObj.Key.Label = info.Name +} + +func deployKeyToAPI(orgKey, repoSlug string, info *gitprovider.DeployKeyInfo) *DeployKey { + k := &DeployKey{ + Key: Key{ + Text: string(info.Key), + }, + Repository: Repository{ + Slug: repoSlug, + Project: Project{ + Key: orgKey, + }, + }, + } + setKeyName(info) + deployKeyInfoToAPIObj(info, k) + return k +} + +// TO DO: Implement this +func validateDeployKeyAPI(apiObj *DeployKey) error { + return nil +} + +func deployKeyFromAPI(apiObj *DeployKey) gitprovider.DeployKeyInfo { + deRefBool := apiObj.Permission == stashPermissionRead + return gitprovider.DeployKeyInfo{ + Name: apiObj.Key.Label, + Key: []byte(apiObj.Key.Text), + ReadOnly: &deRefBool, + } +} diff --git a/stash/client_repository_pullrequest.go b/stash/client_repository_pullrequest.go new file mode 100644 index 00000000..e1325550 --- /dev/null +++ b/stash/client_repository_pullrequest.go @@ -0,0 +1,83 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "context" + "fmt" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/fluxcd/go-git-providers/validation" +) + +// PullRequestClient implements the gitprovider.PullRequestClient interface. +var _ gitprovider.PullRequestClient = &PullRequestClient{} + +// PullRequestClient operates on the pull requests for a specific repository. +type PullRequestClient struct { + *clientContext + ref gitprovider.RepositoryRef +} + +// Create creates a pull request with the given specifications. +func (c *PullRequestClient) Create(ctx context.Context, title, branch, baseBranch, description string) (gitprovider.PullRequest, error) { + projectKey, repoSlug := getStashRefs(c.ref) + + // check if it is a user repository + // if yes, we need to add a tilde to the user login and use it as the project key + if r, ok := c.ref.(gitprovider.UserRepositoryRef); ok { + projectKey = addTilde(r.UserLogin) + } + + pr := &CreatePullRequest{ + Title: title, + Description: description, + State: "OPEN", + Open: true, + Closed: false, + Locked: false, + ToRef: Ref{ + ID: fmt.Sprintf("refs/heads/%s", baseBranch), + Repository: Repository{ + Slug: repoSlug, + Project: Project{Key: projectKey}, + }, + }, + FromRef: Ref{ + ID: fmt.Sprintf("refs/heads/%s", branch), + Repository: Repository{ + Slug: repoSlug, + Project: Project{Key: projectKey}, + }, + }, + } + + created, err := c.client.PullRequests.Create(ctx, projectKey, repoSlug, pr) + if err != nil { + return nil, fmt.Errorf("failed to create pull request: %w", err) + } + return newPullRequest(created), nil +} + +func validatePullRequestsAPI(apiObj *PullRequest) error { + return validateAPIObject("Stash.PullRequest", func(validator validation.Validator) { + // Make sure there is a version and a title + if apiObj.Version == 0 || apiObj.Title == "" { + validator.Required("ID") + } + }) +} diff --git a/stash/client_repository_teamaccess.go b/stash/client_repository_teamaccess.go new file mode 100644 index 00000000..9899a492 --- /dev/null +++ b/stash/client_repository_teamaccess.go @@ -0,0 +1,267 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "context" + "errors" + "fmt" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +const ( + stashPermissionProjectRead = "PROJECT_READ" + stashPermissionProjectWrite = "PROJECT_WRITE" + stashPermissionProjectAdmin = "PROJECT_ADMIN" +) + +// TeamAccessClient implements the gitprovider.TeamAccessClient interface. +var _ gitprovider.TeamAccessClient = &TeamAccessClient{} + +// TeamAccessClient operates on the teams list for a specific repository. +type TeamAccessClient struct { + *clientContext + ref gitprovider.RepositoryRef +} + +// Get a team's access permission for a given repository. +// Teams are groups in Stash. +// ErrNotFound is returned if the resource does not exist. +func (c *TeamAccessClient) Get(ctx context.Context, name string) (gitprovider.TeamAccess, error) { + projectKey, repoSlug := getStashRefs(c.ref) + // Repo level permissions + repoPerm, err := c.client.Repositories.GetRepositoryGroupPermission( + ctx, + projectKey, + repoSlug, + name) + + if err != nil { + if !errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("failed to get repository team access: %w", err) + } + } + + // Try to get both project and repo level permissions, then return the highest one + orgPerm, err := c.client.Projects.GetProjectGroupPermission(ctx, + projectKey, + name) + + if err != nil { + if !errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("failed to get organisation team access: %w", err) + } + } + + // If both are not found, return ErrNotFound + if repoPerm == nil && orgPerm == nil { + return nil, ErrNotFound + } + + // Use a set to avoid duplicates + perMap := make(map[string]bool) + if repoPerm != nil { + perMap[repoPerm.Permission] = true + } + + if orgPerm != nil { + // Get the project level permissions and figure the repo level permissions + switch orgPerm.Permission { + case stashPermissionProjectRead: + perMap[stashPermissionRead] = true + case stashPermissionProjectWrite: + perMap[stashPermissionWrite] = true + case stashPermissionProjectAdmin: + perMap[stashPermissionAdmin] = true + } + } + + // Get the highest permission level + permLevel, err := getGitProviderPermission(getStashPermissionFromMap(perMap)) + if err != nil { + return nil, err + } + + return newTeamAccess(c, gitprovider.TeamAccessInfo{ + Name: name, + Permission: permLevel, + }), nil +} + +// List lists the team access control list for this repository. +// List returns all available team access lists, using multiple paginated requests if needed. +func (c *TeamAccessClient) List(ctx context.Context) ([]gitprovider.TeamAccess, error) { + projectKey, repoSlug := getStashRefs(c.ref) + // Init a set of team access permissions + namePermissions := make(map[string][]string) + + // Repo level permissions + repoPerms, err := c.client.Repositories.AllGroupsPermission(ctx, + projectKey, + repoSlug, + c.maxPages) + + if err != nil { + if !errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("failed to get repository teams access: %w", err) + } + } + + // project level permissions + orgPerms, err := c.client.Projects.AllGroupsPermission(ctx, + projectKey, + c.maxPages) + + if err != nil { + if !errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("failed to get organisation teams access: %w", err) + } + } + + // If both are not found, return ErrNotFound + if repoPerms == nil && orgPerms == nil { + return nil, ErrNotFound + } + + // Add repo level permissions to the set + if repoPerms != nil && len(repoPerms) > 0 { + for _, repoPerm := range repoPerms { + namePermissions[repoPerm.Group.Name] = append(namePermissions[repoPerm.Group.Name], repoPerm.Permission) + } + } + + if orgPerms != nil && len(orgPerms) > 0 { + // Add project level permissions to the set + for _, orgPerm := range orgPerms { + switch orgPerm.Permission { + case stashPermissionProjectRead: + namePermissions[orgPerm.Group.Name] = append(namePermissions[orgPerm.Group.Name], stashPermissionRead) + case stashPermissionProjectWrite: + namePermissions[orgPerm.Group.Name] = append(namePermissions[orgPerm.Group.Name], stashPermissionWrite) + case stashPermissionProjectAdmin: + namePermissions[orgPerm.Group.Name] = append(namePermissions[orgPerm.Group.Name], stashPermissionAdmin) + } + } + } + + teamsAccess := make([]gitprovider.TeamAccess, 0, len(namePermissions)) + + for k, v := range namePermissions { + perMap := make(map[string]bool) + for _, perm := range v { + perMap[perm] = true + } + + permLevel, err := getGitProviderPermission(getStashPermissionFromMap(perMap)) + if err != nil { + return nil, err + } + + n := newTeamAccess(c, gitprovider.TeamAccessInfo{ + Name: k, + Permission: permLevel, + }) + + teamsAccess = append(teamsAccess, n) + + } + + return teamsAccess, nil +} + +// Create adds a given team to the repo's team access control list. +// The team shall exist in Stash. +// ErrAlreadyExists will be returned if the resource already exists. +func (c *TeamAccessClient) Create(ctx context.Context, team gitprovider.TeamAccessInfo) (gitprovider.TeamAccess, error) { + projectKey, repoSlug := getStashRefs(c.ref) + permission, err := getStashPermission(*team.Permission) + if err != nil { + return nil, err + } + + type group struct { + Name string "json:\"name,omitempty\"" + } + + permGroup := &RepositoryGroupPermission{ + Group: group{ + Name: team.Name, + }, + Permission: permission, + } + + err = c.client.Repositories.UpdateRepositoryGroupPermission(ctx, + projectKey, repoSlug, permGroup) + if err != nil { + return nil, fmt.Errorf("failed to update repository team access: %w", err) + } + + // Shall fing the group in the admin level + teamCreated, err := c.Get(ctx, team.Name) + if err != nil { + return nil, fmt.Errorf("failed to get team access: %w", err) + } + + return teamCreated, nil +} + +// Reconcile makes sure the given desired state (req) becomes the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be deleted and recreated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +func (c *TeamAccessClient) Reconcile(ctx context.Context, + req gitprovider.TeamAccessInfo, +) (gitprovider.TeamAccess, bool, error) { + // First thing, validate and default the request to ensure a valid and fully-populated object + // (to minimize any possible diffs between desired and actual state) + if err := gitprovider.ValidateAndDefaultInfo(&req); err != nil { + return nil, false, err + } + + actual, err := c.Get(ctx, req.Name) + if err != nil { + // Create if not found + if errors.Is(err, gitprovider.ErrNotFound) { + resp, err := c.Create(ctx, req) + return resp, true, err + } + + // Unexpected path, Get should succeed or return NotFound + return nil, false, err + } + + // If the desired matches the actual state, just return the actual state + if req.Equals(actual.Get()) { + return actual, false, nil + } + + // Populate the desired state to the current-actual object + if err := actual.Set(req); err != nil { + return actual, false, err + } + + // Update the actual state to be the desired state + // by issuing a Create, which uses a PUT underneath. + _, err = c.Create(ctx, actual.Get()) + if err != nil { + return actual, false, err + } + + return actual, true, nil +} diff --git a/stash/integration_repositories_org_test.go b/stash/integration_repositories_org_test.go new file mode 100644 index 00000000..24065fb6 --- /dev/null +++ b/stash/integration_repositories_org_test.go @@ -0,0 +1,429 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "context" + "errors" + "fmt" + "math/rand" + "reflect" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/fluxcd/go-git-providers/gitprovider/testutils" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Stash Provider", func() { + var ( + ctx context.Context = context.Background() + repoRef gitprovider.OrgRepositoryRef + ) + + validateOrgRepo := func(repo gitprovider.OrgRepository, expectedRepo gitprovider.RepositoryRef) { + info := repo.Get() + // Expect certain fields to be set + Expect(repo.Repository()).To(Equal(expectedRepo)) + Expect(*info.Description).To(Equal(defaultDescription)) + Expect(*info.Visibility).To(Equal(gitprovider.RepositoryVisibilityPrivate)) + // Stash does not provide the default as part of a repository api object + //Expect(*info.DefaultBranch).To(Equal(defaultBranch)) + + // Expect high-level fields to match their underlying data + internal := repo.APIObject().(*Repository) + Expect(repo.Repository().GetRepository()).To(Equal(internal.Name)) + Expect(repo.Repository().(gitprovider.Slugger).GetSlug()).To(Equal(internal.Slug)) + Expect(repo.Repository().GetIdentity()).To(Equal(testOrgName)) + Expect(*info.Visibility).To(Equal(gitprovider.RepositoryVisibilityPrivate)) + // Stash does not provide the default as part of a repository api object + //Expect(*info.DefaultBranch).To(Equal(defaultBranch)) + } + + It("should be possible to create an organization repo", func() { + // Get the test organization + orgRef := newOrgRef(testOrgName) + testOrg, err := client.Organizations().Get(ctx, orgRef) + Expect(err).ToNot(HaveOccurred()) + + // First, check what repositories are available + repos, err := client.OrgRepositories().List(ctx, testOrg.Organization()) + Expect(err).ToNot(HaveOccurred()) + + // Generate a repository name which doesn't exist already + testOrgRepoName = fmt.Sprintf("test-org-repo-%03d", rand.Intn(1000)) + for findOrgRepo(repos, testOrgRepoName) != nil { + testOrgRepoName = fmt.Sprintf("test-org-repo-%03d", rand.Intn(1000)) + } + + // We know that a repo with this name doesn't exist in the organization, let's verify we get an + // ErrNotFound + repoRef = newOrgRepoRef(testOrg.Organization(), testOrgRepoName) + httpsURL := repoRef.GetCloneURL(gitprovider.TransportTypeHTTPS) + Expect(httpsURL).NotTo(Equal("")) + _, err = client.OrgRepositories().Get(ctx, repoRef) + Expect(errors.Is(err, gitprovider.ErrNotFound)).To(BeTrue()) + + // Create a new repo + repo, err := client.OrgRepositories().Create(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + // Default visibility is private, no need to set this at least now + Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }, &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + LicenseTemplate: gitprovider.LicenseTemplateVar(gitprovider.LicenseTemplateApache2), + }) + Expect(err).ToNot(HaveOccurred()) + + getRepoRef, err := client.OrgRepositories().Get(ctx, repoRef) + Expect(err).ToNot(HaveOccurred()) + + validateOrgRepo(repo, getRepoRef.Repository()) + + getRepo, err := client.OrgRepositories().Get(ctx, repoRef) + Expect(err).ToNot(HaveOccurred()) + // Expect the two responses (one from POST and one from GET to have equal "spec") + getSpec := repositoryFromAPI(getRepo.APIObject().(*Repository)) + postSpec := repositoryFromAPI(repo.APIObject().(*Repository)) + Expect(getSpec.Equals(postSpec)).To(BeTrue()) + + // store the repo for later use + repoRef = getRepoRef.Repository().(gitprovider.OrgRepositoryRef) + }) + + It("should error at creation time if the org repo already does exist", func() { + _, err := client.OrgRepositories().Create(ctx, repoRef, gitprovider.RepositoryInfo{}) + Expect(errors.Is(err, gitprovider.ErrAlreadyExists)).To(BeTrue()) + }) + + It("should not update if the org repo already exists when reconciling", func() { + // get the repo first to be sure to get the slug + repo, err := client.OrgRepositories().Get(ctx, repoRef) + + // No-op reconcile + resp, actionTaken, err := client.OrgRepositories().Reconcile(ctx, repo.Repository().(gitprovider.OrgRepositoryRef), gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + //DefaultBranch: gitprovider.StringVar(defaultBranch), + Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }) + + reflect.DeepEqual(repositoryFromAPI(resp.APIObject().(*Repository)), gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + //DefaultBranch: gitprovider.StringVar(defaultBranch), + Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeFalse()) + // no-op set & reconcile + Expect(resp.Set(resp.Get())).ToNot(HaveOccurred()) + actionTaken, err = resp.Reconcile(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeFalse()) + + // Update reconcile + newDesc := "New description" + req := resp.Get() + req.Description = gitprovider.StringVar(newDesc) + Expect(resp.Set(req)).ToNot(HaveOccurred()) + actionTaken, err = resp.Reconcile(ctx) + // Expect the update to succeed, and modify the state + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeTrue()) + Expect(*resp.Get().Description).To(Equal(newDesc)) + + // Delete the repository and later re-create + Expect(resp.Delete(ctx)).ToNot(HaveOccurred()) + + var newRepo gitprovider.OrgRepository + retryOp := testutils.NewRetry() + Eventually(func() bool { + var err error + // Reconcile and create + newRepo, actionTaken, err = client.OrgRepositories().Reconcile(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + }, &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + LicenseTemplate: gitprovider.LicenseTemplateVar(gitprovider.LicenseTemplateMIT), + }) + return retryOp.Retry(err, fmt.Sprintf("reconcile org repository: %s", repoRef.RepositoryName)) + }, retryOp.Timeout(), retryOp.Interval()).Should(BeTrue()) + + Expect(actionTaken).To(BeTrue()) + validateOrgRepo(newRepo, repo.Repository().(gitprovider.OrgRepositoryRef)) + }) + + It("should update teams with access and permissions when reconciling", func() { + + // Get the test organization + orgRef := newOrgRef(testOrgName) + testOrg, err := client.Organizations().Get(ctx, orgRef) + Expect(err).ToNot(HaveOccurred()) + + // List all the teams with access to the org + // There should be 1 existing subgroup already + teams, err := testOrg.Teams().List(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(len(teams)).To(Equal(1), "The 1 team wasn't there...") + + // First, check what repositories are available + repos, err := client.OrgRepositories().List(ctx, testOrg.Organization()) + Expect(err).ToNot(HaveOccurred()) + + // Generate an org repo name which doesn't exist already + testSharedOrgRepoName = fmt.Sprintf("test-shared-org-repo-%03d", rand.Intn(1000)) + for findOrgRepo(repos, testSharedOrgRepoName) != nil { + testSharedOrgRepoName = fmt.Sprintf("test-shared-org-repo-%03d", rand.Intn(1000)) + } + + // We know that a repo with this name doesn't exist in the organization, let's verify we get an + // ErrNotFound + sharedRepoRef := newOrgRepoRef(testOrg.Organization(), testSharedOrgRepoName) + _, err = client.OrgRepositories().Get(ctx, sharedRepoRef) + Expect(errors.Is(err, gitprovider.ErrNotFound)).To(BeTrue()) + + // Create a new repo + repo, err := client.OrgRepositories().Create(ctx, sharedRepoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + // Default visibility is private, no need to set this at least now + Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }, &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + LicenseTemplate: gitprovider.LicenseTemplateVar(gitprovider.LicenseTemplateApache2), + }) + Expect(err).ToNot(HaveOccurred()) + + getRepoRef, err := client.OrgRepositories().Get(ctx, sharedRepoRef) + Expect(err).ToNot(HaveOccurred()) + + validateOrgRepo(repo, getRepoRef.Repository()) + + // 2 teams should have access to the repo + projectTeams, err := repo.TeamAccess().List(ctx) + Expect(err).ToNot(HaveOccurred()) + // go-git-provider-testing & fluxcd-test-team group already exists, so we should have 2 teams + Expect(len(projectTeams)).To(Equal(1)) + + // Add a team to the project + permission := gitprovider.RepositoryPermissionPull + _, err = repo.TeamAccess().Create(ctx, gitprovider.TeamAccessInfo{ + Name: testTeamName, + Permission: &permission, + }) + Expect(err).ToNot(HaveOccurred()) + + // List all the teams with access to the project + // Only + projectTeams, err = repo.TeamAccess().List(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(len(projectTeams)).To(Equal(2), "Project teams didn't equal 2") + var firstTeam gitprovider.TeamAccess + for _, v := range projectTeams { + if v.Get().Name == testTeamName { + firstTeam = v + } + } + Expect(firstTeam.Get().Name).To(Equal(testTeamName)) + + // Update the permission level and update + ta, err := repo.TeamAccess().Get(ctx, testTeamName) + Expect(err).ToNot(HaveOccurred()) + + // Set permission level to Push and call Reconcile + pushPermission := gitprovider.RepositoryPermissionPush + pushTeamAccess := ta + pushTeamAccessInfo := pushTeamAccess.Get() + pushTeamAccessInfo.Permission = &pushPermission + ta.Set(pushTeamAccessInfo) + actionTaken, err := ta.Reconcile(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(Equal(true)) + + // Get the team access info again and verify it has been updated + updatedTA, err := repo.TeamAccess().Get(ctx, testTeamName) + Expect(err).ToNot(HaveOccurred()) + Expect(*updatedTA.Get().Permission).To(Equal(gitprovider.RepositoryPermissionPush)) + + // Assert that reconciling works + teamInfo := gitprovider.TeamAccessInfo{ + Name: testTeamName, + Permission: &pushPermission, + } + + ta, actionTaken, err = repo.TeamAccess().Reconcile(ctx, teamInfo) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(Equal(false)) + }) + + It("should create, delete and reconcile deploy keys", func() { + // Get the test organization + orgRef := newOrgRef(testOrgName) + testOrg, err := client.Organizations().Get(ctx, orgRef) + //Expect(err).ToNot(HaveOccurred()) + // + testDeployKeyName := "test-deploy-key" + SharedRepoRef := newOrgRepoRef(testOrg.Organization(), testSharedOrgRepoName) + + orgRepo, err := client.OrgRepositories().Get(ctx, SharedRepoRef) + Expect(err).ToNot(HaveOccurred()) + + // List keys should return 0 + keys, err := orgRepo.DeployKeys().List(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(len(keys)).To(Equal(0)) + + rsaGen := testutils.NewRSAGenerator(256) + keyPair1, err := rsaGen.Generate() + Expect(err).ToNot(HaveOccurred()) + pubKey := keyPair1.PublicKey + + readOnly := false + testDeployKeyInfo := gitprovider.DeployKeyInfo{ + Name: testDeployKeyName, + Key: pubKey, + ReadOnly: &readOnly, + } + _, err = orgRepo.DeployKeys().Create(ctx, testDeployKeyInfo) + Expect(err).ToNot(HaveOccurred()) + + // List keys should now return 1 + keys, err = orgRepo.DeployKeys().List(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(len(keys)).To(Equal(1)) + + // Getting the key directly should return the same object + getKey, err := orgRepo.DeployKeys().Get(ctx, testDeployKeyName) + Expect(err).ToNot(HaveOccurred()) + + deployKeyStr := string(testDeployKeyInfo.Key) + Expect(string(getKey.Get().Key)).To(Equal(deployKeyStr)) + Expect(getKey.Get().Name).To(Equal(testDeployKeyInfo.Name)) + + Expect(getKey.Set(getKey.Get())).ToNot(HaveOccurred()) + actionTaken, err := getKey.Reconcile(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeFalse()) + + // Reconcile creates a new key if the title and key is different + title := "new-title" + req := getKey.Get() + req.Name = title + + keyPair2, err := rsaGen.Generate() + Expect(err).ToNot(HaveOccurred()) + anotherPubKey := keyPair2.PublicKey + req.Key = anotherPubKey + + Expect(getKey.Set(req)).ToNot(HaveOccurred()) + actionTaken, err = getKey.Reconcile(ctx) + // Expect the update to succeed, and modify the state + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeTrue()) + + getKey, err = orgRepo.DeployKeys().Get(ctx, title) + Expect(err).ToNot(HaveOccurred()) + Expect(getKey.Get().Name).To(Equal(title)) + + // Delete the keys + keys, err = orgRepo.DeployKeys().List(ctx) + Expect(err).ToNot(HaveOccurred()) + for _, key := range keys { + err = key.Delete(ctx) + Expect(err).ToNot(HaveOccurred()) + } + }) + + It("should be possible to create a pr for an org repository", func() { + // Get the test organization + orgRef := newOrgRef(testOrgName) + testOrg, err := client.Organizations().Get(ctx, orgRef) + Expect(err).ToNot(HaveOccurred()) + + testRepoName = fmt.Sprintf("test-org-repo2-%03d", rand.Intn(1000)) + repoRef := newOrgRepoRef(testOrg.Organization(), testRepoName) + + defaultBranch := "master" + description := "test description" + // Create a new repo + orgRepo, err := client.OrgRepositories().Create(ctx, repoRef, + gitprovider.RepositoryInfo{ + Description: &description, + Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }, + &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + }) + Expect(err).ToNot(HaveOccurred()) + + var commits []gitprovider.Commit = []gitprovider.Commit{} + retryOp := testutils.NewRetry() + Eventually(func() bool { + var err error + commits, err = orgRepo.Commits().ListPage(ctx, defaultBranch, 1, 0) + if err == nil && len(commits) == 0 { + err = errors.New("empty commits list") + } + return retryOp.Retry(err, fmt.Sprintf("get commits, repository: %s", orgRepo.Repository().GetRepository())) + }, retryOp.Timeout(), retryOp.Interval()).Should(BeTrue()) + + latestCommit := commits[0] + + branchName := fmt.Sprintf("test-branch-%03d", rand.Intn(1000)) + branchName2 := fmt.Sprintf("test-branch-%03d", rand.Intn(1000)) + + err = orgRepo.Branches().Create(ctx, branchName, latestCommit.Get().Sha) + Expect(err).ToNot(HaveOccurred()) + + err = orgRepo.Branches().Create(ctx, branchName2, "wrong-sha") + Expect(err).To(HaveOccurred()) + + path := "setup/config.txt" + content := "yaml content" + files := []gitprovider.CommitFile{ + { + Path: &path, + Content: &content, + }, + } + + _, err = orgRepo.Commits().Create(ctx, branchName, "added config file", files) + Expect(err).ToNot(HaveOccurred()) + + pr, err := orgRepo.PullRequests().Create(ctx, "Added config file", branchName, defaultBranch, "added config file") + Expect(err).ToNot(HaveOccurred()) + Expect(pr.Get().WebURL).ToNot(BeEmpty()) + }) +}) + +func findOrgRepo(repos []gitprovider.OrgRepository, name string) gitprovider.OrgRepository { + if name == "" { + return nil + } + for _, repo := range repos { + if repo.Repository().GetRepository() == name { + return repo + } + } + return nil +} + +func newOrgRepoRef(orgRef gitprovider.OrganizationRef, repoName string) gitprovider.OrgRepositoryRef { + return gitprovider.OrgRepositoryRef{ + OrganizationRef: orgRef, + RepositoryName: repoName, + } +} diff --git a/stash/integration_repositories_user_test.go b/stash/integration_repositories_user_test.go new file mode 100644 index 00000000..7eb9ec8f --- /dev/null +++ b/stash/integration_repositories_user_test.go @@ -0,0 +1,233 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "context" + "errors" + "fmt" + "math/rand" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/fluxcd/go-git-providers/gitprovider/testutils" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Stash Provider", func() { + var ( + ctx context.Context = context.Background() + repoRef gitprovider.UserRepositoryRef + ) + + validateUserRepo := func(repo gitprovider.UserRepository, expectedRepoRef gitprovider.RepositoryRef) { + info := repo.Get() + // Expect certain fields to be set + Expect(repo.Repository()).To(Equal(expectedRepoRef)) + Expect(*info.Description).To(Equal(defaultDescription)) + Expect(*info.Visibility).To(Equal(gitprovider.RepositoryVisibilityPrivate)) + // Stash does not provide the default as part of a repository api object + //Expect(*info.DefaultBranch).To(Equal(defaultBranch)) + // Expect high-level fields to match their underlying data + internal := repo.APIObject().(*Repository) + Expect(repo.Repository().GetRepository()).To(Equal(internal.Name)) + Expect(repo.Repository().GetIdentity()).To(Equal(testUserName)) + if !internal.Public { + Expect(*info.Visibility).To(Equal(gitprovider.RepositoryVisibilityPrivate)) + } + + //Expect(*info.DefaultBranch).To(Equal(internal.Branch)) + } + + It("should be possible to create a user repo", func() { + // First, check what repositories are available + repos, err := client.UserRepositories().List(ctx, newUserRef(testUserName)) + Expect(err).ToNot(HaveOccurred()) + + // Generate a repository name which doesn't exist already + testRepoName = fmt.Sprintf("test-user-repo-%03d", rand.Intn(1000)) + for findUserRepo(repos, testRepoName) != nil { + testRepoName = fmt.Sprintf("test-user-repo-%03d", rand.Intn(1000)) + } + + fmt.Print("Creating repository ", testRepoName, "...") + + // We know that a repo with this name doesn't exist in the organization, let's verify we get an + // ErrNotFound + repoRef = newUserRepoRef(testUserName, testRepoName) + _, err = client.UserRepositories().Get(ctx, repoRef) + Expect(errors.Is(err, gitprovider.ErrNotFound)).To(BeTrue()) + + // Create a new repo + repo, err := client.UserRepositories().Create(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + // Default visibility is private, no need to set this at least now + Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }, &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + LicenseTemplate: gitprovider.LicenseTemplateVar(gitprovider.LicenseTemplateApache2), + }) + Expect(err).ToNot(HaveOccurred()) + + getRepoRef, err := client.UserRepositories().Get(ctx, repoRef) + Expect(err).ToNot(HaveOccurred()) + + validateUserRepo(repo, getRepoRef.Repository()) + + getRepo, err := client.UserRepositories().Get(ctx, repoRef) + Expect(err).ToNot(HaveOccurred()) + // Expect the two responses (one from POST and one from GET to have equal "spec") + getSpec := repositoryFromAPI(getRepo.APIObject().(*Repository)) + postSpec := repositoryFromAPI(repo.APIObject().(*Repository)) + Expect(getSpec.Equals(postSpec)).To(BeTrue()) + + // Store the repo for later use + repoRef = getRepoRef.Repository().(gitprovider.UserRepositoryRef) + }) + + It("should error at creation time if the user repo already does exist", func() { + _, err := client.UserRepositories().Create(ctx, repoRef, gitprovider.RepositoryInfo{}) + Expect(errors.Is(err, gitprovider.ErrAlreadyExists)).To(BeTrue()) + }) + + It("should update if the user repo already exists when reconciling", func() { + // get the repo first to be sure to get the slug + repo, err := client.UserRepositories().Get(ctx, repoRef) + Expect(err).ToNot(HaveOccurred()) + // No-op reconcile + resp, actionTaken, err := client.UserRepositories().Reconcile(ctx, repo.Repository().(gitprovider.UserRepositoryRef), gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + //DefaultBranch: gitprovider.StringVar(defaultBranchName), + Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeFalse()) + // no-op set & reconcile + Expect(resp.Set(resp.Get())).ToNot(HaveOccurred()) + actionTaken, err = resp.Reconcile(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeFalse()) + + // Update reconcile + newDesc := "New description" + req := resp.Get() + req.Description = gitprovider.StringVar(newDesc) + Expect(resp.Set(req)).ToNot(HaveOccurred()) + actionTaken, err = resp.Reconcile(ctx) + // Expect the update to succeed, and modify the state + Expect(err).ToNot(HaveOccurred()) + Expect(actionTaken).To(BeTrue()) + Expect(*resp.Get().Description).To(Equal(newDesc)) + + // Delete the repository and later re-create + Expect(resp.Delete(ctx)).ToNot(HaveOccurred()) + + var newRepo gitprovider.UserRepository + retryOp := testutils.NewRetry() + Eventually(func() bool { + var err error + // Reconcile and create + newRepo, actionTaken, err = client.UserRepositories().Reconcile(ctx, repoRef, gitprovider.RepositoryInfo{ + Description: gitprovider.StringVar(defaultDescription), + }, &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + LicenseTemplate: gitprovider.LicenseTemplateVar(gitprovider.LicenseTemplateMIT), + }) + return retryOp.Retry(err, fmt.Sprintf("new user repository: %s", repoRef.RepositoryName)) + }, retryOp.Timeout(), retryOp.Interval()).Should(BeTrue()) + + // Expect the create to succeed, and have modified the state. Also validate the newRepo data + Expect(actionTaken).To(BeTrue()) + validateUserRepo(newRepo, repo.Repository().(gitprovider.UserRepositoryRef)) + }) + + It("should be possible to create a pr for a user repository", func() { + + testRepoName = fmt.Sprintf("test-user-repo2-%03d", rand.Intn(1000)) + repoRef := newUserRepoRef(testUserName, testRepoName) + + defaultBranch := "master" + description := "test description" + // Create a new repo + userRepo, err := client.UserRepositories().Create(ctx, repoRef, + gitprovider.RepositoryInfo{ + Description: &description, + Visibility: gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate), + }, + &gitprovider.RepositoryCreateOptions{ + AutoInit: gitprovider.BoolVar(true), + }) + Expect(err).ToNot(HaveOccurred()) + + var commits []gitprovider.Commit = []gitprovider.Commit{} + retryOp := testutils.NewRetry() + Eventually(func() bool { + var err error + commits, err = userRepo.Commits().ListPage(ctx, defaultBranch, 1, 0) + if err == nil && len(commits) == 0 { + err = errors.New("empty commits list") + } + return retryOp.Retry(err, fmt.Sprintf("get commits, repository: %s", userRepo.Repository().GetRepository())) + }, retryOp.Timeout(), retryOp.Interval()).Should(BeTrue()) + + latestCommit := commits[0] + + branchName := fmt.Sprintf("test-branch-%03d", rand.Intn(1000)) + branchName2 := fmt.Sprintf("test-branch-%03d", rand.Intn(1000)) + + err = userRepo.Branches().Create(ctx, branchName, latestCommit.Get().Sha) + Expect(err).ToNot(HaveOccurred()) + + err = userRepo.Branches().Create(ctx, branchName2, "wrong-sha") + Expect(err).To(HaveOccurred()) + + path := "setup/config.txt" + content := "yaml content" + files := []gitprovider.CommitFile{ + { + Path: &path, + Content: &content, + }, + } + + _, err = userRepo.Commits().Create(ctx, branchName, "added config file", files) + Expect(err).ToNot(HaveOccurred()) + + pr, err := userRepo.PullRequests().Create(ctx, "Added config file", branchName, defaultBranch, "added config file") + Expect(err).ToNot(HaveOccurred()) + Expect(pr.Get().WebURL).ToNot(BeEmpty()) + }) +}) + +func findUserRepo(repos []gitprovider.UserRepository, name string) gitprovider.UserRepository { + if name == "" { + return nil + } + for _, repo := range repos { + if repo.Repository().GetRepository() == name { + return repo + } + } + return nil +} + +func newUserRepoRef(userLogin, repoName string) gitprovider.UserRepositoryRef { + return gitprovider.UserRepositoryRef{ + UserRef: newUserRef(userLogin), + RepositoryName: repoName, + } +} diff --git a/stash/resource_commit.go b/stash/resource_commit.go new file mode 100644 index 00000000..e159c77e --- /dev/null +++ b/stash/resource_commit.go @@ -0,0 +1,53 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "time" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +func newCommit(commit *CommitObject) *commitType { + return &commitType{ + k: *commit, + } +} + +var _ gitprovider.Commit = &commitType{} + +type commitType struct { + k CommitObject +} + +func (c *commitType) Get() gitprovider.CommitInfo { + return commitFromAPI(c.k) +} + +func (c *commitType) APIObject() interface{} { + return &c.k +} + +func commitFromAPI(commit CommitObject) gitprovider.CommitInfo { + t := time.Unix(commit.AuthorTimestamp, 0) + return gitprovider.CommitInfo{ + Sha: commit.ID, + Author: commit.Author.Name, + Message: commit.Message, + CreatedAt: t, + } +} diff --git a/stash/resource_deploykey.go b/stash/resource_deploykey.go new file mode 100644 index 00000000..93cfe499 --- /dev/null +++ b/stash/resource_deploykey.go @@ -0,0 +1,136 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "context" + "fmt" + "strings" + + "github.com/fluxcd/go-git-providers/gitprovider" + + "encoding/hex" + "math/rand" +) + +func newDeployKey(c *DeployKeyClient, key *DeployKey) *deployKey { + return &deployKey{ + k: *key, + c: c, + } +} + +var _ gitprovider.DeployKey = &deployKey{} + +type deployKey struct { + k DeployKey + c *DeployKeyClient +} + +func (dk *deployKey) Get() gitprovider.DeployKeyInfo { + return deployKeyFromAPI(&dk.k) +} + +func (dk *deployKey) Set(info gitprovider.DeployKeyInfo) error { + if err := info.ValidateInfo(); err != nil { + return err + } + dk.k.Key.Text = string(info.Key) + setKeyName(&info) + deployKeyInfoToAPIObj(&info, &dk.k) + return nil +} + +func (dk *deployKey) APIObject() interface{} { + return &dk.k +} + +func (dk *deployKey) Repository() gitprovider.RepositoryRef { + return dk.c.ref +} + +// Update will apply the desired state in this object to the server. +// Only set fields will be respected (i.e. PATCH behaviour). +// In order to apply changes to this object, use the .Set({Resource}Info) error +// function, or cast .APIObject() to a pointer to the provider-specific type +// and set custom fields there. +// +// ErrNotFound is returned if the resource does not exist. +// +// The internal API object will be overridden with the received server data. +func (dk *deployKey) Update(ctx context.Context) error { + // update by calling client + apiObj, err := dk.c.update(ctx, dk.Repository(), dk.Get()) + if err != nil { + // Log the error and return it + dk.c.log.V(1).Error(err, "failed to update deploy key", "org", dk.Repository().GetIdentity(), "repo", dk.Repository().GetRepository()) + return err + } + dk.k = *apiObj + return nil +} + +// Delete deletes a deploy key from the repository. +// ErrNotFound is returned if the resource does not exist. +func (dk *deployKey) Delete(ctx context.Context) error { + return dk.c.delete(ctx, dk.Repository(), dk.Get()) +} + +// Reconcile makes sure the desired state in this object (called "req" here) becomes +// the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be updated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +// +// The internal API object will be overridden with the received server data if actionTaken == true. +func (dk *deployKey) Reconcile(ctx context.Context) (bool, error) { + _, actionTaken, err := dk.c.Reconcile(ctx, deployKeyFromAPI(&dk.k)) + + if err != nil { + // Log the error and return it + dk.c.log.V(1).Error(err, "failed to reconcile deploy key", + "org", dk.Repository().GetIdentity(), + "repo", dk.Repository().GetRepository(), + "actionTaken", actionTaken) + return actionTaken, err + } + + return actionTaken, nil +} + +func setKeyName(info *gitprovider.DeployKeyInfo) { + keyFields := strings.Split(string(info.Key), " ") + if len(keyFields) < 3 && len(info.Name) == 0 { + info.Name = randName() + return + } + if len(info.Name) > 0 { + info.Key = []byte(fmt.Sprintf("%s %s %s", keyFields[0], keyFields[1], info.Name)) + return + } + if len(keyFields) == 3 && len(info.Name) == 0 { + info.Name = keyFields[2] + return + } +} + +func randName() string { + b := make([]byte, 4) //equals 8 characters + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/stash/resource_pullrequest.go b/stash/resource_pullrequest.go new file mode 100644 index 00000000..7641e28a --- /dev/null +++ b/stash/resource_pullrequest.go @@ -0,0 +1,54 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "github.com/fluxcd/go-git-providers/gitprovider" +) + +func newPullRequest(apiObj *PullRequest) *pullrequest { + return &pullrequest{ + pr: *apiObj, + } +} + +var _ gitprovider.PullRequest = &pullrequest{} + +type pullrequest struct { + pr PullRequest +} + +func (pr *pullrequest) Get() gitprovider.PullRequestInfo { + return pullrequestFromAPI(&pr.pr) +} + +func (pr *pullrequest) APIObject() interface{} { + return &pr.pr +} + +func pullrequestFromAPI(apiObj *PullRequest) gitprovider.PullRequestInfo { + return gitprovider.PullRequestInfo{ + WebURL: getSelfref(apiObj.Self), + } +} + +func getSelfref(selves []Self) string { + if len(selves) == 0 { + return "no http ref found" + } + return selves[0].Href +} diff --git a/stash/resource_repository.go b/stash/resource_repository.go new file mode 100644 index 00000000..2eddd103 --- /dev/null +++ b/stash/resource_repository.go @@ -0,0 +1,246 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "context" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +func newUserRepository(ctx *clientContext, apiObj *Repository, ref gitprovider.RepositoryRef) *userRepository { + return &userRepository{ + c: &UserRepositoriesClient{ + clientContext: ctx, + }, + repository: *apiObj, + ref: ref, + deployKeys: &DeployKeyClient{ + clientContext: ctx, + ref: ref, + }, + commits: &CommitClient{ + clientContext: ctx, + ref: ref, + }, + branches: &BranchClient{ + clientContext: ctx, + ref: ref, + }, + pullRequests: &PullRequestClient{ + clientContext: ctx, + ref: ref, + }, + } +} + +var _ gitprovider.UserRepository = &userRepository{} + +type userRepository struct { + repository Repository + ref gitprovider.RepositoryRef + c *UserRepositoriesClient + deployKeys *DeployKeyClient + branches *BranchClient + pullRequests *PullRequestClient + commits *CommitClient +} + +func (r *userRepository) Branches() gitprovider.BranchClient { + return r.branches +} + +func (r *userRepository) Commits() gitprovider.CommitClient { + return r.commits +} + +func (r *userRepository) PullRequests() gitprovider.PullRequestClient { + return r.pullRequests +} + +func (r *userRepository) Get() gitprovider.RepositoryInfo { + return repositoryFromAPI(&r.repository) +} + +func (r *userRepository) Set(info gitprovider.RepositoryInfo) error { + if err := info.ValidateInfo(); err != nil { + return err + } + repositoryInfoToAPIObj(&info, &r.repository) + return nil +} + +func (r *userRepository) APIObject() interface{} { + return &r.repository +} + +func (r *userRepository) Repository() gitprovider.RepositoryRef { + return r.ref +} + +func (r *userRepository) DeployKeys() gitprovider.DeployKeyClient { + return r.deployKeys +} + +// The internal API object will be overridden with the received server data. +func (r *userRepository) Update(ctx context.Context) error { + // update by calling client + ref := r.ref.(gitprovider.UserRepositoryRef) + apiObj, err := update(ctx, r.c.client, addTilde(ref.UserLogin), ref.GetSlug(), &r.repository) + if err != nil { + // Log the error and return it + r.c.log.V(1).Error(err, "Error updating repository", + "org", r.Repository().GetIdentity(), + "repo", r.Repository().GetRepository()) + return err + } + r.repository = *apiObj + return nil + +} + +// Reconcile makes sure the desired state in this object (called "req" here) becomes +// the actual state in the backing Git provider. + +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be updated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +// +// The internal API object will be overridden with the received server data if actionTaken == true. +func (r *userRepository) Reconcile(ctx context.Context) (bool, error) { + _, actionTaken, err := r.c.Reconcile(ctx, r.ref.(gitprovider.UserRepositoryRef), repositoryFromAPI(&r.repository)) + + if err != nil { + // Log the error and return it + r.c.log.V(1).Error(err, "Error reconciling repository", + "org", r.Repository().GetIdentity(), + "repo", r.Repository().GetRepository(), + "actionTaken", actionTaken) + return actionTaken, err + } + + return actionTaken, nil +} + +// Delete deletes the current resource irreversibly. +// ErrNotFound is returned if the resource doesn't exist anymore. +func (r *userRepository) Delete(ctx context.Context) error { + ref := r.ref.(gitprovider.UserRepositoryRef) + return delete(ctx, r.c.client, addTilde(ref.UserLogin), ref.GetSlug()) +} + +func newOrgRepository(ctx *clientContext, apiObj *Repository, ref gitprovider.RepositoryRef) *orgRepository { + return &orgRepository{ + userRepository: *newUserRepository(ctx, apiObj, ref), + teamAccess: &TeamAccessClient{ + clientContext: ctx, + ref: ref, + }, + c: &OrgRepositoriesClient{ + clientContext: ctx, + }, + } +} + +var _ gitprovider.OrgRepository = &orgRepository{} + +type orgRepository struct { + userRepository + teamAccess *TeamAccessClient + c *OrgRepositoriesClient +} + +func (r *orgRepository) TeamAccess() gitprovider.TeamAccessClient { + return r.teamAccess +} + +// Reconcile makes sure the desired state in this object (called "req" here) becomes +// the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be updated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +// +// The internal API object will be overridden with the received server data if actionTaken == true. +func (r *orgRepository) Reconcile(ctx context.Context) (bool, error) { + _, actionTaken, err := r.c.Reconcile(ctx, r.ref.(gitprovider.OrgRepositoryRef), repositoryFromAPI(&r.repository)) + + if err != nil { + // Log the error and return it + r.c.log.V(1).Error(err, "Error reconciling repository", + "org", r.Repository().GetIdentity(), + "repo", r.Repository().GetRepository(), + "actionTaken", actionTaken) + return actionTaken, err + } + + return actionTaken, nil + +} + +// The internal API object will be overridden with the received server data. +func (r *orgRepository) Update(ctx context.Context) error { + ref := r.ref.(gitprovider.OrgRepositoryRef) + // update by calling client + apiObj, err := update(ctx, r.c.client, ref.GetKey(), ref.GetSlug(), &r.repository) + if err != nil { + // Log the error and return it + r.c.log.V(1).Error(err, "Error updating repository", + "org", r.Repository().GetIdentity(), + "repo", r.Repository().GetRepository()) + return err + } + r.repository = *apiObj + return nil + +} + +// Delete deletes the current resource irreversibly. +// ErrNotFound is returned if the resource doesn't exist anymore. +func (r *orgRepository) Delete(ctx context.Context) error { + ref := r.ref.(gitprovider.OrgRepositoryRef) + return delete(ctx, r.c.client, ref.GetKey(), ref.GetSlug()) +} + +func repositoryFromAPI(apiObj *Repository) gitprovider.RepositoryInfo { + repo := gitprovider.RepositoryInfo{ + Description: &apiObj.Description, + } + repo.Visibility = gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPrivate) + if apiObj.Public { + repo.Visibility = gitprovider.RepositoryVisibilityVar(gitprovider.RepositoryVisibilityPublic) + } + return repo +} + +func repositoryToAPI(repo *gitprovider.RepositoryInfo, ref gitprovider.RepositoryRef) *Repository { + apiObj := &Repository{ + Name: *gitprovider.StringVar(ref.GetRepository()), + ScmID: "git", + } + repositoryInfoToAPIObj(repo, apiObj) + return apiObj +} + +func repositoryInfoToAPIObj(repo *gitprovider.RepositoryInfo, apiObj *Repository) { + if repo.Description != nil { + apiObj.Description = *repo.Description + } + if repo.Visibility != nil { + apiObj.Public = *gitprovider.StringVar(string(*repo.Visibility)) == "true" + } +} diff --git a/stash/resource_teamaccess.go b/stash/resource_teamaccess.go new file mode 100644 index 00000000..267c9564 --- /dev/null +++ b/stash/resource_teamaccess.go @@ -0,0 +1,153 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "context" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +const ( + stashPermissionRead = "REPO_READ" + stashPermissionWrite = "REPO_WRITE" + stashPermissionAdmin = "REPO_ADMIN" +) + +var ( + permissionPriority = map[int]gitprovider.RepositoryPermission{ + 10: gitprovider.RepositoryPermissionPull, + 20: gitprovider.RepositoryPermissionTriage, + 30: gitprovider.RepositoryPermissionPush, + 40: gitprovider.RepositoryPermissionMaintain, + 50: gitprovider.RepositoryPermissionAdmin, + } + + stashPriority = map[string]int{ + stashPermissionRead: 10, + stashPermissionWrite: 30, + stashPermissionAdmin: 50, + } +) + +func newTeamAccess(c *TeamAccessClient, ta gitprovider.TeamAccessInfo) *teamAccess { + return &teamAccess{ + ta: ta, + c: c, + } +} + +var _ gitprovider.TeamAccess = &teamAccess{} + +type teamAccess struct { + ta gitprovider.TeamAccessInfo + c *TeamAccessClient +} + +func (ta *teamAccess) Get() gitprovider.TeamAccessInfo { + return ta.ta +} + +func (ta *teamAccess) Set(info gitprovider.TeamAccessInfo) error { + if err := info.ValidateInfo(); err != nil { + return err + } + ta.ta = info + return nil +} + +func (ta *teamAccess) APIObject() interface{} { + return nil +} + +func (ta *teamAccess) Repository() gitprovider.RepositoryRef { + return ta.c.ref +} + +func (ta *teamAccess) Delete(ctx context.Context) error { + return gitprovider.ErrNoProviderSupport +} + +func (ta *teamAccess) Update(ctx context.Context) error { + // Update the actual state to be the desired state + // by issuing a Create, which uses a PUT underneath. + resp, err := ta.c.Create(ctx, ta.Get()) + if err != nil { + // Log the error and return it + ta.c.log.V(1).Error(err, "Error updating team access", + "org", ta.Repository().GetIdentity(), + "repo", ta.Repository().GetRepository()) + return err + } + return ta.Set(resp.Get()) +} + +// Reconcile makes sure the given desired state (req) becomes the actual state in the backing Git provider. +// +// If req doesn't exist under the hood, it is created (actionTaken == true). +// If req doesn't equal the actual state, the resource will be deleted and recreated (actionTaken == true). +// If req is already the actual state, this is a no-op (actionTaken == false). +func (ta *teamAccess) Reconcile(ctx context.Context) (bool, error) { + _, actionTaken, err := ta.c.Reconcile(ctx, ta.ta) + + if err != nil { + // Log the error and return it + ta.c.log.V(1).Error(err, "Error reconciling team access", + "org", ta.Repository().GetIdentity(), + "repo", ta.Repository().GetRepository(), + "actionTaken", actionTaken) + return actionTaken, err + } + + return actionTaken, nil +} + +func getGitProviderPermission(permissionLevel int) (*gitprovider.RepositoryPermission, error) { + var permissionObj gitprovider.RepositoryPermission + var ok bool + + if permissionObj, ok = permissionPriority[permissionLevel]; ok { + return &permissionObj, nil + } + return nil, gitprovider.ErrInvalidPermissionLevel +} + +func getStashPermissionFromMap(permissionMap map[string]bool) int { + lastPriority := 0 + for key, ok := range permissionMap { + if ok { + priority, ok := stashPriority[key] + if ok && priority > lastPriority { + lastPriority = priority + } + } + } + return lastPriority +} + +func getStashPermission(permission gitprovider.RepositoryPermission) (string, error) { + for key, value := range permissionPriority { + if value == permission { + for stashPerm, v := range stashPriority { + if v == key { + return stashPerm, nil + } + } + } + } + return "", gitprovider.ErrInvalidPermissionLevel +} diff --git a/stash/resource_teamaccess_test.go b/stash/resource_teamaccess_test.go new file mode 100644 index 00000000..77976852 --- /dev/null +++ b/stash/resource_teamaccess_test.go @@ -0,0 +1,95 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package stash + +import ( + "reflect" + "testing" + + "github.com/fluxcd/go-git-providers/gitprovider" +) + +func Test_getGitProviderPermission(t *testing.T) { + tests := []struct { + name string + permission string + want *gitprovider.RepositoryPermission + }{ + { + name: "pull", + permission: stashPermissionRead, + want: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionPull), + }, + { + name: "push", + permission: stashPermissionWrite, + want: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionPush), + }, + { + name: "admin", + permission: stashPermissionAdmin, + want: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionAdmin), + }, + { + name: "false data", + permission: "", + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + perMap := make(map[string]bool) + perMap[tt.permission] = true + gotPermission, _ := getGitProviderPermission(getStashPermissionFromMap(perMap)) + if gotPermission != nil && tt.want != nil && !reflect.DeepEqual(gotPermission, tt.want) { + t.Errorf("getPermissionFromMap() = %v, want %v", *gotPermission, *tt.want) + } + }) + } +} + +func Test_getStashPermission(t *testing.T) { + tests := []struct { + name string + permission *gitprovider.RepositoryPermission + want string + }{ + { + name: "pull", + permission: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionPull), + want: "REPO_READ", + }, + { + name: "push", + permission: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionPush), + want: "REPO_WRITE", + }, + { + name: "admin", + permission: gitprovider.RepositoryPermissionVar(gitprovider.RepositoryPermissionAdmin), + want: "REPO_ADMIN", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPermission, _ := getStashPermission(*tt.permission) + if !reflect.DeepEqual(gotPermission, tt.want) { + t.Errorf("getPermissionFromMap() = %v, want %v", gotPermission, tt.want) + } + }) + } +}