From bd781bbcfb8957b08c54f5e3a47b05a5c7c6e6cb Mon Sep 17 00:00:00 2001 From: stefanprodan Date: Thu, 18 Jun 2020 01:55:29 +0300 Subject: [PATCH] Extract git operations --- pkg/git/provider.go | 9 +++ pkg/git/provider_github.go | 161 +++++++++++++++++++++++++++++++++++++ pkg/git/provider_gitlab.go | 147 +++++++++++++++++++++++++++++++++ pkg/git/repository.go | 145 +++++++++++++++++++++++++++++++++ 4 files changed, 462 insertions(+) create mode 100644 pkg/git/provider.go create mode 100644 pkg/git/provider_github.go create mode 100644 pkg/git/provider_gitlab.go create mode 100644 pkg/git/repository.go diff --git a/pkg/git/provider.go b/pkg/git/provider.go new file mode 100644 index 00000000..8ec35c8e --- /dev/null +++ b/pkg/git/provider.go @@ -0,0 +1,9 @@ +package git + +import "context" + +type Provider interface { + CreateRepository(ctx context.Context, r *Repository) (bool, error) + AddTeam(ctx context.Context, r *Repository, name, permission string) (bool, error) + AddDeployKey(ctx context.Context, r *Repository, key, keyName string) (bool, error) +} diff --git a/pkg/git/provider_github.go b/pkg/git/provider_github.go new file mode 100644 index 00000000..efb2edc1 --- /dev/null +++ b/pkg/git/provider_github.go @@ -0,0 +1,161 @@ +package git + +import ( + "context" + "fmt" + "github.com/google/go-github/v32/github" + "strings" +) + +// GithubProvider represents a GitHub API wrapper +type GithubProvider struct { + IsPrivate bool + IsPersonal bool +} + +const ( + GitHubTokenName = "GITHUB_TOKEN" + GitHubDefaultHostname = "github.com" +) + +func (p *GithubProvider) newClient(r *Repository) (*github.Client, error) { + auth := github.BasicAuthTransport{ + Username: "git", + Password: r.Token, + } + + gh := github.NewClient(auth.Client()) + if r.Host != GitHubDefaultHostname { + baseURL := fmt.Sprintf("https://%s/api/v3/", r.Host) + uploadURL := fmt.Sprintf("https://%s/api/uploads/", r.Host) + if g, err := github.NewEnterpriseClient(baseURL, uploadURL, auth.Client()); err == nil { + gh = g + } else { + return nil, err + } + } + + return gh, nil +} + +// CreateRepository returns false if the repository exists +func (p *GithubProvider) CreateRepository(ctx context.Context, r *Repository) (bool, error) { + gh, err := p.newClient(r) + if err != nil { + return false, fmt.Errorf("client error: %w", err) + } + org := "" + if !p.IsPersonal { + org = r.Owner + } + + if _, _, err := gh.Repositories.Get(ctx, org, r.Name); err == nil { + return false, nil + } + + autoInit := true + _, _, err = gh.Repositories.Create(ctx, org, &github.Repository{ + AutoInit: &autoInit, + Name: &r.Name, + Private: &p.IsPrivate, + }) + if err != nil { + if !strings.Contains(err.Error(), "name already exists on this account") { + return false, fmt.Errorf("create repository error: %w", err) + } + } else { + return true, nil + } + return false, nil +} + +// AddTeam returns false if the team is already assigned to the repository +func (p *GithubProvider) AddTeam(ctx context.Context, r *Repository, name, permission string) (bool, error) { + gh, err := p.newClient(r) + if err != nil { + return false, fmt.Errorf("client error: %w", err) + } + + // check team exists + _, _, err = gh.Teams.GetTeamBySlug(ctx, r.Owner, name) + if err != nil { + return false, fmt.Errorf("get team %s error: %w", name, err) + } + + // check if team is assigned to the repo + _, resp, err := gh.Teams.IsTeamRepoBySlug(ctx, r.Owner, name, r.Owner, r.Name) + if resp == nil && err != nil { + return false, fmt.Errorf("is team %s error: %w", name, err) + } + + // add team to the repo + if resp.StatusCode == 404 { + _, err = gh.Teams.AddTeamRepoBySlug(ctx, r.Owner, name, r.Owner, r.Name, &github.TeamAddTeamRepoOptions{ + Permission: permission, + }) + if err != nil { + return false, fmt.Errorf("add team %s error: %w", name, err) + } + return true, nil + } + + return false, nil +} + +// AddDeployKey returns false if the key exists and the content is the same +func (p *GithubProvider) AddDeployKey(ctx context.Context, r *Repository, key, keyName string) (bool, error) { + gh, err := p.newClient(r) + if err != nil { + return false, fmt.Errorf("client error: %w", err) + } + + // list deploy keys + keys, resp, err := gh.Repositories.ListKeys(ctx, r.Owner, r.Name, nil) + if err != nil { + return false, fmt.Errorf("list deploy keys error: %w", err) + } + if resp.StatusCode >= 300 { + return false, fmt.Errorf("list deploy keys failed with status code: %s", resp.Status) + } + + // check if the key exists + shouldCreateKey := true + var existingKey *github.Key + for _, k := range keys { + if k.Title != nil && k.Key != nil && *k.Title == keyName { + if *k.Key != key { + existingKey = k + } else { + shouldCreateKey = false + } + break + } + } + + // delete existing key if the value differs + if existingKey != nil { + resp, err := gh.Repositories.DeleteKey(ctx, r.Owner, r.Name, *existingKey.ID) + if err != nil { + return false, fmt.Errorf("delete deploy key error: %w", err) + } + if resp.StatusCode >= 300 { + return false, fmt.Errorf("delete deploy key failed with status code: %s", resp.Status) + } + } + + // create key + if shouldCreateKey { + isReadOnly := true + _, _, err = gh.Repositories.CreateKey(ctx, r.Owner, r.Name, &github.Key{ + Title: &keyName, + Key: &key, + ReadOnly: &isReadOnly, + }) + if err != nil { + return false, fmt.Errorf("create deploy key error: %w", err) + } + return true, nil + } + + return false, nil +} diff --git a/pkg/git/provider_gitlab.go b/pkg/git/provider_gitlab.go new file mode 100644 index 00000000..07d97649 --- /dev/null +++ b/pkg/git/provider_gitlab.go @@ -0,0 +1,147 @@ +package git + +import ( + "context" + "fmt" + "github.com/xanzy/go-gitlab" +) + +// GitLabProvider represents a GitLab API wrapper +type GitLabProvider struct { + IsPrivate bool + IsPersonal bool +} + +const ( + GitLabTokenName = "GITLAB_TOKEN" + GitLabDefaultHostname = "gitlab.com" +) + +func (p *GitLabProvider) newClient(r *Repository) (*gitlab.Client, error) { + gl, err := gitlab.NewClient(r.Token) + if err != nil { + return nil, err + } + + if r.Host != GitLabDefaultHostname { + gl, err = gitlab.NewClient(r.Token, gitlab.WithBaseURL(fmt.Sprintf("https://%s/api/v4", r.Host))) + if err != nil { + return nil, err + } + } + return gl, nil +} + +// CreateRepository returns false if the repository already exists +func (p *GitLabProvider) CreateRepository(ctx context.Context, r *Repository) (bool, error) { + gl, err := p.newClient(r) + if err != nil { + return false, fmt.Errorf("client error: %w", err) + } + + var id *int + if !p.IsPersonal { + groups, _, err := gl.Groups.ListGroups(&gitlab.ListGroupsOptions{Search: gitlab.String(r.Owner)}, gitlab.WithContext(ctx)) + if err != nil { + return false, fmt.Errorf("list groups error: %w", err) + } + + if len(groups) > 0 { + id = &groups[0].ID + } + } + + visibility := gitlab.PublicVisibility + if p.IsPrivate { + visibility = gitlab.PrivateVisibility + } + + projects, _, err := gl.Projects.ListProjects(&gitlab.ListProjectsOptions{Search: gitlab.String(r.Name)}, gitlab.WithContext(ctx)) + if err != nil { + return false, fmt.Errorf("list projects error: %w", err) + } + + if len(projects) == 0 { + p := &gitlab.CreateProjectOptions{ + Name: gitlab.String(r.Name), + NamespaceID: id, + Visibility: &visibility, + InitializeWithReadme: gitlab.Bool(true), + } + + _, _, err := gl.Projects.CreateProject(p) + if err != nil { + return false, fmt.Errorf("create project error: %w", err) + } + return true, nil + } + + return false, nil +} + +// AddTeam returns false if the team is already assigned to the repository +func (p *GitLabProvider) AddTeam(ctx context.Context, r *Repository, name, permission string) (bool, error) { + return false, nil +} + +// AddDeployKey returns false if the key exists and the content is the same +func (p *GitLabProvider) AddDeployKey(ctx context.Context, r *Repository, key, keyName string) (bool, error) { + gl, err := p.newClient(r) + if err != nil { + return false, fmt.Errorf("client error: %w", err) + } + + // list deploy keys + var projId int + projects, _, err := gl.Projects.ListProjects(&gitlab.ListProjectsOptions{Search: gitlab.String(r.Name)}, gitlab.WithContext(ctx)) + if err != nil { + return false, fmt.Errorf("list projects error: %w", err) + } + if len(projects) > 0 { + projId = projects[0].ID + } else { + return false, fmt.Errorf("no project found") + } + + // check if the key exists + keys, _, err := gl.DeployKeys.ListProjectDeployKeys(projId, &gitlab.ListProjectDeployKeysOptions{}) + if err != nil { + return false, fmt.Errorf("list keys error: %w", err) + } + + shouldCreateKey := true + var existingKey *gitlab.DeployKey + for _, k := range keys { + if k.Title == keyName { + if k.Key != key { + existingKey = k + } else { + shouldCreateKey = false + } + break + } + } + + // delete existing key if the value differs + if existingKey != nil { + _, err := gl.DeployKeys.DeleteDeployKey(projId, existingKey.ID, gitlab.WithContext(ctx)) + if err != nil { + return false, fmt.Errorf("delete key error: %w", err) + } + } + + // create key + if shouldCreateKey { + _, _, err := gl.DeployKeys.AddDeployKey(projId, &gitlab.AddDeployKeyOptions{ + Title: gitlab.String(keyName), + Key: gitlab.String(key), + CanPush: gitlab.Bool(false), + }, gitlab.WithContext(ctx)) + if err != nil { + return false, fmt.Errorf("add key error: %w", err) + } + return true, nil + } + + return false, nil +} diff --git a/pkg/git/repository.go b/pkg/git/repository.go new file mode 100644 index 00000000..76440e6b --- /dev/null +++ b/pkg/git/repository.go @@ -0,0 +1,145 @@ +package git + +import ( + "context" + "fmt" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" +) + +// Repository represents a git repository wrapper +type Repository struct { + Name string + Owner string + Host string + Branch string + Token string + AuthorName string + AuthorEmail string +} + +// NewRepository returns a git repository wrapper +func NewRepository(name, owner, host, branch, token, authorName, authorEmail string) (*Repository, error) { + if name == "" { + return nil, fmt.Errorf("name required") + } + if owner == "" { + return nil, fmt.Errorf("owner required") + } + if host == "" { + return nil, fmt.Errorf("host required") + } + if branch == "" { + return nil, fmt.Errorf("branch required") + } + if token == "" { + return nil, fmt.Errorf("token required") + } + if authorName == "" { + authorName = "tk" + } + if authorEmail == "" { + authorEmail = "tk@users.noreply.git-scm.com" + } + + return &Repository{ + Name: name, + Owner: owner, + Host: host, + Branch: branch, + Token: token, + AuthorName: authorName, + AuthorEmail: authorEmail, + }, nil +} + +// GetURL returns the repository HTTPS address +func (r *Repository) GetURL() string { + return fmt.Sprintf("https://%s/%s/%s", r.Host, r.Owner, r.Name) +} + +// GetSSH returns the repository SSH address +func (r *Repository) GetSSH() string { + return fmt.Sprintf("ssh://git@%s/%s/%s", r.Host, r.Owner, r.Name) +} + +func (r *Repository) auth() transport.AuthMethod { + return &http.BasicAuth{ + Username: "git", + Password: r.Token, + } +} + +// Checkout repository at specified path +func (r *Repository) Checkout(ctx context.Context, path string) (*git.Repository, error) { + repo, err := git.PlainCloneContext(ctx, path, false, &git.CloneOptions{ + URL: r.GetURL(), + Auth: r.auth(), + RemoteName: git.DefaultRemoteName, + ReferenceName: plumbing.NewBranchReferenceName(r.Branch), + SingleBranch: true, + NoCheckout: false, + Progress: nil, + Tags: git.NoTags, + }) + if err != nil { + return nil, fmt.Errorf("git clone error: %w", err) + } + + _, err = repo.Head() + if err != nil { + return nil, fmt.Errorf("git resolve HEAD error: %w", err) + } + + return repo, nil +} + +// Commit changes for the specified path, returns false if no changes are made +func (r *Repository) Commit(ctx context.Context, repo *git.Repository, path, message string) (bool, error) { + w, err := repo.Worktree() + if err != nil { + return false, err + } + + _, err = w.Add(path) + if err != nil { + return false, err + } + + status, err := w.Status() + if err != nil { + return false, err + } + + if !status.IsClean() { + if _, err := w.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: r.AuthorName, + Email: r.AuthorEmail, + When: time.Now(), + }, + }); err != nil { + return false, err + } + return true, nil + } + + return false, nil +} + +// Push commits to origin +func (r *Repository) Push(ctx context.Context, repo *git.Repository) error { + err := repo.PushContext(ctx, &git.PushOptions{ + Auth: r.auth(), + Progress: nil, + }) + if err != nil { + return fmt.Errorf("git push error: %w", err) + } + return nil +}