Просмотр исходного кода

Merge branch 'gitlab-integration' into dev

Mohammed Nafees 4 лет назад
Родитель
Сommit
ec0a086ccc
59 измененных файлов с 3340 добавлено и 243 удалено
  1. 31 2
      api/server/handlers/handler.go
  2. 114 0
      api/server/handlers/oauth_callback/gitlab.go
  3. 55 0
      api/server/handlers/project_integration/create_gitlab.go
  4. 154 0
      api/server/handlers/project_integration/get_gitlab_repo_buildpack.go
  5. 144 0
      api/server/handlers/project_integration/get_gitlab_repo_contents.go
  6. 150 0
      api/server/handlers/project_integration/get_gitlab_repo_procfile.go
  7. 118 0
      api/server/handlers/project_integration/list_git.go
  8. 44 0
      api/server/handlers/project_integration/list_gitlab.go
  9. 123 0
      api/server/handlers/project_integration/list_gitlab_repo_branches.go
  10. 108 0
      api/server/handlers/project_integration/list_gitlab_repos.go
  11. 1 1
      api/server/handlers/project_oauth/digitalocean.go
  12. 78 0
      api/server/handlers/project_oauth/gitlab.go
  13. 1 1
      api/server/handlers/project_oauth/slack.go
  14. 72 47
      api/server/handlers/release/create.go
  15. 53 20
      api/server/handlers/release/delete.go
  16. 1 1
      api/server/handlers/user/github_start.go
  17. 1 1
      api/server/handlers/user/google_start.go
  18. 24 0
      api/server/router/oauth_callback.go
  19. 232 0
      api/server/router/project_integration.go
  20. 28 0
      api/server/router/project_oauth.go
  21. 20 0
      api/server/shared/commonutils/gitlab.go
  22. 12 8
      api/types/git_action_config.go
  23. 34 0
      api/types/project_integration.go
  24. 5 5
      api/types/release.go
  25. 1 1
      cli/cmd/deploy/create.go
  26. 13 2
      dashboard/src/components/SearchBar.tsx
  27. 0 2
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  28. 47 22
      dashboard/src/components/repo-selector/BranchList.tsx
  29. 26 7
      dashboard/src/components/repo-selector/ContentsList.tsx
  30. 246 77
      dashboard/src/components/repo-selector/RepoList.tsx
  31. 205 0
      dashboard/src/main/home/integrations/GitlabIntegrationList.tsx
  32. 22 3
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  33. 8 4
      dashboard/src/main/home/integrations/Integrations.tsx
  34. 2 2
      dashboard/src/main/home/integrations/SlackIntegrationList.tsx
  35. 3 0
      dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx
  36. 145 0
      dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx
  37. 1 1
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  38. 0 1
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  39. 67 0
      dashboard/src/shared/api.tsx
  40. 1 0
      dashboard/src/shared/common.tsx
  41. 17 9
      dashboard/src/shared/types.tsx
  42. 11 0
      ee/integrations/vault/types.go
  43. 34 0
      ee/integrations/vault/vault.go
  44. 6 3
      go.mod
  45. 13 0
      go.sum
  46. 306 0
      internal/integrations/ci/gitlab/ci.go
  47. 10 6
      internal/models/gitrepo.go
  48. 36 0
      internal/models/integrations/gitlab.go
  49. 26 10
      internal/models/integrations/oauth.go
  50. 8 7
      internal/models/project.go
  51. 16 0
      internal/oauth/config.go
  52. 8 0
      internal/repository/credentials/credentials.go
  53. 320 0
      internal/repository/gorm/auth.go
  54. 2 0
      internal/repository/gorm/migrate.go
  55. 12 0
      internal/repository/gorm/repository.go
  56. 14 0
      internal/repository/integrations.go
  57. 2 0
      internal/repository/repository.go
  58. 97 0
      internal/repository/test/auth.go
  59. 12 0
      internal/repository/test/repository.go

+ 31 - 2
api/server/handlers/handler.go

@@ -17,7 +17,14 @@ type PorterHandler interface {
 	Repo() repository.Repository
 	HandleAPIError(w http.ResponseWriter, r *http.Request, err apierrors.RequestError)
 	HandleAPIErrorNoWrite(w http.ResponseWriter, r *http.Request, err apierrors.RequestError)
-	PopulateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error
+	PopulateOAuthSession(
+		w http.ResponseWriter,
+		r *http.Request,
+		state string,
+		isUser, isProject bool,
+		integrationClient types.OAuthIntegrationClient,
+		integrationID uint,
+	) error
 }
 
 type PorterHandlerWriter interface {
@@ -81,7 +88,14 @@ func IgnoreAPIError(w http.ResponseWriter, r *http.Request, err apierrors.Reques
 	return
 }
 
-func (d *DefaultPorterHandler) PopulateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error {
+func (d *DefaultPorterHandler) PopulateOAuthSession(
+	w http.ResponseWriter,
+	r *http.Request,
+	state string,
+	isProject, isUser bool,
+	integrationClient types.OAuthIntegrationClient,
+	integrationID uint,
+) error {
 	session, err := d.Config().Store.Get(r, d.Config().ServerConf.CookieName)
 
 	if err != nil {
@@ -106,6 +120,21 @@ func (d *DefaultPorterHandler) PopulateOAuthSession(w http.ResponseWriter, r *ht
 		session.Values["project_id"] = project.ID
 	}
 
+	if isUser {
+		user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+		if user == nil {
+			return fmt.Errorf("could not read user")
+		}
+
+		session.Values["user_id"] = user.ID
+	}
+
+	if integrationID != 0 && len(integrationClient) > 0 {
+		session.Values["integration_id"] = integrationID
+		session.Values["integration_client"] = string(integrationClient)
+	}
+
 	if err := session.Save(r, w); err != nil {
 		return err
 	}

+ 114 - 0
api/server/handlers/oauth_callback/gitlab.go

@@ -0,0 +1,114 @@
+package oauth_callback
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
+)
+
+type OAuthCallbackGitlabHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewOAuthCallbackGitlabHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *OAuthCallbackGitlabHandler {
+	return &OAuthCallbackGitlabHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *OAuthCallbackGitlabHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	session, err := p.Config().Store.Get(r, p.Config().ServerConf.CookieName)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+	projID, _ := session.Values["project_id"].(uint)
+	integrationID := session.Values["integration_id"].(uint)
+
+	giIntegration, err := p.Repo().GitlabIntegration().ReadGitlabIntegration(projID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("gitlab integration with id %d not found in project %d",
+					integrationID, projID),
+			))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	token, err := commonutils.GetGitlabOAuthConf(p.Config(), giIntegration).
+		Exchange(context.Background(), r.URL.Query().Get("code"))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	if !token.Valid() {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("invalid token")))
+		return
+	}
+
+	oauthInt := &integrations.GitlabAppOAuthIntegration{
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+			Expiry:       token.Expiry,
+		},
+		UserID:        userID,
+		ProjectID:     projID,
+		IntegrationID: integrationID,
+	}
+
+	// create the oauth integration first
+	_, err = p.Repo().GitlabAppOAuthIntegration().CreateGitlabAppOAuthIntegration(oauthInt)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
+		// attempt to parse the redirect uri, if it fails just redirect to dashboard
+		redirectURI, err := url.Parse(redirectStr)
+
+		if err != nil {
+			http.Redirect(w, r, "/dashboard", http.StatusFound)
+		}
+
+		http.Redirect(w, r, fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery), http.StatusFound)
+	} else {
+		http.Redirect(w, r, "/dashboard", http.StatusFound)
+	}
+}

+ 55 - 0
api/server/handlers/project_integration/create_gitlab.go

@@ -0,0 +1,55 @@
+package project_integration
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type CreateGitlabIntegration struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewCreateGitlabIntegration(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateGitlabIntegration {
+	return &CreateGitlabIntegration{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *CreateGitlabIntegration) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateGitlabRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	gitlabIntegration, err := p.Repo().GitlabIntegration().CreateGitlabIntegration(&ints.GitlabIntegration{
+		ProjectID:       project.ID,
+		InstanceURL:     request.InstanceURL,
+		AppClientID:     []byte(request.AppClientID),
+		AppClientSecret: []byte(request.AppClientSecret),
+	})
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := types.CreateGitlabResponse{
+		GitlabIntegration: gitlabIntegration.ToGitlabIntegrationType(),
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 154 - 0
api/server/handlers/project_integration/get_gitlab_repo_buildpack.go

@@ -0,0 +1,154 @@
+package project_integration
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/integrations/buildpacks"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/xanzy/go-gitlab"
+	"gorm.io/gorm"
+)
+
+type GetGitlabRepoBuildpackHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGetGitlabRepoBuildpackHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetGitlabRepoBuildpackHandler {
+	return &GetGitlabRepoBuildpackHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *GetGitlabRepoBuildpackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	request := &types.GetBuildpackRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	integrationID, reqErr := requestutils.GetURLParamUint(r, "integration_id")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	owner, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoOwner)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoName)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	branch, reqErr := requestutils.GetURLParamString(r, types.URLParamGitBranch)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	gi, err := p.Repo().GitlabIntegration().ReadGitlabIntegration(project.ID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("no gitlab integration with ID: %d", integrationID), http.StatusNotFound))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	giAppOAuth, err := p.Repo().GitlabAppOAuthIntegration().ReadGitlabAppOAuthIntegration(user.ID, project.ID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	accessToken, _, err := oauth.GetAccessToken(giAppOAuth.SharedOAuthModel, commonutils.GetGitlabOAuthConf(
+		p.Config(), gi,
+	), oauth.MakeUpdateGitlabAppOAuthIntegrationFunction(giAppOAuth, p.Repo()))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("invalid gitlab access token"),
+			http.StatusUnauthorized))
+		return
+	}
+
+	client, err := gitlab.NewOAuthClient(accessToken, gitlab.WithBaseURL(gi.InstanceURL))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	tree, resp, err := client.Repositories.ListTree(fmt.Sprintf("%s/%s", owner, name), &gitlab.ListTreeOptions{
+		Path: gitlab.String(request.Dir),
+		Ref:  gitlab.String(branch),
+	})
+
+	if resp.StatusCode == http.StatusUnauthorized {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+		return
+	} else if resp.StatusCode == http.StatusNotFound {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("no such gitlab project found"), http.StatusNotFound))
+		return
+	}
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, tree)
+}
+
+func initBuilderInfo() map[string]*buildpacks.BuilderInfo {
+	builders := make(map[string]*buildpacks.BuilderInfo)
+	builders[buildpacks.PaketoBuilder] = &buildpacks.BuilderInfo{
+		Name: "Paketo",
+		Builders: []string{
+			"paketobuildpacks/builder:full",
+		},
+	}
+	builders[buildpacks.HerokuBuilder] = &buildpacks.BuilderInfo{
+		Name: "Heroku",
+		Builders: []string{
+			"heroku/buildpacks:20",
+			"heroku/buildpacks:18",
+		},
+	}
+	return builders
+}

+ 144 - 0
api/server/handlers/project_integration/get_gitlab_repo_contents.go

@@ -0,0 +1,144 @@
+package project_integration
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/xanzy/go-gitlab"
+	"gorm.io/gorm"
+)
+
+type GetGitlabRepoContentsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGetGitlabRepoContentsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetGitlabRepoContentsHandler {
+	return &GetGitlabRepoContentsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *GetGitlabRepoContentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	request := &types.GetContentsRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	integrationID, reqErr := requestutils.GetURLParamUint(r, "integration_id")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	owner, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoOwner)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoName)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	branch, reqErr := requestutils.GetURLParamString(r, types.URLParamGitBranch)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	gi, err := p.Repo().GitlabIntegration().ReadGitlabIntegration(project.ID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("no gitlab integration with ID: %d", integrationID), http.StatusNotFound))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	giAppOAuth, err := p.Repo().GitlabAppOAuthIntegration().ReadGitlabAppOAuthIntegration(user.ID, project.ID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	accessToken, _, err := oauth.GetAccessToken(giAppOAuth.SharedOAuthModel, commonutils.GetGitlabOAuthConf(
+		p.Config(), gi,
+	), oauth.MakeUpdateGitlabAppOAuthIntegrationFunction(giAppOAuth, p.Repo()))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("invalid gitlab access token"),
+			http.StatusUnauthorized))
+		return
+	}
+
+	client, err := gitlab.NewOAuthClient(accessToken, gitlab.WithBaseURL(gi.InstanceURL))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	tree, resp, err := client.Repositories.ListTree(fmt.Sprintf("%s/%s", owner, name), &gitlab.ListTreeOptions{
+		Path: gitlab.String(request.Dir),
+		Ref:  gitlab.String(branch),
+	})
+
+	if resp.StatusCode == http.StatusUnauthorized {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+		return
+	} else if resp.StatusCode == http.StatusNotFound {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("no such gitlab project found"), http.StatusNotFound))
+		return
+	}
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.GetContentsResponse
+
+	for _, node := range tree {
+		res = append(res, types.GithubDirectoryItem{
+			Path: node.Path,
+			Type: node.Type,
+		})
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 150 - 0
api/server/handlers/project_integration/get_gitlab_repo_procfile.go

@@ -0,0 +1,150 @@
+package project_integration
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"regexp"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/xanzy/go-gitlab"
+	"gorm.io/gorm"
+)
+
+var procfileRegex = regexp.MustCompile("^([A-Za-z0-9_]+):\\s*(.+)$")
+
+type GetGitlabRepoProcfileHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGetGitlabRepoProcfileHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetGitlabRepoProcfileHandler {
+	return &GetGitlabRepoProcfileHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *GetGitlabRepoProcfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	request := &types.GetProcfileRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	integrationID, reqErr := requestutils.GetURLParamUint(r, "integration_id")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	owner, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoOwner)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoName)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	branch, reqErr := requestutils.GetURLParamString(r, types.URLParamGitBranch)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	gi, err := p.Repo().GitlabIntegration().ReadGitlabIntegration(project.ID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("no gitlab integration with ID: %d", integrationID), http.StatusNotFound))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	giAppOAuth, err := p.Repo().GitlabAppOAuthIntegration().ReadGitlabAppOAuthIntegration(user.ID, project.ID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	accessToken, _, err := oauth.GetAccessToken(giAppOAuth.SharedOAuthModel, commonutils.GetGitlabOAuthConf(
+		p.Config(), gi,
+	), oauth.MakeUpdateGitlabAppOAuthIntegrationFunction(giAppOAuth, p.Repo()))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("invalid gitlab access token"),
+			http.StatusUnauthorized))
+		return
+	}
+
+	client, err := gitlab.NewOAuthClient(accessToken, gitlab.WithBaseURL(gi.InstanceURL))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	file, resp, err := client.RepositoryFiles.GetRawFile(fmt.Sprintf("%s/%s", owner, name), request.Path,
+		&gitlab.GetRawFileOptions{
+			Ref: gitlab.String(branch),
+		},
+	)
+
+	if resp.StatusCode == http.StatusUnauthorized {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+		return
+	} else if resp.StatusCode == http.StatusNotFound {
+		w.WriteHeader(http.StatusNotFound)
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("no such procfile exists")))
+		return
+	}
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	parsedContents := make(types.GetProcfileResponse)
+
+	// parse the procfile information
+	for _, line := range strings.Split(string(file), "\n") {
+		if matches := procfileRegex.FindStringSubmatch(line); matches != nil {
+			parsedContents[matches[1]] = matches[2]
+		}
+	}
+
+	p.WriteResult(w, r, parsedContents)
+}

+ 118 - 0
api/server/handlers/project_integration/list_git.go

@@ -0,0 +1,118 @@
+package project_integration
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/google/go-github/v39/github"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type ListGitIntegrationHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewListGitIntegrationHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListGitIntegrationHandler {
+	return &ListGitIntegrationHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *ListGitIntegrationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	gitlabInts, err := p.Repo().GitlabIntegration().ListGitlabIntegrationsByProjectID(project.ID)
+
+	var res types.ListGitIntegrationResponse
+
+	if err == nil {
+		for _, gitlabInt := range gitlabInts {
+			res = append(res, &types.GitIntegration{
+				Provider:      "gitlab",
+				InstanceURL:   gitlabInt.InstanceURL,
+				IntegrationID: gitlabInt.ID,
+			})
+		}
+	}
+
+	tok, err := gitinstallation.GetGithubAppOauthTokenFromRequest(p.Config(), r)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			// return empty array, this is not an error
+			p.WriteResult(w, r, res)
+		} else {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		}
+
+		return
+	}
+
+	client := github.NewClient(p.Config().GithubAppConf.Client(context.Background(), tok))
+
+	var accountIDs []int64
+	accountIDMap := make(map[int64]string)
+
+	ghAuthUser, _, err := client.Users.Get(context.Background(), "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		p.WriteResult(w, r, res)
+		return
+	}
+
+	accountIDs = append(accountIDs, ghAuthUser.GetID())
+	accountIDMap[ghAuthUser.GetID()] = ghAuthUser.GetLogin()
+
+	opts := &github.ListOptions{
+		PerPage: 100,
+		Page:    1,
+	}
+
+	for {
+		orgs, pages, err := client.Organizations.List(context.Background(), "", opts)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			p.WriteResult(w, r, res)
+			return
+		}
+
+		for _, org := range orgs {
+			accountIDs = append(accountIDs, org.GetID())
+			accountIDMap[org.GetID()] = org.GetLogin()
+		}
+
+		if pages.NextPage == 0 {
+			break
+		}
+	}
+
+	installationData, err := p.Repo().GithubAppInstallation().ReadGithubAppInstallationByAccountIDs(accountIDs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		p.WriteResult(w, r, res)
+	}
+
+	for _, data := range installationData {
+		res = append(res, &types.GitIntegration{
+			Provider:       "github",
+			Name:           accountIDMap[data.AccountID],
+			InstallationID: data.InstallationID,
+		})
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 44 - 0
api/server/handlers/project_integration/list_gitlab.go

@@ -0,0 +1,44 @@
+package project_integration
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListGitlabHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewListGitlabHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListGitlabHandler {
+	return &ListGitlabHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ListGitlabHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	gitlabInts, err := p.Repo().GitlabIntegration().ListGitlabIntegrationsByProjectID(project.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListGitlabResponse = make([]*types.GitlabIntegration, 0)
+
+	for _, gitlabInt := range gitlabInts {
+		res = append(res, gitlabInt.ToGitlabIntegrationType())
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 123 - 0
api/server/handlers/project_integration/list_gitlab_repo_branches.go

@@ -0,0 +1,123 @@
+package project_integration
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/xanzy/go-gitlab"
+	"gorm.io/gorm"
+)
+
+type ListGitlabRepoBranchesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewListGitlabRepoBranchesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListGitlabRepoBranchesHandler {
+	return &ListGitlabRepoBranchesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *ListGitlabRepoBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	integrationID, reqErr := requestutils.GetURLParamUint(r, "integration_id")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	owner, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoOwner)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoName)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	gi, err := p.Repo().GitlabIntegration().ReadGitlabIntegration(project.ID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("no gitlab integration with ID: %d", integrationID), http.StatusNotFound))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	giAppOAuth, err := p.Repo().GitlabAppOAuthIntegration().ReadGitlabAppOAuthIntegration(user.ID, project.ID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	accessToken, _, err := oauth.GetAccessToken(giAppOAuth.SharedOAuthModel, commonutils.GetGitlabOAuthConf(
+		p.Config(), gi,
+	), oauth.MakeUpdateGitlabAppOAuthIntegrationFunction(giAppOAuth, p.Repo()))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("invalid gitlab access token"),
+			http.StatusUnauthorized))
+		return
+	}
+
+	client, err := gitlab.NewOAuthClient(accessToken, gitlab.WithBaseURL(gi.InstanceURL))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	branches, resp, err := client.Branches.ListBranches(fmt.Sprintf("%s/%s", owner, name), &gitlab.ListBranchesOptions{})
+
+	if resp.StatusCode == http.StatusUnauthorized {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+		return
+	} else if resp.StatusCode == http.StatusNotFound {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("no such gitlab project found"), http.StatusNotFound))
+		return
+	}
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res []string
+
+	for _, branch := range branches {
+		res = append(res, branch.Name)
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 108 - 0
api/server/handlers/project_integration/list_gitlab_repos.go

@@ -0,0 +1,108 @@
+package project_integration
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/xanzy/go-gitlab"
+	"gorm.io/gorm"
+)
+
+type ListGitlabReposHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewListGitlabReposHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListGitlabReposHandler {
+	return &ListGitlabReposHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *ListGitlabReposHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	integrationID, reqErr := requestutils.GetURLParamUint(r, "integration_id")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	gi, err := p.Repo().GitlabIntegration().ReadGitlabIntegration(project.ID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("no gitlab integration with ID: %d", integrationID), http.StatusNotFound))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	giAppOAuth, err := p.Repo().GitlabAppOAuthIntegration().ReadGitlabAppOAuthIntegration(user.ID, project.ID, integrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	accessToken, _, err := oauth.GetAccessToken(giAppOAuth.SharedOAuthModel, commonutils.GetGitlabOAuthConf(
+		p.Config(), gi,
+	), oauth.MakeUpdateGitlabAppOAuthIntegrationFunction(giAppOAuth, p.Repo()))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("invalid gitlab access token"),
+			http.StatusUnauthorized))
+		return
+	}
+
+	client, err := gitlab.NewOAuthClient(accessToken, gitlab.WithBaseURL(gi.InstanceURL))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	giProjects, resp, err := client.Projects.ListProjects(&gitlab.ListProjectsOptions{
+		Simple: gitlab.Bool(true),
+	})
+
+	if resp.StatusCode == http.StatusUnauthorized {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+		return
+	}
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res []string
+
+	for _, giProject := range giProjects {
+		res = append(res, giProject.PathWithNamespace)
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 1 - 1
api/server/handlers/project_oauth/digitalocean.go

@@ -29,7 +29,7 @@ func NewProjectOAuthDOHandler(
 func (p *ProjectOAuthDOHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	state := oauth.CreateRandomState()
 
-	if err := p.PopulateOAuthSession(w, r, state, true); err != nil {
+	if err := p.PopulateOAuthSession(w, r, state, false, true, "", 0); err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 78 - 0
api/server/handlers/project_oauth/gitlab.go

@@ -0,0 +1,78 @@
+package project_oauth
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+)
+
+type ProjectOAuthGitlabHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewProjectOAuthGitlabHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ProjectOAuthGitlabHandler {
+	return &ProjectOAuthGitlabHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *ProjectOAuthGitlabHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	integrationIDStr := r.URL.Query().Get("integration_id")
+
+	if len(integrationIDStr) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("required query param integration_id")))
+		return
+	}
+
+	integrationID, err := strconv.ParseUint(integrationIDStr, 10, 32)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	giIntegration, err := p.Repo().GitlabIntegration().ReadGitlabIntegration(proj.ID, uint(integrationID))
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			p.HandleAPIError(w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("gitlab integration with id %d not found in project %d", integrationID, proj.ID),
+			))
+		} else {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		}
+
+		return
+	}
+
+	state := oauth.CreateRandomState()
+
+	if err := p.PopulateOAuthSession(w, r, state, true, true, types.OAuthGitlab, uint(integrationID)); err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	gitlabConf := commonutils.GetGitlabOAuthConf(p.Config(), giIntegration)
+
+	// specify access type offline to get a refresh token
+	url := gitlabConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, http.StatusFound)
+}

+ 1 - 1
api/server/handlers/project_oauth/slack.go

@@ -29,7 +29,7 @@ func NewProjectOAuthSlackHandler(
 func (p *ProjectOAuthSlackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	state := oauth.CreateRandomState()
 
-	if err := p.PopulateOAuthSession(w, r, state, true); err != nil {
+	if err := p.PopulateOAuthSession(w, r, state, false, true, "", 0); err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 72 - 47
api/server/handlers/release/create.go

@@ -18,6 +18,7 @@ import (
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
+	"github.com/porter-dev/porter/internal/integrations/ci/gitlab"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/registry"
@@ -160,13 +161,13 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 	}
 
-	if request.GithubActionConfig != nil {
+	if request.GitActionConfig != nil {
 		_, _, err := createGitAction(
 			c.Config(),
 			user.ID,
 			cluster.ProjectID,
 			cluster.ID,
-			request.GithubActionConfig,
+			request.GitActionConfig,
 			request.Name,
 			namespace,
 			release,
@@ -288,56 +289,80 @@ func createGitAction(
 		return nil, nil, err
 	}
 
-	// create the commit in the git repo
-	gaRunner := &actions.GithubActions{
-		InstanceName:           config.ServerConf.InstanceName,
-		ServerURL:              config.ServerConf.ServerURL,
-		GithubOAuthIntegration: nil,
-		GithubAppID:            config.GithubAppConf.AppID,
-		GithubAppSecretPath:    config.GithubAppConf.SecretPath,
-		GithubInstallationID:   request.GitRepoID,
-		GitRepoName:            repoSplit[1],
-		GitRepoOwner:           repoSplit[0],
-		Repo:                   config.Repo,
-		ProjectID:              projectID,
-		ClusterID:              clusterID,
-		ReleaseName:            name,
-		ReleaseNamespace:       namespace,
-		GitBranch:              request.GitBranch,
-		DockerFilePath:         request.DockerfilePath,
-		FolderPath:             request.FolderPath,
-		ImageRepoURL:           request.ImageRepoURI,
-		PorterToken:            encoded,
-		Version:                "v0.1.0",
-		ShouldCreateWorkflow:   request.ShouldCreateWorkflow,
-		DryRun:                 release == nil,
-	}
-
-	// Save the github err for after creating the git action config. However, we
-	// need to call Setup() in order to get the workflow file before writing the
-	// action config, in the case of a dry run, since the dry run does not create
-	// a git action config.
-	workflowYAML, githubErr := gaRunner.Setup()
+	var workflowYAML []byte
+	var gitErr error
+
+	if request.GitlabIntegrationID != 0 {
+		giRunner := &gitlab.GitlabCI{
+			ServerURL:        config.ServerConf.ServerURL,
+			GitRepoOwner:     repoSplit[0],
+			GitRepoName:      repoSplit[1],
+			Repo:             config.Repo,
+			ProjectID:        projectID,
+			ClusterID:        clusterID,
+			UserID:           userID,
+			IntegrationID:    request.GitlabIntegrationID,
+			PorterConf:       config,
+			ReleaseName:      name,
+			ReleaseNamespace: namespace,
+			FolderPath:       request.FolderPath,
+			PorterToken:      encoded,
+		}
 
-	if gaRunner.DryRun {
-		if githubErr != nil {
-			return nil, nil, githubErr
+		gitErr = giRunner.Setup()
+	} else {
+		// create the commit in the git repo
+		gaRunner := &actions.GithubActions{
+			InstanceName:           config.ServerConf.InstanceName,
+			ServerURL:              config.ServerConf.ServerURL,
+			GithubOAuthIntegration: nil,
+			GithubAppID:            config.GithubAppConf.AppID,
+			GithubAppSecretPath:    config.GithubAppConf.SecretPath,
+			GithubInstallationID:   request.GitRepoID,
+			GitRepoName:            repoSplit[1],
+			GitRepoOwner:           repoSplit[0],
+			Repo:                   config.Repo,
+			ProjectID:              projectID,
+			ClusterID:              clusterID,
+			ReleaseName:            name,
+			ReleaseNamespace:       namespace,
+			GitBranch:              request.GitBranch,
+			DockerFilePath:         request.DockerfilePath,
+			FolderPath:             request.FolderPath,
+			ImageRepoURL:           request.ImageRepoURI,
+			PorterToken:            encoded,
+			Version:                "v0.1.0",
+			ShouldCreateWorkflow:   request.ShouldCreateWorkflow,
+			DryRun:                 release == nil,
 		}
 
-		return nil, workflowYAML, nil
+		// Save the github err for after creating the git action config. However, we
+		// need to call Setup() in order to get the workflow file before writing the
+		// action config, in the case of a dry run, since the dry run does not create
+		// a git action config.
+		workflowYAML, githubErr := gaRunner.Setup()
+
+		if gaRunner.DryRun {
+			if githubErr != nil {
+				return nil, nil, githubErr
+			}
+
+			return nil, workflowYAML, nil
+		}
 	}
 
 	// handle write to the database
 	ga, err := config.Repo.GitActionConfig().CreateGitActionConfig(&models.GitActionConfig{
-		ReleaseID:      release.ID,
-		GitRepo:        request.GitRepo,
-		GitBranch:      request.GitBranch,
-		ImageRepoURI:   request.ImageRepoURI,
-		GitRepoID:      request.GitRepoID,
-		DockerfilePath: request.DockerfilePath,
-		FolderPath:     request.FolderPath,
-		IsInstallation: true,
-		Version:        "v0.1.0",
+		ReleaseID:           release.ID,
+		GitRepo:             request.GitRepo,
+		GitBranch:           request.GitBranch,
+		ImageRepoURI:        request.ImageRepoURI,
+		GitRepoID:           request.GitRepoID,
+		GitlabIntegrationID: request.GitlabIntegrationID,
+		DockerfilePath:      request.DockerfilePath,
+		FolderPath:          request.FolderPath,
+		IsInstallation:      true,
+		Version:             "v0.1.0",
 	})
 
 	if err != nil {
@@ -353,8 +378,8 @@ func createGitAction(
 		return nil, nil, err
 	}
 
-	if githubErr != nil {
-		return nil, nil, githubErr
+	if gitErr != nil {
+		return nil, nil, gitErr
 	}
 
 	return ga.ToGitActionConfigType(), workflowYAML, nil

+ 53 - 20
api/server/handlers/release/delete.go

@@ -1,7 +1,9 @@
 package release
 
 import (
+	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -9,6 +11,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/integrations/ci/gitlab"
 	"github.com/porter-dev/porter/internal/models"
 	"helm.sh/helm/v3/pkg/release"
 )
@@ -56,28 +59,58 @@ func (c *DeleteReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			gitAction := rel.GitActionConfig
 
 			if gitAction != nil && gitAction.ID != 0 {
-				gaRunner, err := getGARunner(
-					c.Config(),
-					user.ID,
-					cluster.ProjectID,
-					cluster.ID,
-					rel.GitActionConfig,
-					helmRelease.Name,
-					helmRelease.Namespace,
-					rel,
-					helmRelease,
-				)
-
-				if err != nil {
-					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-					return
-				}
+				if gitAction.GitlabIntegrationID != 0 {
+					repoSplit := strings.Split(gitAction.GitRepo, "/")
+
+					if len(repoSplit) != 2 {
+						c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("invalid formatting of repo name")))
+						return
+					}
+
+					giRunner := &gitlab.GitlabCI{
+						ServerURL:        c.Config().ServerConf.ServerURL,
+						GitRepoOwner:     repoSplit[0],
+						GitRepoName:      repoSplit[1],
+						Repo:             c.Repo(),
+						ProjectID:        cluster.ProjectID,
+						ClusterID:        cluster.ID,
+						UserID:           user.ID,
+						IntegrationID:    gitAction.GitlabIntegrationID,
+						PorterConf:       c.Config(),
+						ReleaseName:      helmRelease.Name,
+						ReleaseNamespace: helmRelease.Namespace,
+					}
+
+					err = giRunner.Cleanup()
+
+					if err != nil {
+						c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+						return
+					}
+				} else {
+					gaRunner, err := getGARunner(
+						c.Config(),
+						user.ID,
+						cluster.ProjectID,
+						cluster.ID,
+						rel.GitActionConfig,
+						helmRelease.Name,
+						helmRelease.Namespace,
+						rel,
+						helmRelease,
+					)
+
+					if err != nil {
+						c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+						return
+					}
 
-				err = gaRunner.Cleanup()
+					err = gaRunner.Cleanup()
 
-				if err != nil {
-					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-					return
+					if err != nil {
+						c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+						return
+					}
 				}
 			}
 		}

+ 1 - 1
api/server/handlers/user/github_start.go

@@ -29,7 +29,7 @@ func NewUserOAuthGithubHandler(
 func (p *UserOAuthGithubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	state := oauth.CreateRandomState()
 
-	if err := p.PopulateOAuthSession(w, r, state, false); err != nil {
+	if err := p.PopulateOAuthSession(w, r, state, false, false, "", 0); err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 1 - 1
api/server/handlers/user/google_start.go

@@ -29,7 +29,7 @@ func NewUserOAuthGoogleHandler(
 func (p *UserOAuthGoogleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	state := oauth.CreateRandomState()
 
-	if err := p.PopulateOAuthSession(w, r, state, false); err != nil {
+	if err := p.PopulateOAuthSession(w, r, state, false, false, "", 0); err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 24 - 0
api/server/router/oauth_callback.go

@@ -74,5 +74,29 @@ func GetOAuthCallbackRoutes(
 		Router:   r,
 	})
 
+	// GET /api/oauth/gitlab/callback -> oauth_callback.NewOAuthCallbackGitlabHandler
+	gitlabEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/gitlab/callback",
+			},
+		},
+	)
+
+	gitlabHandler := oauth_callback.NewOAuthCallbackGitlabHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: gitlabEndpoint,
+		Handler:  gitlabHandler,
+		Router:   r,
+	})
+
 	return routes
 }

+ 232 - 0
api/server/router/project_integration.go

@@ -1,6 +1,8 @@
 package router
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi"
 	project_integration "github.com/porter-dev/porter/api/server/handlers/project_integration"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -327,5 +329,235 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/integrations/gitlab
+	listGitlabEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/gitlab",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listGitlabHandler := project_integration.NewListGitlabHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listGitlabEndpoint,
+		Handler:  listGitlabHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/integrations/gitlab
+	createGitlabEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/gitlab",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	createGitlabHandler := project_integration.NewCreateGitlabIntegration(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: createGitlabEndpoint,
+		Handler:  createGitlabHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/projects/{project_id}/integrations/gitlab/{integration_id}
+
+	// DELETE /api/projects/{project_id}/integrations/gitlab/{integration_id}
+
+	// GET /api/projects/{project_id}/integrations/git
+	listGitIntegrationsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/git",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listGitIntegrationsHandler := project_integration.NewListGitIntegrationHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listGitIntegrationsEndpoint,
+		Handler:  listGitIntegrationsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos
+	listGitlabReposEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/gitlab/{integration_id}/repos",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listGitlabReposHandler := project_integration.NewListGitlabReposHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listGitlabReposEndpoint,
+		Handler:  listGitlabReposHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/{owner}/{name}/branches
+	listGitlabRepoBranchesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/gitlab/{integration_id}/repos/{%s}/{%s}/branches", relPath, types.URLParamGitRepoOwner, types.URLParamGitRepoName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listGitlabRepoBranchesHandler := project_integration.NewListGitlabRepoBranchesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listGitlabRepoBranchesEndpoint,
+		Handler:  listGitlabRepoBranchesHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/{owner}/{name}/{branch}/contents
+	getGitlabRepoContentsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf("%s/gitlab/{integration_id}/repos/{%s}/{%s}/{%s}/contents", relPath,
+					types.URLParamGitRepoOwner, types.URLParamGitRepoName, types.URLParamGitBranch),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getGitlabRepoContentsHandler := project_integration.NewGetGitlabRepoContentsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getGitlabRepoContentsEndpoint,
+		Handler:  getGitlabRepoContentsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/{owner}/{name}/{branch}/buildpack/detect
+	getGitlabRepoBuildpackEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf("%s/gitlab/{integration_id}/repos/{%s}/{%s}/{%s}/buildpack/detect", relPath,
+					types.URLParamGitRepoOwner, types.URLParamGitRepoName, types.URLParamGitBranch),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getGitlabRepoBuildpackHandler := project_integration.NewGetGitlabRepoBuildpackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getGitlabRepoBuildpackEndpoint,
+		Handler:  getGitlabRepoBuildpackHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/{owner}/{name}/{branch}/procfile
+	getGitlabRepoProcfileEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf("%s/gitlab/{integration_id}/repos/{%s}/{%s}/{%s}/procfile", relPath,
+					types.URLParamGitRepoOwner, types.URLParamGitRepoName, types.URLParamGitBranch),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getGitlabRepoProcfileHandler := project_integration.NewGetGitlabRepoProcfileHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getGitlabRepoProcfileEndpoint,
+		Handler:  getGitlabRepoProcfileHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 28 - 0
api/server/router/project_oauth.go

@@ -109,5 +109,33 @@ func getProjectOAuthRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/oauth/gitlab -> project_integration.NewProjectOAuthGitlabHandler
+	gitlabEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/gitlab",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	gitlabHandler := project_oauth.NewProjectOAuthGitlabHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: gitlabEndpoint,
+		Handler:  gitlabHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 20 - 0
api/server/shared/commonutils/gitlab.go

@@ -0,0 +1,20 @@
+package commonutils
+
+import (
+	"github.com/porter-dev/porter/api/server/shared/config"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"golang.org/x/oauth2"
+)
+
+func GetGitlabOAuthConf(conf *config.Config, giIntegration *ints.GitlabIntegration) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     string(giIntegration.AppClientID),
+		ClientSecret: string(giIntegration.AppClientSecret),
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  giIntegration.InstanceURL + "/oauth/authorize",
+			TokenURL: giIntegration.InstanceURL + "/oauth/token",
+		},
+		RedirectURL: conf.ServerConf.ServerURL + "/api/oauth/gitlab/callback",
+		Scopes:      []string{"api", "profile", "email"},
+	}
+}

+ 12 - 8
api/types/git_action_config.go

@@ -11,9 +11,12 @@ type GitActionConfig struct {
 	// The complete image repository uri to pull from
 	ImageRepoURI string `json:"image_repo_uri"`
 
-	// The git integration id
+	// The github integration ID
 	GitRepoID uint `json:"git_repo_id"`
 
+	// The gitlab integration ID
+	GitlabIntegrationID uint `json:"gitlab_integration_id"`
+
 	// The path to the dockerfile in the git repo
 	DockerfilePath string `json:"dockerfile_path"`
 
@@ -22,13 +25,14 @@ type GitActionConfig struct {
 }
 
 type CreateGitActionConfigRequest struct {
-	GitRepo        string `json:"git_repo" form:"required"`
-	GitBranch      string `json:"git_branch"`
-	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
-	DockerfilePath string `json:"dockerfile_path"`
-	FolderPath     string `json:"folder_path"`
-	GitRepoID      uint   `json:"git_repo_id" form:"required"`
-	RegistryID     uint   `json:"registry_id"`
+	GitRepo             string `json:"git_repo" form:"required"`
+	GitBranch           string `json:"git_branch"`
+	ImageRepoURI        string `json:"image_repo_uri" form:"required"`
+	DockerfilePath      string `json:"dockerfile_path"`
+	FolderPath          string `json:"folder_path"`
+	GitRepoID           uint   `json:"git_repo_id"`
+	GitlabIntegrationID uint   `json:"gitlab_integration_id"`
+	RegistryID          uint   `json:"registry_id"`
 
 	ShouldCreateWorkflow bool `json:"should_create_workflow"`
 }

+ 34 - 0
api/types/project_integration.go

@@ -7,6 +7,7 @@ const (
 	OAuthGithub       OAuthIntegrationClient = "github"
 	OAuthDigitalOcean OAuthIntegrationClient = "do"
 	OAuthGoogle       OAuthIntegrationClient = "google"
+	OAuthGitlab       OAuthIntegrationClient = "gitlab"
 )
 
 // OAuthIntegrationClient is the name of an OAuth mechanism client
@@ -159,3 +160,36 @@ type CreateAzureResponse struct {
 }
 
 type ListAzureResponse []*AzureIntegration
+
+type GitlabIntegration struct {
+	CreatedAt time.Time `json:"created_at"`
+
+	ID uint `json:"id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	InstanceURL string `json:"instance_url"`
+}
+
+type ListGitlabResponse []*GitlabIntegration
+
+type CreateGitlabRequest struct {
+	InstanceURL     string `json:"instance_url"`
+	AppClientID     string `json:"client_id"`
+	AppClientSecret string `json:"client_secret"`
+}
+
+type CreateGitlabResponse struct {
+	*GitlabIntegration
+}
+
+type GitIntegration struct {
+	Provider       string `json:"provider" form:"required"`
+	Name           string `json:"name,omitempty"`
+	InstallationID int64  `json:"installation_id,omitempty"`
+	InstanceURL    string `json:"instance_url,omitempty"`
+	IntegrationID  uint   `json:"integration_id,omitempty"`
+}
+
+type ListGitIntegrationResponse []*GitIntegration

+ 5 - 5
api/types/release.go

@@ -45,11 +45,11 @@ type CreateReleaseBaseRequest struct {
 type CreateReleaseRequest struct {
 	*CreateReleaseBaseRequest
 
-	ImageURL           string                        `json:"image_url" form:"required"`
-	GithubActionConfig *CreateGitActionConfigRequest `json:"github_action_config,omitempty"`
-	BuildConfig        *CreateBuildConfigRequest     `json:"build_config,omitempty"`
-	Tags               []string                      `json:"tags,omitempty"`
-	SyncedEnvGroups    []string                      `json:"synced_env_groups,omitempty"`
+	ImageURL        string                        `json:"image_url" form:"required"`
+	GitActionConfig *CreateGitActionConfigRequest `json:"git_action_config,omitempty"`
+	BuildConfig     *CreateBuildConfigRequest     `json:"build_config,omitempty"`
+	Tags            []string                      `json:"tags,omitempty"`
+	SyncedEnvGroups []string                      `json:"synced_env_groups,omitempty"`
 }
 
 type CreateAddonRequest struct {

+ 1 - 1
cli/cmd/deploy/create.go

@@ -135,7 +135,7 @@ func (c *CreateAgent) CreateFromGithub(
 				Name:            opts.ReleaseName,
 			},
 			ImageURL: imageURL,
-			GithubActionConfig: &types.CreateGitActionConfigRequest{
+			GitActionConfig: &types.CreateGitActionConfigRequest{
 				GitRepo:              ghOpts.Repo,
 				GitBranch:            ghOpts.Branch,
 				ImageRepoURI:         imageURL,

+ 13 - 2
dashboard/src/components/SearchBar.tsx

@@ -6,13 +6,19 @@ interface Props {
   setSearchFilter: (x: string) => void;
   disabled: boolean;
   prompt?: string;
+  fullWidth?: boolean;
 }
 
-const SearchBar: React.FC<Props> = ({ setSearchFilter, disabled, prompt }) => {
+const SearchBar: React.FC<Props> = ({
+  setSearchFilter,
+  disabled,
+  prompt,
+  fullWidth,
+}) => {
   const [searchInput, setSearchInput] = useState("");
 
   return (
-    <SearchRowWrapper>
+    <SearchRowWrapper fullWidth={fullWidth}>
       <SearchBarWrapper>
         <i className="material-icons">search</i>
         <SearchInput
@@ -55,6 +61,11 @@ const SearchRowWrapper = styled(SearchRow)`
   border-bottom: 0;
   border: 1px solid #ffffff55;
   border-radius: 3px;
+  ${(props: { fullWidth: boolean }) => {
+    if (props.fullWidth) {
+      return "width: 100%;";
+    }
+  }}
 `;
 
 const ButtonWrapper = styled.div`

+ 0 - 2
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -160,12 +160,10 @@ const ExpandedWrapper = styled.div`
   border-radius: 3px;
   border: 1px solid #ffffff44;
   max-height: 275px;
-  overflow-y: auto;
 `;
 
 const ExpandedWrapperAlt = styled(ExpandedWrapper)`
   border: 0;
-  overflow: hidden;
 `;
 
 const BackButton = styled.div`

+ 47 - 22
dashboard/src/components/repo-selector/BranchList.tsx

@@ -24,28 +24,53 @@ const BranchList: React.FC<Props> = ({ setBranch, actionConfig }) => {
 
   useEffect(() => {
     // Get branches
-    api
-      .getBranches(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          git_repo_id: actionConfig.git_repo_id,
-          kind: "github",
-          owner: actionConfig.git_repo.split("/")[0],
-          name: actionConfig.git_repo.split("/")[1],
-        }
-      )
-      .then((res) => {
-        setBranches(res.data);
-        setLoading(false);
-        setError(false);
-      })
-      .catch((err) => {
-        console.log(err);
-        setLoading(false);
-        setError(true);
-      });
+
+    if (actionConfig.git_repo_id !== 0) {
+      api
+        .getBranches(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            git_repo_id: actionConfig.git_repo_id,
+            kind: "github",
+            owner: actionConfig.git_repo.split("/")[0],
+            name: actionConfig.git_repo.split("/")[1],
+          }
+        )
+        .then((res) => {
+          setBranches(res.data);
+          setLoading(false);
+          setError(false);
+        })
+        .catch((err) => {
+          console.log(err);
+          setLoading(false);
+          setError(true);
+        });
+    } else {
+      api
+        .getGitlabBranches(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            integration_id: actionConfig.gitlab_integration_id,
+            repo_owner: actionConfig.git_repo.split("/")[0],
+            repo_name: actionConfig.git_repo.split("/")[1],
+          }
+        )
+        .then((res) => {
+          setBranches(res.data);
+          setLoading(false);
+          setError(false);
+        })
+        .catch((err) => {
+          console.log(err);
+          setLoading(false);
+          setError(true);
+        });
+    }
   }, []);
 
   const renderBranchList = () => {

+ 26 - 7
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -63,13 +63,23 @@ export default class ContentsList extends Component<PropsType, StateType> {
     this.setState({ currentDir: x }, () => this.updateContents());
   };
 
-  updateContents = () => {
+  fetchContents = () => {
     let { currentProject } = this.context;
-    let { actionConfig, branch } = this.props;
-
-    // Get branch contents
-    api
-      .getBranchContents(
+    const { actionConfig, branch } = this.props;
+    if (actionConfig.gitlab_integration_id > 0) {
+      return api.getGitlabFolderContent(
+        "<token>",
+        { dir: this.state.currentDir || "./" },
+        {
+          project_id: currentProject.id,
+          integration_id: actionConfig.gitlab_integration_id,
+          repo_owner: actionConfig.git_repo.split("/")[0],
+          repo_name: actionConfig.git_repo.split("/")[1],
+          branch: branch,
+        }
+      );
+    } else {
+      return api.getBranchContents(
         "<token>",
         { dir: this.state.currentDir || "./" },
         {
@@ -80,7 +90,16 @@ export default class ContentsList extends Component<PropsType, StateType> {
           name: actionConfig.git_repo.split("/")[1],
           branch: branch,
         }
-      )
+      );
+    }
+  };
+
+  updateContents = () => {
+    let { currentProject } = this.context;
+    let { actionConfig, branch } = this.props;
+
+    // Get branch contents
+    this.fetchContents()
       .then((res) => {
         let files = [] as FileType[];
         let folders = [] as FileType[];

+ 246 - 77
dashboard/src/components/repo-selector/RepoList.tsx

@@ -8,6 +8,10 @@ import { Context } from "shared/Context";
 
 import Loading from "../Loading";
 import SearchBar from "../SearchBar";
+import OptionsDropdown from "components/OptionsDropdown";
+import { SelectField } from "material-ui";
+import SelectRow from "components/form-components/SelectRow";
+import SearchSelector from "components/SearchSelector";
 
 interface GithubAppAccessData {
   has_access: boolean;
@@ -30,87 +34,162 @@ const RepoList: React.FC<Props> = ({
   readOnly,
   filteredRepos,
 }) => {
+  const [providers, setProviders] = useState([]);
+  const [currentProvider, setCurrentProvider] = useState(null);
   const [repos, setRepos] = useState<RepoType[]>([]);
   const [repoLoading, setRepoLoading] = useState(true);
   const [selectedRepo, setSelectedRepo] = useState(null);
   const [repoError, setRepoError] = useState(false);
-  const [accessLoading, setAccessLoading] = useState(true);
-  const [accessError, setAccessError] = useState(false);
-  const [accessData, setAccessData] = useState<GithubAppAccessData>({
-    has_access: false,
-  });
   const [searchFilter, setSearchFilter] = useState(null);
   const { currentProject } = useContext(Context);
 
-  const loadData = async () => {
-    try {
-      const { data } = await api.getGithubAccounts("<token>", {}, {});
+  // const loadData = async () => {
+  //   try {
+  //     const { data } = await api.getGithubAccounts("<token>", {}, {});
+
+  //     setAccessData(data);
+  //     setAccessLoading(false);
+  //   } catch (error) {
+  //     setAccessError(true);
+  //     setAccessLoading(false);
+  //   }
+
+  //   let ids: number[] = [];
+
+  //   if (!userId && userId !== 0) {
+  //     ids = await api
+  //       .getGitRepos("token", {}, { project_id: currentProject.id })
+  //       .then((res) => res.data);
+  //   } else {
+  //     setRepoLoading(false);
+  //     setRepoError(true);
+  //     return;
+  //   }
+
+  //   const repoListPromises = ids.map((id) =>
+  //     api.getGitRepoList(
+  //       "<token>",
+  //       {},
+  //       { project_id: currentProject.id, git_repo_id: id }
+  //     )
+  //   );
+
+  //   try {
+  //     const resolvedRepoList = await Promise.allSettled(repoListPromises);
+
+  //     const repos: RepoType[][] = resolvedRepoList.map((repo) =>
+  //       repo.status === "fulfilled" ? repo.value.data : []
+  //     );
+
+  //     const names = new Set();
+  //     // note: would be better to use .flat() here but you need es2019 for
+  //     setRepos(
+  //       repos
+  //         .map((arr, idx) =>
+  //           arr.map((el) => {
+  //             el.GHRepoID = ids[idx];
+  //             return el;
+  //           })
+  //         )
+  //         .reduce((acc, val) => acc.concat(val), [])
+  //         .reduce((acc, val) => {
+  //           if (!names.has(val.FullName)) {
+  //             names.add(val.FullName);
+  //             return acc.concat(val);
+  //           } else {
+  //             return acc;
+  //           }
+  //         }, [])
+  //     );
+  //     setRepoLoading(false);
+  //   } catch (err) {
+  //     setRepoLoading(false);
+  //     setRepoError(true);
+  //   }
+  // };
 
-      setAccessData(data);
-      setAccessLoading(false);
-    } catch (error) {
-      setAccessError(true);
-      setAccessLoading(false);
-    }
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getGitProviders("<token>", {}, { project_id: currentProject.id })
+      .then((res) => {
+        const data = res.data;
+        if (isSubscribed) {
+          setProviders(data);
+
+          setCurrentProvider(data[0]);
+        }
+      });
 
-    let ids: number[] = [];
+    return () => {
+      isSubscribed = false;
+    };
+  }, []);
 
-    if (!userId && userId !== 0) {
-      ids = await api
-        .getGitRepos("token", {}, { project_id: currentProject.id })
-        .then((res) => res.data);
-    } else {
-      setRepoLoading(false);
-      setRepoError(true);
-      return;
-    }
+  const loadGithubRepos = async (repoId: number) => {
+    try {
+      const res = await api.getGitRepoList<
+        { FullName: string; Kind: "github" }[]
+      >("<token>", {}, { project_id: currentProject.id, git_repo_id: repoId });
 
-    const repoListPromises = ids.map((id) =>
-      api.getGitRepoList(
-        "<token>",
-        {},
-        { project_id: currentProject.id, git_repo_id: id }
-      )
-    );
+      const repos = res.data.map((repo) => ({ ...repo, GHRepoID: repoId }));
+      return repos;
+    } catch (error) {}
+  };
 
+  const loadGitlabRepos = async (integrationId: number) => {
     try {
-      const resolvedRepoList = await Promise.allSettled(repoListPromises);
-
-      const repos: RepoType[][] = resolvedRepoList.map((repo) =>
-        repo.status === "fulfilled" ? repo.value.data : []
+      const res = await api.getGitlabRepos<string[]>(
+        "<token>",
+        {},
+        { project_id: currentProject.id, integration_id: integrationId }
       );
+      const repos: RepoType[] = res.data.map((repo) => ({
+        FullName: repo,
+        Kind: "gitlab",
+        GitIntegrationId: integrationId,
+      }));
+      return repos;
+    } catch (error) {}
+  };
 
-      const names = new Set();
-      // note: would be better to use .flat() here but you need es2019 for
-      setRepos(
-        repos
-          .map((arr, idx) =>
-            arr.map((el) => {
-              el.GHRepoID = ids[idx];
-              return el;
-            })
-          )
-          .reduce((acc, val) => acc.concat(val), [])
-          .reduce((acc, val) => {
-            if (!names.has(val.FullName)) {
-              names.add(val.FullName);
-              return acc.concat(val);
-            } else {
-              return acc;
-            }
-          }, [])
-      );
-      setRepoLoading(false);
-    } catch (err) {
-      setRepoLoading(false);
-      setRepoError(true);
+  const loadRepos = (provider: any) => {
+    if (provider.provider === "github") {
+      return loadGithubRepos(provider.installation_id);
+    } else {
+      return loadGitlabRepos(provider.integration_id);
     }
   };
 
-  // TODO: Try to unhook before unmount
   useEffect(() => {
-    loadData();
-  }, []);
+    let isSubscribed = true;
+    if (!currentProvider) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    setRepoLoading(true);
+
+    loadRepos(currentProvider)
+      .then((repos) => {
+        if (isSubscribed) {
+          setRepos(repos);
+        }
+      })
+      .catch((err) => {
+        setRepos([]);
+        console.log(err);
+      })
+      .finally(() => {
+        setRepoLoading(false);
+      });
+  }, [currentProvider]);
+
+  // TODO: Try to unhook before unmount
+  // useEffect(() => {
+  //   loadData();
+  // }, []);
 
   // clear out actionConfig and SelectedRepository if new search is performed
   useEffect(() => {
@@ -119,6 +198,7 @@ const RepoList: React.FC<Props> = ({
       image_repo_uri: null,
       git_branch: null,
       git_repo_id: 0,
+      gitlab_integration_id: 0,
     });
     setSelectedRepo(null);
   }, [searchFilter]);
@@ -126,13 +206,17 @@ const RepoList: React.FC<Props> = ({
   const setRepo = (x: RepoType) => {
     let updatedConfig = actionConfig;
     updatedConfig.git_repo = x.FullName;
-    updatedConfig.git_repo_id = x.GHRepoID;
+    if (x.Kind === "gitlab") {
+      updatedConfig.gitlab_integration_id = x.GitIntegrationId;
+    } else {
+      updatedConfig.git_repo_id = x.GHRepoID;
+    }
     setActionConfig(updatedConfig);
     setSelectedRepo(x.FullName);
   };
 
   const renderRepoList = () => {
-    if (repoLoading || accessLoading) {
+    if (repoLoading) {
       return (
         <LoadingWrapper>
           <Loading />
@@ -140,19 +224,21 @@ const RepoList: React.FC<Props> = ({
       );
     } else if (repoError) {
       return <LoadingWrapper>Error loading repos.</LoadingWrapper>;
-    } else if (repos.length == 0) {
-      if (accessError) {
+    } else if (!Array.isArray(repos) || repos.length === 0) {
+      if (currentProvider.provider === "gitlab") {
         return (
           <LoadingWrapper>
-            No connected Github repos found.
-            <A href={"/api/integrations/github-app/oauth"}>
-              Authorize Porter to view your repositories.
+            We couldn't reach gitlab to get your repos. You can
+            <A
+              style={{ marginRight: "5px" }}
+              href={`/api/projects/${currentProject.id}/oauth/gitlab?integration_id=${currentProvider.integration_id}`}
+            >
+              Connect your gitlab account to porter
             </A>
+            or select another git provider.
           </LoadingWrapper>
         );
-      }
-
-      if (accessData.accounts?.length === 0) {
+      } else {
         return (
           <LoadingWrapper>
             No connected Github repos found. You can
@@ -206,11 +292,19 @@ const RepoList: React.FC<Props> = ({
     } else {
       return (
         <>
-          <SearchBar
-            setSearchFilter={setSearchFilter}
-            disabled={repoError || repoLoading || accessError || accessLoading}
-            prompt={"Search repos..."}
-          />
+          <div style={{ display: "flex" }}>
+            <ProviderSelector
+              values={providers}
+              currentValue={currentProvider}
+              onChange={setCurrentProvider}
+            />
+            <SearchBar
+              setSearchFilter={setSearchFilter}
+              disabled={repoError || repoLoading}
+              prompt={"Search repos..."}
+              fullWidth
+            />
+          </div>
           <RepoListWrapper>
             <ExpandedWrapper>{renderRepoList()}</ExpandedWrapper>
           </RepoListWrapper>
@@ -224,6 +318,80 @@ const RepoList: React.FC<Props> = ({
 
 export default RepoList;
 
+const ProviderSelector = (props: {
+  values: any[];
+  currentValue: any;
+  onChange: (provider: any) => void;
+}) => {
+  const { values, currentValue, onChange } = props;
+  const [isOpen, setIsOpen] = useState(false);
+  const icon = `devicon-${currentValue?.provider}-plain colored`;
+  return (
+    <ProviderSelectorStyles.Wrapper>
+      <ProviderSelectorStyles.Button onClick={() => setIsOpen((prev) => !prev)}>
+        <ProviderSelectorStyles.Icon className={icon} />
+        {currentValue?.name || currentValue?.instance_url}
+      </ProviderSelectorStyles.Button>
+      {isOpen ? (
+        <ProviderSelectorStyles.OptionWrapper>
+          {values.map((provider) => {
+            return (
+              <ProviderSelectorStyles.Option onClick={() => onChange(provider)}>
+                <ProviderSelectorStyles.Icon
+                  className={`devicon-${provider?.provider}-plain colored`}
+                />
+                {provider?.name || provider?.instance_url}
+              </ProviderSelectorStyles.Option>
+            );
+          })}
+        </ProviderSelectorStyles.OptionWrapper>
+      ) : null}
+    </ProviderSelectorStyles.Wrapper>
+  );
+};
+
+const ProviderSelectorStyles = {
+  Wrapper: styled.div`
+    position: relative;
+    margin-bottom: 10px;
+    margin-right: 5px;
+  `,
+  Button: styled.div`
+    border-radius: 5px;
+    border: 1px solid white;
+    height: 100%;
+    border-bottom: 0;
+    border: 1px solid #ffffff55;
+    border-radius: 3px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 6px 15px;
+  `,
+  OptionWrapper: styled.div`
+    position: absolute;
+    background-color: #202227;
+  `,
+  Option: styled.div`
+    white-space: nowrap;
+    padding: 8px 10px;
+    display: flex;
+    align-items: center;
+    :not(:last-child) {
+      border-bottom: 1px solid black;
+      border-bottom-width: 90%;
+    }
+    :hover {
+      background-color: #363940;
+    }
+  `,
+  Icon: styled.span`
+    font-size: 24px;
+    margin-right: 15px;
+    color: white;
+  `,
+};
+
 const RepoListWrapper = styled.div`
   border: 1px solid #ffffff55;
   border-radius: 3px;
@@ -318,5 +486,6 @@ const A = styled.a`
   color: #8590ff;
   text-decoration: underline;
   margin-left: 5px;
+
   cursor: pointer;
 `;

+ 205 - 0
dashboard/src/main/home/integrations/GitlabIntegrationList.tsx

@@ -0,0 +1,205 @@
+import React, { useContext, useRef, useState } from "react";
+import ConfirmOverlay from "../../../components/ConfirmOverlay";
+import styled from "styled-components";
+import { Context } from "../../../shared/Context";
+import api from "../../../shared/api";
+import { integrationList } from "shared/common";
+import DynamicLink from "components/DynamicLink";
+
+interface Props {
+  gitlabData: any[];
+}
+
+const GitlabIntegrationList: React.FC<Props> = (props) => {
+  const [isDelete, setIsDelete] = useState(false);
+  const [deleteIndex, setDeleteIndex] = useState(-1); // guaranteed to be set when used
+  const { currentProject, setCurrentError } = useContext(Context);
+  const deleted = useRef(new Set());
+
+  const handleDelete = () => {
+    alert("NOT IMPLEMENTED");
+    // api
+    //   .deleteSlackIntegration(
+    //     "<token>",
+    //     {},
+    //     {
+    //       project_id: currentProject.id,
+    //       slack_integration_id: props.gitlabData[deleteIndex].id,
+    //     }
+    //   )
+    //   .then(() => {
+    //     deleted.current.add(deleteIndex);
+    //     setIsDelete(false);
+    //   })
+    //   .catch((err) => {
+    //     setCurrentError(err);
+    //   });
+  };
+
+  return (
+    <>
+      <ConfirmOverlay
+        show={isDelete}
+        message={
+          deleteIndex != -1 &&
+          `Are you sure you want to delete the gitlab instance ${props.gitlabData[deleteIndex].instance_url}?`
+        }
+        onYes={handleDelete}
+        onNo={() => setIsDelete(false)}
+      />
+      <StyledIntegrationList>
+        {props.gitlabData?.length > 0 ? (
+          props.gitlabData.map((inst, idx) => {
+            if (deleted.current.has(idx)) return null;
+            return (
+              <Integration
+                onClick={() => {}}
+                disabled={false}
+                key={`${inst.team_id}-${inst.channel}`}
+              >
+                <MainRow disabled={false}>
+                  <Flex>
+                    <Icon src={integrationList.gitlab.icon} />
+                    <Label>{inst.instance_url}</Label>
+                  </Flex>
+                  <MaterialIconTray disabled={false}>
+                    <i
+                      className="material-icons"
+                      onClick={() => {
+                        setDeleteIndex(idx);
+                        setIsDelete(true);
+                      }}
+                    >
+                      delete
+                    </i>
+                    <i
+                      className="material-icons"
+                      onClick={() => {
+                        window.open(inst.instance_url, "_blank");
+                      }}
+                    >
+                      launch
+                    </i>
+                  </MaterialIconTray>
+                </MainRow>
+              </Integration>
+            );
+          })
+        ) : (
+          <Placeholder>No GitLab instances setted up yet.</Placeholder>
+        )}
+      </StyledIntegrationList>
+    </>
+  );
+};
+
+export default GitlabIntegrationList;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 250px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  justify-content: center;
+  margin-top: 30px;
+  background: #ffffff11;
+  color: #ffffff44;
+  border-radius: 5px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const StyledIntegrationList = styled.div`
+  margin-top: 20px;
+  margin-bottom: 80px;
+`;
+
+const MainRow = styled.div`
+  height: 70px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 25px;
+  border-radius: 5px;
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#ffffff11"};
+    > i {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    color: #ffffff44;
+    margin-right: -7px;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;
+
+const Integration = styled.div`
+  margin-left: -2px;
+  display: flex;
+  flex-direction: column;
+  background: #26282f;
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  margin-bottom: 15px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+`;
+
+const Icon = styled.img`
+  width: 27px;
+  margin-right: 12px;
+  margin-bottom: -1px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size: 24px;
+    color: #969fbbaa;
+    padding: 3px;
+    margin-right: 11px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const MaterialIconTray = styled.div`
+  max-width: 60px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  > i {
+    background: #26282f;
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    margin: 0 5px;
+    color: #ffffff44;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;

+ 22 - 3
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -10,6 +10,7 @@ import { pushFiltered } from "shared/routing";
 import Loading from "../../../components/Loading";
 import SlackIntegrationList from "./SlackIntegrationList";
 import TitleSection from "components/TitleSection";
+import GitlabIntegrationList from "./GitlabIntegrationList";
 
 type Props = RouteComponentProps & {
   category: string;
@@ -22,6 +23,7 @@ const IntegrationCategories: React.FC<Props> = (props) => {
   const [currentIntegrationData, setCurrentIntegrationData] = useState([]);
   const [loading, setLoading] = useState(false);
   const [slackData, setSlackData] = useState([]);
+  const [gitlabData, setGitlabData] = useState([]);
 
   const { currentProject, setCurrentModal } = useContext(Context);
 
@@ -79,6 +81,17 @@ const IntegrationCategories: React.FC<Props> = (props) => {
           })
           .catch(console.log);
         break;
+      case "gitlab":
+        api
+          .getGitlabIntegration(
+            "<token>",
+            {},
+            { project_id: currentProject.id }
+          )
+          .then((res) => {
+            setGitlabData(res.data);
+            setLoading(false);
+          });
       default:
         console.log("Unknown integration category.");
     }
@@ -110,7 +123,11 @@ const IntegrationCategories: React.FC<Props> = (props) => {
         </TitleSection>
         <Button
           onClick={() => {
-            if (props.category != "slack") {
+            if (props.category === "gitlab") {
+              pushFiltered(props, `/integrations/gitlab/create/gitlab`, [
+                "project_id",
+              ]);
+            } else if (props.category != "slack") {
               setCurrentModal("IntegrationsModal", {
                 category: currentCategory,
                 setCurrentIntegration: (x: string) =>
@@ -131,6 +148,8 @@ const IntegrationCategories: React.FC<Props> = (props) => {
       </Flex>
       {loading ? (
         <Loading />
+      ) : props.category === "gitlab" ? (
+        <GitlabIntegrationList gitlabData={gitlabData} />
       ) : props.category == "slack" ? (
         <SlackIntegrationList slackData={slackData} />
       ) : (
@@ -158,8 +177,8 @@ const Flex = styled.div`
 
   > i {
     cursor: pointer;
-    font-size 24px;
-    color: #969Fbbaa;
+    font-size: 24px;
+    color: #969fbbaa;
     padding: 3px;
     margin-right: 11px;
     border-radius: 100px;

+ 8 - 4
dashboard/src/main/home/integrations/Integrations.tsx

@@ -12,7 +12,11 @@ import TitleSection from "components/TitleSection";
 
 type PropsType = RouteComponentProps;
 
-const IntegrationCategoryStrings = ["registry", "slack"]; /*"kubernetes",*/
+const IntegrationCategoryStrings = [
+  "registry",
+  "slack",
+  "gitlab",
+]; /*"kubernetes",*/
 
 const Integrations: React.FC<PropsType> = (props) => {
   return (
@@ -69,7 +73,7 @@ const Integrations: React.FC<PropsType> = (props) => {
 
             <IntegrationList
               currentCategory={""}
-              integrations={["registry", "slack"]}
+              integrations={["registry", "slack", "gitlab"]}
               setCurrent={(x) =>
                 pushFiltered(props, `/integrations/${x}`, ["project_id"])
               }
@@ -106,8 +110,8 @@ const Flex = styled.div`
 
   > i {
     cursor: pointer;
-    font-size 24px;
-    color: #969Fbbaa;
+    font-size: 24px;
+    color: #969fbbaa;
     padding: 3px;
     margin-right: 11px;
     border-radius: 100px;

+ 2 - 2
dashboard/src/main/home/integrations/SlackIntegrationList.tsx

@@ -176,8 +176,8 @@ const Flex = styled.div`
 
   > i {
     cursor: pointer;
-    font-size 24px;
-    color: #969Fbbaa;
+    font-size: 24px;
+    color: #969fbbaa;
     padding: 3px;
     margin-right: 11px;
     border-radius: 100px;

+ 3 - 0
dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx

@@ -7,6 +7,7 @@ import GKEForm from "./GKEForm";
 import EKSForm from "./EKSForm";
 import GCRForm from "./GCRForm";
 import ECRForm from "./ECRForm";
+import GitlabForm from "./GitlabForm";
 
 type PropsType = {
   integrationName: string;
@@ -33,6 +34,8 @@ export default class CreateIntegrationForm extends Component<
         return <ECRForm closeForm={this.props.closeForm} />;
       case "gcr":
         return <GCRForm closeForm={this.props.closeForm} />;
+      case "gitlab":
+        return <GitlabForm closeForm={this.props.closeForm} />;
       default:
         return null;
     }

+ 145 - 0
dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx

@@ -0,0 +1,145 @@
+import Heading from "components/form-components/Heading";
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import React, { useContext, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+
+const URLRegex = /(http(s)?):\/\/[(www\.)?a-zA-Z0-9@:%._\+~#=\-]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
+
+type Props = {
+  closeForm: () => void;
+};
+
+const GitlabForm: React.FC<Props> = () => {
+  const { currentProject } = useContext(Context);
+  const [instanceUrl, setInstanceUrl] = useState("");
+  const [clientId, setClientId] = useState("");
+  const [clientSecret, setClientSecret] = useState("");
+  const [error, setError] = useState<{
+    message: string;
+    input: "client_id" | "client_secret" | "instance_url";
+  }>(null);
+
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const { pushFiltered } = useRouting();
+
+  const submit = async () => {
+    if (!URLRegex.test(instanceUrl)) {
+      if (!instanceUrl.includes("http") || !instanceUrl.includes("https")) {
+        setError({
+          message:
+            "Invalid URL, please make sure the URL contains the http/s protocol.",
+          input: "instance_url",
+        });
+        return;
+      }
+
+      setError({
+        message: "Invalid URL, please check again.",
+        input: "instance_url",
+      });
+      return;
+    }
+
+    if (!clientId || !clientId.trim().length) {
+      setError({
+        message: "Invalid Client ID",
+        input: "client_id",
+      });
+      return;
+    }
+
+    if (!clientSecret || !clientSecret.trim().length) {
+      setError({
+        message: "Invalid Client Secret",
+        input: "client_secret",
+      });
+      return;
+    }
+
+    setError(null);
+
+    setButtonStatus("loading");
+
+    try {
+      await api.createGitlabIntegration(
+        "<token>",
+        {
+          instance_url: instanceUrl,
+          client_id: clientId,
+          client_secret: clientSecret,
+        },
+        { id: currentProject.id }
+      );
+
+      setButtonStatus("successful");
+      pushFiltered(`/integrations/gitlab`, ["project_id"]);
+    } catch (error) {
+      setButtonStatus("Couldn't save the instance. Please try again.");
+    } finally {
+      setTimeout(() => {
+        setButtonStatus("");
+      }, 1000);
+    }
+  };
+
+  return (
+    <>
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Gitlab instance settings</Heading>
+
+          <InputRow
+            type="string"
+            label="Instance URL"
+            value={instanceUrl}
+            setValue={(val: string) => setInstanceUrl(val)}
+            isRequired
+            width="100%"
+            hasError={error?.input === "instance_url"}
+          />
+          <InputRow
+            type="string"
+            label="Client ID"
+            value={clientId}
+            setValue={(val: string) => setClientId(val)}
+            isRequired
+            width="100%"
+            hasError={error?.input === "client_id"}
+          />
+          <InputRow
+            type="string"
+            label="Client Secret"
+            value={clientSecret}
+            setValue={(val: string) => setClientSecret(val)}
+            isRequired
+            width="100%"
+            hasError={error?.input === "client_secret"}
+          />
+        </CredentialWrapper>
+        <SaveButton
+          onClick={submit}
+          text="Save new Gitlab instance"
+          status={buttonStatus || error?.message}
+        />
+      </StyledForm>
+    </>
+  );
+};
+
+export default GitlabForm;
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -312,7 +312,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           template_name: props.currentTemplate.name.toLowerCase().trim(),
           template_version: props.currentTemplate?.currentVersion || "latest",
           name: release_name,
-          github_action_config: githubActionConfig,
+          git_action_config: githubActionConfig,
           build_config: buildConfig,
           synced_env_groups: synced.map((s: any) => s.name),
         },

+ 0 - 1
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -474,6 +474,5 @@ const StyledSourceBox = styled.div`
   border-radius: 5px;
   font-size: 13px;
   margin-top: 6px;
-  overflow: auto;
   margin-bottom: 25px;
 `;

+ 67 - 0
dashboard/src/shared/api.tsx

@@ -61,6 +61,11 @@ const getAzureIntegration = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/integrations/azure`
 );
 
+const getGitlabIntegration = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/integrations/gitlab`
+);
+
 const createAWSIntegration = baseApi<
   {
     aws_region: string;
@@ -99,6 +104,17 @@ const createAzureIntegration = baseApi<
   return `/api/projects/${pathParams.id}/integrations/azure`;
 });
 
+const createGitlabIntegration = baseApi<
+  {
+    instance_url: string;
+    client_id: string;
+    client_secret: string;
+  },
+  { id: number }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.id}/integrations/gitlab`;
+});
+
 const createEmailVerification = baseApi<{}, {}>("POST", (pathParams) => {
   return `/api/email/verify/initiate`;
 });
@@ -1798,6 +1814,51 @@ const updateReleaseTags = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/update_tags`
 );
 
+const getGitProviders = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/integrations/git`
+);
+
+const getGitlabRepos = baseApi<
+  {},
+  { project_id: number; integration_id: number }
+>(
+  "GET",
+  ({ project_id, integration_id }) =>
+    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos`
+);
+
+const getGitlabBranches = baseApi<
+  {},
+  {
+    project_id: number;
+    integration_id: number;
+    repo_owner: string;
+    repo_name: string;
+  }
+>(
+  "GET",
+  ({ project_id, integration_id, repo_owner, repo_name }) =>
+    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/branches`
+);
+
+const getGitlabFolderContent = baseApi<
+  {
+    dir: string;
+  },
+  {
+    project_id: number;
+    integration_id: number;
+    repo_owner: string;
+    repo_name: string;
+    branch: string;
+  }
+>(
+  "GET",
+  ({ project_id, integration_id, repo_owner, repo_name, branch }) =>
+    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/${branch}/contents`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1807,9 +1868,11 @@ export default {
   getAWSIntegration,
   getGCPIntegration,
   getAzureIntegration,
+  getGitlabIntegration,
   createAWSIntegration,
   overwriteAWSIntegration,
   createAzureIntegration,
+  createGitlabIntegration,
   createEmailVerification,
   createEnvironment,
   deleteEnvironment,
@@ -1967,4 +2030,8 @@ export default {
   getTagsByProjectId,
   createTag,
   updateReleaseTags,
+  getGitProviders,
+  getGitlabRepos,
+  getGitlabBranches,
+  getGitlabFolderContent,
 };

+ 1 - 0
dashboard/src/shared/common.tsx

@@ -108,6 +108,7 @@ export const integrationList: any = {
   gitlab: {
     icon: "https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png",
     label: "Gitlab",
+    buttonText: "Add instance",
   },
   rds: {
     icon:

+ 17 - 9
dashboard/src/shared/types.tsx

@@ -223,11 +223,18 @@ export interface FormElement {
   };
 }
 
-export interface RepoType {
+export type RepoType = {
   FullName: string;
-  kind: string;
-  GHRepoID: number;
-}
+} & (
+  | {
+      Kind: "github";
+      GHRepoID: number;
+    }
+  | {
+      Kind: "gitlab";
+      GitIntegrationId: number;
+    }
+);
 
 export interface FileType {
   path: string;
@@ -275,19 +282,20 @@ export interface InviteType {
   id: number;
 }
 
-export interface ActionConfigType {
+export type ActionConfigType = {
   git_repo: string;
   git_branch: string;
   image_repo_uri: string;
-  git_repo_id: number;
-}
+  git_repo_id?: number;
+  gitlab_integration_id?: number;
+};
 
-export interface FullActionConfigType extends ActionConfigType {
+export type FullActionConfigType = ActionConfigType & {
   dockerfile_path: string;
   folder_path: string;
   registry_id: number;
   should_create_workflow: boolean;
-}
+};
 
 export interface CapabilityType {
   github: boolean;

+ 11 - 0
ee/integrations/vault/types.go

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package vault
@@ -58,6 +59,16 @@ type GetAzureCredentialData struct {
 	Data     *credentials.AzureCredential `json:"data"`
 }
 
+type GetGitlabCredentialResponse struct {
+	*VaultGetResponse
+	Data *GetGitlabCredentialData `json:"data"`
+}
+
+type GetGitlabCredentialData struct {
+	Metadata *VaultMetadata                `json:"metadata"`
+	Data     *credentials.GitlabCredential `json:"data"`
+}
+
 type CreatePolicyRequest struct {
 	Policy string `json:"policy"`
 }

+ 34 - 0
ee/integrations/vault/vault.go

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package vault
@@ -185,6 +186,39 @@ func (c *Client) getAzureCredentialPath(azIntegration *integrations.AzureIntegra
 	)
 }
 
+func (c *Client) WriteGitlabCredential(giIntegration *integrations.GitlabIntegration, data *credentials.GitlabCredential) error {
+	reqData := &CreateVaultSecretRequest{
+		Data: data,
+	}
+
+	return c.postRequest(fmt.Sprintf("/v1/%s", c.getGitlabCredentialPath(giIntegration)), reqData, nil)
+}
+
+func (c *Client) GetGitlabCredential(giIntegration *integrations.GitlabIntegration) (*credentials.GitlabCredential, error) {
+	resp := &GetGitlabCredentialResponse{}
+
+	err := c.getRequest(fmt.Sprintf("/v1/%s", c.getGitlabCredentialPath(giIntegration)), resp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Data.Data, nil
+}
+
+func (c *Client) CreateGitlabToken(giIntegration *integrations.GitlabIntegration) (string, error) {
+	panic("not implemented")
+}
+
+func (c *Client) getGitlabCredentialPath(giIntegration *integrations.GitlabIntegration) string {
+	return fmt.Sprintf(
+		"kv/data/secret/%s/%d/gitlab/%d",
+		c.secretPrefix,
+		giIntegration.ProjectID,
+		giIntegration.ID,
+	)
+}
+
 const readOnlyPolicyTemplate = `path "%s" {
   capabilities = ["read"]
 }`

+ 6 - 3
go.mod

@@ -47,8 +47,8 @@ require (
 	github.com/spf13/viper v1.10.0
 	github.com/stretchr/testify v1.7.0
 	golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
-	golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4
-	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
+	golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2
+	golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5
 	google.golang.org/api v0.62.0
 	google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731
 	google.golang.org/grpc v1.46.0
@@ -80,8 +80,11 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v0.5.0 // indirect
 	github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect
 	github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
+	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+	github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
 	github.com/kylelemons/godebug v1.1.0 // indirect
 	github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect
+	github.com/xanzy/go-gitlab v0.68.0 // indirect
 )
 
 require (
@@ -249,7 +252,7 @@ require (
 	golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
 	golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect
 	golang.org/x/text v0.3.7 // indirect
-	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect
+	golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	gopkg.in/gorp.v1 v1.7.2 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect

+ 13 - 0
go.sum

@@ -876,8 +876,10 @@ github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
 github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg=
 github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
 github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
 github.com/hashicorp/go-getter v1.5.3/go.mod h1:BrrV/1clo8cCYu6mxvboYg+KutTiFnXjMEgDD8+i7ZI=
+github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
 github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
 github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
 github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
@@ -889,6 +891,8 @@ github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHh
 github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
 github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
 github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
+github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ=
+github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
 github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
 github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
 github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I=
@@ -1597,6 +1601,8 @@ github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+
 github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
 github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
 github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
+github.com/xanzy/go-gitlab v0.68.0 h1:b2iMQHgZ1V+NyRqLRJVv6RFfr4xnd/AASeS/PETYL0Y=
+github.com/xanzy/go-gitlab v0.68.0/go.mod h1:o4yExCtdaqlM8YGdDJWuZoBmfxBsmA9TPEjs9mx1UO4=
 github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
 github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
 github.com/xanzy/ssh-agent v0.3.1 h1:AmzO1SSWxw73zxFZPRwaMN1MohDw8UyHnmuxyceTEGo=
@@ -1859,9 +1865,12 @@ golang.org/x/net v0.0.0-20211203184738-4852103109b8/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
 golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2 h1:NWy5+hlRbC7HK+PmcXVUmW1IMyFce7to56IUvhUFm7Y=
+golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1881,6 +1890,8 @@ golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
+golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -2057,6 +2068,8 @@ golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs=
 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
+golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

+ 306 - 0
internal/integrations/ci/gitlab/ci.go

@@ -0,0 +1,306 @@
+package gitlab
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/xanzy/go-gitlab"
+	"gopkg.in/yaml.v2"
+)
+
+type GitlabCI struct {
+	ServerURL    string
+	GitRepoName  string
+	GitRepoOwner string
+
+	Repo repository.Repository
+
+	ProjectID     uint
+	ClusterID     uint
+	UserID        uint
+	IntegrationID uint
+
+	PorterConf       *config.Config
+	ReleaseName      string
+	ReleaseNamespace string
+	FolderPath       string
+	PorterToken      string
+
+	defaultGitBranch string
+	pID              string
+}
+
+func (g *GitlabCI) Setup() error {
+	client, err := g.getClient()
+
+	if err != nil {
+		return err
+	}
+
+	g.pID = fmt.Sprintf("%s/%s", g.GitRepoOwner, g.GitRepoName)
+
+	branches, _, err := client.Branches.ListBranches(g.pID, &gitlab.ListBranchesOptions{})
+
+	if err != nil {
+		return fmt.Errorf("error fetching list of branches: %w", err)
+	}
+
+	for _, branch := range branches {
+		if branch.Default {
+			g.defaultGitBranch = branch.Name
+			break
+		}
+	}
+
+	err = g.createGitlabSecret(client)
+
+	if err != nil {
+		return err
+	}
+
+	jobName := getGitlabStageJobName(g.ReleaseName)
+
+	ciFile, resp, err := client.RepositoryFiles.GetFile(g.pID, ".gitlab-ci.yml", &gitlab.GetFileOptions{
+		Ref: gitlab.String(g.defaultGitBranch),
+	})
+
+	if resp.StatusCode == http.StatusNotFound {
+		// create .gitlab-ci.yml
+		contentsMap := make(map[string]interface{})
+		contentsMap["stages"] = []string{
+			jobName,
+		}
+		contentsMap[jobName] = g.getCIJob(jobName)
+
+		contentsYAML, _ := yaml.Marshal(contentsMap)
+
+		_, _, err = client.RepositoryFiles.CreateFile(g.pID, ".gitlab-ci.yml", &gitlab.CreateFileOptions{
+			Branch:        gitlab.String(g.defaultGitBranch),
+			AuthorName:    gitlab.String("Porter Bot"),
+			AuthorEmail:   gitlab.String("contact@getporter.dev"),
+			Content:       gitlab.String(string(contentsYAML)),
+			CommitMessage: gitlab.String("Create .gitlab-ci.yml file"),
+		})
+
+		if err != nil {
+			return fmt.Errorf("error creating .gitlab-ci.yml file: %w", err)
+		}
+	} else if err != nil {
+		return fmt.Errorf("error getting .gitlab-ci.yml file: %w", err)
+	} else {
+		// update .gitlab-ci.yml if needed
+		ciFileContentsMap := make(map[string]interface{})
+		err = yaml.Unmarshal([]byte(ciFile.Content), ciFileContentsMap)
+
+		if err != nil {
+			return fmt.Errorf("error unmarshalling existing .gitlab-ci.yml: %w", err)
+		}
+
+		stages, ok := ciFileContentsMap["stages"].([]string)
+
+		if !ok {
+			return fmt.Errorf("error converting stages to string slice")
+		}
+
+		stageExists := false
+
+		for _, stage := range stages {
+			if stage == jobName {
+				stageExists = true
+				break
+			}
+		}
+
+		if !stageExists {
+			stages = append(stages, jobName)
+
+			ciFileContentsMap["stages"] = stages
+		}
+
+		ciFileContentsMap[jobName] = g.getCIJob(jobName)
+
+		contentsYAML, _ := yaml.Marshal(ciFileContentsMap)
+
+		_, _, err = client.RepositoryFiles.UpdateFile(g.pID, ".gitlab-ci.yml", &gitlab.UpdateFileOptions{
+			Branch:        gitlab.String(g.defaultGitBranch),
+			AuthorName:    gitlab.String("Porter Bot"),
+			AuthorEmail:   gitlab.String("contact@getporter.dev"),
+			Content:       gitlab.String(string(contentsYAML)),
+			CommitMessage: gitlab.String("Update .gitlab-ci.yml file"),
+		})
+
+		if err != nil {
+			return fmt.Errorf("error updating .gitlab-ci.yml file to add porter job: %w", err)
+		}
+	}
+
+	return nil
+}
+
+func (g *GitlabCI) Cleanup() error {
+	client, err := g.getClient()
+
+	if err != nil {
+		return err
+	}
+
+	g.pID = fmt.Sprintf("%s/%s", g.GitRepoOwner, g.GitRepoName)
+
+	branches, _, err := client.Branches.ListBranches(g.pID, &gitlab.ListBranchesOptions{})
+
+	if err != nil {
+		return fmt.Errorf("error fetching list of branches: %w", err)
+	}
+
+	for _, branch := range branches {
+		if branch.Default {
+			g.defaultGitBranch = branch.Name
+			break
+		}
+	}
+
+	err = g.deleteGitlabSecret(client)
+
+	if err != nil {
+		return err
+	}
+
+	jobName := getGitlabStageJobName(g.ReleaseName)
+
+	ciFile, resp, err := client.RepositoryFiles.GetFile(g.pID, ".gitlab-ci.yml", &gitlab.GetFileOptions{
+		Ref: gitlab.String(g.defaultGitBranch),
+	})
+
+	if resp.StatusCode == http.StatusNotFound {
+		return nil
+	} else if err != nil {
+		return fmt.Errorf("error getting .gitlab-ci.yml file: %w", err)
+	}
+
+	ciFileContentsMap := make(map[string]interface{})
+	err = yaml.Unmarshal([]byte(ciFile.Content), ciFileContentsMap)
+
+	if err != nil {
+		return fmt.Errorf("error unmarshalling existing .gitlab-ci.yml: %w", err)
+	}
+
+	stages, ok := ciFileContentsMap["stages"].([]string)
+
+	if !ok {
+		return fmt.Errorf("error converting stages to string slice")
+	}
+
+	var newStages []string
+
+	for _, stage := range stages {
+		if stage != jobName {
+			newStages = append(newStages, stage)
+		}
+	}
+
+	ciFileContentsMap["stage"] = newStages
+
+	delete(ciFileContentsMap, jobName)
+
+	contentsYAML, _ := yaml.Marshal(ciFileContentsMap)
+
+	_, _, err = client.RepositoryFiles.UpdateFile(g.pID, ".gitlab-ci.yml", &gitlab.UpdateFileOptions{
+		Branch:        gitlab.String(g.defaultGitBranch),
+		AuthorName:    gitlab.String("Porter Bot"),
+		AuthorEmail:   gitlab.String("contact@getporter.dev"),
+		Content:       gitlab.String(string(contentsYAML)),
+		CommitMessage: gitlab.String("Update .gitlab-ci.yml file"),
+	})
+
+	if err != nil {
+		return fmt.Errorf("error updating .gitlab-ci.yml file to remove porter job: %w", err)
+	}
+
+	return nil
+}
+
+func (g *GitlabCI) getClient() (*gitlab.Client, error) {
+	gi, err := g.Repo.GitlabIntegration().ReadGitlabIntegration(g.ProjectID, g.IntegrationID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	oauthInt, err := g.Repo.GitlabAppOAuthIntegration().ReadGitlabAppOAuthIntegration(g.UserID, g.ProjectID, g.IntegrationID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	accessToken, _, err := oauth.GetAccessToken(oauthInt.SharedOAuthModel, commonutils.GetGitlabOAuthConf(g.PorterConf, gi),
+		oauth.MakeUpdateGitlabAppOAuthIntegrationFunction(oauthInt, g.Repo))
+
+	if err != nil {
+		return nil, err
+	}
+
+	client, err := gitlab.NewOAuthClient(accessToken, gitlab.WithBaseURL(gi.InstanceURL))
+
+	if err != nil {
+		return nil, err
+	}
+
+	return client, nil
+}
+
+func (g *GitlabCI) getCIJob(jobName string) map[string]interface{} {
+	return map[string]interface{}{
+		"image":   "public.ecr.aws/o1j4x7p4/porter-cli:latest",
+		"stage":   jobName,
+		"timeout": "20 minutes",
+		"variables": map[string]string{
+			"GIT_STRATEGY": "clone",
+		},
+		"script": []string{
+			fmt.Sprintf("export PORTER_HOST=\"%s\"", g.ServerURL),
+			fmt.Sprintf("export PORTER_PROJECT=\"%d\"", g.ProjectID),
+			fmt.Sprintf("export PORTER_CLUSTER=\"%d\"", g.ClusterID),
+			fmt.Sprintf("export PORTER_TOKEN=\"$%s\"", g.getPorterTokenSecretName()),
+			"export PORTER_TAG=\"$(echo $CI_COMMIT_SHA | cut -c1-7)\"",
+			fmt.Sprintf("porter update --app \"%s\" --tag \"$PORTER_TAG\" --namespace \"%s\" --path \"%s\" --stream",
+				g.ReleaseName, g.ReleaseNamespace, g.FolderPath),
+		},
+	}
+}
+
+func (g *GitlabCI) createGitlabSecret(client *gitlab.Client) error {
+	_, _, err := client.ProjectVariables.CreateVariable(g.pID, &gitlab.CreateProjectVariableOptions{
+		Key:    gitlab.String(g.getPorterTokenSecretName()),
+		Value:  gitlab.String(g.PorterToken),
+		Masked: gitlab.Bool(true),
+	})
+
+	if err != nil {
+		return fmt.Errorf("error creating porter token variable: %w", err)
+	}
+
+	return nil
+}
+
+func (g *GitlabCI) deleteGitlabSecret(client *gitlab.Client) error {
+	_, err := client.ProjectVariables.RemoveVariable(g.pID, g.getPorterTokenSecretName(), &gitlab.RemoveProjectVariableOptions{})
+
+	if err != nil {
+		return fmt.Errorf("error removing porter token variable: %w", err)
+	}
+
+	return nil
+}
+
+func (g *GitlabCI) getPorterTokenSecretName() string {
+	return fmt.Sprintf("PORTER_TOKEN_%d", g.ProjectID)
+}
+
+func getGitlabStageJobName(releaseName string) string {
+	return fmt.Sprintf("porter-%s", strings.ToLower(strings.ReplaceAll(releaseName, "_", "-")))
+}

+ 10 - 6
internal/models/gitrepo.go

@@ -43,6 +43,9 @@ type GitActionConfig struct {
 	// The git repo ID (legacy field)
 	GitRepoID uint `json:"git_repo_id"`
 
+	// The gitlab integration ID
+	GitlabIntegrationID uint `json:"gitlab_integration_id"`
+
 	// The path to the dockerfile in the git repo
 	DockerfilePath string `json:"dockerfile_path"`
 
@@ -58,11 +61,12 @@ type GitActionConfig struct {
 // ToGitActionConfigType generates an external GitActionConfig to be shared over REST
 func (r *GitActionConfig) ToGitActionConfigType() *types.GitActionConfig {
 	return &types.GitActionConfig{
-		GitRepo:        r.GitRepo,
-		GitBranch:      r.GitBranch,
-		ImageRepoURI:   r.ImageRepoURI,
-		GitRepoID:      r.GitRepoID,
-		DockerfilePath: r.DockerfilePath,
-		FolderPath:     r.FolderPath,
+		GitRepo:             r.GitRepo,
+		GitBranch:           r.GitBranch,
+		ImageRepoURI:        r.ImageRepoURI,
+		GitRepoID:           r.GitRepoID,
+		GitlabIntegrationID: r.GitlabIntegrationID,
+		DockerfilePath:      r.DockerfilePath,
+		FolderPath:          r.FolderPath,
 	}
 }

+ 36 - 0
internal/models/integrations/gitlab.go

@@ -0,0 +1,36 @@
+package integrations
+
+import (
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+// GitlabIntegration takes care of Gitlab app related data
+type GitlabIntegration struct {
+	gorm.Model
+
+	// Project ID of the project that this gitlab integration is linked with
+	ProjectID uint `json:"project_id"`
+
+	// URL of the Gitlab instance to talk to
+	InstanceURL string `json:"instance_url"`
+
+	// ------------------------------------------------------------------
+	// All fields encrypted before storage.
+	// ------------------------------------------------------------------
+
+	// Gitlab instance-wide app's client ID
+	AppClientID []byte `json:"app_client_id"`
+
+	// Gitlab instance-wide app's client secret
+	AppClientSecret []byte `json:"app_client_secret"`
+}
+
+func (gi *GitlabIntegration) ToGitlabIntegrationType() *types.GitlabIntegration {
+	return &types.GitlabIntegration{
+		CreatedAt:   gi.CreatedAt,
+		ID:          gi.ID,
+		ProjectID:   gi.ProjectID,
+		InstanceURL: gi.InstanceURL,
+	}
+}

+ 26 - 10
internal/models/integrations/oauth.go

@@ -72,16 +72,6 @@ func (g *OAuthIntegration) PopulateTargetMetadata() {
 	}
 }
 
-// GithubAppOAuthIntegration is the model used for storing github app oauth data
-// Unlike the above, this model is tied to a specific user, not a project
-type GithubAppOAuthIntegration struct {
-	gorm.Model
-	SharedOAuthModel
-
-	// The id of the user that linked this auth mechanism
-	UserID uint `json:"user_id"`
-}
-
 // ToOAuthIntegrationType generates an external OAuthIntegration to be shared over REST
 func (o *OAuthIntegration) ToOAuthIntegrationType() *types.OAuthIntegration {
 	return &types.OAuthIntegration{
@@ -94,3 +84,29 @@ func (o *OAuthIntegration) ToOAuthIntegrationType() *types.OAuthIntegration {
 		TargetName:  o.TargetName,
 	}
 }
+
+// GithubAppOAuthIntegration is the model used for storing github app oauth data
+// Unlike the above, this model is tied to a specific user, not a project
+type GithubAppOAuthIntegration struct {
+	gorm.Model
+	SharedOAuthModel
+
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
+}
+
+// GitlabAppOAuthIntegration is the model used for storing gitlab app oauth data
+// Unlike the above, this model is tied to a specific user, not a project
+type GitlabAppOAuthIntegration struct {
+	gorm.Model
+	SharedOAuthModel
+
+	// The id of the user that linked with this auth mechanism
+	UserID uint `json:"user_id"`
+
+	// The id of the project that linked with this auth mechanism
+	ProjectID uint `json:"project_id"`
+
+	// The id of the gitlab integration linked with this auth mechanism
+	IntegrationID uint `json:"integration_id"`
+}

+ 8 - 7
internal/models/project.go

@@ -49,13 +49,14 @@ type Project struct {
 	Infras []Infra `json:"infras"`
 
 	// auth mechanisms
-	KubeIntegrations  []ints.KubeIntegration  `json:"kube_integrations"`
-	BasicIntegrations []ints.BasicIntegration `json:"basic_integrations"`
-	OIDCIntegrations  []ints.OIDCIntegration  `json:"oidc_integrations"`
-	OAuthIntegrations []ints.OAuthIntegration `json:"oauth_integrations"`
-	AWSIntegrations   []ints.AWSIntegration   `json:"aws_integrations"`
-	GCPIntegrations   []ints.GCPIntegration   `json:"gcp_integrations"`
-	AzureIntegrations []ints.AzureIntegration `json:"azure_integrations"`
+	KubeIntegrations   []ints.KubeIntegration   `json:"kube_integrations"`
+	BasicIntegrations  []ints.BasicIntegration  `json:"basic_integrations"`
+	OIDCIntegrations   []ints.OIDCIntegration   `json:"oidc_integrations"`
+	OAuthIntegrations  []ints.OAuthIntegration  `json:"oauth_integrations"`
+	AWSIntegrations    []ints.AWSIntegration    `json:"aws_integrations"`
+	GCPIntegrations    []ints.GCPIntegration    `json:"gcp_integrations"`
+	AzureIntegrations  []ints.AzureIntegration  `json:"azure_integrations"`
+	GitlabIntegrations []ints.GitlabIntegration `json:"gitlab_integrations"`
 
 	PreviewEnvsEnabled  bool
 	RDSDatabasesEnabled bool

+ 16 - 0
internal/oauth/config.go

@@ -152,6 +152,22 @@ func MakeUpdateGithubAppOauthIntegrationFunction(
 	}
 }
 
+// MakeUpdateGitlabAppOAuthIntegrationFunction creates a function to be passed to GetAccessToken that updates the GitlabAppOAuthIntegration
+// if it needs to be updated
+func MakeUpdateGitlabAppOAuthIntegrationFunction(
+	o *integrations.GitlabAppOAuthIntegration,
+	repo repository.Repository) func(accessToken []byte, refreshToken []byte, expiry time.Time) error {
+	return func(accessToken []byte, refreshToken []byte, expiry time.Time) error {
+		o.AccessToken = accessToken
+		o.RefreshToken = refreshToken
+		o.Expiry = expiry
+
+		_, err := repo.GitlabAppOAuthIntegration().UpdateGitlabAppOAuthIntegration(o)
+
+		return err
+	}
+}
+
 // GetAccessToken retrieves an access token for a given client. It updates the
 // access token in the DB if necessary
 func GetAccessToken(

+ 8 - 0
internal/repository/credentials/credentials.go

@@ -54,6 +54,11 @@ type AzureCredential struct {
 	AKSPassword []byte `json:"aks_password,omitempty"`
 }
 
+type GitlabCredential struct {
+	AppClientID     []byte `json:"app_client_id"`
+	AppClientSecret []byte `json:"app_client_secret"`
+}
+
 type CredentialStorage interface {
 	WriteOAuthCredential(oauthIntegration *integrations.OAuthIntegration, data *OAuthCredential) error
 	GetOAuthCredential(oauthIntegration *integrations.OAuthIntegration) (*OAuthCredential, error)
@@ -67,4 +72,7 @@ type CredentialStorage interface {
 	WriteAzureCredential(azIntegration *integrations.AzureIntegration, data *AzureCredential) error
 	GetAzureCredential(azIntegration *integrations.AzureIntegration) (*AzureCredential, error)
 	CreateAzureToken(azIntegration *integrations.AzureIntegration) (string, error)
+	WriteGitlabCredential(giIntegration *integrations.GitlabIntegration, data *GitlabCredential) error
+	GetGitlabCredential(giIntegration *integrations.GitlabIntegration) (*GitlabCredential, error)
+	CreateGitlabToken(giIntegration *integrations.GitlabIntegration) (string, error)
 }

+ 320 - 0
internal/repository/gorm/auth.go

@@ -1570,3 +1570,323 @@ func (repo *AzureIntegrationRepository) DecryptAzureIntegrationData(
 
 	return nil
 }
+
+// GitlabIntegrationRepository uses gorm.DB for querying the database
+type GitlabIntegrationRepository struct {
+	db             *gorm.DB
+	key            *[32]byte
+	storageBackend credentials.CredentialStorage
+}
+
+// NewGitlabIntegrationRepository returns a GitlabIntegrationRepository which uses
+// gorm.DB for querying the database
+func NewGitlabIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+	storageBackend credentials.CredentialStorage,
+) repository.GitlabIntegrationRepository {
+	return &GitlabIntegrationRepository{db, key, storageBackend}
+}
+
+// CreateIntegration adds a new GitlabIntegration row to the gitlab_integration table in the database
+func (repo *GitlabIntegrationRepository) CreateGitlabIntegration(gi *ints.GitlabIntegration) (*ints.GitlabIntegration, error) {
+	err := repo.EncryptGitlabIntegrationData(gi, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// if storage backend is not nil, strip out credential data, which will be stored in credential
+	// storage backend after write to DB
+	var credentialData = &credentials.GitlabCredential{}
+
+	if repo.storageBackend != nil {
+		credentialData.AppClientID = gi.AppClientID
+		credentialData.AppClientSecret = gi.AppClientSecret
+
+		gi.AppClientID = []byte{}
+		gi.AppClientSecret = []byte{}
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", gi.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("GitlabIntegrations")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(gi); err != nil {
+		return nil, err
+	}
+
+	if repo.storageBackend != nil {
+		err = repo.storageBackend.WriteGitlabCredential(gi, credentialData)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return gi, nil
+}
+
+func (repo *GitlabIntegrationRepository) ReadGitlabIntegration(projectID, id uint) (*ints.GitlabIntegration, error) {
+	gi := &ints.GitlabIntegration{}
+
+	if err := repo.db.Where("project_id = ? AND id = ?", projectID, id).First(&gi).Error; err != nil {
+		return nil, err
+	}
+
+	if repo.storageBackend != nil {
+		credentialData, err := repo.storageBackend.GetGitlabCredential(gi)
+
+		if err != nil {
+			return nil, err
+		}
+
+		gi.AppClientID = credentialData.AppClientID
+
+		gi.AppClientSecret = credentialData.AppClientSecret
+	}
+
+	err := repo.DecryptGitlabIntegrationData(gi, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return gi, nil
+}
+
+func (repo *GitlabIntegrationRepository) ListGitlabIntegrationsByProjectID(projectID uint) ([]*ints.GitlabIntegration, error) {
+	gi := []*ints.GitlabIntegration{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&gi).Error; err != nil {
+		return nil, err
+	}
+
+	return gi, nil
+}
+
+// EncryptGitlabIntegrationData will encrypt the gitlab integration data before
+// writing to the DB
+func (repo *GitlabIntegrationRepository) EncryptGitlabIntegrationData(
+	gi *ints.GitlabIntegration,
+	key *[32]byte,
+) error {
+	if len(gi.AppClientID) > 0 {
+		cipherData, err := encryption.Encrypt(gi.AppClientID, key)
+
+		if err != nil {
+			return err
+		}
+
+		gi.AppClientID = cipherData
+	}
+
+	if len(gi.AppClientSecret) > 0 {
+		cipherData, err := encryption.Encrypt(gi.AppClientSecret, key)
+
+		if err != nil {
+			return err
+		}
+
+		gi.AppClientSecret = cipherData
+	}
+
+	return nil
+}
+
+// DecryptGitlabIntegrationData will decrypt the gitlab integration data before
+// returning it from the DB
+func (repo *GitlabIntegrationRepository) DecryptGitlabIntegrationData(
+	gi *ints.GitlabIntegration,
+	key *[32]byte,
+) error {
+	if len(gi.AppClientID) > 0 {
+		plaintext, err := encryption.Decrypt(gi.AppClientID, key)
+
+		if err != nil {
+			return err
+		}
+
+		gi.AppClientID = plaintext
+	}
+
+	if len(gi.AppClientSecret) > 0 {
+		plaintext, err := encryption.Decrypt(gi.AppClientSecret, key)
+
+		if err != nil {
+			return err
+		}
+
+		gi.AppClientSecret = plaintext
+	}
+
+	return nil
+}
+
+// GitlabAppOAuthIntegrationRepository uses gorm.DB for querying the database
+type GitlabAppOAuthIntegrationRepository struct {
+	db             *gorm.DB
+	key            *[32]byte
+	storageBackend credentials.CredentialStorage
+}
+
+// NewGitlabAppOAuthIntegrationRepository returns a GitlabAppOAuthIntegrationRepository which uses
+// gorm.DB for querying the database
+func NewGitlabAppOAuthIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+	storageBackend credentials.CredentialStorage,
+) repository.GitlabAppOAuthIntegrationRepository {
+	return &GitlabAppOAuthIntegrationRepository{db, key, storageBackend}
+}
+
+func (repo *GitlabAppOAuthIntegrationRepository) CreateGitlabAppOAuthIntegration(
+	gi *ints.GitlabAppOAuthIntegration,
+) (*ints.GitlabAppOAuthIntegration, error) {
+	err := repo.EncryptGitlabAppOAuthIntegrationData(gi, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// if storage backend is not nil, strip out credential data, which will be stored in credential
+	// storage backend after write to DB
+	// var credentialData = &credentials.GitlabCredential{}
+
+	// if repo.storageBackend != nil {
+	// 	credentialData.AppClientID = gi.AppClientID
+	// 	credentialData.AppClientSecret = gi.AppClientSecret
+
+	// 	gi.AppClientID = []byte{}
+	// 	gi.AppClientSecret = []byte{}
+	// }
+
+	if err := repo.db.Create(gi).Error; err != nil {
+		return nil, err
+	}
+	return gi, nil
+}
+
+func (repo *GitlabAppOAuthIntegrationRepository) ReadGitlabAppOAuthIntegration(
+	userID, projectID, integrationID uint,
+) (*ints.GitlabAppOAuthIntegration, error) {
+	gi := &ints.GitlabAppOAuthIntegration{}
+
+	if err := repo.db.Where("user_id = ? AND project_id = ? AND integration_id = ?", userID, projectID, integrationID).First(&gi).Error; err != nil {
+		return nil, err
+	}
+
+	// if repo.storageBackend != nil {
+	// 	credentialData, err := repo.storageBackend.GetGitlabCredential(gi)
+
+	// 	if err != nil {
+	// 		return nil, err
+	// 	}
+
+	// 	gi.AppClientID = credentialData.AppClientID
+
+	// 	gi.AppClientSecret = credentialData.AppClientSecret
+	// }
+
+	err := repo.DecryptGitlabAppOAuthIntegrationData(gi, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return gi, nil
+}
+
+func (repo *GitlabAppOAuthIntegrationRepository) UpdateGitlabAppOAuthIntegration(
+	gi *ints.GitlabAppOAuthIntegration,
+) (*ints.GitlabAppOAuthIntegration, error) {
+	err := repo.EncryptGitlabAppOAuthIntegrationData(gi, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// if storage backend is not nil, strip out credential data, which will be stored in credential
+	// storage backend after write to DB
+	// var credentialData = &credentials.GitlabCredential{}
+
+	// if repo.storageBackend != nil {
+	// 	credentialData.AppClientID = gi.AppClientID
+	// 	credentialData.AppClientSecret = gi.AppClientSecret
+
+	// 	gi.AppClientID = []byte{}
+	// 	gi.AppClientSecret = []byte{}
+	// }
+
+	if err := repo.db.Save(gi).Error; err != nil {
+		return nil, err
+	}
+
+	return gi, nil
+}
+
+// EncryptGitlabAppOAuthIntegrationData will encrypt the gitlab app oauth integration data before
+// writing to the DB
+func (repo *GitlabAppOAuthIntegrationRepository) EncryptGitlabAppOAuthIntegrationData(
+	gi *ints.GitlabAppOAuthIntegration,
+	key *[32]byte,
+) error {
+	if len(gi.AccessToken) > 0 {
+		cipherData, err := encryption.Encrypt(gi.AccessToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		gi.AccessToken = cipherData
+	}
+
+	if len(gi.RefreshToken) > 0 {
+		cipherData, err := encryption.Encrypt(gi.RefreshToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		gi.RefreshToken = cipherData
+	}
+
+	return nil
+}
+
+// DecryptAppOAuthGitlabIntegrationData will decrypt the gitlab app oauth integration data before
+// returning it from the DB
+func (repo *GitlabAppOAuthIntegrationRepository) DecryptGitlabAppOAuthIntegrationData(
+	gi *ints.GitlabAppOAuthIntegration,
+	key *[32]byte,
+) error {
+	if len(gi.AccessToken) > 0 {
+		plaintext, err := encryption.Decrypt(gi.AccessToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		gi.AccessToken = plaintext
+	}
+
+	if len(gi.RefreshToken) > 0 {
+		plaintext, err := encryption.Decrypt(gi.RefreshToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		gi.RefreshToken = plaintext
+	}
+
+	return nil
+}

+ 2 - 0
internal/repository/gorm/migrate.go

@@ -56,6 +56,8 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&ints.GCPIntegration{},
 		&ints.AWSIntegration{},
 		&ints.AzureIntegration{},
+		&ints.GitlabIntegration{},
+		&ints.GitlabAppOAuthIntegration{},
 		&ints.TokenCache{},
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},

+ 12 - 0
internal/repository/gorm/repository.go

@@ -33,6 +33,8 @@ type GormRepository struct {
 	githubAppInstallation     repository.GithubAppInstallationRepository
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
+	gitlabIntegration         repository.GitlabIntegrationRepository
+	gitlabAppOAuthIntegration repository.GitlabAppOAuthIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
 	jobNotificationConfig     repository.JobNotificationConfigRepository
 	buildEvent                repository.BuildEventRepository
@@ -149,6 +151,14 @@ func (t *GormRepository) SlackIntegration() repository.SlackIntegrationRepositor
 	return t.slackIntegration
 }
 
+func (t *GormRepository) GitlabIntegration() repository.GitlabIntegrationRepository {
+	return t.gitlabIntegration
+}
+
+func (t *GormRepository) GitlabAppOAuthIntegration() repository.GitlabAppOAuthIntegrationRepository {
+	return t.gitlabAppOAuthIntegration
+}
+
 func (t *GormRepository) NotificationConfig() repository.NotificationConfigRepository {
 	return t.notificationConfig
 }
@@ -219,6 +229,8 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		githubAppInstallation:     NewGithubAppInstallationRepository(db),
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		slackIntegration:          NewSlackIntegrationRepository(db, key),
+		gitlabIntegration:         NewGitlabIntegrationRepository(db, key, storageBackend),
+		gitlabAppOAuthIntegration: NewGitlabAppOAuthIntegrationRepository(db, key, storageBackend),
 		notificationConfig:        NewNotificationConfigRepository(db),
 		jobNotificationConfig:     NewJobNotificationConfigRepository(db),
 		buildEvent:                NewBuildEventRepository(db),

+ 14 - 0
internal/repository/integrations.go

@@ -86,3 +86,17 @@ type GithubAppInstallationRepository interface {
 	ReadGithubAppInstallationByAccountIDs(accountIDs []int64) ([]*ints.GithubAppInstallation, error)
 	DeleteGithubAppInstallationByAccountID(accountID int64) error
 }
+
+// GitlabIntegrationRepository represents the set of queries on the GitlabIntegration model
+type GitlabIntegrationRepository interface {
+	CreateGitlabIntegration(gi *ints.GitlabIntegration) (*ints.GitlabIntegration, error)
+	ReadGitlabIntegration(projectID, id uint) (*ints.GitlabIntegration, error)
+	ListGitlabIntegrationsByProjectID(projectID uint) ([]*ints.GitlabIntegration, error)
+}
+
+// GitlabAppOAuthIntegrationRepository represents the set of queries on the GitlabOAuthIntegration model
+type GitlabAppOAuthIntegrationRepository interface {
+	CreateGitlabAppOAuthIntegration(gi *ints.GitlabAppOAuthIntegration) (*ints.GitlabAppOAuthIntegration, error)
+	ReadGitlabAppOAuthIntegration(userID, projectID, integrationID uint) (*ints.GitlabAppOAuthIntegration, error)
+	UpdateGitlabAppOAuthIntegration(gi *ints.GitlabAppOAuthIntegration) (*ints.GitlabAppOAuthIntegration, error)
+}

+ 2 - 0
internal/repository/repository.go

@@ -27,6 +27,8 @@ type Repository interface {
 	GithubAppInstallation() GithubAppInstallationRepository
 	GithubAppOAuthIntegration() GithubAppOAuthIntegrationRepository
 	SlackIntegration() SlackIntegrationRepository
+	GitlabIntegration() GitlabIntegrationRepository
+	GitlabAppOAuthIntegration() GitlabAppOAuthIntegrationRepository
 	NotificationConfig() NotificationConfigRepository
 	JobNotificationConfig() JobNotificationConfigRepository
 	BuildEvent() BuildEventRepository

+ 97 - 0
internal/repository/test/auth.go

@@ -606,3 +606,100 @@ func (repo *AzureIntegrationRepository) ListAzureIntegrationsByProjectID(
 ) ([]*ints.AzureIntegration, error) {
 	panic("unimplemented")
 }
+
+type GitlabIntegrationRepository struct {
+	canQuery           bool
+	gitlabIntegrations []*ints.GitlabIntegration
+}
+
+func NewGitlabIntegrationRepository(canQuery bool) repository.GitlabIntegrationRepository {
+	return &GitlabIntegrationRepository{
+		canQuery,
+		[]*ints.GitlabIntegration{},
+	}
+}
+
+func (repo *GitlabIntegrationRepository) CreateGitlabIntegration(gi *ints.GitlabIntegration) (*ints.GitlabIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	repo.gitlabIntegrations = append(repo.gitlabIntegrations, gi)
+	gi.ID = uint(len(repo.gitlabIntegrations))
+
+	return gi, nil
+}
+
+func (repo *GitlabIntegrationRepository) ReadGitlabIntegration(projectID, id uint) (*ints.GitlabIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.gitlabIntegrations) || repo.gitlabIntegrations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.gitlabIntegrations[index], nil
+}
+func (repo *GitlabIntegrationRepository) ListGitlabIntegrationsByProjectID(projectID uint) ([]*ints.GitlabIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*ints.GitlabIntegration, 0)
+
+	for _, gitlabAM := range repo.gitlabIntegrations {
+		if gitlabAM.ProjectID == projectID {
+			res = append(res, gitlabAM)
+		}
+	}
+
+	return res, nil
+}
+
+type GitlabAppOAuthIntegrationRepository struct {
+	canQuery                   bool
+	gitlabAppOAuthIntegrations []*ints.GitlabAppOAuthIntegration
+}
+
+func NewGitlabAppOAuthIntegrationRepository(canQuery bool) repository.GitlabAppOAuthIntegrationRepository {
+	return &GitlabAppOAuthIntegrationRepository{
+		canQuery,
+		[]*ints.GitlabAppOAuthIntegration{},
+	}
+}
+
+func (repo *GitlabAppOAuthIntegrationRepository) CreateGitlabAppOAuthIntegration(
+	gi *ints.GitlabAppOAuthIntegration,
+) (*ints.GitlabAppOAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	repo.gitlabAppOAuthIntegrations = append(repo.gitlabAppOAuthIntegrations, gi)
+	gi.ID = uint(len(repo.gitlabAppOAuthIntegrations))
+
+	return gi, nil
+}
+
+func (repo *GitlabAppOAuthIntegrationRepository) ReadGitlabAppOAuthIntegration(
+	userID, projectID, integrationID uint,
+) (*ints.GitlabAppOAuthIntegration, error) {
+	panic("not implemented")
+}
+
+func (repo *GitlabAppOAuthIntegrationRepository) UpdateGitlabAppOAuthIntegration(gi *ints.GitlabAppOAuthIntegration) (*ints.GitlabAppOAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(gi.ID-1) >= len(repo.gitlabAppOAuthIntegrations) || repo.gitlabAppOAuthIntegrations[gi.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(gi.ID - 1)
+	repo.gitlabAppOAuthIntegrations[index] = gi
+
+	return gi, nil
+}

+ 12 - 0
internal/repository/test/repository.go

@@ -29,6 +29,8 @@ type TestRepository struct {
 	azIntegration             repository.AzureIntegrationRepository
 	githubAppInstallation     repository.GithubAppInstallationRepository
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
+	gitlabIntegration         repository.GitlabIntegrationRepository
+	gitlabAppOAuthIntegration repository.GitlabAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
 	jobNotificationConfig     repository.JobNotificationConfigRepository
@@ -139,6 +141,14 @@ func (t *TestRepository) GithubAppOAuthIntegration() repository.GithubAppOAuthIn
 	return t.githubAppOAuthIntegration
 }
 
+func (t *TestRepository) GitlabIntegration() repository.GitlabIntegrationRepository {
+	return t.gitlabIntegration
+}
+
+func (t *TestRepository) GitlabAppOAuthIntegration() repository.GitlabAppOAuthIntegrationRepository {
+	return t.gitlabAppOAuthIntegration
+}
+
 func (t *TestRepository) SlackIntegration() repository.SlackIntegrationRepository {
 	return t.slackIntegration
 }
@@ -215,6 +225,8 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		azIntegration:             NewAzureIntegrationRepository(),
 		githubAppInstallation:     NewGithubAppInstallationRepository(canQuery),
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(canQuery),
+		gitlabIntegration:         NewGitlabIntegrationRepository(canQuery),
+		gitlabAppOAuthIntegration: NewGitlabAppOAuthIntegrationRepository(canQuery),
 		slackIntegration:          NewSlackIntegrationRepository(canQuery),
 		notificationConfig:        NewNotificationConfigRepository(canQuery),
 		jobNotificationConfig:     NewJobNotificationConfigRepository(canQuery),