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

Merge branch 'master' into belanger/add-swagger-docs

Mohammed Nafees 3 лет назад
Родитель
Сommit
4d80d91ede
100 измененных файлов с 4616 добавлено и 528 удалено
  1. 29 0
      api/client/k8s.go
  2. 64 0
      api/server/authz/gitlab_integration.go
  3. 2 0
      api/server/authz/policy.go
  4. 0 10
      api/server/authz/policy/loader.go
  5. 22 5
      api/server/handlers/environment/create.go
  6. 2 2
      api/server/handlers/environment/create_deployment.go
  7. 28 18
      api/server/handlers/environment/delete.go
  8. 1 1
      api/server/handlers/environment/enable_pull_request.go
  9. 2 2
      api/server/handlers/environment/finalize_deployment.go
  10. 2 2
      api/server/handlers/environment/get_deployment.go
  11. 2 2
      api/server/handlers/environment/list_deployments.go
  12. 2 2
      api/server/handlers/environment/update_deployment.go
  13. 2 2
      api/server/handlers/environment/update_deployment_status.go
  14. 4 3
      api/server/handlers/gitinstallation/get_buildpack.go
  15. 3 2
      api/server/handlers/gitinstallation/get_contents.go
  16. 3 2
      api/server/handlers/gitinstallation/get_procfile.go
  17. 3 2
      api/server/handlers/gitinstallation/get_tarball_url.go
  18. 0 42
      api/server/handlers/gitinstallation/helpers.go
  19. 2 1
      api/server/handlers/gitinstallation/list_branches.go
  20. 1 1
      api/server/handlers/gitinstallation/rerun_workflow.go
  21. 31 2
      api/server/handlers/handler.go
  22. 132 0
      api/server/handlers/oauth_callback/gitlab.go
  23. 73 0
      api/server/handlers/project_integration/create_gitlab.go
  24. 160 0
      api/server/handlers/project_integration/get_gitlab_repo_buildpack.go
  25. 112 0
      api/server/handlers/project_integration/get_gitlab_repo_contents.go
  26. 111 0
      api/server/handlers/project_integration/get_gitlab_repo_procfile.go
  27. 117 0
      api/server/handlers/project_integration/list_git.go
  28. 44 0
      api/server/handlers/project_integration/list_gitlab.go
  29. 77 0
      api/server/handlers/project_integration/list_gitlab_repo_branches.go
  30. 119 0
      api/server/handlers/project_integration/list_gitlab_repos.go
  31. 1 1
      api/server/handlers/project_oauth/digitalocean.go
  32. 78 0
      api/server/handlers/project_oauth/gitlab.go
  33. 1 1
      api/server/handlers/project_oauth/slack.go
  34. 95 70
      api/server/handlers/release/create.go
  35. 53 20
      api/server/handlers/release/delete.go
  36. 0 2
      api/server/handlers/release/get_gha_template.go
  37. 1 1
      api/server/handlers/release/ugprade.go
  38. 1 1
      api/server/handlers/release/update_rollback.go
  39. 1 1
      api/server/handlers/user/github_start.go
  40. 1 1
      api/server/handlers/user/google_start.go
  41. 24 0
      api/server/router/oauth_callback.go
  42. 241 0
      api/server/router/project_integration.go
  43. 28 0
      api/server/router/project_oauth.go
  44. 5 0
      api/server/router/router.go
  45. 43 0
      api/server/shared/commonutils/git_utils.go
  46. 20 0
      api/server/shared/commonutils/gitlab.go
  47. 3 0
      api/server/shared/config/env/envconfs.go
  48. 2 0
      api/server/shared/config/metadata.go
  49. 12 8
      api/types/git_action_config.go
  50. 13 12
      api/types/policy.go
  51. 34 0
      api/types/project_integration.go
  52. 5 5
      api/types/release.go
  53. 1 0
      api/types/request.go
  54. 15 0
      cli/cmd/config.go
  55. 41 2
      cli/cmd/config/config.go
  56. 1 1
      cli/cmd/deploy/create.go
  57. 1 1
      cli/cmd/portforward.go
  58. 1 1
      cli/cmd/run.go
  59. 24 0
      dashboard/package-lock.json
  60. 13 2
      dashboard/src/components/SearchBar.tsx
  61. 1 2
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  62. 50 22
      dashboard/src/components/repo-selector/BranchList.tsx
  63. 29 11
      dashboard/src/components/repo-selector/BuildpackSelection.tsx
  64. 105 38
      dashboard/src/components/repo-selector/ContentsList.tsx
  65. 302 91
      dashboard/src/components/repo-selector/RepoList.tsx
  66. 1 0
      dashboard/src/main/Main.tsx
  67. 2 2
      dashboard/src/main/home/WelcomeForm.tsx
  68. 49 17
      dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx
  69. 48 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx
  70. 161 0
      dashboard/src/main/home/integrations/GitlabIntegrationList.tsx
  71. 22 3
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  72. 15 6
      dashboard/src/main/home/integrations/Integrations.tsx
  73. 2 2
      dashboard/src/main/home/integrations/SlackIntegrationList.tsx
  74. 3 0
      dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx
  75. 147 0
      dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx
  76. 27 11
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  77. 12 8
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  78. 2 3
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  79. 27 2
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  80. 6 0
      dashboard/src/shared/Context.tsx
  81. 104 1
      dashboard/src/shared/api.tsx
  82. 2 1
      dashboard/src/shared/common.tsx
  83. 30 0
      dashboard/src/shared/hooks/useOutsideAlerter.ts
  84. 27 10
      dashboard/src/shared/types.tsx
  85. 11 0
      ee/integrations/vault/types.go
  86. 36 0
      ee/integrations/vault/vault.go
  87. 85 0
      ee/migrate/migrate_vault.go
  88. 12 8
      go.mod
  89. 26 0
      go.sum
  90. 91 5
      internal/integrations/buildpacks/go.go
  91. 245 7
      internal/integrations/buildpacks/nodejs.go
  92. 142 9
      internal/integrations/buildpacks/python.go
  93. 138 3
      internal/integrations/buildpacks/ruby.go
  94. 12 1
      internal/integrations/buildpacks/shared.go
  95. 62 2
      internal/integrations/ci/actions/actions.go
  96. 117 24
      internal/integrations/ci/actions/preview.go
  97. 1 1
      internal/integrations/ci/actions/steps.go
  98. 509 0
      internal/integrations/ci/gitlab/ci.go
  99. 10 6
      internal/models/gitrepo.go
  100. 36 0
      internal/models/integrations/gitlab.go

+ 29 - 0
api/client/k8s.go

@@ -3,8 +3,12 @@ package client
 import (
 	"context"
 	"fmt"
+	"io"
+	"os"
 
+	"github.com/fatih/color"
 	"github.com/porter-dev/porter/api/types"
+
 	v1 "k8s.io/api/batch/v1"
 )
 
@@ -55,9 +59,34 @@ func (c *Client) GetKubeconfig(
 	ctx context.Context,
 	projectID uint,
 	clusterID uint,
+	localKubeconfigPath string,
 ) (*types.GetTemporaryKubeconfigResponse, error) {
 	resp := &types.GetTemporaryKubeconfigResponse{}
 
+	if localKubeconfigPath != "" {
+		color.New(color.FgBlue).Printf("using local kubeconfig: %s\n", localKubeconfigPath)
+
+		if _, err := os.Stat(localKubeconfigPath); !os.IsNotExist(err) {
+			file, err := os.Open(localKubeconfigPath)
+
+			if err != nil {
+				return nil, err
+			}
+
+			data, err := io.ReadAll(file)
+
+			if err != nil {
+				return nil, err
+			}
+
+			resp.Kubeconfig = append(resp.Kubeconfig, data...)
+
+			return resp, nil
+		}
+	}
+
+	color.New(color.FgBlue).Println("using remote kubeconfig")
+
 	err := c.getRequest(
 		fmt.Sprintf(
 			"/projects/%d/clusters/%d/kubeconfig",

+ 64 - 0
api/server/authz/gitlab_integration.go

@@ -0,0 +1,64 @@
+package authz
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"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"
+	"gorm.io/gorm"
+)
+
+type GitlabIntegrationScopedFactory struct {
+	config *config.Config
+}
+
+func NewGitlabIntegrationScopedFactory(
+	config *config.Config,
+) *GitlabIntegrationScopedFactory {
+	return &GitlabIntegrationScopedFactory{config}
+}
+
+func (p *GitlabIntegrationScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &GitlabIntegrationScopedMiddleware{next, p.config}
+}
+
+type GitlabIntegrationScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *GitlabIntegrationScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project to check scopes
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the integration id from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	integrationID := reqScopes[types.GitlabIntegrationScope].Resource.UInt
+	gi, err := p.config.Repo.GitlabIntegration().ReadGitlabIntegration(proj.ID, integrationID)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("gitlab integration not found with id %d", integrationID),
+			), true)
+
+			return
+		}
+
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
+		return
+	}
+
+	ctx := NewGitlabIntegrationContext(r.Context(), gi)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewGitlabIntegrationContext(ctx context.Context, gi *ints.GitlabIntegration) context.Context {
+	return context.WithValue(ctx, types.GitlabIntegrationScope, gi)
+}

+ 2 - 0
api/server/authz/policy.go

@@ -132,6 +132,8 @@ func getRequestActionForEndpoint(
 			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamReleaseName)
 		case types.InviteScope:
 			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamInviteID)
+		case types.GitlabIntegrationScope:
+			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamIntegrationID)
 		}
 
 		if reqErr != nil {

+ 0 - 10
api/server/authz/policy/loader.go

@@ -40,16 +40,6 @@ func (b *RepoPolicyDocumentLoader) LoadPolicyDocuments(
 			return nil, apierrors.NewErrForbidden(fmt.Errorf("project id %d does not match token id %d", opts.ProjectID, opts.ProjectToken.ProjectID))
 		}
 
-		proj, err := b.projRepo.ReadProject(opts.ProjectID)
-
-		if err != nil {
-			return nil, apierrors.NewErrForbidden(fmt.Errorf("error fetching project: %w", err))
-		}
-
-		if !proj.APITokensEnabled {
-			return nil, apierrors.NewErrForbidden(fmt.Errorf("api tokens are not enabled for this project"))
-		}
-
 		// load the policy
 		apiPolicy, reqErr := GetAPIPolicyFromUID(b.policyRepo, opts.ProjectToken.ProjectID, opts.ProjectToken.PolicyUID)
 

+ 22 - 5
api/server/handlers/environment/create.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strconv"
@@ -9,9 +10,9 @@ import (
 	ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v41/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/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/auth/token"
@@ -41,7 +42,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return
@@ -134,8 +135,18 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	})
 
 	if err != nil {
-		c.deleteEnvAndReportError(w, r, env, err)
-		return
+		unwrappedErr := errors.Unwrap(err)
+
+		if unwrappedErr != nil {
+			if errors.Is(unwrappedErr, actions.ErrProtectedBranch) {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
+			} else if errors.Is(unwrappedErr, actions.ErrCreatePRForProtectedBranch) {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusPreconditionFailed))
+			}
+		} else {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	}
 
 	c.WriteResult(w, r, env.ToEnvironmentType())
@@ -144,7 +155,13 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 func (c *CreateEnvironmentHandler) deleteEnvAndReportError(
 	w http.ResponseWriter, r *http.Request, env *models.Environment, err error,
 ) {
-	c.Repo().Environment().DeleteEnvironment(env)
+	_, delErr := c.Repo().Environment().DeleteEnvironment(env)
+
+	if delErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(delErr))
+		return
+	}
+
 	c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 }
 

+ 2 - 2
api/server/handlers/environment/create_deployment.go

@@ -8,9 +8,9 @@ import (
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
 	"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/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -38,7 +38,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return

+ 28 - 18
api/server/handlers/environment/delete.go

@@ -1,13 +1,15 @@
 package environment
 
 import (
+	"errors"
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"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/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
@@ -36,7 +38,7 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return
@@ -58,22 +60,6 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	err = actions.DeleteEnv(&actions.EnvOpts{
-		Client:            client,
-		ServerURL:         c.Config().ServerConf.ServerURL,
-		GitRepoOwner:      env.GitRepoOwner,
-		GitRepoName:       env.GitRepoName,
-		ProjectID:         project.ID,
-		ClusterID:         cluster.ID,
-		GitInstallationID: uint(ga.InstallationID),
-		EnvironmentName:   env.Name,
-	})
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	// delete all corresponding deployments
 	agent, err := c.GetAgent(r, cluster, "")
 
@@ -101,5 +87,29 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	err = actions.DeleteEnv(&actions.EnvOpts{
+		Client:            client,
+		ServerURL:         c.Config().ServerConf.ServerURL,
+		GitRepoOwner:      env.GitRepoOwner,
+		GitRepoName:       env.GitRepoName,
+		ProjectID:         project.ID,
+		ClusterID:         cluster.ID,
+		GitInstallationID: uint(ga.InstallationID),
+		EnvironmentName:   env.Name,
+	})
+
+	if err != nil {
+		if errors.Is(err, actions.ErrProtectedBranch) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("We were unable to delete the Porter Preview Environment workflow files for this "+
+					"repository as the default branch is protected. Please manually delete them."), http.StatusConflict,
+			))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	c.WriteResult(w, r, env.ToEnvironmentType())
 }

+ 1 - 1
api/server/handlers/environment/enable_pull_request.go

@@ -101,7 +101,7 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	namespace := fmt.Sprintf("pr-%d-%s", request.Number, strings.ReplaceAll(env.GitRepoName, "_", "-"))
+	namespace := fmt.Sprintf("pr-%d-%s", request.Number, strings.ToLower(strings.ReplaceAll(env.GitRepoName, "_", "-")))
 
 	// create the deployment
 	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{

+ 2 - 2
api/server/handlers/environment/finalize_deployment.go

@@ -7,9 +7,9 @@ import (
 
 	"github.com/google/go-github/v41/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/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -35,7 +35,7 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return

+ 2 - 2
api/server/handlers/environment/get_deployment.go

@@ -6,9 +6,9 @@ import (
 	"net/http"
 
 	"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/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -35,7 +35,7 @@ func (c *GetDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return

+ 2 - 2
api/server/handlers/environment/list_deployments.go

@@ -6,9 +6,9 @@ import (
 	"net/http"
 
 	"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/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -41,7 +41,7 @@ func (c *ListDeploymentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return

+ 2 - 2
api/server/handlers/environment/update_deployment.go

@@ -5,9 +5,9 @@ import (
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"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/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -35,7 +35,7 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return

+ 2 - 2
api/server/handlers/environment/update_deployment_status.go

@@ -5,9 +5,9 @@ import (
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"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/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -35,7 +35,7 @@ func (c *UpdateDeploymentStatusHandler) ServeHTTP(w http.ResponseWriter, r *http
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return

+ 4 - 3
api/server/handlers/gitinstallation/get_buildpack.go

@@ -11,6 +11,7 @@ import (
 	"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/integrations/buildpacks"
@@ -58,13 +59,13 @@ func (c *GithubGetBuildpackHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
-	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return
 	}
 
-	branch, ok := GetBranch(c, w, r)
+	branch, ok := commonutils.GetBranchParam(c, w, r)
 
 	if !ok {
 		return
@@ -103,7 +104,7 @@ func (c *GithubGetBuildpackHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 					return
 				}
 			}()
-			buildpacks.Runtimes[idx].Detect(
+			buildpacks.Runtimes[idx].DetectGithub(
 				client, directoryContents, owner, name, request.Dir, repoContentOptions,
 				builderInfoMap[buildpacks.PaketoBuilder], builderInfoMap[buildpacks.HerokuBuilder],
 			)

+ 3 - 2
api/server/handlers/gitinstallation/get_contents.go

@@ -9,6 +9,7 @@ import (
 	"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"
 )
@@ -37,13 +38,13 @@ func (c *GithubGetContentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return
 	}
 
-	branch, ok := GetBranch(c, w, r)
+	branch, ok := commonutils.GetBranchParam(c, w, r)
 
 	if !ok {
 		return

+ 3 - 2
api/server/handlers/gitinstallation/get_procfile.go

@@ -11,6 +11,7 @@ import (
 	"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"
 )
@@ -41,13 +42,13 @@ func (c *GithubGetProcfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return
 	}
 
-	branch, ok := GetBranch(c, w, r)
+	branch, ok := commonutils.GetBranchParam(c, w, r)
 
 	if !ok {
 		return

+ 3 - 2
api/server/handlers/gitinstallation/get_tarball_url.go

@@ -9,6 +9,7 @@ import (
 	"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"
 )
@@ -29,13 +30,13 @@ func NewGithubGetTarballURLHandler(
 }
 
 func (c *GithubGetTarballURLHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return
 	}
 
-	branch, ok := GetBranch(c, w, r)
+	branch, ok := commonutils.GetBranchParam(c, w, r)
 
 	if !ok {
 		return

+ 0 - 42
api/server/handlers/gitinstallation/helpers.go

@@ -3,14 +3,10 @@ package gitinstallation
 import (
 	"context"
 	"net/http"
-	"net/url"
 
 	ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v41/github"
-	"github.com/porter-dev/porter/api/server/handlers"
-	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"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/models/integrations"
@@ -136,41 +132,3 @@ func permissionToString(permission *string) string {
 
 	return *permission
 }
-
-// GetOwnerAndNameParams gets the owner and name ref for the Github repo
-func GetOwnerAndNameParams(c handlers.PorterHandler, w http.ResponseWriter, r *http.Request) (string, string, bool) {
-	owner, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoOwner)
-
-	if reqErr != nil {
-		c.HandleAPIError(w, r, reqErr)
-		return "", "", false
-	}
-
-	name, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoName)
-
-	if reqErr != nil {
-		c.HandleAPIError(w, r, reqErr)
-		return "", "", false
-	}
-
-	return owner, name, true
-}
-
-// GetBranch gets the unencoded branch
-func GetBranch(c handlers.PorterHandler, w http.ResponseWriter, r *http.Request) (string, bool) {
-	branch, reqErr := requestutils.GetURLParamString(r, types.URLParamGitBranch)
-
-	if reqErr != nil {
-		c.HandleAPIError(w, r, reqErr)
-		return "", false
-	}
-
-	branch, err := url.QueryUnescape(branch)
-
-	if reqErr != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return "", false
-	}
-
-	return branch, true
-}

+ 2 - 1
api/server/handlers/gitinstallation/list_branches.go

@@ -10,6 +10,7 @@ import (
 	"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"
 )
@@ -29,7 +30,7 @@ func NewGithubListBranchesHandler(
 }
 
 func (c *GithubListBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return

+ 1 - 1
api/server/handlers/gitinstallation/rerun_workflow.go

@@ -28,7 +28,7 @@ func NewRerunWorkflowHandler(
 }
 
 func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
 
 	if !ok {
 		return

+ 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
 	}

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

@@ -0,0 +1,132 @@
+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/api/types"
+	"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.OAuthIntegration{
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+			Expiry:       token.Expiry,
+		},
+		Client:    types.OAuthGitlab,
+		UserID:    userID,
+		ProjectID: projID,
+	}
+
+	oauthInt, err = p.Repo().OAuthIntegration().CreateOAuthIntegration(oauthInt)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if oauthInt.ID == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating oauth integration for gitlab")))
+		return
+	}
+
+	giOAuthInt := &integrations.GitlabAppOAuthIntegration{
+		OAuthIntegrationID:  oauthInt.ID,
+		GitlabIntegrationID: integrationID,
+	}
+
+	// create the oauth integration first
+	_, err = p.Repo().GitlabAppOAuthIntegration().CreateGitlabAppOAuthIntegration(giOAuthInt)
+
+	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)
+	}
+}

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

@@ -0,0 +1,73 @@
+package project_integration
+
+import (
+	"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/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)
+
+	metadata := p.Config().Metadata
+
+	if !metadata.Gitlab {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("gitlab integration endpoints are not enabled")))
+		return
+	}
+
+	request := &types.CreateGitlabRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	_, err := url.Parse(request.InstanceURL)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("malformed gitlab instance URL"), http.StatusBadRequest,
+		))
+		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)
+}

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

@@ -0,0 +1,160 @@
+package project_integration
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"sync"
+
+	"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/integrations/buildpacks"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/xanzy/go-gitlab"
+)
+
+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)
+	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
+
+	request := &types.GetBuildpackRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	owner, name, ok := commonutils.GetOwnerAndNameParams(p, w, r)
+
+	if !ok {
+		return
+	}
+
+	branch, ok := commonutils.GetBranchParam(p, w, r)
+
+	if !ok {
+		return
+	}
+
+	client, err := getGitlabClient(p.Repo(), user.ID, project.ID, gi, p.Config())
+
+	if err != nil {
+		if errors.Is(err, errUnauthorizedGitlabUser) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(errUnauthorizedGitlabUser, http.StatusUnauthorized))
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	dir, err := url.QueryUnescape(request.Dir)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("malformed query param dir")))
+		return
+	}
+
+	dir = strings.TrimPrefix(dir, "./")
+
+	if len(dir) == 0 {
+		dir = "."
+	}
+
+	tree, resp, err := client.Repositories.ListTree(fmt.Sprintf("%s/%s", owner, name), &gitlab.ListTreeOptions{
+		Path: gitlab.String(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
+	}
+
+	builderInfoMap := initBuilderInfo()
+	var wg sync.WaitGroup
+	wg.Add(len(buildpacks.Runtimes))
+	for i := range buildpacks.Runtimes {
+		go func(idx int) {
+			defer func() {
+				if rec := recover(); rec != nil {
+					p.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("panic detected in runtime detection")))
+					return
+				}
+			}()
+			buildpacks.Runtimes[idx].DetectGitlab(
+				client, tree, owner, name, dir, branch,
+				builderInfoMap[buildpacks.PaketoBuilder], builderInfoMap[buildpacks.HerokuBuilder],
+			)
+			wg.Done()
+		}(i)
+	}
+	wg.Wait()
+
+	// FIXME: add Java buildpacks
+	builderInfoMap[buildpacks.PaketoBuilder].Others = append(builderInfoMap[buildpacks.PaketoBuilder].Others,
+		buildpacks.BuildpackInfo{
+			Name:      "Java",
+			Buildpack: "gcr.io/paketo-buildpacks/java",
+		})
+	builderInfoMap[buildpacks.HerokuBuilder].Others = append(builderInfoMap[buildpacks.HerokuBuilder].Others,
+		buildpacks.BuildpackInfo{
+			Name:      "Java",
+			Buildpack: "heroku/java",
+		})
+
+	var builders []*buildpacks.BuilderInfo
+	for _, v := range builderInfoMap {
+		builders = append(builders, v)
+	}
+
+	p.WriteResult(w, r, builders)
+}
+
+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
+}

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

@@ -0,0 +1,112 @@
+package project_integration
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/xanzy/go-gitlab"
+)
+
+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)
+	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
+
+	request := &types.GetContentsRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	owner, name, ok := commonutils.GetOwnerAndNameParams(p, w, r)
+
+	if !ok {
+		return
+	}
+
+	branch, ok := commonutils.GetBranchParam(p, w, r)
+
+	if !ok {
+		return
+	}
+
+	dir, err := url.QueryUnescape(request.Dir)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("malformed query param dir")))
+		return
+	}
+
+	dir = strings.TrimPrefix(dir, "./")
+
+	if len(dir) == 0 {
+		dir = "."
+	}
+
+	client, err := getGitlabClient(p.Repo(), user.ID, project.ID, gi, p.Config())
+
+	if err != nil {
+		if errors.Is(err, errUnauthorizedGitlabUser) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(errUnauthorizedGitlabUser, http.StatusUnauthorized))
+		}
+
+		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(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)
+}

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

@@ -0,0 +1,111 @@
+package project_integration
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/xanzy/go-gitlab"
+)
+
+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)
+	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
+
+	request := &types.GetProcfileRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	owner, name, ok := commonutils.GetOwnerAndNameParams(p, w, r)
+
+	if !ok {
+		return
+	}
+
+	branch, ok := commonutils.GetBranchParam(p, w, r)
+
+	if !ok {
+		return
+	}
+
+	path, err := url.QueryUnescape(request.Path)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("malformed query param path")))
+		return
+	}
+
+	client, err := getGitlabClient(p.Repo(), user.ID, project.ID, gi, p.Config())
+
+	if err != nil {
+		if errors.Is(err, errUnauthorizedGitlabUser) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(errUnauthorizedGitlabUser, http.StatusUnauthorized))
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	file, resp, err := client.RepositoryFiles.GetRawFile(fmt.Sprintf("%s/%s", owner, name),
+		strings.TrimPrefix(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)
+}

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

@@ -0,0 +1,117 @@
+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.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)
+}

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

@@ -0,0 +1,77 @@
+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/types"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/xanzy/go-gitlab"
+)
+
+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)
+	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
+
+	owner, name, ok := commonutils.GetOwnerAndNameParams(p, w, r)
+
+	if !ok {
+		return
+	}
+
+	client, err := getGitlabClient(p.Repo(), user.ID, project.ID, gi, p.Config())
+
+	if err != nil {
+		if errors.Is(err, errUnauthorizedGitlabUser) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(errUnauthorizedGitlabUser, http.StatusUnauthorized))
+		}
+
+		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)
+}

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

@@ -0,0 +1,119 @@
+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/types"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/xanzy/go-gitlab"
+	"gorm.io/gorm"
+)
+
+var errUnauthorizedGitlabUser = errors.New("unauthorized gitlab user")
+
+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)
+	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
+
+	client, err := getGitlabClient(p.Repo(), user.ID, project.ID, gi, p.Config())
+
+	if err != nil {
+		if errors.Is(err, errUnauthorizedGitlabUser) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(errUnauthorizedGitlabUser, http.StatusUnauthorized))
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	giProjects, resp, err := client.Projects.ListProjects(&gitlab.ListProjectsOptions{
+		Simple:     gitlab.Bool(true),
+		Membership: 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)
+}
+
+func getGitlabClient(
+	repo repository.Repository,
+	userID, projectID uint,
+	gi *ints.GitlabIntegration,
+	config *config.Config,
+) (*gitlab.Client, error) {
+	giAppOAuth, err := repo.GitlabAppOAuthIntegration().ReadGitlabAppOAuthIntegration(userID, projectID, gi.ID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, errUnauthorizedGitlabUser
+		}
+
+		return nil, err
+	}
+
+	oauthInt, err := repo.OAuthIntegration().ReadOAuthIntegration(projectID, giAppOAuth.OAuthIntegrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, errUnauthorizedGitlabUser
+		}
+
+		return nil, err
+	}
+
+	accessToken, _, err := oauth.GetAccessToken(oauthInt.SharedOAuthModel, commonutils.GetGitlabOAuthConf(
+		config, gi,
+	), oauth.MakeUpdateGitlabAppOAuthIntegrationFunction(projectID, giAppOAuth, repo))
+
+	if err != nil {
+		return nil, errUnauthorizedGitlabUser
+	}
+
+	client, err := gitlab.NewOAuthClient(accessToken, gitlab.WithBaseURL(gi.InstanceURL))
+
+	if err != nil {
+		return nil, err
+	}
+
+	return client, nil
+}

+ 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
 	}

+ 95 - 70
api/server/handlers/release/create.go

@@ -2,6 +2,7 @@ package release
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -19,6 +20,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"
@@ -46,7 +48,6 @@ func NewCreateReleaseHandler(
 
 func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 	operationID := oauth.CreateRandomState()
@@ -163,34 +164,43 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 	}
 
-	if request.GithubActionConfig != nil {
+	if request.BuildConfig != nil {
+		_, err = createBuildConfig(c.Config(), release, request.BuildConfig)
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if request.GitActionConfig != nil {
 		_, _, err := createGitAction(
 			c.Config(),
-			proj,
 			user.ID,
 			cluster.ProjectID,
 			cluster.ID,
-			request.GithubActionConfig,
+			request.GitActionConfig,
 			request.Name,
 			namespace,
 			release,
 		)
 
 		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
+			unwrappedErr := errors.Unwrap(err)
+
+			if unwrappedErr != nil {
+				if errors.Is(unwrappedErr, actions.ErrProtectedBranch) {
+					c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
+				} else if errors.Is(unwrappedErr, actions.ErrCreatePRForProtectedBranch) {
+					c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusPreconditionFailed))
+				}
+			} else {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
 		}
 	}
 
-	if request.BuildConfig != nil {
-		_, err = createBuildConfig(c.Config(), release, request.BuildConfig)
-	}
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	c.Config().AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
 		&analytics.ApplicationLaunchSuccessTrackOpts{
 			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
@@ -245,7 +255,6 @@ func createReleaseFromHelmRelease(
 
 func createGitAction(
 	config *config.Config,
-	project *models.Project,
 	userID, projectID, clusterID uint,
 	request *types.CreateGitActionConfigRequest,
 	name, namespace string,
@@ -287,63 +296,88 @@ func createGitAction(
 
 	// if this isn't a dry run, generate the token
 	if !isDryRun {
-		encoded, err = getToken(config, project, userID, projectID, clusterID, request)
+		encoded, err = getToken(config, userID, projectID, clusterID, request)
 
 		if err != nil {
 			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:                 isDryRun,
-	}
-
-	// 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],
+			GitBranch:        request.GitBranch,
+			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, gitErr = gaRunner.Setup()
+
+		if gaRunner.DryRun {
+			if gitErr != nil {
+				return nil, nil, gitErr
+			}
+
+			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 {
@@ -359,16 +393,11 @@ func createGitAction(
 		return nil, nil, err
 	}
 
-	if githubErr != nil {
-		return nil, nil, githubErr
-	}
-
-	return ga.ToGitActionConfigType(), workflowYAML, nil
+	return ga.ToGitActionConfigType(), workflowYAML, gitErr
 }
 
 func getToken(
 	config *config.Config,
-	proj *models.Project,
 	userID, projectID, clusterID uint,
 	request *types.CreateGitActionConfigRequest,
 ) (string, error) {
@@ -454,10 +483,6 @@ func getToken(
 		SecretKey:       hashedToken,
 	}
 
-	if !proj.APITokensEnabled {
-		return "", fmt.Errorf("api tokens are not enabled for this project")
-	}
-
 	apiToken, err = config.Repo.APIToken().CreateAPIToken(apiToken)
 
 	if err != 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
+					}
 				}
 			}
 		}

+ 0 - 2
api/server/handlers/release/get_gha_template.go

@@ -27,7 +27,6 @@ func NewGetGHATemplateHandler(
 
 func (c *GetGHATemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 
@@ -39,7 +38,6 @@ func (c *GetGHATemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 	_, workflowYAML, err := createGitAction(
 		c.Config(),
-		proj,
 		user.ID,
 		cluster.ProjectID,
 		cluster.ID,

+ 1 - 1
api/server/handlers/release/ugprade.go

@@ -195,7 +195,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 			gitAction := rel.GitActionConfig
 
-			if gitAction != nil && gitAction.ID != 0 {
+			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
 				gaRunner, err := getGARunner(
 					c.Config(),
 					user.ID,

+ 1 - 1
api/server/handlers/release/update_rollback.go

@@ -74,7 +74,7 @@ func (c *RollbackReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 			gitAction := rel.GitActionConfig
 
-			if gitAction != nil && gitAction.ID != 0 {
+			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
 				gaRunner, err := getGARunner(
 					c.Config(),
 					user.ID,

+ 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

@@ -75,5 +75,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, &router.Route{
+		Endpoint: gitlabEndpoint,
+		Handler:  gitlabHandler,
+		Router:   r,
+	})
+
 	return routes
 }

+ 241 - 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"
@@ -328,5 +330,244 @@ 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, &router.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, &router.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, &router.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: fmt.Sprintf("%s/gitlab/{%s}/repos", relPath, types.URLParamIntegrationID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitlabIntegrationScope,
+			},
+		},
+	)
+
+	listGitlabReposHandler := project_integration.NewListGitlabReposHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.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/{%s}/repos/{%s}/{%s}/branches",
+					relPath, types.URLParamIntegrationID, types.URLParamGitRepoOwner, types.URLParamGitRepoName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitlabIntegrationScope,
+			},
+		},
+	)
+
+	listGitlabRepoBranchesHandler := project_integration.NewListGitlabRepoBranchesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.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/{%s}/repos/{%s}/{%s}/{%s}/contents", relPath,
+					types.URLParamIntegrationID, types.URLParamGitRepoOwner,
+					types.URLParamGitRepoName, types.URLParamGitBranch),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitlabIntegrationScope,
+			},
+		},
+	)
+
+	getGitlabRepoContentsHandler := project_integration.NewGetGitlabRepoContentsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.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/{%s}/repos/{%s}/{%s}/{%s}/buildpack/detect", relPath,
+					types.URLParamIntegrationID, types.URLParamGitRepoOwner,
+					types.URLParamGitRepoName, types.URLParamGitBranch),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitlabIntegrationScope,
+			},
+		},
+	)
+
+	getGitlabRepoBuildpackHandler := project_integration.NewGetGitlabRepoBuildpackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.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/{%s}/repos/{%s}/{%s}/{%s}/procfile", relPath,
+					types.URLParamIntegrationID, types.URLParamGitRepoOwner,
+					types.URLParamGitRepoName, types.URLParamGitBranch),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitlabIntegrationScope,
+			},
+		},
+	)
+
+	getGitlabRepoProcfileHandler := project_integration.NewGetGitlabRepoProcfileHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getGitlabRepoProcfileEndpoint,
+		Handler:  getGitlabRepoProcfileHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

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

@@ -110,5 +110,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, &router.Route{
+		Endpoint: gitlabEndpoint,
+		Handler:  gitlabHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 5 - 0
api/server/router/router.go

@@ -217,6 +217,9 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 	// websocket middleware for upgrading requests
 	websocketMw := middleware.NewWebsocketMiddleware(config)
 
+	// gitlab integration middleware to handle gitlab integrations for a specific project
+	gitlabIntFactory := authz.NewGitlabIntegrationScopedFactory(config)
+
 	for _, route := range routes {
 		atomicGroup := route.Router.Group(nil)
 
@@ -252,6 +255,8 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 				atomicGroup.Use(operationFactory.Middleware)
 			case types.ReleaseScope:
 				atomicGroup.Use(releaseFactory.Middleware)
+			case types.GitlabIntegrationScope:
+				atomicGroup.Use(gitlabIntFactory.Middleware)
 			}
 		}
 

+ 43 - 0
api/server/shared/commonutils/git_utils.go

@@ -4,9 +4,14 @@ import (
 	"context"
 	"errors"
 	"net/http"
+	"net/url"
 	"time"
 
 	"github.com/google/go-github/v41/github"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
 )
 
 var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
@@ -40,3 +45,41 @@ func GetLatestWorkflowRun(client *github.Client, owner, repo, filename, branch s
 
 	return workflowRuns.WorkflowRuns[0], nil
 }
+
+// GetOwnerAndNameParams gets the owner and name ref for the git repo
+func GetOwnerAndNameParams(c handlers.PorterHandler, w http.ResponseWriter, r *http.Request) (string, string, bool) {
+	owner, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoOwner)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return "", "", false
+	}
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return "", "", false
+	}
+
+	return owner, name, true
+}
+
+// GetBranchParam gets the unencoded branch for the git repo
+func GetBranchParam(c handlers.PorterHandler, w http.ResponseWriter, r *http.Request) (string, bool) {
+	branch, reqErr := requestutils.GetURLParamString(r, types.URLParamGitBranch)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return "", false
+	}
+
+	branch, err := url.QueryUnescape(branch)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return "", false
+	}
+
+	return branch, true
+}

+ 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"},
+	}
+}

+ 3 - 0
api/server/shared/config/env/envconfs.go

@@ -99,6 +99,9 @@ type ServerConf struct {
 
 	// Disable filtering for project creation
 	DisableAllowlist bool `env:"DISABLE_ALLOWLIST,default=false"`
+
+	// Enable gitlab integration
+	EnableGitlab bool `env:"ENABLE_GITLAB,default=false"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 2 - 0
api/server/shared/config/metadata.go

@@ -14,6 +14,7 @@ type Metadata struct {
 	Email              bool   `json:"email"`
 	Analytics          bool   `json:"analytics"`
 	Version            string `json:"version"`
+	Gitlab             bool   `json:"gitlab"`
 }
 
 func MetadataFromConf(sc *env.ServerConf, version string) *Metadata {
@@ -27,6 +28,7 @@ func MetadataFromConf(sc *env.ServerConf, version string) *Metadata {
 		Email:              sc.SendgridAPIKey != "",
 		Analytics:          sc.SegmentClientKey != "",
 		Version:            version,
+		Gitlab:             sc.EnableGitlab,
 	}
 }
 

+ 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"`
 }

+ 13 - 12
api/types/policy.go

@@ -5,18 +5,19 @@ import "time"
 type PermissionScope string
 
 const (
-	UserScope            PermissionScope = "user"
-	ProjectScope         PermissionScope = "project"
-	ClusterScope         PermissionScope = "cluster"
-	RegistryScope        PermissionScope = "registry"
-	InviteScope          PermissionScope = "invite"
-	HelmRepoScope        PermissionScope = "helm_repo"
-	InfraScope           PermissionScope = "infra"
-	OperationScope       PermissionScope = "operation"
-	GitInstallationScope PermissionScope = "git_installation"
-	NamespaceScope       PermissionScope = "namespace"
-	SettingsScope        PermissionScope = "settings"
-	ReleaseScope         PermissionScope = "release"
+	UserScope              PermissionScope = "user"
+	ProjectScope           PermissionScope = "project"
+	ClusterScope           PermissionScope = "cluster"
+	RegistryScope          PermissionScope = "registry"
+	InviteScope            PermissionScope = "invite"
+	HelmRepoScope          PermissionScope = "helm_repo"
+	InfraScope             PermissionScope = "infra"
+	OperationScope         PermissionScope = "operation"
+	GitInstallationScope   PermissionScope = "git_installation"
+	NamespaceScope         PermissionScope = "namespace"
+	SettingsScope          PermissionScope = "settings"
+	ReleaseScope           PermissionScope = "release"
+	GitlabIntegrationScope PermissionScope = "gitlab_integration"
 )
 
 type NameOrUInt struct {

+ 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

@@ -47,11 +47,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 - 0
api/types/request.go

@@ -45,6 +45,7 @@ const (
 	URLParamReleaseName       URLParam = "name"
 	URLParamReleaseVersion    URLParam = "version"
 	URLParamWildcard          URLParam = "*"
+	URLParamIntegrationID     URLParam = "integration_id"
 )
 
 type Path struct {

+ 15 - 0
cli/cmd/config.go

@@ -154,6 +154,20 @@ var configSetHostCmd = &cobra.Command{
 	},
 }
 
+var configSetKubeconfigCmd = &cobra.Command{
+	Use:   "set-kubeconfig [kubeconfig-path]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the path to kubeconfig in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := cliConf.SetKubeconfig(args[0])
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
 func init() {
 	rootCmd.AddCommand(configCmd)
 
@@ -162,6 +176,7 @@ func init() {
 	configCmd.AddCommand(configSetHostCmd)
 	configCmd.AddCommand(configSetRegistryCmd)
 	configCmd.AddCommand(configSetHelmRepoCmd)
+	configCmd.AddCommand(configSetKubeconfigCmd)
 }
 
 func printConfig() error {

+ 41 - 2
cli/cmd/config/config.go

@@ -1,6 +1,8 @@
 package config
 
 import (
+	"errors"
+	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -32,8 +34,9 @@ type CLIConfig struct {
 
 	Token string `yaml:"token"`
 
-	Registry uint `yaml:"registry"`
-	HelmRepo uint `yaml:"helm_repo"`
+	Registry   uint   `yaml:"registry"`
+	HelmRepo   uint   `yaml:"helm_repo"`
+	Kubeconfig string `yaml:"kubeconfig"`
 }
 
 // InitAndLoadConfig populates the config object with the following precedence rules:
@@ -209,6 +212,12 @@ func (c *CLIConfig) SetHost(host string) error {
 }
 
 func (c *CLIConfig) SetProject(projectID uint) error {
+	if config.Kubeconfig != "" || viper.IsSet("kubeconfig") {
+		viper.Set("kubeconfig", "")
+		color.New(color.FgBlue).Println("Removing local kubeconfig")
+		config.Kubeconfig = ""
+	}
+
 	viper.Set("project", projectID)
 	color.New(color.FgGreen).Printf("Set the current project as %d\n", projectID)
 	err := viper.WriteConfig()
@@ -223,6 +232,12 @@ func (c *CLIConfig) SetProject(projectID uint) error {
 }
 
 func (c *CLIConfig) SetCluster(clusterID uint) error {
+	if config.Kubeconfig != "" || viper.IsSet("kubeconfig") {
+		viper.Set("kubeconfig", "")
+		color.New(color.FgBlue).Println("Removing local kubeconfig")
+		config.Kubeconfig = ""
+	}
+
 	viper.Set("cluster", clusterID)
 	color.New(color.FgGreen).Printf("Set the current cluster as %d\n", clusterID)
 	err := viper.WriteConfig()
@@ -276,3 +291,27 @@ func (c *CLIConfig) SetHelmRepo(helmRepoID uint) error {
 
 	return nil
 }
+
+func (c *CLIConfig) SetKubeconfig(kubeconfig string) error {
+	path, err := filepath.Abs(kubeconfig)
+
+	if err != nil {
+		return err
+	}
+
+	if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
+		return fmt.Errorf("%s does not exist", path)
+	}
+
+	viper.Set("kubeconfig", path)
+	color.New(color.FgGreen).Printf("Set the path to kubeconfig as %s\n", path)
+	err = viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Kubeconfig = kubeconfig
+
+	return nil
+}

+ 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,

+ 1 - 1
cli/cmd/portforward.go

@@ -145,7 +145,7 @@ func portForward(user *types.GetAuthenticatedUserResponse, client *api.Client, a
 		pod = pods[0]
 	}
 
-	kubeResp, err := client.GetKubeconfig(context.Background(), cliConf.Project, cliConf.Cluster)
+	kubeResp, err := client.GetKubeconfig(context.Background(), cliConf.Project, cliConf.Cluster, cliConf.Kubeconfig)
 
 	if err != nil {
 		return err

+ 1 - 1
cli/cmd/run.go

@@ -264,7 +264,7 @@ func (p *PorterRunSharedConfig) setSharedConfig() error {
 	pID := cliConf.Project
 	cID := cliConf.Cluster
 
-	kubeResp, err := p.Client.GetKubeconfig(context.TODO(), pID, cID)
+	kubeResp, err := p.Client.GetKubeconfig(context.Background(), pID, cID, cliConf.Kubeconfig)
 
 	if err != nil {
 		return err

+ 24 - 0
dashboard/package-lock.json

@@ -1670,6 +1670,30 @@
         "@types/node": "*"
       }
     },
+    "@types/color": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/@types/color/-/color-3.0.3.tgz",
+      "integrity": "sha512-X//qzJ3d3Zj82J9sC/C18ZY5f43utPbAJ6PhYt/M7uG6etcF6MRpKdN880KBy43B0BMzSfeT96MzrsNjFI3GbA==",
+      "dev": true,
+      "requires": {
+        "@types/color-convert": "*"
+      }
+    },
+    "@types/color-convert": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.0.tgz",
+      "integrity": "sha512-m7GG7IKKGuJUXvkZ1qqG3ChccdIM/qBBo913z+Xft0nKCX4hAU/IxKwZBU4cpRZ7GS5kV4vOblUkILtSShCPXQ==",
+      "dev": true,
+      "requires": {
+        "@types/color-name": "*"
+      }
+    },
+    "@types/color-name": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
+      "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
+      "dev": true
+    },
     "@types/connect": {
       "version": "3.4.35",
       "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",

+ 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`

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

@@ -32,6 +32,7 @@ const defaultActionConfig: ActionConfigType = {
   image_repo_uri: "",
   git_branch: "",
   git_repo_id: 0,
+  kind: "github",
 };
 
 const ActionConfEditor: React.FC<Props> = (props) => {
@@ -160,12 +161,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`

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

@@ -24,28 +24,56 @@ 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) {
+      return () => {};
+    }
+
+    if (actionConfig?.kind === "github") {
+      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 = () => {

+ 29 - 11
dashboard/src/components/repo-selector/BuildpackSelection.tsx

@@ -70,22 +70,40 @@ export const BuildpackSelection: React.FC<{
     }
   }, [selectedBuilder, selectedStack, selectedBuildpacks]);
 
-  useEffect(() => {
-    api
-      .detectBuildpack<DetectBuildpackResponse>(
+  const detectBuildpack = () => {
+    if (actionConfig.kind === "gitlab") {
+      return api.detectGitlabBuildpack<DetectBuildpackResponse>(
         "<token>",
-        {
-          dir: folderPath || ".",
-        },
+        { dir: folderPath || "." },
         {
           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],
+          integration_id: actionConfig.gitlab_integration_id,
+
+          repo_owner: actionConfig.git_repo.split("/")[0],
+          repo_name: actionConfig.git_repo.split("/")[1],
           branch: branch,
         }
-      )
+      );
+    }
+
+    return api.detectBuildpack<DetectBuildpackResponse>(
+      "<token>",
+      {
+        dir: folderPath || ".",
+      },
+      {
+        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],
+        branch: branch,
+      }
+    );
+  };
+
+  useEffect(() => {
+    detectBuildpack()
       // getMockData()
       .then(({ data }) => {
         const builders = data;

+ 105 - 38
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -63,15 +63,92 @@ export default class ContentsList extends Component<PropsType, StateType> {
     this.setState({ currentDir: x }, () => this.updateContents());
   };
 
-  updateContents = () => {
+  fetchContents = () => {
+    let { currentProject } = this.context;
+    const { actionConfig, branch } = this.props;
+
+    if (actionConfig.kind === "gitlab") {
+      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,
+          }
+        )
+        .then((res) => {
+          const { data } = res;
+
+          return {
+            data: data.map((x: FileType) => ({
+              ...x,
+              type: x.type === "tree" ? "dir" : "file",
+            })),
+          };
+        });
+    }
+    return api.getBranchContents(
+      "<token>",
+      { dir: this.state.currentDir || "./" },
+      {
+        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],
+        branch: branch,
+      }
+    );
+  };
+
+  detectBuildpacks = () => {
     let { currentProject } = this.context;
     let { actionConfig, branch } = this.props;
 
-    // Get branch contents
-    api
-      .getBranchContents(
+    if (actionConfig.kind === "github") {
+      return api.detectBuildpack(
+        "<token>",
+        {
+          dir: this.state.currentDir || ".",
+        },
+        {
+          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],
+          branch: branch,
+        }
+      );
+    }
+
+    return api.detectGitlabBuildpack(
+      "<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,
+      }
+    );
+  };
+
+  fetchProcfileContent = (procfilePath: string) => {
+    let { currentProject } = this.context;
+    let { actionConfig, branch } = this.props;
+    if (actionConfig.kind === "github") {
+      return api.getProcfileContents(
         "<token>",
-        { dir: this.state.currentDir || "./" },
+        {
+          path: procfilePath,
+        },
         {
           project_id: currentProject.id,
           git_repo_id: actionConfig.git_repo_id,
@@ -80,7 +157,25 @@ export default class ContentsList extends Component<PropsType, StateType> {
           name: actionConfig.git_repo.split("/")[1],
           branch: branch,
         }
-      )
+      );
+    }
+
+    return api.getGitlabProcfileContents(
+      "<token>",
+      { path: procfilePath },
+      {
+        project_id: currentProject.id,
+        integration_id: actionConfig.gitlab_integration_id,
+        owner: actionConfig.git_repo.split("/")[0],
+        name: actionConfig.git_repo.split("/")[1],
+        branch: branch,
+      }
+    );
+  };
+
+  updateContents = () => {
+    // Get branch contents
+    this.fetchContents()
       .then((res) => {
         let files = [] as FileType[];
         let folders = [] as FileType[];
@@ -104,21 +199,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
         this.setState({ loading: false, error: true });
       });
 
-    api
-      .detectBuildpack(
-        "<token>",
-        {
-          dir: this.state.currentDir || ".",
-        },
-        {
-          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],
-          branch: branch,
-        }
-      )
+    this.detectBuildpacks()
       .then(({ data }) => {
         this.setState({
           autoBuildpack: data,
@@ -136,23 +217,9 @@ export default class ContentsList extends Component<PropsType, StateType> {
     let ppath =
       this.props.procfilePath ||
       `${this.state.currentDir ? this.state.currentDir : "."}/Procfile`;
-    api
-      .getProcfileContents(
-        "<token>",
-        {
-          path: ppath,
-        },
-        {
-          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],
-          branch: branch,
-        }
-      )
-      .then((res) => {
-        this.setState({ processes: res.data });
+    this.fetchProcfileContent(ppath)
+      .then(({ data }) => {
+        this.setState({ processes: data });
       })
       .catch((err) => {
         console.log(err);

+ 302 - 91
dashboard/src/components/repo-selector/RepoList.tsx

@@ -1,4 +1,4 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useEffect, useRef, useState } from "react";
 import styled from "styled-components";
 import github from "assets/github.png";
 
@@ -8,12 +8,8 @@ import { Context } from "shared/Context";
 
 import Loading from "../Loading";
 import SearchBar from "../SearchBar";
-
-interface GithubAppAccessData {
-  has_access: boolean;
-  username?: string;
-  accounts?: string[];
-}
+import DynamicLink from "components/DynamicLink";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
 
 type Props = {
   actionConfig: ActionConfigType | null;
@@ -30,87 +26,103 @@ 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 [hasProviders, setHasProviders] = useState(true);
+  const { currentProject, setCurrentError } = useContext(Context);
 
-  const loadData = async () => {
-    try {
-      const { data } = await api.getGithubAccounts("<token>", {}, {});
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getGitProviders("<token>", {}, { project_id: currentProject.id })
+      .then((res) => {
+        const data = res.data;
+        if (!isSubscribed) {
+          return;
+        }
+
+        if (!Array.isArray(data)) {
+          setHasProviders(false);
+          return;
+        }
+
+        setProviders(data);
+        setCurrentProvider(data[0]);
+      })
+      .catch((err) => {
+        setHasProviders(false);
+        setCurrentError(err);
+      });
 
-      setAccessData(data);
-      setAccessLoading(false);
-    } catch (error) {
-      setAccessError(true);
-      setAccessLoading(false);
-    }
+    return () => {
+      isSubscribed = false;
+    };
+  }, []);
 
-    let ids: number[] = [];
+  const loadGithubRepos = async (repoId: number) => {
+    try {
+      const res = await api.getGitRepoList<
+        { FullName: string; Kind: "github" }[]
+      >("<token>", {}, { project_id: currentProject.id, git_repo_id: repoId });
 
-    if (!userId && userId !== 0) {
-      ids = await api
-        .getGitRepos("token", {}, { project_id: currentProject.id })
-        .then((res) => res.data);
-    } else {
-      setRepoLoading(false);
-      setRepoError(true);
-      return;
-    }
+      const repos = res.data.map((repo) => ({ ...repo, GHRepoID: repoId }));
+      return repos;
+    } catch (error) {}
+  };
 
-    const repoListPromises = ids.map((id) =>
-      api.getGitRepoList(
+  const loadGitlabRepos = async (integrationId: number) => {
+    try {
+      const res = await api.getGitlabRepos<string[]>(
         "<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 : []
+        { 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]);
 
   // clear out actionConfig and SelectedRepository if new search is performed
   useEffect(() => {
@@ -119,20 +131,38 @@ const RepoList: React.FC<Props> = ({
       image_repo_uri: null,
       git_branch: null,
       git_repo_id: 0,
+      kind: "github",
     });
     setSelectedRepo(null);
   }, [searchFilter]);
 
   const setRepo = (x: RepoType) => {
-    let updatedConfig = actionConfig;
-    updatedConfig.git_repo = x.FullName;
-    updatedConfig.git_repo_id = x.GHRepoID;
+    let repoConfig: any;
+    if (x.Kind === "gitlab") {
+      repoConfig = {
+        kind: "gitlab",
+        git_repo: x.FullName,
+        gitlab_integration_id: x.GitIntegrationId,
+      };
+    } else {
+      repoConfig = {
+        kind: "github",
+        git_repo: x.FullName,
+        git_repo_id: x.GHRepoID,
+      };
+    }
+
+    const updatedConfig = {
+      ...actionConfig,
+      ...repoConfig,
+    };
+
     setActionConfig(updatedConfig);
     setSelectedRepo(x.FullName);
   };
 
   const renderRepoList = () => {
-    if (repoLoading || accessLoading) {
+    if (repoLoading) {
       return (
         <LoadingWrapper>
           <Loading />
@@ -140,26 +170,29 @@ 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.
+            GitLab could not be reached.
+            <A
+              to={`${window.location.origin}/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
-            <A href={"/api/integrations/github-app/install"}>
+            <A
+              to={`${window.location.origin}/api/integrations/github-app/install`}
+            >
               Install Porter in more repositories
             </A>
-            .
+            or select another git provider.
           </LoadingWrapper>
         );
       }
@@ -191,7 +224,11 @@ const RepoList: React.FC<Props> = ({
             readOnly={readOnly}
             disabled={shouldDisable}
           >
-            <img src={github} alt={"github icon"} />
+            {repo.Kind === "github" ? (
+              <img src={github} alt={"github icon"} />
+            ) : (
+              <i className="devicon-gitlab-plain colored" />
+            )}
             {repo.FullName}
             {shouldDisable && ` - This repo was already added`}
           </RepoName>
@@ -206,11 +243,19 @@ const RepoList: React.FC<Props> = ({
     } else {
       return (
         <>
-          <SearchBar
-            setSearchFilter={setSearchFilter}
-            disabled={repoError || repoLoading || accessError || accessLoading}
-            prompt={"Search repos..."}
-          />
+          <div style={{ display: "flex", marginBottom: "10px" }}>
+            <ProviderSelector
+              values={providers}
+              currentValue={currentProvider}
+              onChange={setCurrentProvider}
+            />
+            <SearchBar
+              setSearchFilter={setSearchFilter}
+              disabled={repoError || repoLoading}
+              prompt={"Search repos . . ."}
+              fullWidth
+            />
+          </div>
           <RepoListWrapper>
             <ExpandedWrapper>{renderRepoList()}</ExpandedWrapper>
           </RepoListWrapper>
@@ -219,11 +264,175 @@ const RepoList: React.FC<Props> = ({
     }
   };
 
+  if (!hasProviders) {
+    return (
+      <>
+        <RepoListWrapper>
+          <ExpandedWrapper>
+            <LoadingWrapper>
+              <div
+                style={{
+                  display: "flex",
+                  flexDirection: "column",
+                  alignItems: "center",
+                  justifyContent: "center",
+                }}
+              >
+                <div>A connected Git provider wasn't found.</div>
+                <div>
+                  You can
+                  <A
+                    to={`${window.location.origin}/api/integrations/github-app/install`}
+                  >
+                    connect a GitHub repo
+                  </A>
+                  or
+                  <A to={"/integrations"}>add a GitLab instance</A>
+                </div>
+              </div>
+            </LoadingWrapper>
+          </ExpandedWrapper>
+        </RepoListWrapper>
+      </>
+    );
+  }
+
   return <>{renderExpanded()}</>;
 };
 
 export default RepoList;
 
+const ProviderSelector = (props: {
+  values: any[];
+  currentValue: any;
+  onChange: (provider: any) => void;
+}) => {
+  const wrapperRef = useRef();
+  const { values, currentValue, onChange } = props;
+  const [isOpen, setIsOpen] = useState(false);
+  const icon = `devicon-${currentValue?.provider}-plain colored`;
+  useOutsideAlerter(wrapperRef, () => {
+    setIsOpen(false);
+  });
+
+  if (!currentValue) {
+    return (
+      <ProviderSelectorStyles.Wrapper>
+        <Loading />
+      </ProviderSelectorStyles.Wrapper>
+    );
+  }
+
+  return (
+    <>
+      <ProviderSelectorStyles.Wrapper ref={wrapperRef} isOpen={isOpen}>
+        <ProviderSelectorStyles.Icon className={icon} />
+
+        <ProviderSelectorStyles.Button
+          onClick={() => setIsOpen((prev) => !prev)}
+        >
+          {currentValue?.name || currentValue?.instance_url}
+        </ProviderSelectorStyles.Button>
+        <i className="material-icons">arrow_drop_down</i>
+        {isOpen ? (
+          <>
+            <ProviderSelectorStyles.OptionWrapper>
+              {values.map((provider) => {
+                return (
+                  <ProviderSelectorStyles.Option
+                    onClick={() => {
+                      setIsOpen(false);
+                      onChange(provider);
+                    }}
+                  >
+                    <ProviderSelectorStyles.Icon
+                      className={`devicon-${provider?.provider}-plain colored`}
+                    />
+                    <ProviderSelectorStyles.Text>
+                      {provider?.name || provider?.instance_url}
+                    </ProviderSelectorStyles.Text>
+                  </ProviderSelectorStyles.Option>
+                );
+              })}
+            </ProviderSelectorStyles.OptionWrapper>
+          </>
+        ) : null}
+      </ProviderSelectorStyles.Wrapper>
+    </>
+  );
+};
+
+const ProviderSelectorStyles = {
+  Wrapper: styled.div<{ isOpen?: boolean }>`
+    position: relative;
+    margin-bottom: 10px;
+    height: 40px;
+    display: flex;
+    min-width: 50%;
+    cursor: pointer;
+    margin-right: 10px;
+    margin-left: 2px;
+    align-items: center;
+
+    > i {
+      margin-left: -26px;
+      margin-right: 10px;
+      z-index: 0;
+      transform: ${(props) => (props.isOpen ? "rotate(180deg)" : "")};
+    }
+  `,
+  Button: styled.div`
+    height: 100%;
+    font-weight: bold;
+    font-size: 14px;
+    border-bottom: 0;
+    z-index: 999;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    padding: 6px 15px;
+    padding-left: 40px;
+    padding-right: 28px;
+    border-bottom: 2px solid #ffffff;
+    padding-top: 11px;
+  `,
+  OptionWrapper: styled.div`
+    top: 40px;
+    position: absolute;
+    background: #37393f;
+    border-radius: 3px;
+    width: calc(100% - 4px);
+    box-shadow: 0 8px 20px 0px #00000088;
+  `,
+  Option: styled.div`
+    display: flex;
+    align-items: center;
+
+    :hover {
+      background-color: #ffffff22;
+    }
+  `,
+  Icon: styled.span`
+    font-size: 24px;
+    margin-left: 9px;
+    margin-right: -29px;
+    color: white;
+  `,
+  Text: styled.div`
+    font-weight: bold;
+    font-size: 14px;
+    margin-left: 40px;
+    height: 45px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    padding: 8px 10px;
+    width: 100%;
+    padding-top: 14px;
+    padding-left: 0;
+  `,
+};
+
 const RepoListWrapper = styled.div`
   border: 1px solid #ffffff55;
   border-radius: 3px;
@@ -314,9 +523,11 @@ const ExpandedWrapperAlt = styled(ExpandedWrapper)`
   overflow-y: auto;
 `;
 
-const A = styled.a`
+const A = styled(DynamicLink)`
   color: #8590ff;
   text-decoration: underline;
   margin-left: 5px;
+  margin-right: 5px;
+
   cursor: pointer;
 `;

+ 1 - 0
dashboard/src/main/Main.tsx

@@ -67,6 +67,7 @@ export default class Main extends Component<PropsType, StateType> {
       .then((res) => {
         this.context.setEdition(res.data?.version);
         this.setState({ local: !res.data?.provisioner });
+        this.context.setEnableGitlab(res.data?.gitlab ? true : false);
       })
       .catch((err) => console.log(err));
   }

+ 2 - 2
dashboard/src/main/home/WelcomeForm.tsx

@@ -46,10 +46,10 @@ const WelcomeForm: React.FunctionComponent<Props> = ({}) => {
           <Title>Welcome to Porter</Title>
           <Subtitle>Just two things before getting started.</Subtitle>
           <SubtitleAlt>
-            <Num>1</Num> What is your company name? *
+            <Num>1</Num> What is your company website? *
           </SubtitleAlt>
           <Input
-            placeholder="ex: Acme"
+            placeholder="ex: https://porter.run"
             value={company}
             onChange={(e: any) => setCompany(e.target.value)}
           />

+ 49 - 17
dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx

@@ -231,6 +231,21 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
     }
   };
 
+  const getActionConfig = () => {
+    const actionConf = chart.git_action_config;
+    if (actionConf && actionConf.gitlab_integration_id) {
+      return {
+        kind: "gitlab",
+        ...actionConf,
+      } as FullActionConfigType;
+    }
+
+    return {
+      kind: "github",
+      ...actionConf,
+    } as FullActionConfigType;
+  };
+
   return (
     <Wrapper>
       {isPreviousVersion ? (
@@ -286,7 +301,7 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
             <Heading>Buildpack Settings</Heading>
             <BuildpackConfigSection
               currentChart={chart}
-              actionConfig={chart.git_action_config}
+              actionConfig={getActionConfig()}
               onChange={(buildConfig) => setBuildConfig(buildConfig)}
             />
           </>
@@ -382,28 +397,45 @@ const BuildpackConfigSection: React.FC<{
     );
   };
 
+  const detectBuildpack = () => {
+    if (actionConfig.kind === "gitlab") {
+      return api.detectGitlabBuildpack<DetectBuildpackResponse>(
+        "<token>",
+        { dir: actionConfig.folder_path || "." },
+        {
+          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: actionConfig.git_branch,
+        }
+      );
+    }
+
+    return api.detectBuildpack<DetectBuildpackResponse>(
+      "<token>",
+      {
+        dir: actionConfig.folder_path || ".",
+      },
+      {
+        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],
+        branch: actionConfig.git_branch,
+      }
+    );
+  };
+
   useEffect(() => {
     const currentBuildConfig = currentChart?.build_config;
 
     if (!currentBuildConfig) {
       return;
     }
-
-    api
-      .detectBuildpack<DetectBuildpackResponse>(
-        "<token>",
-        {
-          dir: actionConfig.folder_path || ".",
-        },
-        {
-          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],
-          branch: actionConfig.git_branch,
-        }
-      )
+    detectBuildpack()
       .then(({ data }) => {
         const builders = data;
 

+ 48 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx

@@ -25,12 +25,58 @@ const readableDate = (s: string) => {
   return `${time} on ${date}`;
 };
 
-const renderStatus = (job: any, time: string) => {
+const getLatestPod = (pods: any[]) => {
+  if (!Array.isArray(pods)) {
+    return undefined;
+  }
+
+  return [...pods]
+    .sort((a: any, b: any) => {
+      if (!a?.metadata?.creationTimestamp) {
+        return 1;
+      }
+
+      if (!b?.metadata?.creationTimestamp) {
+        return -1;
+      }
+
+      return (
+        new Date(b?.metadata?.creationTimestamp).getTime() -
+        new Date(a?.metadata?.creationTimestamp).getTime()
+      );
+    })
+    .shift();
+};
+
+const renderStatus = (job: any, pods: any[], time: string) => {
   if (job.status?.succeeded >= 1) {
     return <Status color="#38a88a">Succeeded {time}</Status>;
   }
 
   if (job.status?.failed >= 1) {
+    const appPod = getLatestPod(pods);
+
+    if (appPod) {
+      const appContainerStatus = appPod?.status?.containerStatuses?.find(
+        (container: any) =>
+          container?.state?.terminated?.reason !== "Completed" &&
+          !container?.state?.running
+      );
+
+      if (appContainerStatus) {
+        const reason = appContainerStatus.state.terminated.reason;
+        const exitCode = appContainerStatus.state.terminated.exitCode;
+        const finishTime = appContainerStatus.state.terminated.finishedAt;
+
+        return (
+          <Status color="#cc3d42">
+            Failed at {time ? time : readableDate(finishTime)} - Reason:{" "}
+            {reason} - Exit Code: {exitCode}
+          </Status>
+        );
+      }
+    }
+
     return (
       <Status color="#cc3d42">
         Failed {time}
@@ -163,6 +209,7 @@ const ExpandedJobRun = ({
           <LastDeployed>
             {renderStatus(
               run,
+              pods,
               run.status.completionTime
                 ? readableDate(run.status.completionTime)
                 : ""

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

@@ -0,0 +1,161 @@
+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) => {
+  return (
+    <>
+      <StyledIntegrationList>
+        {props.gitlabData?.length > 0 ? (
+          props.gitlabData.map((inst, idx) => {
+            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={() => {
+                        window.open(inst.instance_url, "_blank");
+                      }}
+                    >
+                      launch
+                    </i>
+                  </MaterialIconTray>
+                </MainRow>
+              </Integration>
+            );
+          })
+        ) : (
+          <Placeholder>No GitLab instances found</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;

+ 15 - 6
dashboard/src/main/home/integrations/Integrations.tsx

@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useContext, useMemo } from "react";
 import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
 
 import { integrationList } from "shared/common";
@@ -9,12 +9,21 @@ import CreateIntegrationForm from "./create-integration/CreateIntegrationForm";
 import IntegrationCategories from "./IntegrationCategories";
 import IntegrationList from "./IntegrationList";
 import TitleSection from "components/TitleSection";
+import { Context } from "shared/Context";
 
 type PropsType = RouteComponentProps;
 
-const IntegrationCategoryStrings = ["registry", "slack"]; /*"kubernetes",*/
-
 const Integrations: React.FC<PropsType> = (props) => {
+  const { enableGitlab } = useContext(Context);
+
+  const IntegrationCategoryStrings = useMemo(() => {
+    if (!enableGitlab) {
+      return ["registry", "slack"];
+    }
+
+    return ["registry", "slack", "gitlab"];
+  }, [enableGitlab]);
+
   return (
     <StyledIntegrations>
       <Switch>
@@ -69,7 +78,7 @@ const Integrations: React.FC<PropsType> = (props) => {
 
             <IntegrationList
               currentCategory={""}
-              integrations={["registry", "slack"]}
+              integrations={IntegrationCategoryStrings}
               setCurrent={(x) =>
                 pushFiltered(props, `/integrations/${x}`, ["project_id"])
               }
@@ -106,8 +115,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;
     }

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

@@ -0,0 +1,147 @@
+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 Application 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}
+          makeFlush={true}
+          text="Save Gitlab Settings"
+          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;
+`;

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

@@ -34,6 +34,7 @@ const defaultActionConfig: ActionConfigType = {
   image_repo_uri: "",
   git_branch: "",
   git_repo_id: 0,
+  kind: "github",
 };
 
 const LaunchFlow: React.FC<PropsType> = (props) => {
@@ -77,16 +78,31 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       imageRepoUri = selectedRegistry?.url;
     }
 
-    return {
-      git_repo: actionConfig.git_repo,
-      git_branch: branch,
-      registry_id: selectedRegistry?.id,
-      dockerfile_path: dockerfilePath,
-      folder_path: folderPath,
-      image_repo_uri: imageRepoUri,
-      git_repo_id: actionConfig.git_repo_id,
-      should_create_workflow: shouldCreateWorkflow,
-    };
+    if (actionConfig.kind === "github") {
+      return {
+        kind: "github",
+        git_repo: actionConfig.git_repo,
+        git_branch: branch,
+        registry_id: selectedRegistry?.id,
+        dockerfile_path: dockerfilePath,
+        folder_path: folderPath,
+        image_repo_uri: imageRepoUri,
+        git_repo_id: actionConfig.git_repo_id,
+        should_create_workflow: shouldCreateWorkflow,
+      };
+    } else {
+      return {
+        kind: "gitlab",
+        git_repo: actionConfig.git_repo,
+        git_branch: branch,
+        registry_id: selectedRegistry?.id,
+        dockerfile_path: dockerfilePath,
+        folder_path: folderPath,
+        image_repo_uri: imageRepoUri,
+        gitlab_integration_id: actionConfig.gitlab_integration_id,
+        should_create_workflow: shouldCreateWorkflow,
+      };
+    }
   };
 
   const handleSubmitAddon = async (wildcard?: any) => {
@@ -312,7 +328,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),
         },

+ 12 - 8
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -156,7 +156,10 @@ class SettingsPage extends Component<PropsType, StateType> {
               // console.log(val);
               onSubmit(val);
             }}
-            hideBottomSpacer={!!this.props.fullActionConfig?.git_repo}
+            hideBottomSpacer={
+              !!this.props.fullActionConfig?.git_repo &&
+              this.props.fullActionConfig?.kind === "github"
+            }
           />
         </FadeWrapper>
       );
@@ -291,13 +294,14 @@ class SettingsPage extends Component<PropsType, StateType> {
             />
           </ClusterSection>
           {this.renderSettingsRegion()}
-          {this.props.fullActionConfig?.git_repo && (
-            <WorkflowPage
-              fullActionConfig={this.props.fullActionConfig}
-              name={this.props.templateName}
-              namespace={this.props.selectedNamespace}
-            />
-          )}
+          {this.props.fullActionConfig?.git_repo &&
+            this.props.fullActionConfig?.kind === "github" && (
+              <WorkflowPage
+                fullActionConfig={this.props.fullActionConfig}
+                name={this.props.templateName}
+                namespace={this.props.selectedNamespace}
+              />
+            )}
         </StyledSettingsPage>
       </PaddingWrapper>
     );

+ 2 - 3
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -65,7 +65,7 @@ class SourcePage extends Component<PropsType, StateType> {
     if (sourceType === "") {
       return (
         <BlockList>
-          {capabilities.github && (
+          {capabilities.github || capabilities.gitlab ? (
             <Block onClick={() => setSourceType("repo")}>
               <BlockIcon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
               <BlockTitle>Git Repository</BlockTitle>
@@ -73,7 +73,7 @@ class SourcePage extends Component<PropsType, StateType> {
                 Deploy using source from a Git repo.
               </BlockDescription>
             </Block>
-          )}
+          ) : null}
           <Block onClick={() => setSourceType("registry")}>
             <BlockIcon src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png" />
             <BlockTitle>Docker Registry</BlockTitle>
@@ -474,6 +474,5 @@ const StyledSourceBox = styled.div`
   border-radius: 5px;
   font-size: 13px;
   margin-top: 6px;
-  overflow: auto;
   margin-bottom: 25px;
 `;

+ 27 - 2
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -43,7 +43,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
       !this.state.tabOptions.find((t) => t.value === "billing")
     ) {
       const tabOptions = this.state.tabOptions;
-      tabOptions.splice(1, 0, { value: "billing", label: "Billing" });
+      // tabOptions.splice(1, 0, { value: "billing", label: "Billing" });
       this.setState({ tabOptions });
       return;
     }
@@ -56,7 +56,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
       const billingIndex = this.state.tabOptions.findIndex(
         (t) => t.value === "billing"
       );
-      tabOptions.splice(billingIndex, 1);
+      // tabOptions.splice(billingIndex, 1);
     }
   }
 
@@ -65,6 +65,10 @@ class ProjectSettings extends Component<PropsType, StateType> {
     this.setState({ projectName: currentProject.name });
     const tabOptions = [];
     tabOptions.push({ value: "manage-access", label: "Manage Access" });
+    tabOptions.push({
+      value: "billing",
+      label: "Billing",
+    });
 
     if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
       if (this.context?.hasBillingEnabled) {
@@ -111,6 +115,14 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return <InvitePage />;
     } else if (this.state.currentTab === "api-tokens") {
       return <APITokensSection />;
+    } else if (this.state.currentTab === "billing") {
+      return (
+        <Placeholder>
+          <Helper>
+          Please contact <a href="mailto:support@porter.run">support@porter.run</a> to upgrade your project's usage limits.
+          </Helper>
+        </Placeholder>
+      );
     } else {
       return (
         <>
@@ -173,6 +185,19 @@ ProjectSettings.contextType = Context;
 
 export default withRouter(withAuth(ProjectSettings));
 
+const Placeholder = styled.div`
+  width: 100%;
+  height: 200px;
+  background: #ffffff11;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  text-align: center;
+  padding: 0 30px;
+  justify-content: center;
+  padding-bottom: 10px;
+`;
+
 const Warning = styled.div`
   font-size: 13px;
   color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>

+ 6 - 0
dashboard/src/shared/Context.tsx

@@ -62,6 +62,8 @@ export interface GlobalContextType {
   setHasFinishedOnboarding: (onboardingStatus: boolean) => void;
   canCreateProject: boolean;
   setCanCreateProject: (canCreateProject: boolean) => void;
+  enableGitlab: boolean;
+  setEnableGitlab: (enableGitlab: boolean) => void;
 }
 
 /**
@@ -187,6 +189,10 @@ class ContextProvider extends Component<PropsType, StateType> {
     setCanCreateProject: (canCreateProject: boolean) => {
       this.setState({ canCreateProject });
     },
+    enableGitlab: false,
+    setEnableGitlab: (enableGitlab) => {
+      this.setState({ enableGitlab });
+    },
   };
 
   render() {

+ 104 - 1
dashboard/src/shared/api.tsx

@@ -62,6 +62,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;
@@ -100,6 +105,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`;
 });
@@ -429,7 +445,7 @@ const deployTemplate = baseApi<
     image_url?: string;
     values?: any;
     name: string;
-    github_action_config?: FullActionConfigType;
+    git_action_config?: FullActionConfigType;
     build_config?: any;
     synced_env_groups?: string[];
   },
@@ -485,6 +501,21 @@ const detectBuildpack = baseApi<
   }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
+const detectGitlabBuildpack = baseApi<
+  { dir: string },
+  {
+    project_id: number;
+    integration_id: number;
+    repo_owner: string;
+    repo_name: string;
+    branch: string;
+  }
+>(
+  "GET",
+  ({ project_id, integration_id, repo_name, repo_owner, branch }) =>
+    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/${branch}/buildpack/detect`
+);
+
 const getBranchContents = baseApi<
   {
     dir: string;
@@ -525,6 +556,25 @@ const getProcfileContents = baseApi<
   }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
+const getGitlabProcfileContents = baseApi<
+  {
+    path: string;
+  },
+  {
+    project_id: number;
+    integration_id: number;
+    owner: string;
+    name: string;
+    branch: string;
+  }
+>(
+  "GET",
+  ({ project_id, integration_id, owner, name, branch }) =>
+    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${owner}/${name}/${encodeURIComponent(
+      branch
+    )}/procfile`
+);
+
 const getBranches = baseApi<
   {},
   {
@@ -1832,6 +1882,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,
@@ -1841,9 +1936,11 @@ export default {
   getAWSIntegration,
   getGCPIntegration,
   getAzureIntegration,
+  getGitlabIntegration,
   createAWSIntegration,
   overwriteAWSIntegration,
   createAzureIntegration,
+  createGitlabIntegration,
   createEmailVerification,
   createEnvironment,
   deleteEnvironment,
@@ -1874,6 +1971,7 @@ export default {
   destroyInfra,
   updateDatabaseStatus,
   detectBuildpack,
+  detectGitlabBuildpack,
   getBranchContents,
   getBranches,
   getMetadata,
@@ -1926,6 +2024,7 @@ export default {
   getOAuthIds,
   getPodEvents,
   getProcfileContents,
+  getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,
   getProjectRepos,
@@ -2006,4 +2105,8 @@ export default {
   getTagsByProjectId,
   createTag,
   updateReleaseTags,
+  getGitProviders,
+  getGitlabRepos,
+  getGitlabBranches,
+  getGitlabFolderContent,
 };

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

@@ -107,7 +107,8 @@ export const integrationList: any = {
   },
   gitlab: {
     icon: "https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png",
-    label: "Gitlab",
+    label: "GitLab",
+    buttonText: "Add an Instance",
   },
   rds: {
     icon:

+ 30 - 0
dashboard/src/shared/hooks/useOutsideAlerter.ts

@@ -0,0 +1,30 @@
+import React, { MutableRefObject, RefObject, useEffect, useRef } from "react";
+
+/**
+ * Hook that alerts clicks outside of the passed ref
+ */
+export function useOutsideAlerter(
+  ref: React.RefObject<HTMLDivElement>,
+  callback: () => void
+) {
+  useEffect(() => {
+    /**
+     * Alert if clicked on outside of element
+     */
+    function handleClickOutside(event: any) {
+      if (
+        ref.current &&
+        !ref.current.contains(event.target) &&
+        typeof callback === "function"
+      ) {
+        callback();
+      }
+    }
+    // Bind the event listener
+    document.addEventListener("mousedown", handleClickOutside);
+    return () => {
+      // Unbind the event listener on clean up
+      document.removeEventListener("mousedown", handleClickOutside);
+    };
+  }, [ref]);
+}

+ 27 - 10
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;
@@ -276,19 +283,27 @@ 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;
-}
-
-export interface FullActionConfigType extends ActionConfigType {
+} & (
+  | {
+      kind: "gitlab";
+      gitlab_integration_id: number;
+    }
+  | {
+      kind: "github";
+      git_repo_id: number;
+    }
+);
+
+export type FullActionConfigType = ActionConfigType & {
   dockerfile_path: string;
   folder_path: string;
   registry_id: number;
   should_create_workflow: boolean;
-}
+};
 
 export interface CapabilityType {
   github: boolean;
@@ -331,6 +346,8 @@ export interface ContextProps {
   setHasFinishedOnboarding: (onboardingStatus: boolean) => void;
   canCreateProject: boolean;
   setCanCreateProject: (canCreateProject: boolean) => void;
+  enableGitlab: boolean;
+  setEnableGitlab: (enableGitlab: boolean) => void;
 }
 
 export enum JobStatusType {

+ 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"`
 }

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

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package vault
@@ -185,6 +186,41 @@ 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"]
 }`

+ 85 - 0
ee/migrate/migrate_vault.go

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package migrate
@@ -52,6 +53,14 @@ func MigrateVault(db *gorm.DB, dbConf *env.DBConf, shouldFinalize bool) error {
 		return err
 	}
 
+	err = migrateGitlabIntegrationModel(db, vaultClient, shouldFinalize)
+
+	if err != nil {
+		fmt.Printf("failed on gitlab migration: %v\n", err)
+
+		return err
+	}
+
 	return nil
 }
 
@@ -286,3 +295,79 @@ func migrateAWSIntegrationModel(db *gorm.DB, client *vault.Client, shouldFinaliz
 
 	return nil
 }
+
+func migrateGitlabIntegrationModel(db *gorm.DB, client *vault.Client, shouldFinalize bool) error {
+	// get count of model
+	var count int64
+
+	if err := db.Model(&ints.GitlabIntegration{}).Count(&count).Error; err != nil {
+		return err
+	}
+
+	// make a map of ids to errors -- we don't clear the integrations with errors
+	errors := make(map[uint]error)
+
+	// iterate (count / stepSize) + 1 times using Limit and Offset
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		giInts := []*ints.GitlabIntegration{}
+
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&giInts).Error; err != nil {
+			return err
+		}
+
+		// decrypt with the old key
+		for _, gi := range giInts {
+			// Check if record already exists in vault client. If so, we don't write anything to vault,
+			// since we don't want to overwrite any data that's been written.
+			if resp, _ := client.GetGitlabCredential(gi); resp != nil {
+				continue
+			}
+
+			// write the data to the vault client
+			if err := client.WriteGitlabCredential(gi, &credentials.GitlabCredential{
+				AppClientID:     gi.AppClientID,
+				AppClientSecret: gi.AppClientSecret,
+			}); err != nil {
+				errors[gi.ID] = err
+				fmt.Printf("gitlab vault write error on ID %d: %v\n", gi.ID, err)
+			}
+		}
+	}
+
+	fmt.Printf("migrated %d gitlab integrations with %d errors\n", count, len(errors))
+
+	if shouldFinalize {
+		saveErrors := make(map[uint]error, 0)
+
+		// iterate a second time, clearing the data
+		// iterate (count / stepSize) + 1 times using Limit and Offset
+		for i := 0; i < (int(count)/stepSize)+1; i++ {
+			giInts := []*ints.GitlabIntegration{}
+
+			if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&giInts).Error; err != nil {
+				return err
+			}
+
+			// decrypt with the old key
+			for _, gi := range giInts {
+				if _, found := errors[gi.ID]; !found {
+					// clear the data from the db, and save
+					gi.AppClientID = []byte{}
+					gi.AppClientSecret = []byte{}
+
+					if err := db.Save(gi).Error; err != nil {
+						saveErrors[gi.ID] = err
+					}
+				}
+			}
+		}
+
+		fmt.Printf("cleared %d gitlab integrations with %d errors\n", count, len(saveErrors))
+
+		for saveErrorID, saveError := range saveErrors {
+			fmt.Printf("gitlab save error on ID %d: %v\n", saveErrorID, saveError)
+		}
+	}
+
+	return nil
+}

+ 12 - 8
go.mod

@@ -45,10 +45,10 @@ require (
 	github.com/spf13/cobra v1.4.0
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.10.0
-	github.com/stretchr/testify v1.7.0
+	github.com/stretchr/testify v1.7.1
 	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
@@ -79,9 +79,13 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect
 	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/cosmtrek/air v1.30.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 (
@@ -128,7 +132,7 @@ require (
 	github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
 	github.com/evanphx/json-patch v4.12.0+incompatible // indirect
 	github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
-	github.com/fsnotify/fsnotify v1.5.1 // indirect
+	github.com/fsnotify/fsnotify v1.5.4 // indirect
 	github.com/fvbommel/sortorder v1.0.1 // indirect
 	github.com/gdamore/encoding v1.0.0 // indirect
 	github.com/gdamore/tcell/v2 v2.5.1 // indirect
@@ -159,7 +163,7 @@ require (
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/heroku/color v0.0.6 // indirect
 	github.com/huandu/xstrings v1.3.2 // indirect
-	github.com/imdario/mergo v0.3.12 // indirect
+	github.com/imdario/mergo v0.3.13 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/itchyny/astgen-go v0.0.0-20210113000433-0da0671862a3 // indirect
 	github.com/itchyny/timefmt-go v0.1.1 // indirect
@@ -246,10 +250,10 @@ require (
 	go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect
 	golang.org/x/mod v0.5.1 // indirect
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
-	golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
+	golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // 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
@@ -257,7 +261,7 @@ require (
 	gopkg.in/src-d/go-billy.v4 v4.3.2 // indirect
 	gopkg.in/src-d/go-git.v4 v4.13.1 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
-	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
+	gopkg.in/yaml.v3 v3.0.0 // indirect
 	k8s.io/apiextensions-apiserver v0.23.1 // indirect
 	k8s.io/apiserver v0.23.1 // indirect
 	k8s.io/component-base v0.23.1 // indirect

+ 26 - 0
go.sum

@@ -457,6 +457,8 @@ github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/cosmtrek/air v1.30.0 h1:Ge27Ye0ZXIcxslU+zy7WKPZAsSz+ws5L+BUGmIRvTLg=
+github.com/cosmtrek/air v1.30.0/go.mod h1:eNJLyoFnfPbr6F/h6FVFyL2Y0ia7A61ULjtk+EB5uaY=
 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@@ -586,6 +588,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
 github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
+github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
+github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
 github.com/fvbommel/sortorder v1.0.1 h1:dSnXLt4mJYH25uDDGa3biZNQsozaUWDSWeKJ0qqFfzE=
 github.com/fvbommel/sortorder v1.0.1/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
@@ -876,8 +880,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 +895,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=
@@ -938,6 +946,8 @@ github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH
 github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
 github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
+github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
+github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
 github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
@@ -1549,6 +1559,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
@@ -1597,6 +1609,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 +1873,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 +1898,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=
@@ -2026,8 +2045,11 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -2057,6 +2079,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=
@@ -2378,6 +2402,8 @@ gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C
 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
+gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gorm.io/driver/postgres v1.2.3 h1:f4t0TmNMy9gh3TU2PX+EppoA6YsgFnyq8Ojtddb42To=
 gorm.io/driver/postgres v1.2.3/go.mod h1:pJV6RgYQPG47aM1f0QeOzFH9HxQc8JcmAgjRCgS0wjs=
 gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=

+ 91 - 5
internal/integrations/buildpacks/go.go

@@ -4,6 +4,7 @@ import (
 	"sync"
 
 	"github.com/google/go-github/v41/github"
+	"github.com/xanzy/go-gitlab"
 )
 
 type goRuntime struct {
@@ -14,7 +15,7 @@ func NewGoRuntime() Runtime {
 	return &goRuntime{}
 }
 
-func (runtime *goRuntime) detectMod(results chan struct {
+func (runtime *goRuntime) detectModGithub(results chan struct {
 	string
 	bool
 }, directoryContent []*github.RepositoryContent) {
@@ -35,7 +36,28 @@ func (runtime *goRuntime) detectMod(results chan struct {
 	runtime.wg.Done()
 }
 
-func (runtime *goRuntime) detectDep(results chan struct {
+func (runtime *goRuntime) detectModGitlab(results chan struct {
+	string
+	bool
+}, tree []*gitlab.TreeNode) {
+	goModFound := false
+	for i := 0; i < len(tree); i++ {
+		name := tree[i].Name
+		if name == "go.mod" {
+			goModFound = true
+			break
+		}
+	}
+	if goModFound {
+		results <- struct {
+			string
+			bool
+		}{mod, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *goRuntime) detectDepGithub(results chan struct {
 	string
 	bool
 }, directoryContent []*github.RepositoryContent) {
@@ -61,7 +83,33 @@ func (runtime *goRuntime) detectDep(results chan struct {
 	runtime.wg.Done()
 }
 
-func (runtime *goRuntime) Detect(
+func (runtime *goRuntime) detectDepGitlab(results chan struct {
+	string
+	bool
+}, tree []*gitlab.TreeNode) {
+	gopkgFound := false
+	vendorFound := false
+	for i := 0; i < len(tree); i++ {
+		name := tree[i].Name
+		if name == "Gopkg.toml" {
+			gopkgFound = true
+		} else if name == "vendor" && tree[i].Type == "tree" {
+			vendorFound = true
+		}
+		if gopkgFound && vendorFound {
+			break
+		}
+	}
+	if gopkgFound && vendorFound {
+		results <- struct {
+			string
+			bool
+		}{dep, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *goRuntime) DetectGithub(
 	client *github.Client,
 	directoryContent []*github.RepositoryContent,
 	owner, name, path string,
@@ -74,8 +122,46 @@ func (runtime *goRuntime) Detect(
 	}, 2)
 
 	runtime.wg.Add(2)
-	go runtime.detectMod(results, directoryContent)
-	go runtime.detectDep(results, directoryContent)
+	go runtime.detectModGithub(results, directoryContent)
+	go runtime.detectDepGithub(results, directoryContent)
+	runtime.wg.Wait()
+	close(results)
+
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "Go",
+		Buildpack: "gcr.io/paketo-buildpacks/go",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "Go",
+		Buildpack: "heroku/go",
+	}
+
+	if len(results) == 0 {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
+	}
+
+	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+
+	return nil
+}
+
+func (runtime *goRuntime) DetectGitlab(
+	client *gitlab.Client,
+	tree []*gitlab.TreeNode,
+	owner, name, path, ref string,
+	paketo, heroku *BuilderInfo,
+) error {
+	results := make(chan struct {
+		string
+		bool
+	}, 2)
+
+	runtime.wg.Add(2)
+	go runtime.detectModGitlab(results, tree)
+	go runtime.detectDepGitlab(results, tree)
 	runtime.wg.Wait()
 	close(results)
 

+ 245 - 7
internal/integrations/buildpacks/nodejs.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/Masterminds/semver/v3"
 	"github.com/google/go-github/v41/github"
+	"github.com/xanzy/go-gitlab"
 )
 
 var (
@@ -28,7 +29,7 @@ func NewNodeRuntime() Runtime {
 	return &nodejsRuntime{}
 }
 
-func (runtime *nodejsRuntime) detectYarn(results chan struct {
+func (runtime *nodejsRuntime) detectYarnGithub(results chan struct {
 	string
 	bool
 }, directoryContent []*github.RepositoryContent) {
@@ -54,7 +55,33 @@ func (runtime *nodejsRuntime) detectYarn(results chan struct {
 	runtime.wg.Done()
 }
 
-func (runtime *nodejsRuntime) detectNPM(results chan struct {
+func (runtime *nodejsRuntime) detectYarnGitlab(results chan struct {
+	string
+	bool
+}, tree []*gitlab.TreeNode) {
+	yarnLockFound := false
+	packageJSONFound := false
+	for i := 0; i < len(tree); i++ {
+		name := tree[i].Name
+		if name == "yarn.lock" {
+			yarnLockFound = true
+		} else if name == "package.json" {
+			packageJSONFound = true
+		}
+		if yarnLockFound && packageJSONFound {
+			break
+		}
+	}
+	if yarnLockFound && packageJSONFound {
+		results <- struct {
+			string
+			bool
+		}{yarn, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *nodejsRuntime) detectNPMGithub(results chan struct {
 	string
 	bool
 }, directoryContent []*github.RepositoryContent) {
@@ -75,7 +102,28 @@ func (runtime *nodejsRuntime) detectNPM(results chan struct {
 	runtime.wg.Done()
 }
 
-func (runtime *nodejsRuntime) detectStandalone(results chan struct {
+func (runtime *nodejsRuntime) detectNPMGitlab(results chan struct {
+	string
+	bool
+}, tree []*gitlab.TreeNode) {
+	packageJSONFound := false
+	for i := 0; i < len(tree); i++ {
+		name := tree[i].Name
+		if name == "package.json" {
+			packageJSONFound = true
+			break
+		}
+	}
+	if packageJSONFound {
+		results <- struct {
+			string
+			bool
+		}{npm, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *nodejsRuntime) detectStandaloneGithub(results chan struct {
 	string
 	bool
 }, directoryContent []*github.RepositoryContent) {
@@ -96,6 +144,27 @@ func (runtime *nodejsRuntime) detectStandalone(results chan struct {
 	runtime.wg.Done()
 }
 
+func (runtime *nodejsRuntime) detectStandaloneGitlab(results chan struct {
+	string
+	bool
+}, tree []*gitlab.TreeNode) {
+	jsFileFound := false
+	for i := 0; i < len(tree); i++ {
+		name := tree[i].Name
+		if name == "server.js" || name == "app.js" || name == "main.js" || name == "index.js" {
+			jsFileFound = true
+			break
+		}
+	}
+	if jsFileFound {
+		results <- struct {
+			string
+			bool
+		}{standalone, true}
+	}
+	runtime.wg.Done()
+}
+
 // copied directly from https://github.com/paketo-buildpacks/node-engine/blob/main/nvmrc_parser.go
 func validateNvmrc(content string) (string, error) {
 	content = strings.TrimSpace(strings.ToLower(content))
@@ -157,7 +226,7 @@ func validateNodeVersion(content string) (string, error) {
 	return content, nil
 }
 
-func (runtime *nodejsRuntime) Detect(
+func (runtime *nodejsRuntime) DetectGithub(
 	client *github.Client,
 	directoryContent []*github.RepositoryContent,
 	owner, name, path string,
@@ -170,9 +239,9 @@ func (runtime *nodejsRuntime) Detect(
 	}, 3)
 
 	runtime.wg.Add(3)
-	go runtime.detectYarn(results, directoryContent)
-	go runtime.detectNPM(results, directoryContent)
-	go runtime.detectStandalone(results, directoryContent)
+	go runtime.detectYarnGithub(results, directoryContent)
+	go runtime.detectNPMGithub(results, directoryContent)
+	go runtime.detectStandaloneGithub(results, directoryContent)
 	runtime.wg.Wait()
 	close(results)
 
@@ -337,3 +406,172 @@ func (runtime *nodejsRuntime) Detect(
 
 	return nil
 }
+
+func (runtime *nodejsRuntime) DetectGitlab(
+	client *gitlab.Client,
+	tree []*gitlab.TreeNode,
+	owner, name, path, ref string,
+	paketo, heroku *BuilderInfo,
+) error {
+	results := make(chan struct {
+		string
+		bool
+	}, 3)
+
+	runtime.wg.Add(3)
+	go runtime.detectYarnGitlab(results, tree)
+	go runtime.detectNPMGitlab(results, tree)
+	go runtime.detectStandaloneGitlab(results, tree)
+	runtime.wg.Wait()
+	close(results)
+
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "NodeJS",
+		Buildpack: "gcr.io/paketo-buildpacks/nodejs",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "NodeJS",
+		Buildpack: "heroku/nodejs",
+	}
+
+	if len(results) == 0 {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
+	}
+
+	// foundYarn := false
+	// foundNPM := false
+	// foundStandalone := false
+	// for result := range results {
+	// 	if result.string == yarn {
+	// 		foundYarn = true
+	// 	} else if result.string == npm {
+	// 		foundNPM = true
+	// 	} else if result.string == standalone {
+	// 		foundStandalone = true
+	// 	}
+	// }
+
+	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+
+	// if foundYarn || foundNPM {
+	// 	// it is safe to assume that the project contains a package.json
+	// 	fileContent, _, err := client.RepositoryFiles.GetRawFile(
+	// 		fmt.Sprintf("%s/%s", owner, name), fmt.Sprintf("%s/package.json", path),
+	// 		&gitlab.GetRawFileOptions{
+	// 			Ref: gitlab.String(ref),
+	// 		},
+	// 	)
+	// 	if err != nil {
+	// 		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+	// 		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+	// 		return fmt.Errorf("error fetching contents of package.json: %v", err)
+	// 	}
+	// 	var packageJSON struct {
+	// 		Scripts map[string]string `json:"scripts"`
+	// 		Engines struct {
+	// 			Node string `json:"node"`
+	// 		} `json:"engines"`
+	// 	}
+
+	// 	data := string(fileContent)
+
+	// 	err = json.NewDecoder(strings.NewReader(data)).Decode(&packageJSON)
+	// 	if err != nil {
+	// 		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+	// 		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+	// 		return fmt.Errorf("error decoding package.json contents to struct: %v", err)
+	// 	}
+
+	// 	if packageJSON.Engines.Node == "" {
+	// 		// we should now check for the node engine version in .nvmrc and then .node-version
+	// 		nvmrcFound := false
+	// 		nodeVersionFound := false
+	// 		for i := 0; i < len(tree); i++ {
+	// 			name := tree[i].Name
+	// 			if name == ".nvmrc" {
+	// 				nvmrcFound = true
+	// 			} else if name == ".node-version" {
+	// 				nodeVersionFound = true
+	// 			}
+	// 		}
+
+	// 		if nvmrcFound {
+	// 			// copy exact behavior of https://github.com/paketo-buildpacks/node-engine/blob/main/nvmrc_parser.go
+	// 			fileContent, _, err = client.RepositoryFiles.GetRawFile(
+	// 				fmt.Sprintf("%s/%s", owner, name), fmt.Sprintf("%s/.nvmrc", path),
+	// 				&gitlab.GetRawFileOptions{
+	// 					Ref: gitlab.String(ref),
+	// 				},
+	// 			)
+	// 			if err != nil {
+	// 				paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+	// 				heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+	// 				return fmt.Errorf("error fetching contents of .nvmrc: %v", err)
+	// 			}
+	// 			data = string(fileContent)
+
+	// 			nvmrcVersion, err := validateNvmrc(data)
+	// 			if err != nil {
+	// 				paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+	// 				heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+	// 				return fmt.Errorf("error validating .nvmrc: %v", err)
+	// 			}
+	// 			nvmrcVersion = formatNvmrcContent(nvmrcVersion)
+
+	// 			if nvmrcVersion != "*" {
+	// 				packageJSON.Engines.Node = data
+	// 			}
+	// 		}
+
+	// 		if packageJSON.Engines.Node == "" && nodeVersionFound {
+	// 			// copy exact behavior of https://github.com/paketo-buildpacks/node-engine/blob/main/node_version_parser.go
+	// 			fileContent, _, err = client.RepositoryFiles.GetRawFile(
+	// 				fmt.Sprintf("%s/%s", owner, name), fmt.Sprintf("%s/.node-version", path),
+	// 				&gitlab.GetRawFileOptions{
+	// 					Ref: gitlab.String(ref),
+	// 				},
+	// 			)
+	// 			if err != nil {
+	// 				paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+	// 				heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+	// 				return fmt.Errorf("error fetching contents of .node-version: %v", err)
+	// 			}
+
+	// 			data = string(fileContent)
+
+	// 			nodeVersion, err := validateNodeVersion(data)
+	// 			if err != nil {
+	// 				paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+	// 				heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+	// 				return fmt.Errorf("error validating .node-version: %v", err)
+	// 			}
+	// 			if nodeVersion != "" {
+	// 				packageJSON.Engines.Node = nodeVersion
+	// 			}
+	// 		}
+	// 	}
+
+	// 	if packageJSON.Engines.Node == "" {
+	// 		// use the default node engine version from https://github.com/paketo-buildpacks/node-engine/blob/main/buildpack.toml
+	// 		packageJSON.Engines.Node = "16.*.*"
+	// 	}
+
+	// 	paketoBuildpackInfo.Config = make(map[string]interface{})
+	// 	paketoBuildpackInfo.Config["scripts"] = packageJSON.Scripts
+	// 	paketoBuildpackInfo.Config["node_engine"] = packageJSON.Engines.Node
+	// 	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+
+	// 	herokuBuildpackInfo.Config = make(map[string]interface{})
+	// 	herokuBuildpackInfo.Config["scripts"] = packageJSON.Scripts
+	// 	herokuBuildpackInfo.Config["node_engine"] = packageJSON.Engines.Node
+	// 	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+	// } else if foundStandalone {
+	// 	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	// 	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+	// }
+
+	return nil
+}

+ 142 - 9
internal/integrations/buildpacks/python.go

@@ -5,6 +5,7 @@ import (
 	"sync"
 
 	"github.com/google/go-github/v41/github"
+	"github.com/xanzy/go-gitlab"
 )
 
 type pythonRuntime struct {
@@ -15,7 +16,7 @@ func NewPythonRuntime() Runtime {
 	return &pythonRuntime{}
 }
 
-func (runtime *pythonRuntime) detectPipenv(results chan struct {
+func (runtime *pythonRuntime) detectPipenvGithub(results chan struct {
 	string
 	bool
 }, directoryContent []*github.RepositoryContent) {
@@ -41,7 +42,33 @@ func (runtime *pythonRuntime) detectPipenv(results chan struct {
 	runtime.wg.Done()
 }
 
-func (runtime *pythonRuntime) detectPip(results chan struct {
+func (runtime *pythonRuntime) detectPipenvGitlab(results chan struct {
+	string
+	bool
+}, tree []*gitlab.TreeNode) {
+	pipfileFound := false
+	pipfileLockFound := false
+	for i := 0; i < len(tree); i++ {
+		name := tree[i].Name
+		if name == "Pipfile" {
+			pipfileFound = true
+		} else if name == "Pipfile.lock" {
+			pipfileLockFound = true
+		}
+		if pipfileFound && pipfileLockFound {
+			break
+		}
+	}
+	if pipfileFound && pipfileLockFound {
+		results <- struct {
+			string
+			bool
+		}{pipenv, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *pythonRuntime) detectPipGithub(results chan struct {
 	string
 	bool
 }, directoryContent []*github.RepositoryContent) {
@@ -61,7 +88,27 @@ func (runtime *pythonRuntime) detectPip(results chan struct {
 	runtime.wg.Done()
 }
 
-func (runtime *pythonRuntime) detectConda(results chan struct {
+func (runtime *pythonRuntime) detectPipGitlab(results chan struct {
+	string
+	bool
+}, tree []*gitlab.TreeNode) {
+	requirementsTxtFound := false
+	for i := 0; i < len(tree); i++ {
+		name := tree[i].Name
+		if name == "requirements.txt" {
+			requirementsTxtFound = true
+		}
+	}
+	if requirementsTxtFound {
+		results <- struct {
+			string
+			bool
+		}{pip, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *pythonRuntime) detectCondaGithub(results chan struct {
 	string
 	bool
 }, directoryContent []*github.RepositoryContent) {
@@ -86,7 +133,32 @@ func (runtime *pythonRuntime) detectConda(results chan struct {
 	runtime.wg.Done()
 }
 
-func (runtime *pythonRuntime) detectStandalone(results chan struct {
+func (runtime *pythonRuntime) detectCondaGitlab(results chan struct {
+	string
+	bool
+}, tree []*gitlab.TreeNode) {
+	environmentFound := false
+	packageListFound := false
+	for i := 0; i < len(tree); i++ {
+		name := tree[i].Name
+		if name == "environment.yml" {
+			environmentFound = true
+			break
+		} else if name == "package-list.txt" {
+			packageListFound = true
+			break
+		}
+	}
+	if environmentFound || packageListFound {
+		results <- struct {
+			string
+			bool
+		}{conda, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *pythonRuntime) detectStandaloneGithub(results chan struct {
 	string
 	bool
 }, directoryContent []*github.RepositoryContent) {
@@ -107,7 +179,28 @@ func (runtime *pythonRuntime) detectStandalone(results chan struct {
 	runtime.wg.Done()
 }
 
-func (runtime *pythonRuntime) Detect(
+func (runtime *pythonRuntime) detectStandaloneGitlab(results chan struct {
+	string
+	bool
+}, tree []*gitlab.TreeNode) {
+	pyFound := false
+	for i := 0; i < len(tree); i++ {
+		name := tree[i].Name
+		if strings.HasSuffix(name, ".py") {
+			pyFound = true
+			break
+		}
+	}
+	if pyFound {
+		results <- struct {
+			string
+			bool
+		}{standalone, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *pythonRuntime) DetectGithub(
 	client *github.Client,
 	directoryContent []*github.RepositoryContent,
 	owner, name, path string,
@@ -120,10 +213,50 @@ func (runtime *pythonRuntime) Detect(
 	}, 4)
 
 	runtime.wg.Add(4)
-	go runtime.detectPipenv(results, directoryContent)
-	go runtime.detectPip(results, directoryContent)
-	go runtime.detectConda(results, directoryContent)
-	go runtime.detectStandalone(results, directoryContent)
+	go runtime.detectPipenvGithub(results, directoryContent)
+	go runtime.detectPipGithub(results, directoryContent)
+	go runtime.detectCondaGithub(results, directoryContent)
+	go runtime.detectStandaloneGithub(results, directoryContent)
+	runtime.wg.Wait()
+	close(results)
+
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "Python",
+		Buildpack: "gcr.io/paketo-buildpacks/python",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "Python",
+		Buildpack: "heroku/python",
+	}
+
+	if len(results) == 0 {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
+	}
+
+	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+
+	return nil
+}
+
+func (runtime *pythonRuntime) DetectGitlab(
+	client *gitlab.Client,
+	tree []*gitlab.TreeNode,
+	owner, name, path, ref string,
+	paketo, heroku *BuilderInfo,
+) error {
+	results := make(chan struct {
+		string
+		bool
+	}, 4)
+
+	runtime.wg.Add(4)
+	go runtime.detectPipenvGitlab(results, tree)
+	go runtime.detectPipGitlab(results, tree)
+	go runtime.detectCondaGitlab(results, tree)
+	go runtime.detectStandaloneGitlab(results, tree)
 	runtime.wg.Wait()
 	close(results)
 

+ 138 - 3
internal/integrations/buildpacks/ruby.go

@@ -9,6 +9,7 @@ import (
 	"sync"
 
 	"github.com/google/go-github/v41/github"
+	"github.com/xanzy/go-gitlab"
 )
 
 type rubyRuntime struct {
@@ -115,7 +116,7 @@ func (runtime *rubyRuntime) detectPassenger(gemfileContent string, results chan
 	runtime.wg.Done()
 }
 
-func (runtime *rubyRuntime) detectRackup(
+func (runtime *rubyRuntime) detectRackupGithub(
 	client *github.Client, owner, name string,
 	repoContentOptions github.RepositoryContentGetOptions, results chan struct {
 		string
@@ -155,6 +156,43 @@ func (runtime *rubyRuntime) detectRackup(
 	runtime.wg.Done()
 }
 
+func (runtime *rubyRuntime) detectRackupGitlab(
+	client *gitlab.Client, owner, name, ref string, results chan struct {
+		string
+		bool
+	},
+) {
+	fileContent, _, err := client.RepositoryFiles.GetRawFile(
+		fmt.Sprintf("%s/%s", owner, name), "Gemfile.lock", &gitlab.GetRawFileOptions{
+			Ref: gitlab.String(ref),
+		})
+	if err != nil {
+		runtime.wg.Done()
+		return
+	}
+	gemfileLockContent := string(fileContent)
+
+	rackFound := false
+	scanner := bufio.NewScanner(strings.NewReader(gemfileLockContent))
+	for scanner.Scan() {
+		if strings.TrimSpace(scanner.Text()) == "GEM" {
+			for scanner.Scan() {
+				if strings.Contains(scanner.Text(), "rack") {
+					rackFound = true
+					break
+				}
+			}
+		}
+	}
+	if rackFound {
+		results <- struct {
+			string
+			bool
+		}{rackup, true}
+	}
+	runtime.wg.Done()
+}
+
 func (runtime *rubyRuntime) detectRake(gemfileContent string, results chan struct {
 	string
 	bool
@@ -179,7 +217,7 @@ func (runtime *rubyRuntime) detectRake(gemfileContent string, results chan struc
 	runtime.wg.Done()
 }
 
-func (runtime *rubyRuntime) Detect(
+func (runtime *rubyRuntime) DetectGithub(
 	client *github.Client,
 	directoryContent []*github.RepositoryContent,
 	owner, name, path string,
@@ -265,7 +303,104 @@ func (runtime *rubyRuntime) Detect(
 	}
 	go runtime.detectPassenger(gemfileContent, results)
 	if !configRuFound && gemfileLockFound {
-		go runtime.detectRackup(client, owner, name, repoContentOptions, results)
+		go runtime.detectRackupGithub(client, owner, name, repoContentOptions, results)
+	}
+	if rakefileFound {
+		go runtime.detectRake(gemfileContent, results)
+	}
+	runtime.wg.Wait()
+	close(results)
+
+	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+
+	return nil
+}
+
+func (runtime *rubyRuntime) DetectGitlab(
+	client *gitlab.Client,
+	tree []*gitlab.TreeNode,
+	owner, name, path, ref string,
+	paketo, heroku *BuilderInfo,
+) error {
+	gemfileFound := false
+	gemfileLockFound := false
+	configRuFound := false
+	rakefileFound := false
+	for i := range tree {
+		name := tree[i].Name
+		if name == "Gemfile" {
+			gemfileFound = true
+		} else if name == "Gemfile.lock" {
+			gemfileLockFound = true
+		} else if name == "config.ru" {
+			configRuFound = true
+		} else if name == "Rakefile" || name == "Rakefile.rb" || name == "rakefile" || name == "rakefile.rb" {
+			rakefileFound = true
+		}
+	}
+
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "Ruby",
+		Buildpack: "gcr.io/paketo-buildpacks/ruby",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "Ruby",
+		Buildpack: "heroku/ruby",
+	}
+
+	if !gemfileFound {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
+	}
+
+	fileContent, _, err := client.RepositoryFiles.GetRawFile(
+		fmt.Sprintf("%s/%s", owner, name), "Gemfile", &gitlab.GetRawFileOptions{
+			Ref: gitlab.String(ref),
+		})
+	if err != nil {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return fmt.Errorf("error fetching contents of Gemfile for %s/%s: %v", owner, name, err)
+	}
+	gemfileContent := string(fileContent)
+
+	count := 6
+	if !configRuFound {
+		// unicorn needs config.ru
+		count -= 1
+		if !gemfileLockFound {
+			// rackup needs one of Gemfile.lock or config.ru
+			count -= 1
+		}
+	}
+	if !rakefileFound {
+		count -= 1
+	}
+	results := make(chan struct {
+		string
+		bool
+	}, count)
+
+	runtime.wg.Add(count)
+	go runtime.detectPuma(gemfileContent, results)
+	go runtime.detectThin(gemfileContent, results)
+	if configRuFound {
+		{
+			// FIXME: find a better, more readable way of doing this
+			results <- struct {
+				string
+				bool
+			}{rackup, true}
+			runtime.wg.Done()
+		}
+
+		go runtime.detectUnicorn(gemfileContent, results)
+	}
+	go runtime.detectPassenger(gemfileContent, results)
+	if !configRuFound && gemfileLockFound {
+		go runtime.detectRackupGitlab(client, owner, name, ref, results)
 	}
 	if rakefileFound {
 		go runtime.detectRake(gemfileContent, results)

+ 12 - 1
internal/integrations/buildpacks/shared.go

@@ -2,6 +2,7 @@ package buildpacks
 
 import (
 	"github.com/google/go-github/v41/github"
+	"github.com/xanzy/go-gitlab"
 )
 
 const (
@@ -48,7 +49,7 @@ type BuilderInfo struct {
 }
 
 type Runtime interface {
-	Detect(
+	DetectGithub(
 		*github.Client, // github client to pull contents of files
 		[]*github.RepositoryContent, // the root folder structure of the git repo
 		string, // owner
@@ -58,6 +59,16 @@ type Runtime interface {
 		*BuilderInfo, // paketo
 		*BuilderInfo, // heroku
 	) error
+	DetectGitlab(
+		*gitlab.Client, // github client to pull contents of files
+		[]*gitlab.TreeNode, // the root folder structure of the git repo
+		string, // owner
+		string, // name
+		string, // path
+		string, // SHA, branch or tag
+		*BuilderInfo, // paketo
+		*BuilderInfo, // heroku
+	) error
 }
 
 // Runtimes is a list of all API runtimes

+ 62 - 2
internal/integrations/ci/actions/actions.go

@@ -3,6 +3,7 @@ package actions
 import (
 	"context"
 	"encoding/base64"
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -20,6 +21,11 @@ import (
 	"gopkg.in/yaml.v2"
 )
 
+var (
+	ErrProtectedBranch            = errors.New("protected branch")
+	ErrCreatePRForProtectedBranch = errors.New("unable to create PR to merge workflow files into protected branch")
+)
+
 type GithubActions struct {
 	ServerURL    string
 	InstanceName string
@@ -97,9 +103,63 @@ func (g *GithubActions) Setup() ([]byte, error) {
 			branch = g.defaultBranch
 		}
 
+		// check if the branch is protected
+		githubBranch, _, err := client.Repositories.GetBranch(
+			context.Background(),
+			g.GitRepoOwner,
+			g.GitRepoName,
+			branch,
+			true,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
 		isOAuth := g.GithubOAuthIntegration != nil
 
-		_, err = commitGithubFile(client, g.getPorterYMLFileName(), workflowYAML, g.GitRepoOwner, g.GitRepoName, branch, isOAuth)
+		if githubBranch.GetProtected() {
+			err = createNewBranch(client, g.GitRepoOwner, g.GitRepoName, branch, "porter-setup")
+
+			if err != nil {
+				return nil, fmt.Errorf(
+					"Unable to create PR to merge workflow files into protected branch: %s.\n"+
+						"To enable automatic deployments to Porter, please create a Github workflow "+
+						"file in this branch with the following contents:\n"+
+						"--------\n%s--------\nERROR: %w", branch, string(workflowYAML), ErrCreatePRForProtectedBranch,
+				)
+			}
+
+			_, err = commitWorkflowFile(client, g.getPorterYMLFileName(), workflowYAML, g.GitRepoOwner,
+				g.GitRepoName, "porter-setup", isOAuth)
+
+			if err != nil {
+				return nil, fmt.Errorf(
+					"Unable to create PR to merge workflow files into protected branch: %s.\n"+
+						"To enable automatic deployments to Porter, please create a Github workflow "+
+						"file in this branch with the following contents:\n"+
+						"--------\n%s--------\nERROR: %w", branch, string(workflowYAML), ErrCreatePRForProtectedBranch,
+				)
+			}
+
+			pr, _, err := client.PullRequests.Create(
+				context.Background(), g.GitRepoOwner, g.GitRepoName, &github.NewPullRequest{
+					Title: github.String("Enable Porter automatic deployments"),
+					Base:  github.String(branch),
+					Head:  github.String("porter-setup"),
+				},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+
+			return nil, fmt.Errorf("Please merge %s to enable automatic deployments on Porter.\nERROR: %w",
+				pr.GetHTMLURL(), ErrProtectedBranch)
+		}
+
+		_, err = commitWorkflowFile(client, g.getPorterYMLFileName(), workflowYAML, g.GitRepoOwner,
+			g.GitRepoName, branch, isOAuth)
 		if err != nil {
 			return workflowYAML, err
 		}
@@ -382,7 +442,7 @@ func getPorterTokenSecretName(projectID uint) string {
 	return fmt.Sprintf("PORTER_TOKEN_%d", projectID)
 }
 
-func commitGithubFile(
+func commitWorkflowFile(
 	client *github.Client,
 	filename string,
 	contents []byte,

+ 117 - 24
internal/integrations/ci/actions/preview.go

@@ -79,7 +79,78 @@ func SetupEnv(opts *EnvOpts) error {
 		return err
 	}
 
-	_, err = commitGithubFile(
+	githubBranch, _, err := opts.Client.Repositories.GetBranch(
+		context.Background(), opts.GitRepoOwner, opts.GitRepoName, defaultBranch, true,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	if githubBranch.GetProtected() {
+		err = createNewBranch(opts.Client, opts.GitRepoOwner, opts.GitRepoName, defaultBranch, "porter-preview")
+
+		if err != nil {
+			return fmt.Errorf(
+				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
+					"To enable Porter Preview Environment deployments, please create Github workflow "+
+					"files in this branch with the following contents:\n"+
+					"--------\n%s--------\n--------\n%s--------\nERROR: %w",
+				defaultBranch, string(applyWorkflowYAML), string(deleteWorkflowYAML), ErrCreatePRForProtectedBranch,
+			)
+		}
+
+		_, err = commitWorkflowFile(
+			opts.Client,
+			fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
+			applyWorkflowYAML, opts.GitRepoOwner,
+			opts.GitRepoName, "porter-preview", false,
+		)
+
+		if err != nil {
+			return fmt.Errorf(
+				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
+					"To enable Porter Preview Environment deployments, please create Github workflow "+
+					"files in this branch with the following contents:\n"+
+					"--------\n%s--------\n--------\n%s--------\nERROR: %w",
+				defaultBranch, string(applyWorkflowYAML), string(deleteWorkflowYAML), ErrCreatePRForProtectedBranch,
+			)
+		}
+
+		_, err = commitWorkflowFile(
+			opts.Client,
+			fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
+			deleteWorkflowYAML, opts.GitRepoOwner,
+			opts.GitRepoName, "porter-preview", false,
+		)
+
+		if err != nil {
+			return fmt.Errorf(
+				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
+					"To enable Porter Preview Environment deployments, please create a Github workflow "+
+					"file in this branch with the following contents:\n"+
+					"--------\n%s--------\nERROR: %w",
+				defaultBranch, string(deleteWorkflowYAML), ErrCreatePRForProtectedBranch,
+			)
+		}
+
+		pr, _, err := opts.Client.PullRequests.Create(
+			context.Background(), opts.GitRepoOwner, opts.GitRepoName, &github.NewPullRequest{
+				Title: github.String("Enable Porter Preview Environment deployments"),
+				Base:  github.String(defaultBranch),
+				Head:  github.String("porter-preview"),
+			},
+		)
+
+		if err != nil {
+			return err
+		}
+
+		return fmt.Errorf("Please merge %s to enable Porter Preview Environment deployments.\nERROR: %w",
+			pr.GetHTMLURL(), ErrProtectedBranch)
+	}
+
+	_, err = commitWorkflowFile(
 		opts.Client,
 		fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
 		applyWorkflowYAML,
@@ -92,13 +163,13 @@ func SetupEnv(opts *EnvOpts) error {
 	if err != nil {
 		if strings.Contains(err.Error(), "409 Could not create file") {
 			// possibly a write-protected branch
-			err = createPorterPreviewBranch(opts, defaultBranch)
+			err = createNewBranch(opts.Client, opts.GitRepoOwner, opts.GitRepoName, defaultBranch, "porter-preview")
 
 			if err != nil {
 				return fmt.Errorf("write-protected branch %s. Error creating porter-preview branch: %w", defaultBranch, err)
 			}
 
-			_, err = commitGithubFile(
+			_, err = commitWorkflowFile(
 				opts.Client,
 				fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
 				applyWorkflowYAML,
@@ -112,7 +183,7 @@ func SetupEnv(opts *EnvOpts) error {
 				return fmt.Errorf("write-protected branch %s. Error committing to porter-preview branch: %w", defaultBranch, err)
 			}
 
-			_, err = commitGithubFile(
+			_, err = commitWorkflowFile(
 				opts.Client,
 				fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
 				deleteWorkflowYAML,
@@ -144,7 +215,7 @@ func SetupEnv(opts *EnvOpts) error {
 		return err
 	}
 
-	_, err = commitGithubFile(
+	_, err = commitWorkflowFile(
 		opts.Client,
 		fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
 		deleteWorkflowYAML,
@@ -197,6 +268,18 @@ func DeleteEnv(opts *EnvOpts) error {
 		}
 	}
 
+	githubBranch, _, err := opts.Client.Repositories.GetBranch(
+		context.Background(), opts.GitRepoOwner, opts.GitRepoName, defaultBranch, true,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	if githubBranch.GetProtected() {
+		return ErrProtectedBranch
+	}
+
 	err = deleteGithubFile(
 		opts.Client,
 		fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
@@ -310,36 +393,46 @@ func getPreviewDeleteActionYAML(opts *EnvOpts) ([]byte, error) {
 	return yaml.Marshal(actionYAML)
 }
 
-func createPorterPreviewBranch(opts *EnvOpts, defaultBranch string) error {
-	_, resp, err := opts.Client.Repositories.GetBranch(
-		context.Background(), opts.GitRepoOwner, opts.GitRepoName, "porter-preview", false,
+func createNewBranch(
+	client *github.Client,
+	gitRepoOwner, gitRepoName, baseBranch, headBranch string,
+) error {
+	_, resp, err := client.Repositories.GetBranch(
+		context.Background(), gitRepoOwner, gitRepoName, headBranch, true,
 	)
 
-	if resp.StatusCode == http.StatusNotFound {
-		branch, _, err := opts.Client.Repositories.GetBranch(
-			context.Background(), opts.GitRepoOwner, opts.GitRepoName, defaultBranch, false,
-		)
-
-		if err != nil {
-			return err
-		}
+	headBranchRef := fmt.Sprintf("refs/heads/%s", headBranch)
 
-		_, _, err = opts.Client.Git.CreateRef(
-			context.Background(), opts.GitRepoOwner, opts.GitRepoName, &github.Reference{
-				Ref: github.String("refs/heads/porter-preview"),
-				Object: &github.GitObject{
-					SHA: branch.Commit.SHA,
-				},
-			},
+	if err == nil {
+		// delete the stale branch
+		_, err := client.Git.DeleteRef(
+			context.Background(), gitRepoOwner, gitRepoName, headBranchRef,
 		)
 
 		if err != nil {
 			return err
 		}
+	} else if resp.StatusCode != http.StatusNotFound {
+		return err
+	}
+
+	base, _, err := client.Repositories.GetBranch(
+		context.Background(), gitRepoOwner, gitRepoName, baseBranch, true,
+	)
 
-		return nil
+	if err != nil {
+		return err
 	}
 
+	_, _, err = client.Git.CreateRef(
+		context.Background(), gitRepoOwner, gitRepoName, &github.Reference{
+			Ref: github.String(headBranchRef),
+			Object: &github.GitObject{
+				SHA: base.Commit.SHA,
+			},
+		},
+	)
+
 	if err != nil {
 		return err
 	}

+ 1 - 1
internal/integrations/ci/actions/steps.go

@@ -55,7 +55,7 @@ func getCreatePreviewEnvStep(
 			"project": fmt.Sprintf("%d", projectID),
 			"token":   fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
 			"namespace": fmt.Sprintf("pr-${{ github.event.inputs.pr_number }}-%s",
-				strings.ReplaceAll(repoName, "_", "-")),
+				strings.ToLower(strings.ReplaceAll(repoName, "_", "-"))),
 			"pr_id":           "${{ github.event.inputs.pr_number }}",
 			"pr_name":         "${{ github.event.inputs.pr_title }}",
 			"installation_id": fmt.Sprintf("%d", gitInstallationID),

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

@@ -0,0 +1,509 @@
+package gitlab
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"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
+	GitBranch    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
+	gitlabInstanceURL 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.GetRawFile(g.pID, ".gitlab-ci.yml", &gitlab.GetRawFileOptions{
+		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
+
+		// to preserve the order of the YAML, we use a MapSlice
+		ciFileContentsMap := yaml.MapSlice{}
+		err = yaml.Unmarshal(ciFile, &ciFileContentsMap)
+
+		if err != nil {
+			return fmt.Errorf("error unmarshalling existing .gitlab-ci.yml: %w", err)
+		}
+
+		var stagesInt []interface{}
+		stagesIdx := -1
+
+		for idx, elem := range ciFileContentsMap {
+			if key, ok := elem.Key.(string); ok {
+				if key == "stages" {
+					stages, ok := elem.Value.([]interface{})
+
+					if !ok {
+						return fmt.Errorf("error converting stages to interface slice")
+					}
+
+					stagesInt = stages
+					stagesIdx = idx
+
+					break
+				}
+			} else {
+				return fmt.Errorf("invalid key '%v' in .gitlab-ci.yml", elem.Key)
+			}
+		}
+
+		// two cases can happen here:
+		// 1: "stages" exists
+		// 2: "stages" does not exist
+
+		if stagesIdx >= 0 { // 1: "stages" exists
+			stageExists := false
+
+			for _, stage := range stagesInt {
+				stageStr, ok := stage.(string)
+				if !ok {
+					return fmt.Errorf("error converting from interface to string")
+				}
+
+				if stageStr == jobName {
+					stageExists = true
+					break
+				}
+			}
+
+			if !stageExists {
+				stagesInt = append(stagesInt, jobName)
+
+				ciFileContentsMap[stagesIdx] = yaml.MapItem{
+					Key:   "stages",
+					Value: stagesInt,
+				}
+			}
+		} else { // 2: "stages" does not exist
+			stagesInt = append(stagesInt, jobName)
+
+			ciFileContentsMap = append(ciFileContentsMap, yaml.MapItem{
+				Key:   "stages",
+				Value: stagesInt,
+			})
+		}
+
+		ciFileContentsMap = append(ciFileContentsMap, yaml.MapItem{
+			Key:   jobName,
+			Value: g.getCIJob(jobName),
+		})
+
+		contentsYAML, err := yaml.Marshal(ciFileContentsMap)
+
+		if err != nil {
+			return fmt.Errorf("error marshalling contents of .gitlab-ci.yml while updating to add porter job")
+		}
+
+		_, _, 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.GetRawFile(g.pID, ".gitlab-ci.yml", &gitlab.GetRawFileOptions{
+		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 := yaml.MapSlice{}
+	err = yaml.Unmarshal(ciFile, &ciFileContentsMap)
+
+	if err != nil {
+		return fmt.Errorf("error unmarshalling existing .gitlab-ci.yml: %w", err)
+	}
+
+	var stagesInt []interface{}
+	stagesIdx := -1
+
+	for idx, elem := range ciFileContentsMap {
+		if key, ok := elem.Key.(string); ok {
+			if key == "stages" {
+				stages, ok := elem.Value.([]interface{})
+
+				if !ok {
+					return fmt.Errorf("error converting stages to interface slice")
+				}
+
+				stagesInt = stages
+				stagesIdx = idx
+
+				break
+			}
+		} else {
+			return fmt.Errorf("invalid key '%v' in .gitlab-ci.yml", elem.Key)
+		}
+	}
+
+	if stagesIdx >= 0 { // "stages" exists
+		var newStages []string
+
+		for _, stage := range stagesInt {
+			stageStr, ok := stage.(string)
+			if !ok {
+				return fmt.Errorf("error converting from interface to string")
+			}
+
+			if stageStr != jobName {
+				newStages = append(newStages, stageStr)
+			}
+		}
+
+		ciFileContentsMap[stagesIdx] = yaml.MapItem{
+			Key:   "stages",
+			Value: newStages,
+		}
+	}
+
+	newCIFileContentsMap := yaml.MapSlice{}
+
+	for _, elem := range ciFileContentsMap {
+		if key, ok := elem.Key.(string); ok {
+			if key != jobName {
+				newCIFileContentsMap = append(newCIFileContentsMap, elem)
+			}
+		} else {
+			return fmt.Errorf("invalid key '%v' in .gitlab-ci.yml", elem.Key)
+		}
+	}
+
+	contentsYAML, err := yaml.Marshal(newCIFileContentsMap)
+
+	if err != nil {
+		return fmt.Errorf("error unmarshalling contents of .gitlab-ci.yml while updating to remove porter job")
+	}
+
+	_, _, 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
+	}
+
+	giOAuthInt, err := g.Repo.GitlabAppOAuthIntegration().ReadGitlabAppOAuthIntegration(g.UserID, g.ProjectID, g.IntegrationID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	oauthInt, err := g.Repo.OAuthIntegration().ReadOAuthIntegration(g.ProjectID, giOAuthInt.OAuthIntegrationID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	accessToken, _, err := oauth.GetAccessToken(
+		oauthInt.SharedOAuthModel,
+		commonutils.GetGitlabOAuthConf(g.PorterConf, gi),
+		oauth.MakeUpdateGitlabAppOAuthIntegrationFunction(g.ProjectID, giOAuthInt, g.Repo),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client, err := gitlab.NewOAuthClient(accessToken, gitlab.WithBaseURL(gi.InstanceURL))
+
+	if err != nil {
+		return nil, err
+	}
+
+	g.gitlabInstanceURL = gi.InstanceURL
+
+	return client, nil
+}
+
+func (g *GitlabCI) getCIJob(jobName string) yaml.MapSlice {
+	res := yaml.MapSlice{}
+	url, _ := url.Parse(g.gitlabInstanceURL)
+
+	res = append(res,
+		yaml.MapItem{
+			Key: "rules",
+			Value: []map[string]string{
+				{
+					"if": fmt.Sprintf("$CI_COMMIT_BRANCH == \"%s\" && $CI_PIPELINE_SOURCE == \"push\"", g.GitBranch),
+				},
+			},
+		},
+	)
+
+	if url.Hostname() == "gitlab.com" || url.Hostname() == "www.gitlab.com" {
+		res = append(res,
+			yaml.MapItem{
+				Key:   "image",
+				Value: "docker:latest",
+			},
+			yaml.MapItem{
+				Key: "services",
+				Value: []string{
+					"docker:dind",
+				},
+			},
+			yaml.MapItem{
+				Key: "script",
+				Value: []string{
+					fmt.Sprintf(
+						"docker run --rm --workdir=\"/app\" "+
+							"-v /var/run/docker.sock:/var/run/docker.sock "+
+							"-v $(pwd):/app "+
+							"public.ecr.aws/o1j4x7p4/porter-cli:latest "+
+							"update --host \"%s\" --project %d --cluster %d "+
+							"--token \"$%s\" --app \"%s\" "+
+							"--tag \"$(echo $CI_COMMIT_SHA | cut -c1-7)\" --namespace \"%s\" --stream",
+						g.ServerURL, g.ProjectID, g.ClusterID, g.getPorterTokenSecretName(),
+						g.ReleaseName, g.ReleaseNamespace,
+					),
+				},
+			},
+			yaml.MapItem{
+				Key: "tags",
+				Value: []string{
+					"docker",
+				},
+			},
+		)
+	} else {
+		res = append(res,
+			yaml.MapItem{
+				Key: "image",
+				Value: map[string]interface{}{
+					"name": "public.ecr.aws/o1j4x7p4/porter-cli:latest",
+					"entrypoint": []string{
+						"",
+					},
+				},
+			},
+			yaml.MapItem{
+				Key: "script",
+				Value: []string{
+					fmt.Sprintf(
+						"porter update --host \"%s\" --project %d --cluster %d "+
+							"--token \"$%s\" --app \"%s\" "+
+							"--tag \"$(echo $CI_COMMIT_SHA | cut -c1-7)\" --namespace \"%s\" --stream",
+						g.ServerURL, g.ProjectID, g.ClusterID, g.getPorterTokenSecretName(),
+						g.ReleaseName, g.ReleaseNamespace,
+					),
+				},
+			},
+			yaml.MapItem{
+				Key: "tags",
+				Value: []string{
+					"porter-runner",
+				},
+			},
+		)
+	}
+
+	res = append(res,
+		yaml.MapItem{
+			Key:   "stage",
+			Value: jobName,
+		},
+		yaml.MapItem{
+			Key:   "timeout",
+			Value: "20 minutes",
+		},
+		yaml.MapItem{
+			Key: "variables",
+			Value: map[string]string{
+				"GIT_STRATEGY": "clone",
+			},
+		},
+	)
+
+	return res
+}
+
+func (g *GitlabCI) createGitlabSecret(client *gitlab.Client) error {
+	_, resp, err := client.ProjectVariables.GetVariable(g.pID, g.getPorterTokenSecretName(),
+		&gitlab.GetProjectVariableOptions{})
+
+	if resp.StatusCode == http.StatusNotFound {
+		_, _, 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
+	} else if err != nil {
+		return fmt.Errorf("error getting porter token variable: %w", err)
+	}
+
+	_, _, err = client.ProjectVariables.UpdateVariable(g.pID, g.getPorterTokenSecretName(),
+		&gitlab.UpdateProjectVariableOptions{
+			Value:  gitlab.String(g.PorterToken),
+			Masked: gitlab.Bool(true),
+		},
+	)
+
+	if err != nil {
+		return fmt.Errorf("error updating 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_%s", g.ProjectID, strings.ToLower(strings.ReplaceAll(g.ReleaseName, "-", "_")))
+}
+
+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,
+	}
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов