Explorar el Código

resolve merge conflicts with master

Alexander Belanger hace 3 años
padre
commit
f0d4448f72
Se han modificado 100 ficheros con 3869 adiciones y 646 borrados
  1. 2 2
      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. 19 4
      api/server/handlers/cluster/create_namespace.go
  6. 5 10
      api/server/handlers/cluster/delete_namespace.go
  7. 13 2
      api/server/handlers/cluster/get_namespace.go
  8. 15 2
      api/server/handlers/cluster/list_namespaces.go
  9. 22 5
      api/server/handlers/environment/create.go
  10. 2 2
      api/server/handlers/environment/create_deployment.go
  11. 28 18
      api/server/handlers/environment/delete.go
  12. 2 2
      api/server/handlers/environment/finalize_deployment.go
  13. 2 2
      api/server/handlers/environment/get_deployment.go
  14. 2 2
      api/server/handlers/environment/list_deployments.go
  15. 2 2
      api/server/handlers/environment/update_deployment.go
  16. 2 2
      api/server/handlers/environment/update_deployment_status.go
  17. 4 3
      api/server/handlers/gitinstallation/get_buildpack.go
  18. 3 2
      api/server/handlers/gitinstallation/get_contents.go
  19. 3 2
      api/server/handlers/gitinstallation/get_procfile.go
  20. 3 2
      api/server/handlers/gitinstallation/get_tarball_url.go
  21. 0 42
      api/server/handlers/gitinstallation/helpers.go
  22. 2 1
      api/server/handlers/gitinstallation/list_branches.go
  23. 1 1
      api/server/handlers/gitinstallation/rerun_workflow.go
  24. 31 2
      api/server/handlers/handler.go
  25. 132 0
      api/server/handlers/oauth_callback/gitlab.go
  26. 73 0
      api/server/handlers/project_integration/create_gitlab.go
  27. 160 0
      api/server/handlers/project_integration/get_gitlab_repo_buildpack.go
  28. 112 0
      api/server/handlers/project_integration/get_gitlab_repo_contents.go
  29. 111 0
      api/server/handlers/project_integration/get_gitlab_repo_procfile.go
  30. 117 0
      api/server/handlers/project_integration/list_git.go
  31. 44 0
      api/server/handlers/project_integration/list_gitlab.go
  32. 77 0
      api/server/handlers/project_integration/list_gitlab_repo_branches.go
  33. 119 0
      api/server/handlers/project_integration/list_gitlab_repos.go
  34. 1 1
      api/server/handlers/project_oauth/digitalocean.go
  35. 78 0
      api/server/handlers/project_oauth/gitlab.go
  36. 1 1
      api/server/handlers/project_oauth/slack.go
  37. 112 3
      api/server/handlers/registry/create.go
  38. 2 0
      api/server/handlers/registry/create_repository.go
  39. 98 71
      api/server/handlers/release/create.go
  40. 53 20
      api/server/handlers/release/delete.go
  41. 0 2
      api/server/handlers/release/get_gha_template.go
  42. 4 4
      api/server/handlers/release/update_rollback.go
  43. 3 3
      api/server/handlers/release/upgrade.go
  44. 1 1
      api/server/handlers/user/github_start.go
  45. 1 1
      api/server/handlers/user/google_start.go
  46. 94 0
      api/server/handlers/v1/registry/list_images.go
  47. 235 0
      api/server/handlers/v1/release/upgrade.go
  48. 2 2
      api/server/router/cluster.go
  49. 24 0
      api/server/router/oauth_callback.go
  50. 241 0
      api/server/router/project_integration.go
  51. 28 0
      api/server/router/project_oauth.go
  52. 5 0
      api/server/router/router.go
  53. 13 6
      api/server/router/v1/cluster.go
  54. 42 10
      api/server/router/v1/registry.go
  55. 26 7
      api/server/router/v1/release.go
  56. 43 0
      api/server/shared/commonutils/git_utils.go
  57. 20 0
      api/server/shared/commonutils/gitlab.go
  58. 3 0
      api/server/shared/config/env/envconfs.go
  59. 2 0
      api/server/shared/config/metadata.go
  60. 11 4
      api/types/build_config.go
  61. 24 20
      api/types/cluster.go
  62. 31 8
      api/types/git_action_config.go
  63. 14 13
      api/types/policy.go
  64. 34 0
      api/types/project_integration.go
  65. 82 10
      api/types/registry.go
  66. 63 17
      api/types/release.go
  67. 1 0
      api/types/request.go
  68. 1 0
      api/types/user.go
  69. 5 3
      cli/cmd/cluster.go
  70. 1 1
      cli/cmd/deploy/create.go
  71. 5 2
      cli/cmd/get.go
  72. 24 0
      dashboard/package-lock.json
  73. 13 2
      dashboard/src/components/SearchBar.tsx
  74. 1 2
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  75. 50 22
      dashboard/src/components/repo-selector/BranchList.tsx
  76. 29 11
      dashboard/src/components/repo-selector/BuildpackSelection.tsx
  77. 105 38
      dashboard/src/components/repo-selector/ContentsList.tsx
  78. 302 91
      dashboard/src/components/repo-selector/RepoList.tsx
  79. 1 0
      dashboard/src/main/Main.tsx
  80. 60 64
      dashboard/src/main/home/WelcomeForm.tsx
  81. 6 6
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  82. 5 5
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  83. 49 17
      dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx
  84. 48 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx
  85. 161 0
      dashboard/src/main/home/integrations/GitlabIntegrationList.tsx
  86. 22 3
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  87. 15 6
      dashboard/src/main/home/integrations/Integrations.tsx
  88. 2 2
      dashboard/src/main/home/integrations/SlackIntegrationList.tsx
  89. 3 0
      dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx
  90. 147 0
      dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx
  91. 27 11
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  92. 16 12
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  93. 2 3
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  94. 2 1
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  95. 27 2
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  96. 6 0
      dashboard/src/shared/Context.tsx
  97. 109 6
      dashboard/src/shared/api.tsx
  98. 2 1
      dashboard/src/shared/common.tsx
  99. 30 0
      dashboard/src/shared/hooks/useOutsideAlerter.ts
  100. 27 10
      dashboard/src/shared/types.tsx

+ 2 - 2
api/client/k8s.go

@@ -38,8 +38,8 @@ func (c *Client) CreateNewK8sNamespace(
 	projectID uint,
 	clusterID uint,
 	name string,
-) (*types.CreateNamespaceResponse, error) {
-	resp := &types.CreateNamespaceResponse{}
+) (*types.NamespaceResponse, error) {
+	resp := &types.NamespaceResponse{}
 
 	err := c.postRequest(
 		fmt.Sprintf(

+ 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

@@ -134,6 +134,8 @@ func getRequestActionForEndpoint(
 			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamStackID)
 		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)
 

+ 19 - 4
api/server/handlers/cluster/create_namespace.go

@@ -1,7 +1,9 @@
 package cluster
 
 import (
+	"fmt"
 	"net/http"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -44,6 +46,15 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	_, err = agent.GetNamespace(request.Name)
+
+	if err == nil { // namespace with name already exists
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("namespace already exists"), http.StatusPreconditionFailed,
+		))
+		return
+	}
+
 	namespace, err := agent.CreateNamespace(request.Name)
 
 	if err != nil {
@@ -51,10 +62,14 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	res := types.CreateNamespaceResponse{
-		Metadata: types.CreateNamespaceResponseMeta{
-			Name: namespace.Name,
-		},
+	res := &types.NamespaceResponse{
+		Name:              namespace.Name,
+		CreationTimestamp: namespace.CreationTimestamp.Time.UTC().Format(time.RFC1123),
+		Status:            string(namespace.Status.Phase),
+	}
+
+	if namespace.DeletionTimestamp != nil {
+		res.DeletionTimestamp = namespace.DeletionTimestamp.Time.UTC().Format(time.RFC1123)
 	}
 
 	w.WriteHeader(http.StatusCreated)

+ 5 - 10
api/server/handlers/cluster/delete_namespace.go

@@ -29,20 +29,15 @@ func NewDeleteNamespaceHandler(
 }
 
 func (c *DeleteNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
-
-	if namespace == "" {
-		request := &types.DeleteNamespaceRequest{}
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-		if ok := c.DecodeAndValidate(w, r, request); !ok {
-			return
-		}
+	namespace, reqErr := requestutils.GetURLParamString(r, types.URLParamNamespace)
 
-		namespace = request.Name
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
+		return
 	}
 
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
-
 	agent, err := c.GetAgent(r, cluster, "")
 
 	if err != nil {

+ 13 - 2
api/server/handlers/cluster/get_namespace.go

@@ -2,6 +2,7 @@ package cluster
 
 import (
 	"net/http"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -30,7 +31,7 @@ func NewGetNamespaceHandler(
 }
 
 func (c *GetNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	namespace, reqErr := requestutils.GetURLParamString(r, types.URLParamNamespace)
+	ns, reqErr := requestutils.GetURLParamString(r, types.URLParamNamespace)
 
 	if reqErr != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
@@ -45,7 +46,7 @@ func (c *GetNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	res, err := agent.GetNamespace(namespace)
+	namespace, err := agent.GetNamespace(ns)
 
 	if err != nil {
 		if errors.IsNotFound(err) {
@@ -57,5 +58,15 @@ func (c *GetNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	res := &types.NamespaceResponse{
+		Name:              namespace.Name,
+		CreationTimestamp: namespace.CreationTimestamp.Time.UTC().Format(time.RFC1123),
+		Status:            string(namespace.Status.Phase),
+	}
+
+	if namespace.DeletionTimestamp != nil {
+		res.DeletionTimestamp = namespace.DeletionTimestamp.Time.UTC().Format(time.RFC1123)
+	}
+
 	c.WriteResult(w, r, res)
 }

+ 15 - 2
api/server/handlers/cluster/list_namespaces.go

@@ -2,6 +2,7 @@ package cluster
 
 import (
 	"net/http"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -44,8 +45,20 @@ func (c *ListNamespacesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	res := types.ListNamespacesResponse{
-		NamespaceList: namespaceList,
+	res := types.ListNamespacesResponse{}
+
+	for _, ns := range namespaceList.Items {
+		namespace := &types.NamespaceResponse{
+			Name:              ns.Name,
+			CreationTimestamp: ns.CreationTimestamp.Time.UTC().Format(time.RFC1123),
+			Status:            string(ns.Status.Phase),
+		}
+
+		if ns.DeletionTimestamp != nil {
+			namespace.DeletionTimestamp = ns.DeletionTimestamp.Time.UTC().Format(time.RFC1123)
+		}
+
+		res = append(res, namespace)
 	}
 
 	c.WriteResult(w, r, res)

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

+ 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,
+		isProject, isUser 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, true, false, "", 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, true, false, "", 0); err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 112 - 3
api/server/handlers/registry/create.go

@@ -1,6 +1,7 @@
 package registry
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -13,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/registry"
+	"gorm.io/gorm"
 )
 
 type RegistryCreateHandler struct {
@@ -49,7 +51,113 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	//  TODO!!!: validate the credentials here!!!
+	// validate request before saving it to the DB
+	integrationIDs := []uint{
+		request.GCPIntegrationID,
+		request.AWSIntegrationID,
+		request.DOIntegrationID,
+		request.BasicIntegrationID,
+		request.AzureIntegrationID,
+	}
+
+	idCount := 0
+
+	for _, id := range integrationIDs {
+		if id != 0 {
+			idCount += 1
+		}
+	}
+
+	if idCount > 1 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("only one integration ID should be set"), http.StatusBadRequest,
+		))
+		return
+	} else if idCount == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("at least one integration ID should be set"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
+	if request.GCPIntegrationID != 0 {
+		_, err = p.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such GCP integration ID: %d for project ID: %d", request.GCPIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.AWSIntegrationID != 0 {
+		_, err = p.Repo().AWSIntegration().ReadAWSIntegration(proj.ID, request.AWSIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such AWS integration ID: %d for project ID: %d", request.AWSIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.DOIntegrationID != 0 {
+		_, err = p.Repo().OAuthIntegration().ReadOAuthIntegration(proj.ID, request.DOIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such DO integration ID: %d for project ID: %d", request.DOIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.BasicIntegrationID != 0 {
+		_, err = p.Repo().BasicIntegration().ReadBasicIntegration(proj.ID, request.BasicIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such basic integration ID: %d for project ID: %d", request.BasicIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.AzureIntegrationID != 0 {
+		_, err = p.Repo().AzureIntegration().ReadAzureIntegration(proj.ID, request.AzureIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such Azure integration ID: %d for project ID: %d", request.AzureIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
 
 	// create a registry model
 	regModel := &models.Registry{
@@ -101,7 +209,7 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		az.ACRName = request.ACRName
 		az.ACRResourceGroupName = request.ACRResourceGroupName
 
-		az, err = p.Repo().AzureIntegration().OverwriteAzureIntegration(az)
+		_, err = p.Repo().AzureIntegration().OverwriteAzureIntegration(az)
 
 		if err != nil {
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -110,7 +218,7 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	}
 
 	// handle write to the database
-	regModel, err := p.Repo().Registry().CreateRegistry(regModel)
+	regModel, err = p.Repo().Registry().CreateRegistry(regModel)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -124,5 +232,6 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		},
 	))
 
+	w.WriteHeader(http.StatusCreated)
 	p.WriteResult(w, r, regModel.ToRegistryType())
 }

+ 2 - 0
api/server/handlers/registry/create_repository.go

@@ -51,4 +51,6 @@ func (p *RegistryCreateRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
+
+	w.WriteHeader(http.StatusCreated)
 }

+ 98 - 71
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(
@@ -204,6 +214,8 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			FlowID: operationID,
 		},
 	))
+
+	w.WriteHeader(http.StatusCreated)
 }
 
 func CreateAppReleaseFromHelmRelease(
@@ -262,7 +274,6 @@ func CreateAddonReleaseFromHelmRelease(
 
 func createGitAction(
 	config *config.Config,
-	project *models.Project,
 	userID, projectID, clusterID uint,
 	request *types.CreateGitActionConfigRequest,
 	name, namespace string,
@@ -304,63 +315,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 {
@@ -376,16 +412,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) {
@@ -471,10 +502,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 {
@@ -529,7 +556,7 @@ type containerEnvConfig struct {
 	} `yaml:"container"`
 }
 
-func getGARunner(
+func GetGARunner(
 	config *config.Config,
 	userID, projectID, clusterID uint,
 	ga *models.GitActionConfig,

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

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

@@ -65,7 +65,7 @@ func (c *RollbackReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		rel, err := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
 
 		if err == nil && rel != nil {
-			err = updateReleaseRepo(c.Config(), rel, helmRelease)
+			err = UpdateReleaseRepo(c.Config(), rel, helmRelease)
 
 			if err != nil {
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -74,8 +74,8 @@ func (c *RollbackReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 			gitAction := rel.GitActionConfig
 
-			if gitAction != nil && gitAction.ID != 0 {
-				gaRunner, err := getGARunner(
+			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
+				gaRunner, err := GetGARunner(
 					c.Config(),
 					user.ID,
 					cluster.ProjectID,
@@ -110,7 +110,7 @@ func (c *RollbackReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	}
 }
 
-func updateReleaseRepo(config *config.Config, release *models.Release, helmRelease *release.Release) error {
+func UpdateReleaseRepo(config *config.Config, release *models.Release, helmRelease *release.Release) error {
 	repository := helmRelease.Config["image"].(map[string]interface{})["repository"]
 	repoStr, ok := repository.(string)
 

+ 3 - 3
api/server/handlers/release/ugprade.go → api/server/handlers/release/upgrade.go

@@ -187,7 +187,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	// update the github actions env if the release exists and is built from source
 	if cName := helmRelease.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
 		if releaseErr == nil && rel != nil {
-			err = updateReleaseRepo(c.Config(), rel, helmRelease)
+			err = UpdateReleaseRepo(c.Config(), rel, helmRelease)
 
 			if err != nil {
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -196,8 +196,8 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 			gitAction := rel.GitActionConfig
 
-			if gitAction != nil && gitAction.ID != 0 {
-				gaRunner, err := getGARunner(
+			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
+				gaRunner, err := GetGARunner(
 					c.Config(),
 					user.ID,
 					cluster.ProjectID,

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

+ 94 - 0
api/server/handlers/v1/registry/list_images.go

@@ -0,0 +1,94 @@
+package registry
+
+import (
+	"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/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/registry"
+)
+
+type RegistryListImagesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewRegistryListImagesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RegistryListImagesHandler {
+	return &RegistryListImagesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *RegistryListImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	reg, _ := r.Context().Value(types.RegistryScope).(*models.Registry)
+
+	repoName, reqErr := requestutils.GetURLParamString(r, types.URLParamWildcard)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
+		return
+	}
+
+	request := &types.V1ListImageRequest{}
+
+	ok := c.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	res := &types.V1ListImageResponse{}
+
+	// cast to a registry from registry package
+	_reg := registry.Registry(*reg)
+	regAPI := &_reg
+
+	if regAPI.AWSIntegrationID != 0 {
+		if request.Num == 0 {
+			request.Num = 1000
+		} else if request.Num < 1 || request.Num > 1000 {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("num should be between 1 and 1000 for ECR images"), http.StatusBadRequest,
+			))
+			return
+		}
+
+		var nextToken *string
+		if request.Next != "" {
+			nextToken = &request.Next
+		}
+
+		imgs, nextToken, err := regAPI.GetECRPaginatedImages(repoName, c.Repo(), request.Num, nextToken)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		if nextToken != nil {
+			res.Next = *nextToken
+		}
+
+		res.Images = append(res.Images, imgs...)
+	} else {
+		imgs, err := regAPI.ListImages(repoName, c.Repo(), c.Config().DOConf)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		res.Images = append(res.Images, imgs...)
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 235 - 0
api/server/handlers/v1/release/upgrade.go

@@ -0,0 +1,235 @@
+package release
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+
+	semver "github.com/Masterminds/semver/v3"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	baseReleaseHandler "github.com/porter-dev/porter/api/server/handlers/release"
+	"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/helm"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	"github.com/porter-dev/porter/internal/models"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+var (
+	createEnvSecretConstraint, _ = semver.NewConstraint(" < 0.1.0")
+)
+
+type UpgradeReleaseHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpgradeReleaseHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpgradeReleaseHandler {
+	return &UpgradeReleaseHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
+
+	helmAgent, err := c.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	request := &types.V1UpgradeReleaseRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	conf := &helm.UpgradeReleaseConfig{
+		Name:       helmRelease.Name,
+		Cluster:    cluster,
+		Repo:       c.Repo(),
+		Registries: registries,
+	}
+
+	// if the chart version is set, load a chart from the repo
+	if request.ChartVersion != "" {
+		cache := c.Config().URLCache
+		chartRepoURL, foundFirst := cache.GetURL(helmRelease.Chart.Metadata.Name)
+
+		if !foundFirst {
+			cache.Update()
+
+			var found bool
+
+			chartRepoURL, found = cache.GetURL(helmRelease.Chart.Metadata.Name)
+
+			if !found {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("chart not found"),
+					http.StatusBadRequest,
+				))
+
+				return
+			}
+		}
+
+		chart, err := baseReleaseHandler.LoadChart(c.Config(), &baseReleaseHandler.LoadAddonChartOpts{
+			ProjectID:       cluster.ProjectID,
+			RepoURL:         chartRepoURL,
+			TemplateName:    helmRelease.Chart.Metadata.Name,
+			TemplateVersion: request.ChartVersion,
+		})
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("chart not found"),
+				http.StatusBadRequest,
+			))
+
+			return
+		}
+
+		conf.Chart = chart
+	}
+
+	conf.Values = request.Values
+
+	newHelmRelease, upgradeErr := helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
+
+	if upgradeErr == nil && newHelmRelease != nil {
+		helmRelease = newHelmRelease
+	}
+
+	slackInts, _ := c.Repo().SlackIntegration().ListSlackIntegrationsByProjectID(cluster.ProjectID)
+
+	rel, releaseErr := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
+
+	var notifConf *types.NotificationConfig
+	notifConf = nil
+	if rel != nil && rel.NotificationConfig != 0 {
+		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(rel.NotificationConfig)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		notifConf = conf.ToNotificationConfigType()
+	}
+
+	notifier := slack.NewSlackNotifier(notifConf, slackInts...)
+
+	notifyOpts := &slack.NotifyOpts{
+		ProjectID:   cluster.ProjectID,
+		ClusterID:   cluster.ID,
+		ClusterName: cluster.Name,
+		Name:        helmRelease.Name,
+		Namespace:   helmRelease.Namespace,
+		URL: fmt.Sprintf(
+			"%s/applications/%s/%s/%s?project_id=%d",
+			c.Config().ServerConf.ServerURL,
+			url.PathEscape(cluster.Name),
+			helmRelease.Namespace,
+			helmRelease.Name,
+			cluster.ProjectID,
+		),
+	}
+
+	if upgradeErr != nil {
+		notifyOpts.Status = slack.StatusHelmFailed
+		notifyOpts.Info = upgradeErr.Error()
+
+		if !cluster.NotificationsDisabled {
+			notifier.Notify(notifyOpts)
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			upgradeErr,
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	if helmRelease.Chart != nil && helmRelease.Chart.Metadata.Name != "job" {
+		notifyOpts.Status = slack.StatusHelmDeployed
+		notifyOpts.Version = helmRelease.Version
+
+		if !cluster.NotificationsDisabled {
+			notifier.Notify(notifyOpts)
+		}
+	}
+
+	// update the github actions env if the release exists and is built from source
+	if cName := helmRelease.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
+		if releaseErr == nil && rel != nil {
+			err = baseReleaseHandler.UpdateReleaseRepo(c.Config(), rel, helmRelease)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			gitAction := rel.GitActionConfig
+
+			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
+				gaRunner, err := baseReleaseHandler.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
+				}
+
+				actionVersion, err := semver.NewVersion(gaRunner.Version)
+
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+
+				if createEnvSecretConstraint.Check(actionVersion) {
+					if err := gaRunner.CreateEnvSecret(); err != nil {
+						c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+						return
+					}
+				}
+			}
+		}
+	}
+}

+ 2 - 2
api/server/router/cluster.go

@@ -609,14 +609,14 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
-	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/delete -> cluster.NewDeleteNamespaceHandler
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewDeleteNamespaceHandler
 	deleteNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,
 			Method: types.HTTPVerbDelete,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/namespaces/delete",
+				RelativePath: fmt.Sprintf("%s/namespaces/{%s}", relPath, types.URLParamNamespace),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,

+ 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

@@ -222,6 +222,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)
 
@@ -259,6 +262,8 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 				atomicGroup.Use(releaseFactory.Middleware)
 			case types.StackScope:
 				atomicGroup.Use(stackFactory.Middleware)
+			case types.GitlabIntegrationScope:
+				atomicGroup.Use(gitlabIntFactory.Middleware)
 			}
 		}
 

+ 13 - 6
api/server/router/v1/cluster.go

@@ -73,7 +73,8 @@ func getV1ClusterRoutes(
 	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewCreateNamespaceHandler
 	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces createNamespace
 	//
-	// Creates a new namespace
+	// Creates a new namespace in the cluster denoted by `cluster_id`. The cluster should belong to the project
+	// denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -93,9 +94,11 @@ func getV1ClusterRoutes(
 	//   '201':
 	//     description: Successfully created a new namespace
 	//     schema:
-	//       $ref: '#/definitions/CreateNamespaceResponse'
+	//       $ref: '#/definitions/NamespaceResponse'
 	//   '403':
 	//     description: Forbidden
+	//   '412':
+	//     description: Namespace already exists
 	createNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -127,7 +130,8 @@ func getV1ClusterRoutes(
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewGetNamespaceHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} getNamespace
 	//
-	// Gets a namespace
+	// Gets a namespace denoted by the name `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id` which itself should belong to the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -143,7 +147,7 @@ func getV1ClusterRoutes(
 	//   '200':
 	//     description: Successfully got the namespace
 	//     schema:
-	//       $ref: '#/definitions/GetNamespaceResponse'
+	//       $ref: '#/definitions/NamespaceResponse'
 	//   '403':
 	//     description: Forbidden
 	//   '404':
@@ -178,7 +182,8 @@ func getV1ClusterRoutes(
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewListNamespacesHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces listNamespaces
 	//
-	// Lists namespaces
+	// Lists all namespaces in the cluster denoted by `cluster_id`. The cluster should belong to
+	// the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -226,7 +231,9 @@ func getV1ClusterRoutes(
 	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewDeleteNamespaceHandler
 	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} deleteNamespace
 	//
-	// Deletes a namespace
+	// Deletes a namespace with the name `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id` which itself should belong to the project denoted by `project_id`.
+	// Note that this endpoint does not indicate if the namespace does not exist.
 	//
 	// ---
 	// produces:

+ 42 - 10
api/server/router/v1/registry.go

@@ -5,6 +5,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
+	v1Registry "github.com/porter-dev/porter/api/server/handlers/v1/registry"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -73,7 +74,7 @@ func getV1RegistryRoutes(
 	// POST /api/v1/projects/{project_id}/registries -> registry.NewRegistryCreateHandler
 	// swagger:operation POST /api/v1/projects/{project_id}/registries createRegistry
 	//
-	// Connects a new image registry
+	// Connects a new image registry to the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -93,8 +94,12 @@ func getV1RegistryRoutes(
 	//     description: Successfully connected the registry
 	//     schema:
 	//       $ref: '#/definitions/CreateRegistryResponse'
+	//   '400':
+	//     description: A malformed or bad request
 	//   '403':
 	//     description: Forbidden
+	//   '404':
+	//     description: A subresource was not found
 	createRegistryEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -125,7 +130,8 @@ func getV1RegistryRoutes(
 	// GET /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryGetHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/registries/{registry_id} getRegistry
 	//
-	// Gets an image registry
+	// Gets an image registry denoted by `registry_id`. The registry should belong to the
+	// project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -137,7 +143,7 @@ func getV1RegistryRoutes(
 	//   - name: project_id
 	//   - name: registry_id
 	// responses:
-	//   '201':
+	//   '200':
 	//     description: Successfully got the registry
 	//     schema:
 	//       $ref: '#/definitions/GetRegistryResponse'
@@ -173,7 +179,7 @@ func getV1RegistryRoutes(
 	// GET /api/v1/projects/{project_id}/registries -> registry.NewRegistryListHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/registries listRegistries
 	//
-	// Lists registries
+	// Lists all registries connected to the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -220,7 +226,8 @@ func getV1RegistryRoutes(
 	// DELETE /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryDeleteHandler
 	// swagger:operation DELETE /api/v1/projects/{project_id}/registries/{registry_id} deleteRegistry
 	//
-	// Deletes an image registry.
+	// Deletes a registry denoted by `registry_id`. The registry should belong to
+	// the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -319,7 +326,8 @@ func getV1RegistryRoutes(
 	// GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories -> registry.NewRegistryListRepositoriesHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories listRegistryRepositories
 	//
-	// Lists image repositories inside the image registry given by `registry_id`
+	// Lists image repositories inside the image registry denoted by `registry_id`. The registry
+	// should belong to the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -367,7 +375,9 @@ func getV1RegistryRoutes(
 	// GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories/* -> registry.NewRegistryListImagesHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories/{repository} listRegistryImages
 	//
-	// Lists all images in an image repository.
+	// Lists all images in the image repository denoted by the name `repository`. The repository should belong
+	// to the registry denoted by `registry_id` which should itself belong to the project denoted by
+	// `project_id`.
 	//
 	// ---
 	// produces:
@@ -380,14 +390,35 @@ func getV1RegistryRoutes(
 	//   - name: registry_id
 	//   - name: repository
 	//     in: path
-	//     description: the image repository name
+	//     description: The image repository name
 	//     type: string
 	//     required: true
+	//   - name: num
+	//     in: query
+	//     description: |
+	//       The number of images to list.
+	//       For ECR images, a maximum of 1000 is allowed.
+	//     type: integer
+	//     required: false
+	//     minimum: 1
+	//   - name: next
+	//     in: query
+	//     description: The next page string used for pagination, from a previous request.
+	//     type: string
+	//   - name: page
+	//     in: query
+	//     description: |
+	//       The page number used for pagination, possibly from a previous request.
+	//       (**DigitalOcean only**)
+	//     type: integer
+	//     minimum: 1
 	// responses:
 	//   '200':
 	//     description: Successfully listed images
 	//     schema:
-	//       $ref: '#/definitions/ListImagesResponse'
+	//       $ref: '#/definitions/V1ListImageResponse'
+	//   '400':
+	//     description: A malformed or bad request
 	//   '403':
 	//     description: Forbidden
 	listImagesEndpoint := factory.NewAPIEndpoint(
@@ -410,8 +441,9 @@ func getV1RegistryRoutes(
 		},
 	)
 
-	listImagesHandler := registry.NewRegistryListImagesHandler(
+	listImagesHandler := v1Registry.NewRegistryListImagesHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 

+ 26 - 7
api/server/router/v1/release.go

@@ -4,6 +4,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/namespace"
 	"github.com/porter-dev/porter/api/server/handlers/release"
+	v1Release "github.com/porter-dev/porter/api/server/handlers/v1/release"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -94,7 +95,8 @@ func getV1ReleaseRoutes(
 	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> release.NewCreateReleaseHandler
 	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases createRelease
 	//
-	// Creates a new release
+	// Creates a new release in the namespace denoted by `namespace`. The namespace should belong to the
+	// cluster denoted by `cluster_id` which itself should belong to the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -114,8 +116,16 @@ func getV1ReleaseRoutes(
 	// responses:
 	//   '201':
 	//     description: Successfully created the release
+	//   '400':
+	//     description: A malformed or bad request
 	//   '403':
 	//     description: Forbidden
+	//   '404':
+	//     description: A subresource was not found
+	//   '409':
+	//     description: A conflict occurred with another external service
+	//   '412':
+	//     description: A precondition failed for the request
 	createReleaseEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -148,7 +158,9 @@ func getV1ReleaseRoutes(
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} -> release.NewReleaseGetHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} getRelease
 	//
-	// Gets a release
+	// Gets the release denoted by the name `name` and its version `version`. The release should belong to the namespace
+	// denoted by `namespace` which itself should belong to the cluster denoted by `cluster_id` and project
+	// denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -163,7 +175,7 @@ func getV1ReleaseRoutes(
 	//   - name: name
 	//   - name: version
 	// responses:
-	//   '201':
+	//   '200':
 	//     description: Successfully got the release
 	//     schema:
 	//       $ref: '#/definitions/GetReleaseResponse'
@@ -201,7 +213,8 @@ func getV1ReleaseRoutes(
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> namespace.NewListReleasesHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases listReleases
 	//
-	// List releases
+	// List all releases in the namespace denoted by `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id` and project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -249,7 +262,9 @@ func getV1ReleaseRoutes(
 	// release.NewUpgradeReleaseHandler
 	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} updateRelease
 	//
-	// Updates a release
+	// Upgrades the release with the name denoted by `name` and version denoted by `version`. The release should belong
+	// to the namespace denoted by `namespace` which itself should belong to the cluster denoted by `cluster_id` and project
+	// denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -271,6 +286,8 @@ func getV1ReleaseRoutes(
 	// responses:
 	//   '200':
 	//     description: Successfully updated the release
+	//   '400':
+	//     description: A malformed or bad request
 	//   '403':
 	//     description: Forbidden
 	upgradeEndpoint := factory.NewAPIEndpoint(
@@ -291,7 +308,7 @@ func getV1ReleaseRoutes(
 		},
 	)
 
-	upgradeHandler := release.NewUpgradeReleaseHandler(
+	upgradeHandler := v1Release.NewUpgradeReleaseHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
@@ -307,7 +324,9 @@ func getV1ReleaseRoutes(
 	// release.NewDeleteReleaseHandler
 	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} deleteRelease
 	//
-	// Deletes a release
+	// Deletes the release with the name denoted by `name` and version denoted by `version`. The release should belong
+	// to the namespace denoted by `namespace` which itself should belong to the cluster denoted by `cluster_id` and project
+	// denoted by `project_id`.
 	//
 	// ---
 	// produces:

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

+ 11 - 4
api/types/build_config.go

@@ -1,16 +1,23 @@
 package types
 
-// BuildConfig
+// The build configuration for this release when using buildpacks
 type BuildConfig struct {
 	Builder    string   `json:"builder"`
 	Buildpacks []string `json:"buildpacks"`
 	Config     []byte   `json:"config"`
 }
 
+// The build configuration for this new release
 type CreateBuildConfigRequest struct {
-	Builder    string                 `json:"builder" form:"required"`
-	Buildpacks []string               `json:"buildpacks"`
-	Config     map[string]interface{} `json:"config,omitempty"`
+	// The name of the builder to use with `pack` (Heroku or Paketo)
+	// required: true
+	Builder string `json:"builder" form:"required"`
+
+	// The list of buildpacks to use for the release
+	Buildpacks []string `json:"buildpacks"`
+
+	// UNUSED
+	Config map[string]interface{} `json:"config,omitempty"`
 }
 
 type UpdateBuildConfigRequest struct {

+ 24 - 20
api/types/cluster.go

@@ -2,7 +2,6 @@ package types
 
 import (
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
-	v1 "k8s.io/api/core/v1"
 )
 
 const (
@@ -185,34 +184,39 @@ const (
 	AWSData          ClusterResolverName = "upload-aws-data"
 )
 
+// NamespaceResponse represents the response type of requests to the namespace resource
+//
 // swagger:model
-type ListNamespacesResponse struct {
-	*v1.NamespaceList
-}
-
-// swagger:model
-type CreateNamespaceRequest struct {
+type NamespaceResponse struct {
+	// the name of the namespace
+	// example: default
 	Name string `json:"name" form:"required"`
-}
 
-type CreateNamespaceResponseMeta struct {
-	Name string `json:"name,omitempty"`
-}
+	// the creation timestamp in UTC of the namespace in RFC 1123 format
+	// example: Mon, 13 Jun 2022 17:49:12 GMT
+	CreationTimestamp string `json:"creationTimestamp" form:"required"`
 
-// swagger:model
-type CreateNamespaceResponse struct {
-	Metadata CreateNamespaceResponseMeta `json:"metadata,omitempty"`
+	// the deletion timestamp in UTC of the namespace in RFC 1123 format, if the namespace is deleted
+	// example: Mon, 13 Jun 2022 17:49:12 GMT
+	DeletionTimestamp string `json:"deletionTimestamp,omitempty"`
+
+	// the status of the namespace
+	// enum: active,terminating
+	// example: active
+	Status string `json:"status" form:"required"`
 }
 
+// ListNamespacesResponse represents the list of all namespaces
+//
 // swagger:model
-type GetNamespaceResponse struct {
-	Metadata struct {
-		Name string `json:"name,omitempty"`
-	} `json:"metadata,omitempty"`
-}
+type ListNamespacesResponse []*NamespaceResponse
 
+// CreateNamespaceRequest represents the request body to create a namespace
+//
 // swagger:model
-type DeleteNamespaceRequest struct {
+type CreateNamespaceRequest struct {
+	// the name of the namespace to create
+	// example: sampleNS
 	Name string `json:"name" form:"required"`
 }
 

+ 31 - 8
api/types/git_action_config.go

@@ -1,6 +1,6 @@
 package types
 
-// GitActionConfig
+// The git configuration for this release (when deployed from a git repository)
 type GitActionConfig struct {
 	// The git repo in ${owner}/${repo} form
 	GitRepo string `json:"git_repo"`
@@ -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"`
 
@@ -21,14 +24,34 @@ type GitActionConfig struct {
 	FolderPath string `json:"folder_path"`
 }
 
+// The git configuration for this new release (when deploying from a git repository)
 type CreateGitActionConfigRequest struct {
-	GitRepo        string `json:"git_repo" form:"required"`
-	GitBranch      string `json:"git_branch"`
-	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
+	// The git repo in ${owner}/${repo} form
+	// required: true
+	GitRepo string `json:"git_repo" form:"required"`
+
+	// The branch name of the git repository
+	GitBranch string `json:"git_branch"`
+
+	// The complete image repository URI to pull from
+	// required: true
+	ImageRepoURI string `json:"image_repo_uri" form:"required"`
+
+	// The path to the Dockerfile in the git repository
 	DockerfilePath string `json:"dockerfile_path"`
-	FolderPath     string `json:"folder_path"`
-	GitRepoID      uint   `json:"git_repo_id" form:"required"`
-	RegistryID     uint   `json:"registry_id"`
 
+	// The path to use as the base directory in the git repository
+	FolderPath string `json:"folder_path"`
+
+	// The Github installation ID with access to the repository
+	GitRepoID uint `json:"git_repo_id"`
+
+	// The Gitlab integration ID with access to the repository
+	GitlabIntegrationID uint `json:"gitlab_integration_id"`
+
+	// The Porter registry ID to link against
+	RegistryID uint `json:"registry_id"`
+
+	// Denotes if the Github workflow files need to be created
 	ShouldCreateWorkflow bool `json:"should_create_workflow"`
 }

+ 14 - 13
api/types/policy.go

@@ -5,19 +5,20 @@ 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"
-	StackScope           PermissionScope = "stack"
+	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"
+	StackScope             PermissionScope = "stack"
+	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

+ 82 - 10
api/types/registry.go

@@ -7,37 +7,58 @@ const (
 )
 
 type Registry struct {
+	// The ID of the registry
+	// minimum: 1
+	// example: 2
 	ID uint `json:"id"`
 
 	// The project that this integration belongs to
+	// minimum: 1
+	// example: 1
 	ProjectID uint `json:"project_id"`
 
 	// Name of the registry
+	// example: my-ecr-reg
 	Name string `json:"name"`
 
 	// URL of the registry
+	// example: 123456789.dkr.ecr.us-west-2.amazonaws.com
 	URL string `json:"url"`
 
 	// The integration service for this registry
-	Service RegistryService `json:"service"`
+	// enum: gcr,ecr,acr,docr,dockerhub
+	// example: ecr
+	Service string `json:"service"`
 
 	// The infra id, if registry was provisioned with Porter
+	// minimum: 1
+	// example: 2
 	InfraID uint `json:"infra_id"`
 
 	// The AWS integration that was used to create or connect the registry
+	// minimum: 1
+	// example: 1
 	AWSIntegrationID uint `json:"aws_integration_id,omitempty"`
 
 	// The Azure integration that was used to create or connect the registry
+	// minimum: 1
+	// example: 0
 	AzureIntegrationID uint `json:"azure_integration_id,omitempty"`
 
 	// The GCP integration that was used to create or connect the registry
+	// minimum: 1
+	// example: 0
 	GCPIntegrationID uint `json:"gcp_integration_id,omitempty"`
 
 	// The DO integration that was used to create or connect the registry:
 	// this points to an OAuthIntegrationID
+	// minimum: 1
+	// example: 0
 	DOIntegrationID uint `json:"do_integration_id,omitempty"`
 
-	// The basic integration that was used to connect the registry:
+	// The basic integration that was used to connect the registry.
+	// minimum: 1
+	// example: 0
 	BasicIntegrationID uint `json:"basic_integration_id,omitempty"`
 }
 
@@ -71,6 +92,7 @@ type Image struct {
 	PushedAt *time.Time `json:"pushed_at"`
 }
 
+// Type of registry service
 type RegistryService string
 
 const (
@@ -86,17 +108,47 @@ type RegistryListResponse []Registry
 
 // swagger:model
 type CreateRegistryRequest struct {
-	URL                string `json:"url"`
-	Name               string `json:"name" form:"required"`
-	GCPIntegrationID   uint   `json:"gcp_integration_id"`
-	AWSIntegrationID   uint   `json:"aws_integration_id"`
-	DOIntegrationID    uint   `json:"do_integration_id"`
-	BasicIntegrationID uint   `json:"basic_integration_id"`
-	AzureIntegrationID uint   `json:"azure_integration_id"`
+	// URL of the container registry
+	// example: 123456789.dkr.ecr.us-west-2.amazonaws.com
+	URL string `json:"url"`
+
+	// Name of the container registry
+	// required: true
+	// example: my-ecr-reg
+	Name string `json:"name" form:"required"`
+
+	// The GCP integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	GCPIntegrationID uint `json:"gcp_integration_id"`
+
+	// The AWS integration ID to be used for this registry
+	// minimum: 1
+	// example: 1
+	AWSIntegrationID uint `json:"aws_integration_id"`
+
+	// The DigitalOcean integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	DOIntegrationID uint `json:"do_integration_id"`
+
+	// The Basic integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	BasicIntegrationID uint `json:"basic_integration_id"`
+
+	// The Azure integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	AzureIntegrationID uint `json:"azure_integration_id"`
 
 	// Additional Azure-specific fields
+
+	// ACR resource group name (**Azure only**)
 	ACRResourceGroupName string `json:"acr_resource_group_name"`
-	ACRName              string `json:"acr_name"`
+
+	// ACR name (**Azure only**)
+	ACRName string `json:"acr_name"`
 }
 
 // swagger:model
@@ -107,6 +159,8 @@ type GetRegistryResponse Registry
 
 // swagger:model
 type CreateRegistryRepositoryRequest struct {
+	// The URL to the repository of a registry (**ECR only**)
+	// required: true
 	ImageRepoURI string `json:"image_repo_uri" form:"required"`
 }
 
@@ -139,3 +193,21 @@ type ListRegistryRepositoryResponse []*RegistryRepository
 
 // swagger:model ListImagesResponse
 type ListImageResponse []*Image
+
+type V1ListImageRequest struct {
+	Num  int64  `schema:"num"`
+	Next string `schema:"next"`
+	Page uint   `schema:"page"`
+}
+
+// swagger:model V1ListImageResponse
+type V1ListImageResponse struct {
+	// The list of repository images with tags
+	Images []*Image `json:"images" form:"required"`
+
+	// The next page number used for pagination (**DigitalOcean only**)
+	NextPage uint `json:"next_page,omitempty"`
+
+	// The next page cursor used for pagination
+	Next string `json:"next,omitempty"`
+}

+ 63 - 17
api/types/release.go

@@ -15,14 +15,29 @@ type Release struct {
 }
 
 type PorterRelease struct {
-	ID              uint             `json:"id"`
-	WebhookToken    string           `json:"webhook_token"`
-	LatestVersion   string           `json:"latest_version"`
+	// The ID of this Porter release
+	ID uint `json:"id"`
+
+	// The webhook token used to secure Github repository webhooks
+	WebhookToken string `json:"webhook_token"`
+
+	// The latest version of this Porter release
+	LatestVersion string `json:"latest_version"`
+
+	// Configuration regarding the connected git repository
 	GitActionConfig *GitActionConfig `json:"git_action_config,omitempty"`
-	ImageRepoURI    string           `json:"image_repo_uri"`
-	BuildConfig     *BuildConfig     `json:"build_config,omitempty"`
-	Tags            []string         `json:"tags,omitempty"`
-	IsStack         bool             `json:"is_stack"`
+
+	// The complete image repository URI for this release
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// The build configuration for this release when using buildpacks
+	BuildConfig *BuildConfig `json:"build_config,omitempty"`
+
+	// The list of tags for this release
+	Tags []string `json:"tags,omitempty"`
+
+	// Whether this release is tied to a stack or not
+	IsStack bool `json:"is_stack"`
 }
 
 // swagger:model
@@ -37,22 +52,44 @@ type UpdateNotificationConfigRequest struct {
 }
 
 type CreateReleaseBaseRequest struct {
-	RepoURL         string                 `json:"-" schema:"repo_url"`
-	TemplateName    string                 `json:"template_name" form:"required"`
-	TemplateVersion string                 `json:"template_version" form:"required"`
-	Values          map[string]interface{} `json:"values"`
-	Name            string                 `json:"name" form:"required"`
+	// The repository URL for this release
+	RepoURL string `json:"repo_url,omitempty" schema:"repo_url"`
+
+	// the Porter charts templated name
+	// required: true
+	TemplateName string `json:"template_name" form:"required"`
+
+	// The Porter charts template version
+	// required: true
+	TemplateVersion string `json:"template_version" form:"required"`
+
+	// The Helm values for this release
+	Values map[string]interface{} `json:"values"`
+
+	// The name of this release
+	// required: true
+	Name string `json:"name" form:"required"`
 }
 
 // swagger:model
 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"`
+	// The repository image URL for this release
+	// required: true
+	ImageURL string `json:"image_url" form:"required"`
+
+	// Configuration regarding the connected git repository
+	GitActionConfig *CreateGitActionConfigRequest `json:"git_action_config,omitempty"`
+
+	// Build configuration options for this release
+	BuildConfig *CreateBuildConfigRequest `json:"build_config,omitempty"`
+
+	// The list of tags for this release
+	Tags []string `json:"tags,omitempty"`
+
+	// The list of synced environment groups for this release
+	SyncedEnvGroups []string `json:"synced_env_groups,omitempty"`
 }
 
 type CreateAddonRequest struct {
@@ -66,6 +103,15 @@ type RollbackReleaseRequest struct {
 }
 
 // swagger:model UpdateReleaseRequest
+type V1UpgradeReleaseRequest struct {
+	// The Helm values to upgrade the release with
+	// required: true
+	Values map[string]interface{} `json:"values" form:"required"`
+
+	// The Porter charts version to upgrade the release with
+	ChartVersion string `json:"version"`
+}
+
 type UpgradeReleaseRequest struct {
 	Values       string `json:"values" form:"required"`
 	ChartVersion string `json:"version"`

+ 1 - 0
api/types/request.go

@@ -46,6 +46,7 @@ const (
 	URLParamStackID           URLParam = "stack_id"
 	URLParamReleaseVersion    URLParam = "version"
 	URLParamWildcard          URLParam = "*"
+	URLParamIntegrationID     URLParam = "integration_id"
 )
 
 type Path struct {

+ 1 - 0
api/types/user.go

@@ -66,4 +66,5 @@ type WelcomeWebhookRequest struct {
 	IsCompany bool   `json:"isCompany" schema:"isCompany"`
 	Company   string `json:"company" schema:"company"`
 	Role      string `json:"role" schema:"role"`
+	Name      string `json:"name" schema:"name"`
 }

+ 5 - 3
cli/cmd/cluster.go

@@ -144,7 +144,7 @@ func listNamespaces(user *types.GetAuthenticatedUserResponse, client *api.Client
 	cID := cliConf.Cluster
 
 	// get the list of namespaces
-	namespaces, err := client.GetK8sNamespaces(
+	namespaceList, err := client.GetK8sNamespaces(
 		context.Background(),
 		pID,
 		cID,
@@ -159,8 +159,10 @@ func listNamespaces(user *types.GetAuthenticatedUserResponse, client *api.Client
 
 	fmt.Fprintf(w, "%s\t%s\n", "NAME", "STATUS")
 
-	for _, namespace := range namespaces.Items {
-		fmt.Fprintf(w, "%s\t%s\n", namespace.Name, namespace.Status.Phase)
+	namespaces := *namespaceList
+
+	for _, namespace := range namespaces {
+		fmt.Fprintf(w, "%s\t%s\n", namespace.Name, namespace.Status)
 	}
 
 	w.Flush()

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

+ 5 - 2
cli/cmd/get.go

@@ -65,10 +65,11 @@ func init() {
 }
 
 type getReleaseInfo struct {
-	Name         string
-	Namespace    string
+	Name         string    `json:"name" yaml:"name"`
+	Namespace    string    `json:"namespace" yaml:"namespace"`
 	LastDeployed time.Time `json:"last_deployed" yaml:"last_deployed"`
 	ReleaseType  string    `json:"release_type" yaml:"release_type"`
+	RevisionID   int       `json:"revision_id" yaml:"revision_id"`
 }
 
 func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
@@ -83,6 +84,7 @@ func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 		Namespace:    rel.Namespace,
 		LastDeployed: rel.Info.LastDeployed,
 		ReleaseType:  rel.Chart.Metadata.Name,
+		RevisionID:   rel.Release.Version,
 	}
 
 	if output == "yaml" {
@@ -106,6 +108,7 @@ func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 		fmt.Printf("Namespace:     %s\n", relInfo.Namespace)
 		fmt.Printf("Last deployed: %s\n", relInfo.LastDeployed)
 		fmt.Printf("Release type:  %s\n", relInfo.ReleaseType)
+		fmt.Printf("Revision ID:   %d\n", relInfo.RevisionID)
 	}
 
 	return nil

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

+ 60 - 64
dashboard/src/main/home/WelcomeForm.tsx

@@ -16,9 +16,10 @@ type StateType = {
 const WelcomeForm: React.FunctionComponent<Props> = ({}) => {
   const context = useContext(Context);
   const [active, setActive] = useState(true);
-  const [isCompany, setIsCompany] = useState(false);
-  const [role, setRole] = useState("unspecified");
+  const [isCompany, setIsCompany] = useState(true);
+  const [name, setName] = useState("");
   const [company, setCompany] = useState("");
+  const [role, setRole] = useState("unspecified");
 
   const submitForm = () => {
     api
@@ -26,6 +27,7 @@ const WelcomeForm: React.FunctionComponent<Props> = ({}) => {
         "<token>",
         {
           email: context.user && context.user.email,
+          name,
           isCompany,
           company,
           role,
@@ -40,70 +42,64 @@ const WelcomeForm: React.FunctionComponent<Props> = ({}) => {
   };
 
   const renderContents = () => {
-    if (isCompany) {
-      return (
-        <FadeWrapper>
-          <Title>Welcome to Porter</Title>
-          <Subtitle>Just two things before getting started.</Subtitle>
-          <SubtitleAlt>
-            <Num>1</Num> What is your company website? *
-          </SubtitleAlt>
-          <Input
-            placeholder="ex: https://porter.run"
-            value={company}
-            onChange={(e: any) => setCompany(e.target.value)}
-          />
-          <SubtitleAlt>
-            <Num>2</Num> What is your role? *
-          </SubtitleAlt>
-          <RadioButton
-            onClick={() => setRole("founder")}
-            selected={role === "founder"}
-          >
-            <i className="material-icons-round">
-              {role === "founder" ? "check_box" : "check_box_outline_blank"}
-            </i>{" "}
-            Founder
-          </RadioButton>
-          <RadioButton
-            onClick={() => setRole("developer")}
-            selected={role === "developer"}
-          >
-            <i className="material-icons-round">
-              {role === "developer" ? "check_box" : "check_box_outline_blank"}
-            </i>{" "}
-            Developer
-          </RadioButton>
-          <RadioButton
-            onClick={() => setRole("devops")}
-            selected={role === "devops"}
-          >
-            <i className="material-icons-round">
-              {role === "devops" ? "check_box" : "check_box_outline_blank"}
-            </i>{" "}
-            DevOps
-          </RadioButton>
-
-          <Submit
-            isDisabled={!company || role === "unspecified"}
-            onClick={() => company && role !== "unspecified" && submitForm()}
-          >
-            <i className="material-icons-round">check</i> Done
-          </Submit>
-        </FadeWrapper>
-      );
-    }
     return (
-      <>
+      <FadeWrapper>
         <Title>Welcome to Porter</Title>
-        <Subtitle delay="0.7s">I am interested in using Porter as:</Subtitle>
-        <Option onClick={() => setIsCompany(true)}>
-          <i className="material-icons-round">people</i> A Company
-        </Option>
-        <Option onClick={() => submitForm()}>
-          <i className="material-icons-round">person</i> An Individual
-        </Option>
-      </>
+        <Subtitle>Just a few things before getting started.</Subtitle>
+        <SubtitleAlt>
+          <Num>1</Num> What is your name? *
+        </SubtitleAlt>
+        <Input
+          placeholder="John Doe"
+          value={name}
+          onChange={(e: any) => setName(e.target.value)}
+        />
+        <SubtitleAlt>
+          <Num>2</Num> What is your company website? *
+        </SubtitleAlt>
+        <Input
+          placeholder="ex: https://porter.run"
+          value={company}
+          onChange={(e: any) => setCompany(e.target.value)}
+        />
+        <SubtitleAlt>
+          <Num>3</Num> What is your role? *
+        </SubtitleAlt>
+        <RadioButton
+          onClick={() => setRole("founder")}
+          selected={role === "founder"}
+        >
+          <i className="material-icons-round">
+            {role === "founder" ? "check_box" : "check_box_outline_blank"}
+          </i>{" "}
+          Founder
+        </RadioButton>
+        <RadioButton
+          onClick={() => setRole("developer")}
+          selected={role === "developer"}
+        >
+          <i className="material-icons-round">
+            {role === "developer" ? "check_box" : "check_box_outline_blank"}
+          </i>{" "}
+          Developer
+        </RadioButton>
+        <RadioButton
+          onClick={() => setRole("devops")}
+          selected={role === "devops"}
+        >
+          <i className="material-icons-round">
+            {role === "devops" ? "check_box" : "check_box_outline_blank"}
+          </i>{" "}
+          DevOps
+        </RadioButton>
+
+        <Submit
+          isDisabled={!company || role === "unspecified"}
+          onClick={() => company && role !== "unspecified" && submitForm()}
+        >
+          <i className="material-icons-round">check</i> Done
+        </Submit>
+      </FadeWrapper>
     );
   };
 

+ 6 - 6
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -50,18 +50,18 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
           }
 
           let defaultNamespace = "default";
-          const availableNamespaces = res.data.items.filter(
+          const availableNamespaces = res.data.filter(
             (namespace: any) => {
-              return namespace.status.phase !== "Terminating";
+              return namespace.status !== "Terminating";
             }
           );
           availableNamespaces.forEach(
-            (x: { metadata: { name: string } }, i: number) => {
+            (x: { name: string }, i: number) => {
               namespaceOptions.push({
-                label: x.metadata.name,
-                value: x.metadata.name,
+                label: x.name,
+                value: x.name,
               });
-              if (x.metadata.name === urlNamespace) {
+              if (x.name === urlNamespace) {
                 defaultNamespace = urlNamespace;
               }
             }

+ 5 - 5
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -122,14 +122,14 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          const availableNamespaces = res.data.items.filter(
+          const availableNamespaces = res.data.filter(
             (namespace: any) => {
-              return namespace.status.phase !== "Terminating";
+              return namespace.status !== "Terminating";
             }
           );
           const namespaceOptions = availableNamespaces.map(
-            (x: { metadata: { name: string } }) => {
-              return { label: x.metadata.name, value: x.metadata.name };
+            (x: { name: string }) => {
+              return { label: x.name, value: x.name };
             }
           );
           if (availableNamespaces.length > 0) {
@@ -340,7 +340,7 @@ const HeaderSection = styled.div`
 
   > i {
     cursor: pointer;
-    font-size 20px;
+    font-size: 20px;
     color: #969Fbbaa;
     padding: 2px;
     border: 2px solid #969fbbaa;

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

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

@@ -99,14 +99,14 @@ class SettingsPage extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          const availableNamespaces = res.data.items.filter(
+          const availableNamespaces = res.data.filter(
             (namespace: any) => {
-              return namespace.status.phase !== "Terminating";
+              return namespace.status !== "Terminating";
             }
           );
           const namespaceOptions = availableNamespaces.map(
-            (x: { metadata: { name: string } }) => {
-              return { label: x.metadata.name, value: x.metadata.name };
+            (x: { name: string }) => {
+              return { label: x.name, value: x.name };
             }
           );
           if (availableNamespaces.length > 0) {
@@ -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;
 `;

+ 2 - 1
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -26,10 +26,11 @@ const DeleteNamespaceModal = () => {
     api
       .deleteNamespace(
         "<token>",
-        { name: currentModalData?.metadata?.name },
+        {},
         {
           id: currentProject.id,
           cluster_id: currentCluster.id,
+          namespace: currentModalData?.metadata?.name,
         }
       )
       .then((res) => {

+ 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() {

+ 109 - 6
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<
   {},
   {
@@ -1138,6 +1188,7 @@ const getMetadata = baseApi<{}, {}>("GET", () => {
 const postWelcome = baseApi<{
   email: string;
   isCompany: boolean;
+  name: string;
   company: string;
   role: string;
 }>("POST", () => {
@@ -1389,16 +1440,15 @@ const createNamespace = baseApi<
 });
 
 const deleteNamespace = baseApi<
-  {
-    name: string;
-  },
+  {},
   {
     id: number;
     cluster_id: number;
+    namespace: string;
   }
 >("DELETE", (pathParams) => {
-  let { id, cluster_id } = pathParams;
-  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/delete`;
+  let { id, cluster_id, namespace } = pathParams;
+  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}`;
 });
 
 const deleteJob = 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 {

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio