From bd781bbcfb8957b08c54f5e3a47b05a5c7c6e6cb Mon Sep 17 00:00:00 2001
From: stefanprodan <stefan.prodan@gmail.com>
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
+}
-- 
GitLab