Bladeren bron

Merge branch 'master' into nafees/pr-env-toggle

Mohammed Nafees 3 jaren geleden
bovenliggende
commit
6c86e997d1
100 gewijzigde bestanden met toevoegingen van 4977 en 399 verwijderingen
  1. 1 0
      .gitignore
  2. 2 2
      api/client/k8s.go
  3. 64 0
      api/server/authz/gitlab_integration.go
  4. 4 0
      api/server/authz/policy.go
  5. 0 10
      api/server/authz/policy/loader.go
  6. 63 0
      api/server/authz/stack.go
  7. 19 2
      api/server/handlers/cluster/create_namespace.go
  8. 5 10
      api/server/handlers/cluster/delete_namespace.go
  9. 13 2
      api/server/handlers/cluster/get_namespace.go
  10. 15 2
      api/server/handlers/cluster/list_namespaces.go
  11. 24 7
      api/server/handlers/environment/create.go
  12. 2 2
      api/server/handlers/environment/create_deployment.go
  13. 28 18
      api/server/handlers/environment/delete.go
  14. 2 2
      api/server/handlers/environment/finalize_deployment.go
  15. 2 2
      api/server/handlers/environment/get_deployment.go
  16. 2 2
      api/server/handlers/environment/list_deployments.go
  17. 50 34
      api/server/handlers/environment/list_deployments_by_cluster.go
  18. 2 2
      api/server/handlers/environment/update_deployment.go
  19. 2 2
      api/server/handlers/environment/update_deployment_status.go
  20. 4 3
      api/server/handlers/gitinstallation/get_buildpack.go
  21. 3 2
      api/server/handlers/gitinstallation/get_contents.go
  22. 3 2
      api/server/handlers/gitinstallation/get_procfile.go
  23. 3 2
      api/server/handlers/gitinstallation/get_tarball_url.go
  24. 0 42
      api/server/handlers/gitinstallation/helpers.go
  25. 2 1
      api/server/handlers/gitinstallation/list_branches.go
  26. 1 1
      api/server/handlers/gitinstallation/rerun_workflow.go
  27. 31 2
      api/server/handlers/handler.go
  28. 132 0
      api/server/handlers/oauth_callback/gitlab.go
  29. 73 0
      api/server/handlers/project_integration/create_gitlab.go
  30. 160 0
      api/server/handlers/project_integration/get_gitlab_repo_buildpack.go
  31. 112 0
      api/server/handlers/project_integration/get_gitlab_repo_contents.go
  32. 111 0
      api/server/handlers/project_integration/get_gitlab_repo_procfile.go
  33. 117 0
      api/server/handlers/project_integration/list_git.go
  34. 44 0
      api/server/handlers/project_integration/list_gitlab.go
  35. 77 0
      api/server/handlers/project_integration/list_gitlab_repo_branches.go
  36. 119 0
      api/server/handlers/project_integration/list_gitlab_repos.go
  37. 1 1
      api/server/handlers/project_oauth/digitalocean.go
  38. 78 0
      api/server/handlers/project_oauth/gitlab.go
  39. 1 1
      api/server/handlers/project_oauth/slack.go
  40. 113 2
      api/server/handlers/registry/create.go
  41. 2 0
      api/server/handlers/registry/create_repository.go
  42. 124 80
      api/server/handlers/release/create.go
  43. 1 1
      api/server/handlers/release/create_webhook.go
  44. 53 20
      api/server/handlers/release/delete.go
  45. 0 2
      api/server/handlers/release/get_gha_template.go
  46. 4 4
      api/server/handlers/release/update_rollback.go
  47. 248 0
      api/server/handlers/release/upgrade.go
  48. 9 0
      api/server/handlers/release/upgrade_webhook.go
  49. 250 0
      api/server/handlers/stack/create.go
  50. 65 0
      api/server/handlers/stack/delete.go
  51. 30 0
      api/server/handlers/stack/get.go
  52. 47 0
      api/server/handlers/stack/get_revision.go
  53. 103 0
      api/server/handlers/stack/helpers.go
  54. 46 0
      api/server/handlers/stack/list.go
  55. 138 0
      api/server/handlers/stack/rollback.go
  56. 154 0
      api/server/handlers/stack/update_source_put.go
  57. 1 1
      api/server/handlers/user/github_start.go
  58. 1 1
      api/server/handlers/user/google_start.go
  59. 94 0
      api/server/handlers/v1/registry/list_images.go
  60. 9 6
      api/server/handlers/v1/release/upgrade.go
  61. 20 12
      api/server/handlers/webhook/github_incoming.go
  62. 2 2
      api/server/router/cluster.go
  63. 24 0
      api/server/router/oauth_callback.go
  64. 241 0
      api/server/router/project_integration.go
  65. 28 0
      api/server/router/project_oauth.go
  66. 13 1
      api/server/router/router.go
  67. 109 0
      api/server/router/v1/cluster.go
  68. 20 0
      api/server/router/v1/namespace.go
  69. 9 0
      api/server/router/v1/project.go
  70. 199 1
      api/server/router/v1/registry.go
  71. 168 1
      api/server/router/v1/release.go
  72. 490 0
      api/server/router/v1/stack.go
  73. 43 0
      api/server/shared/commonutils/git_utils.go
  74. 20 0
      api/server/shared/commonutils/gitlab.go
  75. 4 0
      api/server/shared/config/env/envconfs.go
  76. 9 0
      api/server/shared/config/loader/loader.go
  77. 2 0
      api/server/shared/config/metadata.go
  78. 11 4
      api/types/build_config.go
  79. 29 10
      api/types/cluster.go
  80. 31 8
      api/types/git_action_config.go
  81. 25 4
      api/types/namespace.go
  82. 15 12
      api/types/policy.go
  83. 1 0
      api/types/project.go
  84. 34 0
      api/types/project_integration.go
  85. 93 10
      api/types/registry.go
  86. 66 16
      api/types/release.go
  87. 2 0
      api/types/request.go
  88. 263 0
      api/types/stacks.go
  89. 1 0
      api/types/user.go
  90. 5 3
      cli/cmd/cluster.go
  91. 2 2
      cli/cmd/config/config.go
  92. 1 1
      cli/cmd/deploy/create.go
  93. 9 0
      cli/cmd/deploy/deploy.go
  94. 5 2
      cli/cmd/get.go
  95. 2 0
      cmd/app/main.go
  96. 24 0
      dashboard/package-lock.json
  97. 13 2
      dashboard/src/components/SearchBar.tsx
  98. 1 2
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  99. 50 22
      dashboard/src/components/repo-selector/BranchList.tsx
  100. 29 11
      dashboard/src/components/repo-selector/BuildpackSelection.tsx

+ 1 - 0
.gitignore

@@ -14,6 +14,7 @@ staging.sh
 *.crt
 *.key
 bin
+openapi.yaml
 
 # Local docs directories
 /docs/.obsidian

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

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

@@ -130,8 +130,12 @@ func getRequestActionForEndpoint(
 			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamNamespace)
 		case types.ReleaseScope:
 			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamReleaseName)
+		case types.StackScope:
+			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)
 

+ 63 - 0
api/server/authz/stack.go

@@ -0,0 +1,63 @@
+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"
+	"gorm.io/gorm"
+)
+
+type StackScopedFactory struct {
+	config *config.Config
+}
+
+func NewStackScopedFactory(
+	config *config.Config,
+) *StackScopedFactory {
+	return &StackScopedFactory{config}
+}
+
+func (p *StackScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &StackScopedMiddleware{next, p.config}
+}
+
+type StackScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *StackScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project to check scopes
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the registry id from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	stackID := reqScopes[types.StackScope].Resource.Name
+
+	stack, err := p.config.Repo.Stack().ReadStackByStringID(proj.ID, stackID)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("stack with id %s not found in project %d", stackID, proj.ID),
+			), true)
+		} else {
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
+		}
+
+		return
+	}
+
+	ctx := NewStackContext(r.Context(), stack)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewStackContext(ctx context.Context, stack *models.Stack) context.Context {
+	return context.WithValue(ctx, types.StackScope, stack)
+}

+ 19 - 2
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,8 +62,14 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	res := types.CreateNamespaceResponse{
-		Namespace: namespace,
+	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)

+ 24 - 7
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
@@ -135,8 +136,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())
@@ -145,7 +156,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))
 }
 
@@ -158,11 +175,11 @@ func getGithubClientFromEnvironment(config *config.Config, env *models.Environme
 	}
 
 	// authenticate as github app installation
-	itr, err := ghinstallation.NewKeyFromFile(
+	itr, err := ghinstallation.New(
 		http.DefaultTransport,
 		int64(ghAppId),
 		int64(env.GitInstallationID),
-		config.ServerConf.GithubAppSecretPath,
+		config.ServerConf.GithubAppSecret,
 	)
 
 	if err != nil {

+ 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

+ 50 - 34
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -71,6 +71,8 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 			deployments = append(deployments, deployment)
 		}
 
+		envToGithubClientMap := make(map[uint]*github.Client)
+
 		var wg sync.WaitGroup
 		wg.Add(len(deployments))
 
@@ -82,10 +84,21 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 				return
 			}
 
+			if _, ok := envToGithubClientMap[env.ID]; !ok {
+				client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+
+				envToGithubClientMap[env.ID] = client
+			}
+
 			go func(depl *types.Deployment) {
 				defer wg.Done()
 
-				updateDeploymentWithGithubWorkflowRunStatus(c.Config(), env, depl)
+				updateDeploymentWithGithubWorkflowRunStatus(c.Config(), envToGithubClientMap[env.ID], env, depl)
 			}(deployment)
 		}
 
@@ -99,7 +112,18 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		}
 
 		for _, env := range envList {
-			prs, err := fetchOpenPullRequests(r.Context(), c.Config(), env, deplInfoMap)
+			if _, ok := envToGithubClientMap[env.ID]; !ok {
+				client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+
+				envToGithubClientMap[env.ID] = client
+			}
+
+			prs, err := fetchOpenPullRequests(r.Context(), c.Config(), envToGithubClientMap[env.ID], env, deplInfoMap)
 
 			if err != nil {
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -125,6 +149,13 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 
 		deplInfoMap := make(map[string]bool)
 
+		client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
 		for _, depl := range depls {
 			deployment := depl.ToDeploymentType()
 			deplInfoMap[fmt.Sprintf(
@@ -140,23 +171,16 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		wg.Add(len(deployments))
 
 		for _, deployment := range deployments {
-			env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, deployment.EnvironmentID)
-
-			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-				return
-			}
-
 			go func(depl *types.Deployment) {
 				defer wg.Done()
 
-				updateDeploymentWithGithubWorkflowRunStatus(c.Config(), env, depl)
+				updateDeploymentWithGithubWorkflowRunStatus(c.Config(), client, env, depl)
 			}(deployment)
 		}
 
 		wg.Wait()
 
-		prs, err := fetchOpenPullRequests(r.Context(), c.Config(), env, deplInfoMap)
+		prs, err := fetchOpenPullRequests(r.Context(), c.Config(), client, env, deplInfoMap)
 
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -174,6 +198,7 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 
 func updateDeploymentWithGithubWorkflowRunStatus(
 	config *config.Config,
+	client *github.Client,
 	env *models.Environment,
 	deployment *types.Deployment,
 ) {
@@ -181,25 +206,21 @@ func updateDeploymentWithGithubWorkflowRunStatus(
 		return
 	}
 
-	client, err := getGithubClientFromEnvironment(config, env)
+	latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
+		fmt.Sprintf("porter_%s_env.yml", env.Name), deployment.PRBranchFrom)
 
 	if err == nil {
-		latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
-			fmt.Sprintf("porter_%s_env.yml", env.Name), deployment.PRBranchFrom)
-
-		if err == nil {
-			deployment.LastWorkflowRunURL = latestWorkflowRun.GetHTMLURL()
-
-			if (latestWorkflowRun.GetStatus() == "in_progress" ||
-				latestWorkflowRun.GetStatus() == "queued") &&
-				deployment.Status != types.DeploymentStatusCreating {
-				deployment.Status = types.DeploymentStatusUpdating
-			} else if latestWorkflowRun.GetStatus() == "completed" {
-				if latestWorkflowRun.GetConclusion() == "failure" {
-					deployment.Status = types.DeploymentStatusFailed
-				} else if latestWorkflowRun.GetConclusion() == "timed_out" {
-					deployment.Status = types.DeploymentStatusTimedOut
-				}
+		deployment.LastWorkflowRunURL = latestWorkflowRun.GetHTMLURL()
+
+		if (latestWorkflowRun.GetStatus() == "in_progress" ||
+			latestWorkflowRun.GetStatus() == "queued") &&
+			deployment.Status != types.DeploymentStatusCreating {
+			deployment.Status = types.DeploymentStatusUpdating
+		} else if latestWorkflowRun.GetStatus() == "completed" {
+			if latestWorkflowRun.GetConclusion() == "failure" {
+				deployment.Status = types.DeploymentStatusFailed
+			} else if latestWorkflowRun.GetConclusion() == "timed_out" {
+				deployment.Status = types.DeploymentStatusTimedOut
 			}
 		}
 	}
@@ -208,15 +229,10 @@ func updateDeploymentWithGithubWorkflowRunStatus(
 func fetchOpenPullRequests(
 	ctx context.Context,
 	config *config.Config,
+	client *github.Client,
 	env *models.Environment,
 	deplInfoMap map[string]bool,
 ) ([]*types.EnablePullRequestRequest, error) {
-	client, err := getGithubClientFromEnvironment(config, env)
-
-	if err != nil {
-		return nil, err
-	}
-
 	openPRs, resp, err := client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
 		&github.PullRequestListOptions{
 			ListOptions: github.ListOptions{

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

+ 113 - 2
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,6 +51,114 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
+	// 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{
 		Name:               request.Name,
@@ -99,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))
@@ -108,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))
@@ -122,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)
 }

+ 124 - 80
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()
@@ -137,7 +138,7 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 	}
 
-	release, err := createReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, helmRelease)
+	release, err := CreateAppReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, 0, helmRelease)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -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,11 +214,13 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			FlowID: operationID,
 		},
 	))
+
+	w.WriteHeader(http.StatusCreated)
 }
 
-func createReleaseFromHelmRelease(
+func CreateAppReleaseFromHelmRelease(
 	config *config.Config,
-	projectID, clusterID uint,
+	projectID, clusterID, stackResourceID uint,
 	helmRelease *release.Release,
 ) (*models.Release, error) {
 	token, err := encryption.GenerateRandomBytes(16)
@@ -232,12 +244,29 @@ func createReleaseFromHelmRelease(
 	}
 
 	release := &models.Release{
-		ClusterID:    clusterID,
-		ProjectID:    projectID,
-		Namespace:    helmRelease.Namespace,
-		Name:         helmRelease.Name,
-		WebhookToken: token,
-		ImageRepoURI: repoStr,
+		ClusterID:       clusterID,
+		ProjectID:       projectID,
+		Namespace:       helmRelease.Namespace,
+		Name:            helmRelease.Name,
+		WebhookToken:    token,
+		ImageRepoURI:    repoStr,
+		StackResourceID: stackResourceID,
+	}
+
+	return config.Repo.Release().CreateRelease(release)
+}
+
+func CreateAddonReleaseFromHelmRelease(
+	config *config.Config,
+	projectID, clusterID, stackResourceID uint,
+	helmRelease *release.Release,
+) (*models.Release, error) {
+	release := &models.Release{
+		ClusterID:       clusterID,
+		ProjectID:       projectID,
+		Namespace:       helmRelease.Namespace,
+		Name:            helmRelease.Name,
+		StackResourceID: stackResourceID,
 	}
 
 	return config.Repo.Release().CreateRelease(release)
@@ -245,7 +274,6 @@ func createReleaseFromHelmRelease(
 
 func createGitAction(
 	config *config.Config,
-	project *models.Project,
 	userID, projectID, clusterID uint,
 	request *types.CreateGitActionConfigRequest,
 	name, namespace string,
@@ -287,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 {
@@ -359,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) {
@@ -454,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 {
@@ -512,7 +556,7 @@ type containerEnvConfig struct {
 	} `yaml:"container"`
 }
 
-func getGARunner(
+func GetGARunner(
 	config *config.Config,
 	userID, projectID, clusterID uint,
 	ga *models.GitActionConfig,

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

@@ -29,7 +29,7 @@ func (c *CreateWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	release, err := createReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, helmRelease)
+	release, err := CreateAppReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, 0, helmRelease)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

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

+ 248 - 0
api/server/handlers/release/upgrade.go

@@ -0,0 +1,248 @@
+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"
+	"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"
+	"github.com/porter-dev/porter/internal/stacks"
+	"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.UpgradeReleaseRequest{}
+
+	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 := LoadChart(c.Config(), &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
+	}
+
+	newHelmRelease, upgradeErr := helmAgent.UpgradeRelease(conf, request.Values, 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 = 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 := 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
+					}
+				}
+			}
+		}
+	}
+
+	c.WriteResult(w, r, nil)
+
+	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, helmRelease)
+
+	if err != nil {
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}
+
+// postUpgrade runs any necessary scripting after the release has been upgraded.
+func postUpgrade(config *config.Config, projectID, clusterID uint, release *release.Release) error {
+	// update the relevant helm revision number if tied to a stack resource
+	return stacks.UpdateHelmRevision(config, projectID, clusterID, release)
+}

+ 9 - 0
api/server/handlers/release/upgrade_webhook.go

@@ -204,4 +204,13 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			rel.Chart.Metadata.Name,
 		),
 	}))
+
+	c.WriteResult(w, r, nil)
+
+	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, rel)
+
+	if err != nil {
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		return
+	}
 }

+ 250 - 0
api/server/handlers/stack/create.go

@@ -0,0 +1,250 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"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/encryption"
+	"github.com/porter-dev/porter/internal/models"
+
+	helmrelease "helm.sh/helm/v3/pkg/release"
+)
+
+type StackCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackCreateHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackCreateHandler {
+	return &StackCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	req := &types.CreateStackRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// populate fields with defaults
+	for i, reqResource := range req.AppResources {
+		if reqResource.TemplateRepoURL == "" {
+			req.AppResources[i].TemplateRepoURL = p.Config().ServerConf.DefaultApplicationHelmRepoURL
+		}
+	}
+
+	uid, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	sourceConfigs, err := getSourceConfigModels(req.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resources, err := getResourceModels(req.AppResources, sourceConfigs, p.Config().ServerConf.DefaultApplicationHelmRepoURL)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// write stack to the database with creating status
+	stack := &models.Stack{
+		ProjectID: proj.ID,
+		ClusterID: cluster.ID,
+		Namespace: namespace,
+		Name:      req.Name,
+		UID:       uid,
+		Revisions: []models.StackRevision{
+			{
+				RevisionNumber: 1,
+				Status:         string(types.StackRevisionStatusDeploying),
+				SourceConfigs:  sourceConfigs,
+				Resources:      resources,
+			},
+		},
+	}
+
+	stack, err = p.Repo().Stack().CreateStack(stack)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// apply all app resources
+	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmReleaseMap := make(map[string]*helmrelease.Release)
+
+	deployErrs := make([]string, 0)
+
+	for _, appResource := range req.AppResources {
+		rel, err := applyAppResource(&applyAppResourceOpts{
+			config:     p.Config(),
+			projectID:  proj.ID,
+			namespace:  namespace,
+			cluster:    cluster,
+			registries: registries,
+			helmAgent:  helmAgent,
+			request:    appResource,
+		})
+
+		if err != nil {
+			deployErrs = append(deployErrs, err.Error())
+		} else {
+			helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
+		}
+	}
+
+	// update stack revision status
+	revision := &stack.Revisions[0]
+
+	if len(deployErrs) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "DeployError"
+		revision.Message = strings.Join(deployErrs, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	saveErrs := make([]string, 0)
+
+	for _, resource := range revision.Resources {
+		if rel, exists := helmReleaseMap[fmt.Sprintf("%s/%s", namespace, resource.Name)]; exists {
+			_, err = release.CreateAppReleaseFromHelmRelease(p.Config(), proj.ID, cluster.ID, resource.ID, rel)
+
+			if err != nil {
+				saveErrs = append(saveErrs, fmt.Sprintf("the resource %s/%s could not be saved right now", namespace, resource.Name))
+			}
+		}
+	}
+
+	if len(saveErrs) > 0 {
+		revision.Reason = "SaveError"
+		revision.Message = strings.Join(saveErrs, " , ")
+
+		revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	// read the stack again to get the latest revision info
+	stack, err = p.Repo().Stack().ReadStackByStringID(proj.ID, stack.UID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	p.WriteResult(w, r, stack.ToStackType())
+}
+
+func getSourceConfigModels(sourceConfigs []*types.CreateStackSourceConfigRequest) ([]models.StackSourceConfig, error) {
+	res := make([]models.StackSourceConfig, 0)
+
+	// for now, only write source configs which are deployed as a docker image
+	// TODO: add parsing/writes for git-based sources
+	for _, sourceConfig := range sourceConfigs {
+		if sourceConfig.StackSourceConfigBuild == nil {
+			uid, err := encryption.GenerateRandomBytes(16)
+
+			if err != nil {
+				return nil, err
+			}
+
+			res = append(res, models.StackSourceConfig{
+				UID:          uid,
+				Name:         sourceConfig.Name,
+				ImageRepoURI: sourceConfig.ImageRepoURI,
+				ImageTag:     sourceConfig.ImageTag,
+			})
+		}
+	}
+
+	return res, nil
+}
+
+func getResourceModels(appResources []*types.CreateStackAppResourceRequest, sourceConfigs []models.StackSourceConfig, defaultRepoURL string) ([]models.StackResource, error) {
+	res := make([]models.StackResource, 0)
+
+	for _, appResource := range appResources {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		var linkedSourceConfigUID string
+
+		for _, sourceConfig := range sourceConfigs {
+			if sourceConfig.Name == appResource.SourceConfigName {
+				linkedSourceConfigUID = sourceConfig.UID
+			}
+		}
+
+		if linkedSourceConfigUID == "" {
+			return nil, fmt.Errorf("source config %s does not exist in source config list", appResource.SourceConfigName)
+		}
+
+		res = append(res, models.StackResource{
+			Name:                 appResource.Name,
+			UID:                  uid,
+			StackSourceConfigUID: linkedSourceConfigUID,
+			TemplateRepoURL:      appResource.TemplateRepoURL,
+			TemplateName:         appResource.TemplateName,
+			TemplateVersion:      appResource.TemplateVersion,
+			HelmRevisionID:       1,
+		})
+	}
+
+	return res, nil
+}

+ 65 - 0
api/server/handlers/stack/delete.go

@@ -0,0 +1,65 @@
+package stack
+
+import (
+	"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/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 StackDeleteHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackDeleteHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackDeleteHandler {
+	return &StackDeleteHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	if len(stack.Revisions) > 0 {
+		revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		helmAgent, err := p.GetHelmAgent(r, cluster, namespace)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		// delete all resources in stack
+		for _, appResource := range revision.Resources {
+			deleteAppResource(&deleteAppResourceOpts{
+				helmAgent: helmAgent,
+				name:      appResource.Name,
+			})
+		}
+	}
+
+	stack, err := p.Repo().Stack().DeleteStack(stack)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 30 - 0
api/server/handlers/stack/get.go

@@ -0,0 +1,30 @@
+package stack
+
+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/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StackGetHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewStackGetHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackGetHandler {
+	return &StackGetHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *StackGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	p.WriteResult(w, r, stack.ToStackType())
+}

+ 47 - 0
api/server/handlers/stack/get_revision.go

@@ -0,0 +1,47 @@
+package stack
+
+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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StackGetRevisionHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewStackGetRevisionHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackGetRevisionHandler {
+	return &StackGetRevisionHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *StackGetRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	// read the revision number from the request
+	revNumber, reqErr := requestutils.GetURLParamUint(r, types.URLParamStackRevisionNumber)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, revNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, revision.ToStackRevisionType(stack.UID))
+}

+ 103 - 0
api/server/handlers/stack/helpers.go

@@ -0,0 +1,103 @@
+package stack
+
+import (
+	"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/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+type applyAppResourceOpts struct {
+	config     *config.Config
+	projectID  uint
+	namespace  string
+	cluster    *models.Cluster
+	helmAgent  *helm.Agent
+	request    *types.CreateStackAppResourceRequest
+	registries []*models.Registry
+}
+
+func applyAppResource(opts *applyAppResourceOpts) (*release.Release, error) {
+	if opts.request.TemplateVersion == "latest" {
+		opts.request.TemplateVersion = ""
+	}
+
+	chart, err := loader.LoadChartPublic(opts.request.TemplateRepoURL, opts.request.TemplateName, opts.request.TemplateVersion)
+
+	if err != nil {
+		return nil, err
+	}
+
+	conf := &helm.InstallChartConfig{
+		Chart:      chart,
+		Name:       opts.request.Name,
+		Namespace:  opts.namespace,
+		Values:     opts.request.Values,
+		Cluster:    opts.cluster,
+		Repo:       opts.config.Repo,
+		Registries: opts.registries,
+	}
+
+	return opts.helmAgent.InstallChart(conf, opts.config.DOConf)
+}
+
+type rollbackAppResourceOpts struct {
+	helmAgent      *helm.Agent
+	helmRevisionID uint
+	name           string
+}
+
+func rollbackAppResource(opts *rollbackAppResourceOpts) error {
+	return opts.helmAgent.RollbackRelease(opts.name, int(opts.helmRevisionID))
+}
+
+type updateAppResourceTagOpts struct {
+	helmAgent  *helm.Agent
+	name, tag  string
+	config     *config.Config
+	projectID  uint
+	namespace  string
+	cluster    *models.Cluster
+	registries []*models.Registry
+}
+
+func updateAppResourceTag(opts *updateAppResourceTagOpts) error {
+	// read the current release to get the current values
+	rel, err := opts.helmAgent.GetRelease(opts.name, 0, true)
+
+	if err != nil {
+		return err
+	}
+
+	imagePre := rel.Config["image"]
+	image := imagePre.(map[string]interface{})
+	image["tag"] = opts.tag
+	rel.Config["image"] = image
+
+	conf := &helm.UpgradeReleaseConfig{
+		Name:       opts.name,
+		Cluster:    opts.cluster,
+		Repo:       opts.config.Repo,
+		Registries: opts.registries,
+		Values:     rel.Config,
+	}
+
+	_, err = opts.helmAgent.UpgradeReleaseByValues(conf, opts.config.DOConf)
+
+	return err
+}
+
+type deleteAppResourceOpts struct {
+	helmAgent *helm.Agent
+	name      string
+}
+
+func deleteAppResource(opts *deleteAppResourceOpts) error {
+	_, err := opts.helmAgent.UninstallChart(opts.name)
+
+	return err
+}
+
+// func setValuesWithSourceConfig(values map[string]interface{}, sourceConfig )

+ 46 - 0
api/server/handlers/stack/list.go

@@ -0,0 +1,46 @@
+package stack
+
+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 StackListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewStackListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackListHandler {
+	return &StackListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *StackListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	stacks, err := p.Repo().Stack().ListStacks(proj.ID, cluster.ID, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make([]*types.Stack, 0)
+
+	for _, stack := range stacks {
+		res = append(res, stack.ToStackType())
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 138 - 0
api/server/handlers/stack/rollback.go

@@ -0,0 +1,138 @@
+package stack
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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"
+	"github.com/porter-dev/porter/internal/stacks"
+	"gorm.io/gorm"
+)
+
+type StackRollbackHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackRollbackHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackRollbackHandler {
+	return &StackRollbackHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackRollbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	// namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.StackRollbackRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// read the target revision
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, req.TargetRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the latest revision
+	latestRevision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// clear out model data and create new revision
+	revision.Model = gorm.Model{}
+	revision.RevisionNumber = latestRevision.RevisionNumber + 1
+	revision.Status = string(types.StackRevisionStatusDeploying)
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(revision.Resources, revision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	revision.SourceConfigs = newSourceConfigs
+	revision.Resources = appResources
+
+	revision, err = p.Repo().Stack().AppendNewRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// apply to cluster
+	rollbackErrors := make([]string, 0)
+
+	for _, resource := range revision.Resources {
+		err := rollbackAppResource(&rollbackAppResourceOpts{
+			helmAgent:      helmAgent,
+			helmRevisionID: resource.HelmRevisionID,
+			name:           resource.Name,
+		})
+
+		if err != nil {
+			rollbackErrors = append(rollbackErrors, err.Error())
+		}
+	}
+
+	if len(rollbackErrors) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "RollbackError"
+		revision.Message = strings.Join(rollbackErrors, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the stack again to get the latest revision info
+	stack, err = p.Repo().Stack().ReadStackByStringID(proj.ID, stack.UID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, stack.ToStackType())
+}

+ 154 - 0
api/server/handlers/stack/update_source_put.go

@@ -0,0 +1,154 @@
+package stack
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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"
+	"github.com/porter-dev/porter/internal/stacks"
+	"gorm.io/gorm"
+)
+
+type StackPutSourceConfigHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackPutSourceConfigHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackPutSourceConfigHandler {
+	return &StackPutSourceConfigHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackPutSourceConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	req := &types.PutStackSourceConfigRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// read the latest revision
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	sourceConfigs, err := getSourceConfigModels(req.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// clear out model data and create new revision
+	revision.Model = gorm.Model{}
+	revision.RevisionNumber++
+	revision.Status = string(types.StackRevisionStatusDeploying)
+	prevSourceConfigs := revision.SourceConfigs
+	revision.SourceConfigs = sourceConfigs
+	clonedAppResources, err := stacks.CloneAppResources(revision.Resources, prevSourceConfigs, revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	revision.Resources = clonedAppResources
+
+	revision, err = p.Repo().Stack().AppendNewRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// apply to cluster
+	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	deployErrs := make([]string, 0)
+
+	for i, appResource := range clonedAppResources {
+		// get the corresponding source config tag
+		var imageTag string
+
+		for _, sourceConfig := range sourceConfigs {
+			if sourceConfig.UID == appResource.StackSourceConfigUID {
+				imageTag = sourceConfig.ImageTag
+			}
+		}
+
+		// TODO: case on if image tag is empty
+
+		err = updateAppResourceTag(&updateAppResourceTagOpts{
+			helmAgent:  helmAgent,
+			name:       appResource.Name,
+			tag:        imageTag,
+			config:     p.Config(),
+			projectID:  proj.ID,
+			namespace:  namespace,
+			cluster:    cluster,
+			registries: registries,
+		})
+
+		if err != nil {
+			deployErrs = append(deployErrs, err.Error())
+		}
+
+		clonedAppResources[i].HelmRevisionID++
+	}
+
+	if len(deployErrs) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "DeployError"
+		revision.Message = strings.Join(deployErrs, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the stack again to get the latest revision info
+	stack, err = p.Repo().Stack().ReadStackByStringID(proj.ID, stack.UID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, stack.ToStackType())
+}

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

+ 9 - 6
api/server/handlers/release/ugprade.go → api/server/handlers/v1/release/upgrade.go

@@ -9,6 +9,7 @@ import (
 
 	"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"
@@ -51,7 +52,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	request := &types.UpgradeReleaseRequest{}
+	request := &types.V1UpgradeReleaseRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 		return
@@ -93,7 +94,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			}
 		}
 
-		chart, err := LoadChart(c.Config(), &LoadAddonChartOpts{
+		chart, err := baseReleaseHandler.LoadChart(c.Config(), &baseReleaseHandler.LoadAddonChartOpts{
 			ProjectID:       cluster.ProjectID,
 			RepoURL:         chartRepoURL,
 			TemplateName:    helmRelease.Chart.Metadata.Name,
@@ -117,7 +118,9 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		conf.Chart = chart
 	}
 
-	newHelmRelease, upgradeErr := helmAgent.UpgradeRelease(conf, request.Values, c.Config().DOConf)
+	conf.Values = request.Values
+
+	newHelmRelease, upgradeErr := helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
 
 	if upgradeErr == nil && newHelmRelease != nil {
 		helmRelease = newHelmRelease
@@ -186,7 +189,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 = baseReleaseHandler.UpdateReleaseRepo(c.Config(), rel, helmRelease)
 
 			if err != nil {
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -195,8 +198,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 := baseReleaseHandler.GetGARunner(
 					c.Config(),
 					user.ID,
 					cluster.ProjectID,

+ 20 - 12
api/server/handlers/webhook/github_incoming.go

@@ -37,14 +37,15 @@ func NewGithubIncomingWebhookHandler(
 
 func (c *GithubIncomingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	payload, err := github.ValidatePayload(r, []byte(c.Config().ServerConf.GithubIncomingWebhookSecret))
+
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error validating webhook payload: %w", err)))
 		return
 	}
 
 	event, err := github.ParseWebHook(github.WebHookType(r), payload)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error parsing webhook: %w", err)))
 		return
 	}
 
@@ -53,7 +54,7 @@ func (c *GithubIncomingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		err = c.processPullRequestEvent(event, r)
 
 		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error processing pull request webhook event: %w", err)))
 			return
 		}
 	}
@@ -73,14 +74,15 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 	env, err := c.Repo().Environment().ReadEnvironmentByWebhookIDOwnerRepoName(webhookID, owner, repo)
 
 	if err != nil {
-		return err
+		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s] error reading environment: %w", webhookID, owner, repo, err)
 	}
 
 	// create deployment on GitHub API
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	if err != nil {
-		return err
+		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d] error getting github client: %w",
+			webhookID, owner, repo, env.ID, err)
 	}
 
 	if env.Mode == "auto" && event.GetAction() == "opened" {
@@ -98,7 +100,8 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 		)
 
 		if err != nil {
-			return err
+			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d] error creating workflow dispatch event: %w",
+				webhookID, owner, repo, env.ID, err)
 		}
 	} else if event.GetAction() == "synchronize" || event.GetAction() == "closed" {
 		depl, err := c.Repo().Environment().ReadDeploymentByGitDetails(
@@ -106,7 +109,8 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 		)
 
 		if err != nil {
-			return err
+			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d] error reading deployment: %w",
+				webhookID, owner, repo, env.ID, err)
 		}
 
 		if depl.Status != types.DeploymentStatusInactive {
@@ -125,13 +129,15 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 				)
 
 				if err != nil {
-					return err
+					return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error creating workflow dispatch event: %w",
+						webhookID, owner, repo, env.ID, depl.ID, err)
 				}
 			} else {
 				err = c.deleteDeployment(r, depl, env, client)
 
 				if err != nil {
-					return err
+					return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error deleting deployment: %w",
+						webhookID, owner, repo, env.ID, depl.ID, err)
 				}
 			}
 		}
@@ -149,7 +155,7 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 	cluster, err := c.Repo().Cluster().ReadCluster(env.ProjectID, env.ClusterID)
 
 	if err != nil {
-		return err
+		return fmt.Errorf("[projectID: %d, clusterID: %d] error reading cluster: %w", env.ProjectID, env.ClusterID, err)
 	}
 
 	agent, err := c.GetAgent(r, cluster, "")
@@ -163,7 +169,8 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 		err = agent.DeleteNamespace(depl.Namespace)
 
 		if err != nil {
-			return err
+			return fmt.Errorf("[owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error deleting namespace '%s': %w",
+				env.GitRepoOwner, env.GitRepoName, env.ID, depl.ID, depl.Namespace, err)
 		}
 	}
 
@@ -188,7 +195,8 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 	_, err = c.Repo().Environment().UpdateDeployment(depl)
 
 	if err != nil {
-		return err
+		return fmt.Errorf("[owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error updating deployment: %w",
+			env.GitRepoOwner, env.GitRepoName, env.ID, depl.ID, err)
 	}
 
 	return nil

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

@@ -638,14 +638,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
 }

+ 13 - 1
api/server/router/router.go

@@ -117,7 +117,8 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 
 		v1RegistryRegisterer := v1.NewV1RegistryScopedRegisterer()
 		v1ReleaseRegisterer := v1.NewV1ReleaseScopedRegisterer()
-		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(v1ReleaseRegisterer)
+		v1StackRegisterer := v1.NewV1StackScopedRegisterer()
+		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(v1ReleaseRegisterer, v1StackRegisterer)
 		v1ClusterRegisterer := v1.NewV1ClusterScopedRegisterer(v1NamespaceRegisterer)
 		v1ProjRegisterer := v1.NewV1ProjectScopedRegisterer(
 			v1ClusterRegisterer,
@@ -208,6 +209,10 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 	// after authorization. Each subsequent http.Handler can lookup the release in context.
 	releaseFactory := authz.NewReleaseScopedFactory(config)
 
+	// Create a new "stack-scoped" factory which will create a new stack-scoped request after
+	// authorization. Each subsequent http.Handler can lookup the stack in context.
+	stackFactory := authz.NewStackScopedFactory(config)
+
 	// Policy doc loader loads the policy documents for a specific project.
 	policyDocLoader := policy.NewBasicPolicyDocumentLoader(config.Repo.Project(), config.Repo.Policy())
 
@@ -217,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)
 
@@ -252,6 +260,10 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 				atomicGroup.Use(operationFactory.Middleware)
 			case types.ReleaseScope:
 				atomicGroup.Use(releaseFactory.Middleware)
+			case types.StackScope:
+				atomicGroup.Use(stackFactory.Middleware)
+			case types.GitlabIntegrationScope:
+				atomicGroup.Use(gitlabIntFactory.Middleware)
 			}
 		}
 

+ 109 - 0
api/server/router/v1/cluster.go

@@ -11,6 +11,21 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
+// swagger:parameters createNamespace listNamespaces
+type clusterPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+}
+
 func NewV1ClusterScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 		GetRoutes: GetV1ClusterScopedRoutes,
@@ -56,6 +71,34 @@ func getV1ClusterRoutes(
 	var routes []*router.Route
 
 	// 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 in the cluster denoted by `cluster_id`. The cluster should belong to the project
+	// denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create a new namespace
+	// tags:
+	// - Namespaces
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - in: body
+	//     name: CreateNamespaceRequest
+	//     description: The namespace to create
+	//     schema:
+	//       $ref: '#/definitions/CreateNamespaceRequest'
+	// responses:
+	//   '201':
+	//     description: Successfully created a new namespace
+	//     schema:
+	//       $ref: '#/definitions/NamespaceResponse'
+	//   '403':
+	//     description: Forbidden
+	//   '412':
+	//     description: Namespace already exists
 	createNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -85,6 +128,30 @@ 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 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:
+	// - application/json
+	// summary: Get a namespace
+	// tags:
+	// - Namespaces
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	// responses:
+	//   '200':
+	//     description: Successfully got the namespace
+	//     schema:
+	//       $ref: '#/definitions/NamespaceResponse'
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Not Found
 	getNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -113,6 +180,27 @@ 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 all namespaces in the cluster denoted by `cluster_id`. The cluster should belong to
+	// the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List all namespaces
+	// tags:
+	// - Namespaces
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	// responses:
+	//   '200':
+	//     description: Successfully listed namespaces
+	//     schema:
+	//       $ref: '#/definitions/ListNamespacesResponse'
+	//   '403':
+	//     description: Forbidden
 	listNamespacesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -141,6 +229,27 @@ 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 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:
+	// - application/json
+	// summary: Delete a namespace
+	// tags:
+	// - Namespaces
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	// responses:
+	//   '200':
+	//     description: Successfully deleted namespace
+	//   '403':
+	//     description: Forbidden
 	deleteNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,

+ 20 - 0
api/server/router/v1/namespace.go

@@ -8,6 +8,26 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
+// swagger:parameters getNamespace deleteNamespace createRelease createStack listStacks
+type namespacePathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace name
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+}
+
 func NewV1NamespaceScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 		GetRoutes: GetV1NamespaceScopedRoutes,

+ 9 - 0
api/server/router/v1/project.go

@@ -8,6 +8,15 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
+// swagger:parameters createRegistry listRegistries
+type projectPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+}
+
 func NewV1ProjectScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 		GetRoutes: GetV1ProjectScopedRoutes,

+ 199 - 1
api/server/router/v1/registry.go

@@ -5,12 +5,28 @@ 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"
 	"github.com/porter-dev/porter/api/types"
 )
 
+// swagger:parameters getRegistry deleteRegistry createRegistryRepository listRegistryRepositories listRegistryImages
+type registryPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The registry id
+	// in: path
+	// required: true
+	// minimum: 1
+	RegistryID uint `json:"registry_id"`
+}
+
 func NewV1RegistryScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 		GetRoutes: GetV1RegistryScopedRoutes,
@@ -56,6 +72,34 @@ func getV1RegistryRoutes(
 	var routes []*router.Route
 
 	// POST /api/v1/projects/{project_id}/registries -> registry.NewRegistryCreateHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/registries createRegistry
+	//
+	// Connects a new image registry to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Connect an image registry
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - in: body
+	//     name: CreateRegistryRequest
+	//     description: The registry to connect
+	//     schema:
+	//       $ref: '#/definitions/CreateRegistryRequest'
+	// responses:
+	//   '201':
+	//     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,
@@ -84,6 +128,27 @@ 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 denoted by `registry_id`. The registry should belong to the
+	// project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get an image registry
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	// responses:
+	//   '200':
+	//     description: Successfully got the registry
+	//     schema:
+	//       $ref: '#/definitions/GetRegistryResponse'
+	//   '403':
+	//     description: Forbidden
 	getEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -112,6 +177,26 @@ func getV1RegistryRoutes(
 	})
 
 	// GET /api/v1/projects/{project_id}/registries -> registry.NewRegistryListHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/registries listRegistries
+	//
+	// Lists all registries connected to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List image registries
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	// responses:
+	//   '200':
+	//     description: Successfully listed registries
+	//     schema:
+	//       $ref: '#/definitions/ListRegistriesResponse'
+	//   '403':
+	//     description: Forbidden
 	listRegistriesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
@@ -139,6 +224,25 @@ 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 a registry denoted by `registry_id`. The registry should belong to
+	// the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Disconnect image registry
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	// responses:
+	//   '200':
+	//     description: Successfully disconnected image registry
+	//   '403':
+	//     description: Forbidden
 	deleteEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,
@@ -167,6 +271,30 @@ func getV1RegistryRoutes(
 	})
 
 	// POST /api/v1/projects/{project_id}/registries/{registry_id}/repositories -> registry.NewRegistryCreateRepositoryHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/registries/{registry_id}/repositories createRegistryRepository
+	//
+	// Creates an image repository inside the registry specified by `registry_id`. This method **only** creates repositories for ECR-integrated
+	// repositories.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create image repository
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	//   - in: body
+	//     name: CreateRepositoryRequest
+	//     description: The repository to create
+	//     schema:
+	//       $ref: '#/definitions/CreateRegistryRepositoryRequest'
+	// responses:
+	//   '201':
+	//     description: Successfully created the image repository
+	//   '403':
+	//     description: Forbidden
 	createRepositoryEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -196,6 +324,27 @@ 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 denoted by `registry_id`. The registry
+	// should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List image repositories
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	// responses:
+	//   '200':
+	//     description: Successfully listed image repositories
+	//     schema:
+	//       $ref: '#/definitions/ListRegistryRepositoriesResponse'
+	//   '403':
+	//     description: Forbidden
 	listRepositoriesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
@@ -224,6 +373,54 @@ 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 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:
+	// - application/json
+	// summary: List images
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	//   - name: repository
+	//     in: path
+	//     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/V1ListImageResponse'
+	//   '400':
+	//     description: A malformed or bad request
+	//   '403':
+	//     description: Forbidden
 	listImagesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
@@ -244,8 +441,9 @@ func getV1RegistryRoutes(
 		},
 	)
 
-	listImagesHandler := registry.NewRegistryListImagesHandler(
+	listImagesHandler := v1Registry.NewRegistryListImagesHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 

+ 168 - 1
api/server/router/v1/release.go

@@ -4,12 +4,50 @@ 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"
 	"github.com/porter-dev/porter/api/types"
 )
 
+// swagger:parameters getRelease updateRelease deleteRelease
+type releasePathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The registry id
+	// in: path
+	// required: true
+	// minimum: 1
+	RegistryID uint `json:"registry_id"`
+
+	// The namespace name
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The release name
+	// in: path
+	// required: true
+	Name string `json:"name"`
+
+	// The release version (`0` for latest version)
+	// in: path
+	// required: true
+	// minimum: 0
+	Version uint `json:"version"`
+}
+
+// swagger:parameters listReleases
+type listReleasesRequest struct {
+	*namespacePathParams
+	*types.ListReleasesRequest
+}
+
 func NewV1ReleaseScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 		GetRoutes: GetV1ReleaseScopedRoutes,
@@ -55,6 +93,39 @@ func getV1ReleaseRoutes(
 	var routes []*router.Route
 
 	// 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 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:
+	// - application/json
+	// summary: Create a new release
+	// tags:
+	// - Releases
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - in: body
+	//     name: CreateReleaseRequest
+	//     description: The release to create
+	//     schema:
+	//       $ref: '#/definitions/CreateReleaseRequest'
+	// 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,
@@ -85,6 +156,31 @@ 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 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:
+	// - application/json
+	// summary: Get a release
+	// tags:
+	// - Releases
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - name: version
+	// responses:
+	//   '200':
+	//     description: Successfully got the release
+	//     schema:
+	//       $ref: '#/definitions/GetReleaseResponse'
+	//   '403':
+	//     description: Forbidden
 	getEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -115,6 +211,24 @@ 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 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:
+	// - application/json
+	// summary: List releases
+	// tags:
+	// - Releases
+	// responses:
+	//   '201':
+	//     description: Successfully listed releases
+	//     schema:
+	//       $ref: '#/definitions/ListReleasesResponse'
+	//   '403':
+	//     description: Forbidden
 	listReleasesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -146,6 +260,36 @@ func getV1ReleaseRoutes(
 
 	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} ->
 	// release.NewUpgradeReleaseHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} updateRelease
+	//
+	// 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:
+	// - application/json
+	// summary: Update a release
+	// tags:
+	// - Releases
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - name: version
+	//   - in: body
+	//     name: UpdateReleaseRequest
+	//     description: The release to update
+	//     schema:
+	//       $ref: '#/definitions/UpdateReleaseRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully updated the release
+	//   '400':
+	//     description: A malformed or bad request
+	//   '403':
+	//     description: Forbidden
 	upgradeEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
@@ -164,7 +308,7 @@ func getV1ReleaseRoutes(
 		},
 	)
 
-	upgradeHandler := release.NewUpgradeReleaseHandler(
+	upgradeHandler := v1Release.NewUpgradeReleaseHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
@@ -178,6 +322,29 @@ func getV1ReleaseRoutes(
 
 	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} ->
 	// release.NewDeleteReleaseHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} deleteRelease
+	//
+	// 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:
+	// - application/json
+	// summary: Delete a release
+	// tags:
+	// - Releases
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - name: version
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the release
+	//   '403':
+	//     description: Forbidden
 	deleteEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,

+ 490 - 0
api/server/router/v1/stack.go

@@ -0,0 +1,490 @@
+package v1
+
+import (
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/stack"
+	"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"
+	"github.com/porter-dev/porter/api/types"
+)
+
+// swagger:parameters getStack deleteStack putStackSource rollbackStack
+type stackPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+}
+
+// swagger:parameters getStackRevision
+type stackRevisionPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+
+	// The stack revision number
+	// in: path
+	// required: true
+	// minimum: 1
+	StackRevisionNumber string `json:"stack_revision_number"`
+}
+
+func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetV1StackScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetV1StackScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getV1StackRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getV1StackRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/stacks"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks -> stack.NewStackCreateHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks createStack
+	//
+	// Creates a new stack and triggers a deployment for all resources in the stack.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - in: body
+	//     name: CreateStackRequest
+	//     description: The stack to create
+	//     schema:
+	//       $ref: '#/definitions/CreateStackRequest'
+	// responses:
+	//   '201':
+	//     description: Successfully created the stack
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	createEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	createHandler := stack.NewStackCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createEndpoint,
+		Handler:  createHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks -> stack.NewStackListHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks listStacks
+	//
+	// Lists stacks in a namespace
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List stacks
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	// responses:
+	//   '200':
+	//     description: Successfully listed stacks
+	//     schema:
+	//       $ref: '#/definitions/StackListResponse'
+	//   '403':
+	//     description: Forbidden
+	listEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	listHandler := stack.NewStackListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEndpoint,
+		Handler:  listHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} -> stack.NewStackGetHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} getStack
+	//
+	// Gets a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	// responses:
+	//   '200':
+	//     description: Successfully got the stack
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	getEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	getHandler := stack.NewStackGetHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getEndpoint,
+		Handler:  getHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} -> stack.NewStackGetRevisionHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} getStackRevision
+	//
+	// Gets a stack revision
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get a stack revision
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: stack_revision_number
+	// responses:
+	//   '200':
+	//     description: Successfully got the stack revision
+	//     schema:
+	//       $ref: '#/definitions/StackRevision'
+	//   '403':
+	//     description: Forbidden
+	getRevisionEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/{stack_revision_number}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	getRevisionHandler := stack.NewStackGetRevisionHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getRevisionEndpoint,
+		Handler:  getRevisionHandler,
+		Router:   r,
+	})
+
+	// PUT /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/source -> stack.NewStackPutSourceConfig
+	// swagger:operation PUT /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/source putStackSource
+	//
+	// Updates a stack's source configuration
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Update source configuration
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: PutStackSourceConfigRequest
+	//     description: The source configurations to update
+	//     schema:
+	//       $ref: '#/definitions/PutStackSourceConfigRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully updated the source configuration
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	putSourceEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPut,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/source",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	putSourceHandler := stack.NewStackPutSourceConfigHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: putSourceEndpoint,
+		Handler:  putSourceHandler,
+		Router:   r,
+	})
+
+	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/rollback -> stack.NewStackRollbackHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/rollback rollbackStack
+	//
+	// Performs a rollback for a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Rollback stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: StackRollbackRequest
+	//     description: The target revision to roll back to
+	//     schema:
+	//       $ref: '#/definitions/StackRollbackRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully rolled the stack back
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	rollbackEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/rollback",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	rollbackHandler := stack.NewStackRollbackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: rollbackEndpoint,
+		Handler:  rollbackHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} -> stack.NewStackDeleteHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} deleteStack
+	//
+	// Deletes a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Delete a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the stack
+	//   '403':
+	//     description: Forbidden
+	deleteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	deleteHandler := stack.NewStackDeleteHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEndpoint,
+		Handler:  deleteHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

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

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

@@ -46,6 +46,7 @@ type ServerConf struct {
 	GithubAppWebhookSecret string `env:"GITHUB_APP_WEBHOOK_SECRET"`
 	GithubAppID            string `env:"GITHUB_APP_ID"`
 	GithubAppSecretPath    string `env:"GITHUB_APP_SECRET_PATH"`
+	GithubAppSecret        []byte
 
 	GoogleClientID         string `env:"GOOGLE_CLIENT_ID"`
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`
@@ -99,6 +100,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,

+ 9 - 0
api/server/shared/config/loader/loader.go

@@ -2,6 +2,7 @@ package loader
 
 import (
 	"fmt"
+	"io/ioutil"
 	"net/http"
 	"strconv"
 
@@ -167,6 +168,14 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 				BaseURL:      sc.ServerURL,
 			}, sc.GithubAppName, sc.GithubAppWebhookSecret, sc.GithubAppSecretPath, AppID)
 		}
+
+		secret, err := ioutil.ReadFile(sc.GithubAppSecretPath)
+
+		if err != nil {
+			return nil, fmt.Errorf("could not read github app secret: %s", err)
+		}
+
+		sc.GithubAppSecret = append(sc.GithubAppSecret, secret...)
 	}
 
 	if sc.SlackClientID != "" && sc.SlackClientSecret != "" {

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

+ 29 - 10
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,19 +184,39 @@ const (
 	AWSData          ClusterResolverName = "upload-aws-data"
 )
 
-type ListNamespacesResponse struct {
-	*v1.NamespaceList
-}
-
-type CreateNamespaceRequest struct {
+// NamespaceResponse represents the response type of requests to the namespace resource
+//
+// swagger:model
+type NamespaceResponse struct {
+	// the name of the namespace
+	// example: default
 	Name string `json:"name" form:"required"`
-}
 
-type CreateNamespaceResponse struct {
-	*v1.Namespace
+	// 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"`
+
+	// 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"`
 }
 
-type DeleteNamespaceRequest struct {
+// ListNamespacesResponse represents the list of all namespaces
+//
+// swagger:model
+type ListNamespacesResponse []*NamespaceResponse
+
+// CreateNamespaceRequest represents the request body to create a namespace
+//
+// swagger:model
+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"`
 }

+ 25 - 4
api/types/namespace.go

@@ -16,10 +16,30 @@ const (
 // ReleaseListFilter is a struct that represents the various filter options used for
 // retrieving the releases
 type ReleaseListFilter struct {
-	Namespace    string   `json:"namespace"`
-	Limit        int      `json:"limit"`
-	Skip         int      `json:"skip"`
-	ByDate       bool     `json:"byDate"`
+	// swagger:ignore
+	Namespace string `json:"namespace"`
+
+	// the pagination limit
+	//
+	// in: query
+	// example: 50
+	Limit int `json:"limit"`
+
+	// how many items to skip
+	//
+	// in: query
+	// example: 10
+	Skip int `json:"skip"`
+
+	// whether to sort by date
+	//
+	// in: query
+	// example: false
+	ByDate bool `json:"byDate"`
+
+	// which helm statuses to filter by
+	//
+	// in: query
 	StatusFilter []string `json:"statusFilter"`
 }
 
@@ -60,6 +80,7 @@ type ListReleasesRequest struct {
 	*ReleaseListFilter
 }
 
+// swagger:model
 type ListReleasesResponse []*release.Release
 
 type GetConfigMapRequest struct {

+ 15 - 12
api/types/policy.go

@@ -5,18 +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"
+	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 {
@@ -40,6 +42,7 @@ var ScopeHeirarchy = ScopeTree{
 	ProjectScope: {
 		ClusterScope: {
 			NamespaceScope: {
+				StackScope:   {},
 				ReleaseScope: {},
 			},
 		},

+ 1 - 0
api/types/project.go

@@ -8,6 +8,7 @@ type Project struct {
 	RDSDatabasesEnabled bool    `json:"enable_rds_databases"`
 	ManagedInfraEnabled bool    `json:"managed_infra_enabled"`
 	APITokensEnabled    bool    `json:"api_tokens_enabled"`
+	StacksEnabled       bool    `json:"stacks_enabled"`
 }
 
 type CreateProjectRequest 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

+ 93 - 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 (
@@ -81,23 +103,64 @@ const (
 	DockerHub RegistryService = "dockerhub"
 )
 
+// swagger:model ListRegistriesResponse
 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
+type CreateRegistryResponse Registry
+
+// swagger:model
+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"`
 }
 
@@ -125,6 +188,26 @@ type GetRegistryDOCRTokenRequest struct {
 	ServerURL string `schema:"server_url"`
 }
 
+// swagger:model ListRegistryRepositoriesResponse
 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"`
+}

+ 66 - 16
api/types/release.go

@@ -15,15 +15,32 @@ 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"`
+
+	// 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
 type GetReleaseResponse Release
 
 type UpdateNotificationConfigRequest struct {
@@ -35,21 +52,44 @@ type UpdateNotificationConfigRequest struct {
 }
 
 type CreateReleaseBaseRequest struct {
-	RepoURL         string                 `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 {
@@ -62,6 +102,16 @@ type RollbackReleaseRequest struct {
 	Revision int `json:"revision" form:"required"`
 }
 
+// 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"`

+ 2 - 0
api/types/request.go

@@ -43,8 +43,10 @@ const (
 	URLParamInviteID          URLParam = "invite_id"
 	URLParamNamespace         URLParam = "namespace"
 	URLParamReleaseName       URLParam = "name"
+	URLParamStackID           URLParam = "stack_id"
 	URLParamReleaseVersion    URLParam = "version"
 	URLParamWildcard          URLParam = "*"
+	URLParamIntegrationID     URLParam = "integration_id"
 )
 
 type Path struct {

+ 263 - 0
api/types/stacks.go

@@ -0,0 +1,263 @@
+package types
+
+import "time"
+
+// swagger:model
+type CreateStackRequest struct {
+	// The display name of the stack
+	// required: true
+	Name string `json:"name" form:"required"`
+
+	// A list of app resources to create. An app resource is an application helm chart, such as a `web` or `worker` template.
+	// required: true
+	AppResources []*CreateStackAppResourceRequest `json:"app_resources,omitempty" form:"required,dive,required"`
+
+	// A list of configurations which can build an application. Each application resource must use at least one
+	// source config in order to build application from source. The source config can be specified as a Docker image
+	// registry or linked to a remote Git repository.
+	// required: true
+	SourceConfigs []*CreateStackSourceConfigRequest `json:"source_configs,omitempty" form:"required,dive,required"`
+}
+
+// swagger:model
+type PutStackSourceConfigRequest struct {
+	SourceConfigs []*CreateStackSourceConfigRequest `json:"source_configs,omitempty" form:"required,dive,required"`
+}
+
+const URLParamStackRevisionNumber URLParam = "stack_revision_number"
+
+// swagger:model
+type StackRollbackRequest struct {
+	TargetRevision uint `json:"target_revision"`
+}
+
+// swagger:model
+type PatchStackSourceConfigRequest struct {
+	SourceConfig *UpdateStackSourceConfigRequest `json:"source_config,omitempty" form:"required"`
+}
+
+type CreateStackAppResourceRequest struct {
+	// The URL of the Helm registry to pull the template from. If not set, this defaults to `https://charts.getporter.dev`.
+	TemplateRepoURL string `json:"template_repo_url"`
+
+	// The name of the template in the Helm registry, such as `web`
+	// required: true
+	TemplateName string `json:"template_name" form:"required"`
+
+	// The version of the template in the Helm registry, such as `v0.50.0`
+	// required: true
+	TemplateVersion string `json:"template_version" form:"required"`
+
+	// The values to pass in to the template.
+	Values map[string]interface{} `json:"values"`
+
+	// The name of the resource.
+	// required: true
+	Name string `json:"name" form:"required"`
+
+	// The name of the source config (must exist inside `source_configs`).
+	// required: true
+	SourceConfigName string `json:"source_config_name" form:"required"`
+}
+
+// swagger:model
+type Stack struct {
+	// The time that the stack was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that the stack was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The display name of the stack
+	Name string `json:"name"`
+
+	// A unique id for the stack
+	ID string `json:"id"`
+
+	// The latest revision for the stack
+	LatestRevision *StackRevision `json:"latest_revision,omitempty"`
+
+	// The list of revisions deployed for this stack
+	Revisions []StackRevisionMeta `json:"revisions,omitempty"`
+}
+
+// swagger:model
+type StackListResponse []Stack
+
+type StackResource struct {
+	// The time that this resource was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that this resource was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The stack ID that this resource belongs to
+	StackID string `json:"stack_id"`
+
+	// The numerical revision id that this resource belongs to
+	StackRevisionID uint `json:"stack_revision_id"`
+
+	// The name of the resource
+	Name string `json:"name"`
+
+	// The id for this resource
+	ID string `json:"id"`
+
+	// If this is an app resource, app-specific information for the resource
+	StackAppData *StackResourceAppData `json:"stack_app_data,omitempty"`
+
+	// The source configuration for this stack
+	StackSourceConfig *StackSourceConfig `json:"stack_source_config,omitempty"`
+}
+
+type StackResourceAppData struct {
+	// The URL of the Helm registry to pull the template from
+	TemplateRepoURL string `json:"template_repo_url"`
+
+	// The name of the template in the Helm registry, such as `web`
+	TemplateName string `json:"template_name"`
+
+	// The version of the template in the Helm registry, such as `v0.50.0`
+	TemplateVersion string `json:"template_version"`
+}
+
+type StackRevisionStatus string
+
+const (
+	StackRevisionStatusDeploying StackRevisionStatus = "deploying"
+	StackRevisionStatusFailed    StackRevisionStatus = "failed"
+	StackRevisionStatusDeployed  StackRevisionStatus = "deployed"
+)
+
+type StackRevisionMeta struct {
+	// The time that this revision was created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The id of the revision
+	ID uint `json:"id"`
+
+	// The status of the revision
+	Status StackRevisionStatus `json:"status"`
+
+	// The stack ID that this source config belongs to
+	StackID string `json:"stack_id"`
+}
+
+type StackRevision struct {
+	*StackRevisionMeta
+
+	// The reason for any error or status change
+	Reason string `json:"reason"`
+
+	// The message associated with an error or status change
+	Message string `json:"message"`
+
+	// The list of resources deployed in this revision
+	Resources []StackResource `json:"resources"`
+
+	SourceConfigs []StackSourceConfig `json:"source_configs"`
+}
+
+type StackSourceConfig struct {
+	// The time that the source configuration was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that the source configuration was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The stack ID that this source config belongs to
+	StackID string `json:"stack_id"`
+
+	// The numerical revision id that this source config belongs to
+	StackRevisionID uint `json:"stack_revision_id"`
+
+	// The display name of the stack source
+	Name string `json:"name"`
+
+	// The unique id of the stack source config
+	ID string `json:"id"`
+
+	// The complete image repo uri used by the source
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// The current image tag used by the application
+	ImageTag string `json:"image_tag"`
+
+	// If this field is empty, the resource is deployed directly from the image repo uri
+	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
+}
+
+// swagger:model
+type CreateStackSourceConfigRequest struct {
+	// required: true
+	Name string `json:"name"`
+
+	// required: true
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// required: true
+	ImageTag string `json:"image_tag"`
+
+	// If this field is empty, the resource is deployed directly from the image repo uri
+	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
+}
+
+// swagger:model
+type UpdateStackSourceConfigRequest struct {
+	// required: true
+	Name string `json:"name"`
+
+	// required: true
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// required: true
+	ImageTag string `json:"image_tag"`
+}
+
+type StackSourceConfigBuild struct {
+	// The build method to use: can be `docker` (for dockerfiles), or `pack` (for buildpacks)
+	// required: true
+	Method string `json:"method" form:"required"`
+
+	// The path to the build context (the root folder of the application). For example, `.` or `./app`
+	// required: true
+	FolderPath string `json:"folder_path" form:"required"`
+
+	// The remote Git configuration to use. If not passed in, this application will not appear to be linked to a
+	// remote Git repository.
+	StackSourceConfigBuildGit *StackSourceConfigBuildGit `json:"git,omitempty"`
+
+	// The Dockerfile build configuration, if `method` is `docker`
+	StackSourceConfigBuildDockerfile *StackSourceConfigBuildDockerfile `json:"dockerfile,omitempty"`
+
+	// The buildpack configuration, if method is `pack`
+	StackSourceConfigBuildPack *StackSourceConfigBuildPack `json:"buildpack,omitempty"`
+}
+
+type StackSourceConfigBuildGit struct {
+	// The git integration kind: can be `github` or `gitlab`
+	GitIntegrationKind string `json:"git_integration_kind"`
+
+	// The integration id of the github or gitlab integration
+	GitIntegrationID uint `json:"git_integration_id"`
+
+	// The git repo in ${owner}/${repo} form
+	GitRepo string `json:"git_repo"`
+
+	// The git branch to use
+	GitBranch string `json:"git_branch"`
+}
+
+type StackSourceConfigBuildDockerfile struct {
+	// The path to the dockerfile from the root directory. Defaults to `./Dockerfile`.
+	DockerfilePath string `json:"dockerfile_path" form:"required"`
+}
+
+type StackSourceConfigBuildPack struct {
+	// The buildpack builder to use
+	// required: true
+	Builder string `json:"builder" form:"required"`
+
+	// A list of buildpacks to use
+	Buildpacks []string `json:"buildpacks"`
+}

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

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

@@ -303,8 +303,8 @@ func (c *CLIConfig) SetKubeconfig(kubeconfig string) error {
 		return fmt.Errorf("%s does not exist", path)
 	}
 
-	viper.Set("kubeconfig", kubeconfig)
-	color.New(color.FgGreen).Printf("Set the path to kubeconfig as %s\n", kubeconfig)
+	viper.Set("kubeconfig", path)
+	color.New(color.FgGreen).Printf("Set the path to kubeconfig as %s\n", path)
 	err = viper.WriteConfig()
 
 	if err != nil {

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

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

+ 9 - 0
cli/cmd/deploy/deploy.go

@@ -345,6 +345,15 @@ func (d *DeployAgent) Push() error {
 // reuses the configuration set for the application. If overrideValues is not nil,
 // it will merge the overriding values with the existing configuration.
 func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}) error {
+	// we should fetch the latest release and its config
+	release, err := d.Client.GetRelease(context.TODO(), d.Opts.ProjectID, d.Opts.ClusterID, d.Opts.Namespace, d.App)
+
+	if err != nil {
+		return err
+	}
+
+	d.Release = release
+
 	// if this is a job chart, set "paused" to false so that the job doesn't run, unless
 	// the user has explicitly overriden the "paused" field
 	if _, exists := overrideValues["paused"]; d.Release.Chart.Name() == "job" && !exists {

+ 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

+ 2 - 0
cmd/app/main.go

@@ -1,3 +1,5 @@
+//go:generate swagger generate spec
+
 package main
 
 import (

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

Some files were not shown because too many files changed in this diff