浏览代码

Merge pull request #1829 from porter-dev/nafees/preview-env-new-endpoints

[POR-385] Create new preview environment endpoints and custom Github Action workflow
abelanger5 4 年之前
父节点
当前提交
d544d07fba
共有 89 个文件被更改,包括 4269 次插入1723 次删除
  1. 5 7
      api/client/environment.go
  2. 45 5
      api/server/handlers/environment/create.go
  3. 13 9
      api/server/handlers/environment/create_deployment.go
  4. 1 0
      api/server/handlers/environment/delete.go
  5. 26 11
      api/server/handlers/environment/delete_deployment.go
  6. 128 0
      api/server/handlers/environment/enable_pull_request.go
  7. 16 1
      api/server/handlers/environment/list.go
  8. 1 1
      api/server/handlers/environment/list_deployments.go
  9. 119 7
      api/server/handlers/environment/list_deployments_by_cluster.go
  10. 117 0
      api/server/handlers/environment/reenable_deployment.go
  11. 2 2
      api/server/handlers/environment/update_deployment.go
  12. 2 1
      api/server/handlers/gitinstallation/get_permissions.go
  13. 20 18
      api/server/handlers/gitinstallation/helpers.go
  14. 173 0
      api/server/handlers/webhook/github_incoming.go
  15. 30 0
      api/server/router/base.go
  16. 176 79
      api/server/router/cluster.go
  17. 283 315
      api/server/router/git_installation.go
  18. 2 0
      api/server/shared/config/env/envconfs.go
  19. 24 8
      api/types/environment.go
  20. 5 4
      api/types/git_installation.go
  21. 1 1
      api/types/request.go
  22. 24 16
      cli/cmd/apply.go
  23. 18 31
      cli/cmd/delete.go
  24. 15 0
      dashboard/src/assets/code-branch-icon.tsx
  25. 3 1
      dashboard/src/components/DocsHelper.tsx
  26. 96 0
      dashboard/src/components/OptionsDropdown.tsx
  27. 1 1
      dashboard/src/components/events/useLastSeenPodStatus.ts
  28. 8 2
      dashboard/src/components/form-components/InputRow.tsx
  29. 1 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  30. 1 0
      dashboard/src/main/home/Home.tsx
  31. 11 19
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  32. 1 1
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  33. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  34. 2 22
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  35. 5 82
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  36. 0 10
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  37. 0 495
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx
  38. 0 353
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx
  39. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  40. 2 2
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  41. 2 0
      dashboard/src/main/home/cluster-dashboard/env-groups/utils.ts
  42. 3 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  43. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  44. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  45. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  46. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts
  47. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  48. 115 43
      dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx
  49. 229 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/PreviewEnvironmentsHome.tsx
  50. 58 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/ActionButton.tsx
  51. 15 2
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx
  52. 73 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx
  53. 124 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/RecreateWorkflowFilesModal.tsx
  54. 493 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx
  55. 29 38
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  56. 438 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  57. 303 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx
  58. 348 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx
  59. 105 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx
  60. 165 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/mocks.ts
  61. 33 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx
  62. 38 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts
  63. 0 2
      dashboard/src/main/home/infrastructure/ExpandedInfra.tsx
  64. 0 1
      dashboard/src/main/home/integrations/edit-integration/GCRForm.tsx
  65. 1 1
      dashboard/src/main/home/launch/Launch.tsx
  66. 1 1
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  67. 1 1
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  68. 1 1
      dashboard/src/main/home/modals/PreviewEnvSettingsModal.tsx
  69. 1 1
      dashboard/src/main/home/onboarding/components/RegistryImageList.tsx
  70. 1 1
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_AWSRegistryForm.tsx
  71. 1 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx
  72. 2 2
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_DOProvisionerForm.tsx
  73. 2 2
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx
  74. 2 2
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  75. 53 4
      dashboard/src/main/home/sidebar/Sidebar.tsx
  76. 53 26
      dashboard/src/shared/api.tsx
  77. 0 6
      dashboard/src/shared/baseApi.ts
  78. 0 38
      dashboard/src/shared/baseApi.tsx
  79. 1 1
      dashboard/src/shared/hooks/useEffectDebugger.ts
  80. 4 4
      dashboard/src/shared/hooks/useWebsockets.ts
  81. 3 1
      dashboard/src/shared/routing.tsx
  82. 5 1
      internal/integrations/ci/actions/actions.go
  83. 52 6
      internal/integrations/ci/actions/preview.go
  84. 27 20
      internal/integrations/ci/actions/steps.go
  85. 12 0
      internal/kubernetes/agent.go
  86. 14 1
      internal/models/environment.go
  87. 4 0
      internal/repository/environment.go
  88. 53 0
      internal/repository/gorm/environment.go
  89. 19 0
      internal/repository/test/environment.go

+ 5 - 7
api/client/environment.go

@@ -109,16 +109,14 @@ func (c *Client) FinalizeDeployment(
 
 func (c *Client) DeleteDeployment(
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
-	req *types.DeleteDeploymentRequest,
+	projID, clusterID uint,
+	envID, gitRepoOwner, gitRepoName, prNumber string,
 ) error {
 	return c.deleteRequest(
 		fmt.Sprintf(
-			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment",
-			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+			"/projects/%d/clusters/%d/deployments/%s/%s/%s/%s",
+			projID, clusterID, envID, gitRepoOwner, gitRepoName, prNumber,
 		),
-		req,
-		nil,
+		nil, nil,
 	)
 }

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

@@ -1,8 +1,10 @@
 package environment
 
 import (
+	"fmt"
 	"net/http"
 	"strconv"
+	"strings"
 
 	ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v41/github"
@@ -13,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/encryption"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
@@ -51,6 +54,14 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	// create a random webhook id
+	webhookUID, err := encryption.GenerateRandomBytes(32)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	env, err := c.Repo().Environment().CreateEnvironment(&models.Environment{
 		ProjectID:         project.ID,
 		ClusterID:         cluster.ID,
@@ -58,10 +69,12 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		Name:              request.Name,
 		GitRepoOwner:      owner,
 		GitRepoName:       name,
+		Mode:              request.Mode,
+		WebhookID:         string(webhookUID),
 	})
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 	}
 
@@ -69,7 +82,27 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
+		return
+	}
+
+	webhookURL := fmt.Sprintf("%s/api/github/incoming_webhook/%s", c.Config().ServerConf.ServerURL, string(webhookUID))
+
+	// create incoming webhook
+	_, _, err = client.Repositories.CreateHook(
+		r.Context(), owner, name, &github.Hook{
+			Config: map[string]interface{}{
+				"url":          webhookURL,
+				"content_type": "json",
+				"secret":       c.Config().ServerConf.GithubIncomingWebhookSecret,
+			},
+			Events: []string{"pull_request"},
+			Active: github.Bool(true),
+		},
+	)
+
+	if err != nil && !strings.Contains(err.Error(), "already exists on this repository") {
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 	}
 
@@ -77,14 +110,14 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 	}
 
 	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 	}
 
@@ -101,13 +134,20 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	})
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 	}
 
 	c.WriteResult(w, r, env.ToEnvironmentType())
 }
 
+func (c *CreateEnvironmentHandler) deleteEnvAndReportError(
+	w http.ResponseWriter, r *http.Request, env *models.Environment, err error,
+) {
+	c.Repo().Environment().DeleteEnvironment(env)
+	c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+}
+
 func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
 	// get the github app client
 	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)

+ 13 - 9
api/server/handlers/environment/create_deployment.go

@@ -66,7 +66,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	ghDeployment, err := createDeployment(client, env, request.CreateGHDeploymentRequest)
+	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -84,6 +84,8 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		RepoName:       request.GitHubMetadata.RepoName,
 		PRName:         request.GitHubMetadata.PRName,
 		CommitSHA:      request.GitHubMetadata.CommitSHA,
+		PRBranchFrom:   request.GitHubMetadata.PRBranchFrom,
+		PRBranchInto:   request.GitHubMetadata.PRBranchInto,
 	})
 
 	if err != nil {
@@ -109,16 +111,18 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	c.WriteResult(w, r, depl.ToDeploymentType())
 }
 
-func createDeployment(client *github.Client, env *models.Environment, request *types.CreateGHDeploymentRequest) (*github.Deployment, error) {
-	branch := request.Branch
-	envName := "Preview"
-	automerge := false
+func createDeployment(
+	client *github.Client,
+	env *models.Environment,
+	branchFrom string,
+	actionID uint,
+) (*github.Deployment, error) {
 	requiredContexts := []string{}
 
 	deploymentRequest := github.DeploymentRequest{
-		Ref:              &branch,
-		Environment:      &envName,
-		AutoMerge:        &automerge,
+		Ref:              github.String(branchFrom),
+		Environment:      github.String(env.Name),
+		AutoMerge:        github.Bool(false),
 		RequiredContexts: &requiredContexts,
 	}
 
@@ -138,7 +142,7 @@ func createDeployment(client *github.Client, env *models.Environment, request *t
 	// Create Deployment Status to indicate it's in progress
 
 	state := "in_progress"
-	log_url := fmt.Sprintf("https://github.com/%s/%s/runs/%d", env.GitRepoOwner, env.GitRepoName, request.ActionID)
+	log_url := fmt.Sprintf("https://github.com/%s/%s/runs/%d", env.GitRepoOwner, env.GitRepoName, actionID)
 
 	deploymentStatusRequest := github.DeploymentStatusRequest{
 		State:  &state,

+ 1 - 0
api/server/handlers/environment/delete.go

@@ -27,6 +27,7 @@ func NewDeleteEnvironmentHandler(
 ) *DeleteEnvironmentHandler {
 	return &DeleteEnvironmentHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 

+ 26 - 11
api/server/handlers/environment/delete_deployment.go

@@ -2,6 +2,8 @@ package environment
 
 import (
 	"context"
+	"errors"
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -12,9 +14,10 @@ import (
 	"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/models/integrations"
+	"gorm.io/gorm"
 )
 
 type DeleteDeploymentHandler struct {
@@ -34,32 +37,44 @@ func NewDeleteDeploymentHandler(
 }
 
 func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ga, _ := r.Context().Value(types.GitInstallationScope).(*integrations.GithubAppInstallation)
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
 
-	if !ok {
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
+	// check that the environment belongs to the project and cluster IDs
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("environment id not found in cluster and project")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
 		return
 	}
 
-	request := &types.DeleteDeploymentRequest{}
+	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
 
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
+	if !ok {
 		return
 	}
 
-	// read the environment to get the environment id
-	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
+	prNumber, reqErr := requestutils.GetURLParamUint(r, "pr_number")
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
 		return
 	}
 
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	depl, err := c.Repo().Environment().ReadDeploymentByGitDetails(envID, owner, name, prNumber)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 128 - 0
api/server/handlers/environment/enable_pull_request.go

@@ -0,0 +1,128 @@
+package environment
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"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/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 EnablePullRequestHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewEnablePullRequestHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *EnablePullRequestHandler {
+	return &EnablePullRequestHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	request := &types.PullRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(project.ID, cluster.ID, request.RepoOwner, request.RepoName)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// add an extra check that the installation has permission to read this pull request
+	pr, ghResp, err := client.PullRequests.Get(r.Context(), env.GitRepoOwner, env.GitRepoName, int(request.Number))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ghResp, err = client.Actions.CreateWorkflowDispatchEventByFileName(
+		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
+		github.CreateWorkflowDispatchEventRequest{
+			Ref: request.BranchFrom,
+			Inputs: map[string]interface{}{
+				"pr_number":      strconv.FormatUint(uint64(request.Number), 10),
+				"pr_title":       *pr.Title,
+				"pr_branch_from": request.BranchFrom,
+				"pr_branch_into": request.BranchInto,
+			},
+		},
+	)
+
+	buf := new(bytes.Buffer)
+	buf.ReadFrom(ghResp.Body)
+
+	if ghResp != nil && ghResp.StatusCode == 404 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("workflow file not found"), 404))
+		return
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	namespace := fmt.Sprintf("pr-%d-%s", request.Number, strings.ReplaceAll(env.GitRepoName, "_", "-"))
+
+	// create the deployment
+	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
+		EnvironmentID: env.ID,
+		Namespace:     namespace,
+		Status:        types.DeploymentStatusCreating,
+		PullRequestID: request.Number,
+		RepoOwner:     request.RepoOwner,
+		RepoName:      request.RepoName,
+		PRName:        request.Title,
+		PRBranchFrom:  request.BranchFrom,
+		PRBranchInto:  request.BranchInto,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create the backing namespace
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = agent.CreateNamespace(depl.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+}

+ 16 - 1
api/server/handlers/environment/list.go

@@ -38,7 +38,22 @@ func (c *ListEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	res := make([]*types.Environment, 0)
 
 	for _, env := range envs {
-		res = append(res, env.ToEnvironmentType())
+		environment := env.ToEnvironmentType()
+
+		depls, err := c.Repo().Environment().ListDeployments(env.ID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		environment.DeploymentCount = uint(len(depls))
+
+		if environment.DeploymentCount > 0 {
+			environment.LastDeploymentStatus = string(depls[0].Status)
+		}
+
+		res = append(res, environment)
 	}
 
 	c.WriteResult(w, r, res)

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

@@ -61,7 +61,7 @@ func (c *ListDeploymentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	depls, err := c.Repo().Environment().ListDeployments(env.ID, req.Status...)
+	depls, err := c.Repo().Environment().ListDeployments(env.ID)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 119 - 7
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -1,8 +1,11 @@
 package environment
 
 import (
+	"context"
+	"fmt"
 	"net/http"
 
+	"github.com/google/go-github/v41/github"
 	"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"
@@ -35,18 +38,127 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		return
 	}
 
-	depls, err := c.Repo().Environment().ListDeploymentsByCluster(project.ID, cluster.ID, req.Status...)
+	var deployments []*types.Deployment
+	var pullRequests []*types.PullRequest
+
+	if req.EnvironmentID == 0 {
+		depls, err := c.Repo().Environment().ListDeploymentsByCluster(project.ID, cluster.ID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		deplInfoMap := make(map[string]bool)
+
+		for _, depl := range depls {
+			deployment := depl.ToDeploymentType()
+			deplInfoMap[fmt.Sprintf(
+				"%s-%s-%d", deployment.RepoOwner, deployment.RepoName, deployment.PullRequestID,
+			)] = true
+			deployments = append(deployments, deployment)
+		}
+
+		envList, err := c.Repo().Environment().ListEnvironments(project.ID, cluster.ID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		for _, env := range envList {
+			prs, err := fetchOpenPullRequests(r.Context(), c.Config(), env, deplInfoMap)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			pullRequests = append(pullRequests, prs...)
+		}
+	} else {
+		depls, err := c.Repo().Environment().ListDeployments(req.EnvironmentID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		deplInfoMap := make(map[string]bool)
+
+		for _, depl := range depls {
+			deployment := depl.ToDeploymentType()
+			deplInfoMap[fmt.Sprintf(
+				"%s-%s-%d", deployment.RepoOwner, deployment.RepoName, deployment.PullRequestID,
+			)] = true
+			deployments = append(deployments, deployment)
+		}
+
+		env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, req.EnvironmentID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		prs, err := fetchOpenPullRequests(r.Context(), c.Config(), env, deplInfoMap)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		pullRequests = append(pullRequests, prs...)
+	}
+
+	c.WriteResult(w, r, map[string]interface{}{
+		"pull_requests": pullRequests,
+		"deployments":   deployments,
+	})
+}
+
+func fetchOpenPullRequests(
+	ctx context.Context,
+	config *config.Config,
+	env *models.Environment,
+	deplInfoMap map[string]bool,
+) ([]*types.PullRequest, error) {
+	client, err := getGithubClientFromEnvironment(config, env)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+		return nil, err
 	}
 
-	res := make([]*types.Deployment, 0)
+	openPRs, resp, err := client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
+		&github.PullRequestListOptions{
+			ListOptions: github.ListOptions{
+				PerPage: 50,
+			},
+		},
+	)
+
+	var prs []*types.PullRequest
+
+	if resp != nil && resp.StatusCode == 404 {
+		return prs, nil
+	}
+
+	if err != nil {
+		return nil, err
+	}
 
-	for _, depl := range depls {
-		res = append(res, depl.ToDeploymentType())
+	for _, pr := range openPRs {
+		if _, ok := deplInfoMap[fmt.Sprintf("%s-%s-%d", env.GitRepoOwner, env.GitRepoName, pr.GetNumber())]; !ok {
+			prs = append(prs, &types.PullRequest{
+				Title:      pr.GetTitle(),
+				Number:     uint(pr.GetNumber()),
+				RepoOwner:  env.GitRepoOwner,
+				RepoName:   env.GitRepoName,
+				BranchFrom: pr.GetHead().GetRef(),
+				BranchInto: pr.GetBase().GetRef(),
+			})
+		}
 	}
 
-	c.WriteResult(w, r, res)
+	return prs, nil
 }

+ 117 - 0
api/server/handlers/environment/reenable_deployment.go

@@ -0,0 +1,117 @@
+package environment
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"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/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 ReenableDeploymentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewReenableDeploymentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ReenableDeploymentHandler {
+	return &ReenableDeploymentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ReenableDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	deplID, reqErr := requestutils.GetURLParamUint(r, "deployment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	depl, err := c.Repo().Environment().ReadDeploymentByID(project.ID, cluster.ID, deplID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if depl.Status != types.DeploymentStatusInactive {
+		return
+	}
+
+	depl.Status = types.DeploymentStatusCreating
+
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create the backing namespace
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = agent.CreateNamespace(depl.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, depl.EnvironmentID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ghResp, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
+		github.CreateWorkflowDispatchEventRequest{
+			Ref: depl.PRBranchFrom,
+			Inputs: map[string]interface{}{
+				"pr_number":      strconv.FormatUint(uint64(depl.PullRequestID), 10),
+				"pr_title":       depl.PRName,
+				"pr_branch_from": depl.PRBranchFrom,
+				"pr_branch_into": depl.PRBranchInto,
+			},
+		},
+	)
+
+	if ghResp != nil && ghResp.StatusCode == 404 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("workflow file not found"), 404))
+		return
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

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

@@ -71,7 +71,7 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	ghDeployment, err := createDeployment(client, env, request.CreateGHDeploymentRequest)
+	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -86,7 +86,7 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	// create the deployment
+	// update the deployment
 	depl, err = c.Repo().Environment().UpdateDeployment(depl)
 
 	if err != nil {

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

@@ -38,6 +38,7 @@ func (c *GithubGetPermissionsHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		PreviewEnvironments: p.Administration == "write" &&
 			p.Deployments == "write" &&
 			p.Environments == "write" &&
-			p.PullRequests == "write",
+			p.PullRequests == "write" &&
+			p.RepositoryWebhook == "write",
 	})
 }

+ 20 - 18
api/server/handlers/gitinstallation/helpers.go

@@ -77,15 +77,16 @@ func GetGithubAppClientFromRequest(config *config.Config, r *http.Request) (*git
 }
 
 type GithubAppPermissions struct {
-	Actions        string
-	Administration string
-	Contents       string
-	Deployments    string
-	Environments   string
-	Metadata       string
-	PullRequests   string
-	Secrets        string
-	Workflows      string
+	Actions           string
+	Administration    string
+	Contents          string
+	Deployments       string
+	Environments      string
+	Metadata          string
+	PullRequests      string
+	Secrets           string
+	Workflows         string
+	RepositoryWebhook string
 }
 
 // GetGithubAppClientFromRequest gets the github app installation id from the request and authenticates
@@ -115,15 +116,16 @@ func GetGithubAppPermissions(config *config.Config, r *http.Request) (*GithubApp
 	permissions, err := itr.Permissions()
 
 	return &GithubAppPermissions{
-		Actions:        permissionToString(permissions.Actions),
-		Administration: permissionToString(permissions.Administration),
-		Contents:       permissionToString(permissions.Contents),
-		Deployments:    permissionToString(permissions.Deployments),
-		Environments:   permissionToString(permissions.Environments),
-		Metadata:       permissionToString(permissions.Metadata),
-		PullRequests:   permissionToString(permissions.PullRequests),
-		Secrets:        permissionToString(permissions.Secrets),
-		Workflows:      permissionToString(permissions.Workflows),
+		Actions:           permissionToString(permissions.Actions),
+		Administration:    permissionToString(permissions.Administration),
+		Contents:          permissionToString(permissions.Contents),
+		Deployments:       permissionToString(permissions.Deployments),
+		Environments:      permissionToString(permissions.Environments),
+		Metadata:          permissionToString(permissions.Metadata),
+		PullRequests:      permissionToString(permissions.PullRequests),
+		Secrets:           permissionToString(permissions.Secrets),
+		Workflows:         permissionToString(permissions.Workflows),
+		RepositoryWebhook: permissionToString(permissions.RepositoryHooks),
 	}, err
 }
 

+ 173 - 0
api/server/handlers/webhook/github_incoming.go

@@ -0,0 +1,173 @@
+package webhook
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/bradleyfalzon/ghinstallation/v2"
+	"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/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 GithubIncomingWebhookHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubIncomingWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubIncomingWebhookHandler {
+	return &GithubIncomingWebhookHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+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))
+		return
+	}
+
+	event, err := github.ParseWebHook(github.WebHookType(r), payload)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	switch event := event.(type) {
+	case *github.PullRequestEvent:
+		err = c.processPullRequestEvent(event, r)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}
+
+func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.PullRequestEvent, r *http.Request) error {
+	// get the webhook id from the request
+	webhookID, reqErr := requestutils.GetURLParamString(r, types.URLParamIncomingWebhookID)
+
+	if reqErr != nil {
+		return fmt.Errorf(reqErr.Error())
+	}
+
+	owner := event.GetRepo().GetOwner().GetLogin()
+	repo := event.GetRepo().GetName()
+
+	env, err := c.Repo().Environment().ReadEnvironmentByWebhookIDOwnerRepoName(webhookID, owner, repo)
+
+	if err != nil {
+		return err
+	}
+
+	// create deployment on GitHub API
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		return err
+	}
+
+	if env.Mode == "auto" && event.GetAction() == "opened" {
+		_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+			r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
+			github.CreateWorkflowDispatchEventRequest{
+				Ref: event.PullRequest.GetHead().GetRef(),
+				Inputs: map[string]interface{}{
+					"pr_number":      strconv.FormatUint(uint64(event.PullRequest.GetNumber()), 10),
+					"pr_title":       event.PullRequest.GetTitle(),
+					"pr_branch_from": event.PullRequest.GetHead().GetRef(),
+					"pr_branch_into": event.PullRequest.GetBase().GetRef(),
+				},
+			},
+		)
+
+		if err != nil {
+			return err
+		}
+	} else if event.GetAction() == "synchronize" || event.GetAction() == "closed" {
+		depl, err := c.Repo().Environment().ReadDeploymentByGitDetails(
+			env.ID, owner, repo, uint(event.GetPullRequest().GetNumber()),
+		)
+
+		if err != nil {
+			return err
+		}
+
+		if depl.Status != types.DeploymentStatusInactive {
+			if event.GetAction() == "synchronize" {
+				_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+					r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
+					github.CreateWorkflowDispatchEventRequest{
+						Ref: event.PullRequest.GetHead().GetRef(),
+						Inputs: map[string]interface{}{
+							"pr_number":      strconv.FormatUint(uint64(event.PullRequest.GetNumber()), 10),
+							"pr_title":       event.PullRequest.GetTitle(),
+							"pr_branch_from": event.PullRequest.GetHead().GetRef(),
+							"pr_branch_into": event.PullRequest.GetBase().GetRef(),
+						},
+					},
+				)
+
+				if err != nil {
+					return err
+				}
+			} else {
+				_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+					r.Context(), owner, repo, fmt.Sprintf("porter_%s_delete_env.yml", env.Name),
+					github.CreateWorkflowDispatchEventRequest{
+						Ref: event.PullRequest.GetHead().GetRef(),
+						Inputs: map[string]interface{}{
+							"environment_id": strconv.FormatUint(uint64(depl.EnvironmentID), 10),
+							"repo_owner":     owner,
+							"repo_name":      repo,
+							"pr_number":      strconv.FormatUint(uint64(event.PullRequest.GetNumber()), 10),
+						},
+					},
+				)
+
+				if err != nil {
+					return err
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
+	// get the github app client
+	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// authenticate as github app installation
+	itr, err := ghinstallation.NewKeyFromFile(
+		http.DefaultTransport,
+		int64(ghAppId),
+		int64(env.GitInstallationID),
+		config.ServerConf.GithubAppSecretPath,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}

+ 30 - 0
api/server/router/base.go

@@ -1,6 +1,8 @@
 package router
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/credentials"
@@ -9,6 +11,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/metadata"
 	"github.com/porter-dev/porter/api/server/handlers/release"
 	"github.com/porter-dev/porter/api/server/handlers/user"
+	"github.com/porter-dev/porter/api/server/handlers/webhook"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
@@ -536,5 +539,32 @@ func GetBaseRoutes(
 		Router:   r,
 	})
 
+	if config.ServerConf.GithubIncomingWebhookSecret != "" {
+		// POST /api/github/incoming_webhook/{webhook_id} -> webhook.NewGithubIncomingWebhook
+		githubIncomingWebhookEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: fmt.Sprintf("/github/incoming_webhook/{%s}", types.URLParamIncomingWebhookID),
+				},
+				Scopes: []types.PermissionScope{},
+			},
+		)
+
+		githubIncomingWebhookHandler := webhook.NewGithubIncomingWebhookHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: githubIncomingWebhookEndpoint,
+			Handler:  githubIncomingWebhookHandler,
+			Router:   r,
+		})
+	}
+
 	return routes
 }

+ 176 - 79
api/server/router/cluster.go

@@ -288,91 +288,188 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/environments -> environment.NewListEnvironmentHandler
-	listEnvEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/environments",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	listEnvHandler := environment.NewListEnvironmentHandler(
-		config,
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: listEnvEndpoint,
-		Handler:  listEnvHandler,
-		Router:   r,
-	})
+	if config.ServerConf.GithubIncomingWebhookSecret != "" {
+
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/environments -> environment.NewListEnvironmentHandler
+		listEnvEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/environments",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		listEnvHandler := environment.NewListEnvironmentHandler(
+			config,
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: listEnvEndpoint,
+			Handler:  listEnvHandler,
+			Router:   r,
+		})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewListDeploymentsByClusterHandler
-	listDeploymentsEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/deployments",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewListDeploymentsByClusterHandler
+		listDeploymentsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		listDeploymentsHandler := environment.NewListDeploymentsByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: listDeploymentsEndpoint,
+			Handler:  listDeploymentsHandler,
+			Router:   r,
+		})
 
-	listDeploymentsHandler := environment.NewListDeploymentsByClusterHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/{environment_id}/deployment -> environment.NewGetDeploymentByClusterHandler
+		getDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/{environment_id}/deployment",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		getDeploymentHandler := environment.NewGetDeploymentByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: getDeploymentEndpoint,
+			Handler:  getDeploymentHandler,
+			Router:   r,
+		})
 
-	routes = append(routes, &Route{
-		Endpoint: listDeploymentsEndpoint,
-		Handler:  listDeploymentsHandler,
-		Router:   r,
-	})
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id}/reenable -> environment.NewReenableDeploymentHandler
+		reenableDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPatch,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/{deployment_id}/reenable",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		reenableDeploymentHandler := environment.NewReenableDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: reenableDeploymentEndpoint,
+			Handler:  reenableDeploymentHandler,
+			Router:   r,
+		})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/{environment_id}/deployment -> environment.NewGetDeploymentByClusterHandler
-	getDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/{environment_id}/deployment",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// POST /api/projects/{project_id}/clusters/{cluster_id}/deployments/pull_request -> environment.NewEnablePullRequestHandler
+		enablePullRequestEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/pull_request",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		enablePullRequestHandler := environment.NewEnablePullRequestHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: enablePullRequestEndpoint,
+			Handler:  enablePullRequestHandler,
+			Router:   r,
+		})
 
-	getDeploymentHandler := environment.NewGetDeploymentByClusterHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployments/{environment_id}/{owner}/{name}/{pr_number} ->
+		// environment.NewDeleteDeploymentHandler
+		deleteDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbDelete,
+				Method: types.HTTPVerbDelete,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/deployments/{environment_id}/{%s}/{%s}/{pr_number}",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		deleteDeploymentHandler := environment.NewDeleteDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: deleteDeploymentEndpoint,
+			Handler:  deleteDeploymentHandler,
+			Router:   r,
+		})
 
-	routes = append(routes, &Route{
-		Endpoint: getDeploymentEndpoint,
-		Handler:  getDeploymentHandler,
-		Router:   r,
-	})
+	}
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewClusterListNamespacesHandler
 	listNamespacesEndpoint := factory.NewAPIEndpoint(

+ 283 - 315
api/server/router/git_installation.go

@@ -112,329 +112,297 @@ func getGitInstallationRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id} ->
-	// environment.NewCreateEnvironmentHandler
-	createEnvironmentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	createEnvironmentHandler := environment.NewCreateEnvironmentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: createEnvironmentEndpoint,
-		Handler:  createEnvironmentHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-	// environment.NewCreateDeploymentHandler
-	createDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	createDeploymentHandler := environment.NewCreateDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: createDeploymentEndpoint,
-		Handler:  createDeploymentHandler,
-		Router:   r,
-	})
-
-	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-	// environment.NewCreateDeploymentHandler
-	getDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	getDeploymentHandler := environment.NewGetDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: getDeploymentEndpoint,
-		Handler:  getDeploymentHandler,
-		Router:   r,
-	})
-
-	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployments ->
-	// environment.NewCreateDeploymentHandler
-	listDeploymentsEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployments",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	listDeploymentsHandler := environment.NewListDeploymentsHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: listDeploymentsEndpoint,
-		Handler:  listDeploymentsHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/finalize ->
-	// environment.NewFinalizeDeploymentHandler
-	finalizeDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/finalize",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	finalizeDeploymentHandler := environment.NewFinalizeDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: finalizeDeploymentEndpoint,
-		Handler:  finalizeDeploymentHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update ->
-	// environment.NewFinalizeDeploymentHandler
-	updateDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbUpdate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	updateDeploymentHandler := environment.NewUpdateDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: updateDeploymentEndpoint,
-		Handler:  updateDeploymentHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update/status ->
-	// environment.NewUpdateDeploymentStatusHandler
-	updateDeploymentStatusEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbUpdate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update/status",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
+	if config.ServerConf.GithubIncomingWebhookSecret != "" {
+
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id} ->
+		// environment.NewCreateEnvironmentHandler
+		createEnvironmentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		createEnvironmentHandler := environment.NewCreateEnvironmentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: createEnvironmentEndpoint,
+			Handler:  createEnvironmentHandler,
+			Router:   r,
+		})
 
-	updateDeploymentStatusHandler := environment.NewUpdateDeploymentStatusHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
+		// environment.NewCreateDeploymentHandler
+		createDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		createDeploymentHandler := environment.NewCreateDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: createDeploymentEndpoint,
+			Handler:  createDeploymentHandler,
+			Router:   r,
+		})
 
-	routes = append(routes, &Route{
-		Endpoint: updateDeploymentStatusEndpoint,
-		Handler:  updateDeploymentStatusHandler,
-		Router:   r,
-	})
+		// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
+		// environment.NewCreateDeploymentHandler
+		getDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		getDeploymentHandler := environment.NewGetDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: getDeploymentEndpoint,
+			Handler:  getDeploymentHandler,
+			Router:   r,
+		})
 
-	// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/environment ->
-	// environment.NewDeleteEnvironmentHandler
-	deleteEnvironmentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbDelete,
-			Method: types.HTTPVerbDelete,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployments ->
+		// environment.NewCreateDeploymentHandler
+		listDeploymentsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployments",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		listDeploymentsHandler := environment.NewListDeploymentsHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: listDeploymentsEndpoint,
+			Handler:  listDeploymentsHandler,
+			Router:   r,
+		})
 
-	deleteEnvironmentHandler := environment.NewDeleteEnvironmentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/finalize ->
+		// environment.NewFinalizeDeploymentHandler
+		finalizeDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/finalize",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		finalizeDeploymentHandler := environment.NewFinalizeDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: finalizeDeploymentEndpoint,
+			Handler:  finalizeDeploymentHandler,
+			Router:   r,
+		})
 
-	routes = append(routes, &Route{
-		Endpoint: deleteEnvironmentEndpoint,
-		Handler:  deleteEnvironmentHandler,
-		Router:   r,
-	})
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update ->
+		// environment.NewFinalizeDeploymentHandler
+		updateDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		updateDeploymentHandler := environment.NewUpdateDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: updateDeploymentEndpoint,
+			Handler:  updateDeploymentHandler,
+			Router:   r,
+		})
 
-	// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-	// environment.NewDeleteDeploymentHandler
-	deleteDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbDelete,
-			Method: types.HTTPVerbDelete,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update/status ->
+		// environment.NewUpdateDeploymentStatusHandler
+		updateDeploymentStatusEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update/status",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		updateDeploymentStatusHandler := environment.NewUpdateDeploymentStatusHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: updateDeploymentStatusEndpoint,
+			Handler:  updateDeploymentStatusHandler,
+			Router:   r,
+		})
 
-	deleteDeploymentHandler := environment.NewDeleteDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/environment ->
+		// environment.NewDeleteEnvironmentHandler
+		deleteEnvironmentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbDelete,
+				Method: types.HTTPVerbDelete,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		deleteEnvironmentHandler := environment.NewDeleteEnvironmentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: deleteEnvironmentEndpoint,
+			Handler:  deleteEnvironmentHandler,
+			Router:   r,
+		})
 
-	routes = append(routes, &Route{
-		Endpoint: deleteDeploymentEndpoint,
-		Handler:  deleteDeploymentHandler,
-		Router:   r,
-	})
+	}
 
 	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/repos ->
 	// gitinstallation.GithubListReposHandler

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

@@ -37,6 +37,8 @@ type ServerConf struct {
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 
+	GithubIncomingWebhookSecret string `env:"GITHUB_INCOMING_WEBHOOK_SECRET"`
+
 	GithubAppClientID      string `env:"GITHUB_APP_CLIENT_ID"`
 	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
 	GithubAppName          string `env:"GITHUB_APP_NAME"`

+ 24 - 8
api/types/environment.go

@@ -10,11 +10,15 @@ type Environment struct {
 	GitRepoOwner      string `json:"git_repo_owner"`
 	GitRepoName       string `json:"git_repo_name"`
 
-	Name string `json:"name"`
+	Name                 string `json:"name"`
+	Mode                 string `json:"mode"`
+	DeploymentCount      uint   `json:"deployment_count"`
+	LastDeploymentStatus string `json:"last_deployment_status"`
 }
 
 type CreateEnvironmentRequest struct {
 	Name string `json:"name" form:"required"`
+	Mode string `json:"mode" form:"oneof=auto manual" default:"manual"`
 }
 
 type GitHubMetadata struct {
@@ -23,6 +27,8 @@ type GitHubMetadata struct {
 	RepoName     string `json:"gh_repo_name"`
 	RepoOwner    string `json:"gh_repo_owner"`
 	CommitSHA    string `json:"gh_commit_sha"`
+	PRBranchFrom string `json:"gh_pr_branch_from"`
+	PRBranchInto string `json:"gh_pr_branch_into"`
 }
 
 type DeploymentStatus string
@@ -49,8 +55,7 @@ type Deployment struct {
 }
 
 type CreateGHDeploymentRequest struct {
-	Branch   string `json:"branch" form:"required"`
-	ActionID uint   `json:"action_id" form:"required"`
+	ActionID uint `json:"action_id" form:"required"`
 }
 
 type CreateDeploymentRequest struct {
@@ -69,19 +74,21 @@ type FinalizeDeploymentRequest struct {
 type UpdateDeploymentRequest struct {
 	*CreateGHDeploymentRequest
 
-	CommitSHA string `json:"commit_sha" form:"required"`
-	Namespace string `json:"namespace" form:"required"`
+	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
+	CommitSHA    string `json:"commit_sha" form:"required"`
+	Namespace    string `json:"namespace" form:"required"`
 }
 
 type ListDeploymentRequest struct {
-	Status []string `schema:"status"`
+	EnvironmentID uint `schema:"environment_id"`
 }
 
 type UpdateDeploymentStatusRequest struct {
 	*CreateGHDeploymentRequest
 
-	Status    string `json:"status" form:"required,oneof=created creating inactive failed"`
-	Namespace string `json:"namespace" form:"required"`
+	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
+	Status       string `json:"status" form:"required,oneof=created creating inactive failed"`
+	Namespace    string `json:"namespace" form:"required"`
 }
 
 type DeleteDeploymentRequest struct {
@@ -91,3 +98,12 @@ type DeleteDeploymentRequest struct {
 type GetDeploymentRequest struct {
 	Namespace string `schema:"namespace" form:"required"`
 }
+
+type PullRequest struct {
+	Title      string `json:"pr_title"`
+	Number     uint   `json:"pr_number"`
+	RepoOwner  string `json:"repo_owner"`
+	RepoName   string `json:"repo_name"`
+	BranchFrom string `json:"branch_from"`
+	BranchInto string `json:"branch_into"`
+}

+ 5 - 4
api/types/git_installation.go

@@ -23,10 +23,11 @@ type Repo struct {
 type ListReposResponse []Repo
 
 const (
-	URLParamGitKind      URLParam = "kind"
-	URLParamGitRepoOwner URLParam = "owner"
-	URLParamGitRepoName  URLParam = "name"
-	URLParamGitBranch    URLParam = "branch"
+	URLParamGitKind           URLParam = "kind"
+	URLParamGitRepoOwner      URLParam = "owner"
+	URLParamGitRepoName       URLParam = "name"
+	URLParamGitBranch         URLParam = "branch"
+	URLParamIncomingWebhookID URLParam = "webhook_id"
 )
 
 type ListRepoBranchesResponse []string

+ 1 - 1
api/types/request.go

@@ -26,7 +26,7 @@ const (
 	HTTPVerbGet    HTTPVerb = "GET"
 	HTTPVerbPost   HTTPVerb = "POST"
 	HTTPVerbPut    HTTPVerb = "PUT"
-	HTTPVerbPatch  HTTPVerb = "PUT"
+	HTTPVerbPatch  HTTPVerb = "PATCH"
 	HTTPVerbDelete HTTPVerb = "DELETE"
 )
 

+ 24 - 16
cli/cmd/apply.go

@@ -140,7 +140,11 @@ func hasDeploymentHookEnvVars() bool {
 		return false
 	}
 
-	if branchName := os.Getenv("PORTER_BRANCH_NAME"); branchName == "" {
+	if branchFrom := os.Getenv("PORTER_BRANCH_FROM"); branchFrom == "" {
+		return false
+	}
+
+	if branchInto := os.Getenv("PORTER_BRANCH_INTO"); branchInto == "" {
 		return false
 	}
 
@@ -623,10 +627,10 @@ func (d *Driver) getAddonConfig(resource *models.Resource) (map[string]interface
 }
 
 type DeploymentHook struct {
-	client                                                    *api.Client
-	resourceGroup                                             *switchboardTypes.ResourceGroup
-	gitInstallationID, projectID, clusterID, prID, actionID   uint
-	branch, namespace, repoName, repoOwner, prName, commitSHA string
+	client                                                                    *api.Client
+	resourceGroup                                                             *switchboardTypes.ResourceGroup
+	gitInstallationID, projectID, clusterID, prID, actionID                   uint
+	branchFrom, branchInto, namespace, repoName, repoOwner, prName, commitSHA string
 }
 
 func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.ResourceGroup, namespace string) (*DeploymentHook, error) {
@@ -666,8 +670,11 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 		return nil, fmt.Errorf("cluster id must be set")
 	}
 
-	branchName := os.Getenv("PORTER_BRANCH_NAME")
-	res.branch = branchName
+	branchFrom := os.Getenv("PORTER_BRANCH_FROM")
+	res.branchFrom = branchFrom
+
+	branchInto := os.Getenv("PORTER_BRANCH_INTO")
+	res.branchInto = branchInto
 
 	actionIDStr := os.Getenv("PORTER_ACTION_ID")
 	actionID, err := strconv.Atoi(actionIDStr)
@@ -720,14 +727,15 @@ func (t *DeploymentHook) PreApply() error {
 				Namespace:     t.namespace,
 				PullRequestID: t.prID,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 				},
 				GitHubMetadata: &types.GitHubMetadata{
-					PRName:    t.prName,
-					RepoName:  t.repoName,
-					RepoOwner: t.repoOwner,
-					CommitSHA: t.commitSHA,
+					PRName:       t.prName,
+					RepoName:     t.repoName,
+					RepoOwner:    t.repoOwner,
+					CommitSHA:    t.commitSHA,
+					PRBranchFrom: t.branchFrom,
+					PRBranchInto: t.branchInto,
 				},
 			},
 		)
@@ -739,10 +747,10 @@ func (t *DeploymentHook) PreApply() error {
 			&types.UpdateDeploymentRequest{
 				Namespace: t.namespace,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 				},
-				CommitSHA: t.commitSHA,
+				PRBranchFrom: t.branchFrom,
+				CommitSHA:    t.commitSHA,
 			},
 		)
 	}
@@ -822,10 +830,10 @@ func (t *DeploymentHook) OnError(err error) {
 			&types.UpdateDeploymentStatusRequest{
 				Namespace: t.namespace,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 				},
-				Status: string(types.DeploymentStatusFailed),
+				PRBranchFrom: t.branchFrom,
+				Status:       string(types.DeploymentStatusFailed),
 			},
 		)
 	}

+ 18 - 31
cli/cmd/delete.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"os"
-	"strconv"
 
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
@@ -27,8 +26,6 @@ The following are the environment variables that can be used to set certain valu
 deleting a configuration:
   PORTER_CLUSTER              Cluster ID that contains the project
   PORTER_PROJECT              Project ID that contains the application
-  PORTER_GIT_INSTALLATION_ID  The Github installation ID that this deployment is associated with.
-  PORTER_NAMESPACE            The namespace associated with the deployment.
 	`,
 		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter delete\":"),
 		color.New(color.FgGreen, color.Bold).Sprintf("porter delete"),
@@ -59,47 +56,37 @@ func delete(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []st
 		return fmt.Errorf("cluster id must be set")
 	}
 
-	deplNamespace := os.Getenv("PORTER_NAMESPACE")
-
-	if deplNamespace == "" {
-		return fmt.Errorf("namespace must be set by PORTER_NAMESPACE")
-	}
-
-	var ghID uint
-
-	if ghIDStr := os.Getenv("PORTER_GIT_INSTALLATION_ID"); ghIDStr != "" {
-		ghIDInt, err := strconv.Atoi(ghIDStr)
-
-		if err != nil {
-			return err
-		}
-
-		ghID = uint(ghIDInt)
-	} else if ghIDStr == "" {
-		return fmt.Errorf("Git installation ID must be defined, set by PORTER_GIT_INSTALLATION_ID")
-	}
-
+	var environmentID string
 	var gitRepoName string
 	var gitRepoOwner string
+	var gitPRNumber string
+
+	if envID := os.Getenv("PORTER_ENVIRONMENT_ID"); envID != "" {
+		environmentID = envID
+	} else {
+		return fmt.Errorf("Environment ID must be defined, set by PORTER_ENVIRONMENT_ID")
+	}
 
 	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
 		gitRepoName = repoName
-	} else if repoName == "" {
+	} else {
 		return fmt.Errorf("Repo name must be defined, set by PORTER_REPO_NAME")
 	}
 
 	if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
 		gitRepoOwner = repoOwner
-	} else if repoOwner == "" {
+	} else {
 		return fmt.Errorf("Repo owner must be defined, set by PORTER_REPO_OWNER")
 	}
 
+	if prNumber := os.Getenv("PORTER_PR_NUMBER"); prNumber != "" {
+		gitPRNumber = prNumber
+	} else {
+		return fmt.Errorf("Pull request number must be defined, set by PORTER_PR_NUMBER")
+	}
+
 	return client.DeleteDeployment(
-		context.Background(),
-		projectID, ghID, clusterID,
-		gitRepoOwner, gitRepoName,
-		&types.DeleteDeploymentRequest{
-			Namespace: deplNamespace,
-		},
+		context.Background(), projectID, clusterID, environmentID,
+		gitRepoOwner, gitRepoName, gitPRNumber,
 	)
 }

+ 15 - 0
dashboard/src/assets/code-branch-icon.tsx

@@ -0,0 +1,15 @@
+import React, { SVGProps } from "react";
+
+function Icon(props: SVGProps<SVGElement>) {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      viewBox="0 0 448 512"
+      className={props.className}
+    >
+      <path d="M160 80c0 32.8-19.7 60.1-48 73.3v87.8c18.8-10.9 40.7-17.1 64-17.1h96c35.3 0 64-28.7 64-64v-6.7c-28.3-13.2-48-40.5-48-73.3 0-44.18 35.8-80 80-80s80 35.82 80 80c0 32.8-19.7 60.1-48 73.3v6.7c0 70.7-57.3 128-128 128h-96c-35.3 0-64 28.7-64 64v6.7c28.3 12.3 48 40.5 48 73.3 0 44.2-35.8 80-80 80-44.18 0-80-35.8-80-80 0-32.8 19.75-61 48-73.3V153.3C19.75 140.1 0 112.8 0 80 0 35.82 35.82 0 80 0c44.2 0 80 35.82 80 80zm-80 24c13.25 0 24-10.75 24-24S93.25 56 80 56 56 66.75 56 80s10.75 24 24 24zm288-48c-13.3 0-24 10.75-24 24s10.7 24 24 24 24-10.75 24-24-10.7-24-24-24zM80 456c13.25 0 24-10.7 24-24s-10.75-24-24-24-24 10.7-24 24 10.75 24 24 24z"></path>
+    </svg>
+  );
+}
+
+export default Icon;

+ 3 - 1
dashboard/src/components/DocsHelper.tsx

@@ -5,7 +5,7 @@ import { ClickAwayListener } from "@material-ui/core";
 
 type Props = {
   tooltipText: string;
-  link: string;
+  link?: string;
   placement?: TooltipPlacement;
   disableMargin?: boolean;
 };
@@ -45,9 +45,11 @@ const DocsHelper: React.FC<Props> = ({
             <Tooltip placement={placement}>
               <StyledContent onClick={handleTooltipOpen}>
                 {tooltipText}
+                {link && (
                 <A target="_blank" href={link}>
                   Documentation {">"}
                 </A>
+                )}
               </StyledContent>
             </Tooltip>
           )}

+ 96 - 0
dashboard/src/components/OptionsDropdown.tsx

@@ -0,0 +1,96 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+export const OptionsDropdown: React.FC<{
+  expandIcon?: string;
+  shrinkIcon?: string;
+}> = ({ children, expandIcon = "expand_more", shrinkIcon = "expand_less" }) => {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const handleClick = (e: any) => {
+    e.stopPropagation();
+    setIsOpen(!isOpen);
+  };
+
+  const handleOnBlur = () => {
+    setIsOpen(false);
+  };
+
+  return (
+    <OptionsButton onClick={handleClick} onBlur={handleOnBlur}>
+      <i className="material-icons">{isOpen ? shrinkIcon : expandIcon}</i>
+      {isOpen && <DropdownMenu>{children}</DropdownMenu>}
+    </OptionsButton>
+  );
+};
+
+const OptionsButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  color: #ffffff44;
+  :hover {
+    background: #32343a;
+    cursor: pointer;
+  }
+
+  > i {
+    font-size: 20px;
+  }
+`;
+
+const DropdownMenu = styled.div`
+  position: absolute;
+  right: 12px;
+  top: 30px;
+  overflow: hidden;
+  width: 120px;
+  height: auto;
+  background: #26282f;
+  box-shadow: 0 8px 20px 0px #00000088;
+  color: white;
+  overflow: hidden;
+  border-radius: 5px;
+`;
+
+const DropdownOption = styled.div`
+  width: 100%;
+  height: 37px;
+  font-size: 13px;
+  cursor: pointer;
+  padding-left: 10px;
+  padding-right: 10px;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  :hover {
+    background: #ffffff22;
+  }
+  :not(:first-child) {
+    border-top: 1px solid #00000000;
+  }
+
+  :not(:last-child) {
+    border-bottom: 1px solid #ffffff15;
+  }
+
+  > i {
+    margin-right: 7px;
+    font-size: 16px;
+  }
+`;
+
+export default {
+  Dropdown: OptionsDropdown,
+  Option: DropdownOption,
+};

+ 1 - 1
dashboard/src/components/events/useLastSeenPodStatus.ts

@@ -59,7 +59,7 @@ const useLastSeenPodStatus = ({
           name: podName,
         }
       );
-      console.log(getPodStatus(res.data.status));
+      //console.log(getPodStatus(res.data.status));
 
       setCurrentStatus(getPodStatus(res.data.status));
     } catch (error) {

+ 8 - 2
dashboard/src/components/form-components/InputRow.tsx

@@ -66,7 +66,7 @@ export default class InputRow extends Component<PropsType, StateType> {
             {this.props.isRequired && <Required>{" *"}</Required>}
           </Label>
         )}
-        <InputWrapper hasError={this.props.hasError}>
+        <InputWrapper hasError={this.props.hasError} width={width}>
           <Input
             readOnly={this.state.readOnly}
             onFocus={() => this.setState({ readOnly: false })}
@@ -105,8 +105,14 @@ const InputWrapper = styled.div`
   margin-bottom: -1px;
   align-items: center;
   border: 1px solid
-    ${(props: { hasError: boolean }) => (props.hasError ? "red" : "#ffffff55")};
+    ${(props: { width: string; hasError: boolean }) =>
+      props.hasError ? "red" : "#ffffff55"};
   border-radius: 3px;
+  ${(props: { width: string; hasError: boolean }) => {
+    if (props.width) {
+      return `width:${props.width};`;
+    }
+  }}
 `;
 
 const Input = styled.input<{ disabled: boolean; width: string }>`

+ 1 - 1
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -60,7 +60,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
   useEffect(() => {
     if (hasSetValue(props) && !Array.isArray(state?.synced_env_groups)) {
       const values = props.value[0];
-      console.log(values);
+      // console.log(values);
       const envGroups = values?.synced || [];
       const promises = Promise.all(
         envGroups.map(async (envGroup: any) => {

+ 1 - 0
dashboard/src/main/home/Home.tsx

@@ -467,6 +467,7 @@ class Home extends Component<PropsType, StateType> {
                 "/jobs",
                 "/env-groups",
                 "/databases",
+                "/preview-environments",
               ]}
               render={() => {
                 let { currentCluster } = this.context;

+ 11 - 19
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -38,6 +38,14 @@ const LazyDatabasesRoutes = loadable(() => import("./databases/routes.tsx"), {
   fallback: <Loading />,
 });
 
+const LazyPreviewEnvironmentsRoutes = loadable(
+  // @ts-ignore
+  () => import("./preview-environments/routes.tsx"),
+  {
+    fallback: <Loading />,
+  }
+);
+
 type PropsType = RouteComponentProps &
   WithAuthProps & {
     currentCluster: ClusterType;
@@ -272,24 +280,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     );
   };
 
-  // renderContents = () => {
-  //   let { currentCluster, setSidebar, currentView } = this.props;
-  //   if (currentView === "env-groups") {
-  //     return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
-  //   }
-
-  //   return (
-  //     <>
-  //       <DashboardHeader
-  //         image={currentView === "jobs" ? monojob : monoweb}
-  //         title={currentView}
-  //         description={this.getDescription(currentView)}
-  //       />
-  //       {this.renderBody()}
-  //     </>
-  //   );
-  // };
-
   render() {
     let { currentView } = this.props;
     let { setSidebar } = this.props;
@@ -337,9 +327,11 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {/* {this.renderContents()} */}
           <EnvGroupDashboard currentCluster={this.props.currentCluster} />
         </GuardedRoute>
+        <Route path={"/preview-environments"}>
+          <LazyPreviewEnvironmentsRoutes />
+        </Route>
         <Route path={"/databases"}>
           <LazyDatabasesRoutes />
         </Route>

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

@@ -102,7 +102,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
   }
 
   handleSetActive = (namespace: any) => {
-    console.log("SELECTED", namespace);
+    // console.log("SELECTED", namespace);
     this.props.setNamespace(namespace);
   };
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx

@@ -148,7 +148,7 @@ const JobRunTable: React.FC<Props> = ({
         tmpJobRuns.current = [...tmpJobRuns.current, data];
       },
       onclose: (event) => {
-        console.log(event);
+        // console.log(event);
         closeAllWebsockets();
       },
       onerror: (error) => {

+ 2 - 22
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -11,24 +11,16 @@ import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
-import EnvironmentList from "./preview-environments/EnvironmentList";
 import { useLocation } from "react-router";
 import { getQueryParam } from "shared/routing";
 import IncidentsTab from "./incidents/IncidentsTab";
 
-type TabEnum =
-  | "preview_environments"
-  | "nodes"
-  | "settings"
-  | "namespaces"
-  | "metrics"
-  | "incidents";
+type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "incidents";
 
 const tabOptions: {
   label: string;
   value: TabEnum;
 }[] = [
-  { label: "Preview Environments", value: "preview_environments" },
   { label: "Nodes", value: "nodes" },
   { label: "Incidents", value: "incidents" },
   { label: "Metrics", value: "metrics" },
@@ -37,10 +29,7 @@ const tabOptions: {
 ];
 
 export const Dashboard: React.FunctionComponent = () => {
-  const { currentProject } = useContext(Context);
-  const [currentTab, setCurrentTab] = useState<TabEnum>(() =>
-    currentProject.preview_envs_enabled ? "preview_environments" : "nodes"
-  );
+  const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
   const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
   const [isAuthorized] = useAuth();
   const location = useLocation();
@@ -48,11 +37,6 @@ export const Dashboard: React.FunctionComponent = () => {
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
-      case "preview_environments":
-        if (currentProject.preview_envs_enabled) {
-          return <EnvironmentList />;
-        }
-        return <NodeList />;
       case "incidents":
         return <IncidentsTab />;
       case "settings":
@@ -70,10 +54,6 @@ export const Dashboard: React.FunctionComponent = () => {
   useEffect(() => {
     setCurrentTabOptions(
       tabOptions.filter((option) => {
-        if (option.value === "preview_environments") {
-          return currentProject.preview_envs_enabled;
-        }
-
         if (option.value === "settings") {
           return isAuthorized("cluster", "", ["get", "delete"]);
         }

+ 5 - 82
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -6,25 +6,7 @@ import { pushFiltered } from "shared/routing";
 import { useHistory, useLocation } from "react-router";
 import useAuth from "shared/auth/useAuth";
 
-const OptionsDropdown: React.FC = ({ children }) => {
-  const [isOpen, setIsOpen] = useState(false);
-
-  const handleClick = (e: any) => {
-    e.stopPropagation();
-    setIsOpen(!isOpen);
-  };
-
-  const handleOnBlur = () => {
-    setIsOpen(false);
-  };
-
-  return (
-    <OptionsButton onClick={handleClick} onBlur={handleOnBlur}>
-      <i className="material-icons">{isOpen ? "expand_less" : "expand_more"}</i>
-      {isOpen && <DropdownMenu>{children}</DropdownMenu>}
-    </OptionsButton>
-  );
-};
+import OptionsDropdown from "components/OptionsDropdown";
 
 const useWebsocket = (
   currentProject: ProjectType,
@@ -173,12 +155,12 @@ export const NamespaceList: React.FunctionComponent = () => {
               {isAuthorized("namespace", "", ["get", "delete"]) &&
                 isAvailableForDeletion(namespace?.metadata?.name) &&
                 namespace?.status?.phase === "Active" && (
-                  <OptionsDropdown>
-                    <DropdownOption onClick={() => onDelete(namespace)}>
+                  <OptionsDropdown.Dropdown>
+                    <OptionsDropdown.Option onClick={() => onDelete(namespace)}>
                       <i className="material-icons-outlined">delete</i>
                       <span>Delete</span>
-                    </DropdownOption>
-                  </OptionsDropdown>
+                    </OptionsDropdown.Option>
+                  </OptionsDropdown.Dropdown>
                 )}
             </StyledCard>
           );
@@ -333,62 +315,3 @@ const ContentContainer = styled.div`
   justify-content: space-between;
   height: 100%;
 `;
-
-const OptionsButton = styled.button`
-  position: relative;
-  border: none;
-  background: none;
-  color: white;
-  padding: 5px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 50%;
-  color: #ffffff44;
-  :hover {
-    background: #32343a;
-    cursor: pointer;
-  }
-`;
-
-const DropdownMenu = styled.div`
-  position: absolute;
-  right: 12px;
-  top: 30px;
-  overflow: hidden;
-  width: 120px;
-  height: auto;
-  background: #26282f;
-  box-shadow: 0 8px 20px 0px #00000088;
-  color: white;
-`;
-
-const DropdownOption = styled.div`
-  width: 100%;
-  height: 37px;
-  font-size: 13px;
-  cursor: pointer;
-  padding-left: 10px;
-  padding-right: 10px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  :hover {
-    background: #ffffff22;
-  }
-  :not(:first-child) {
-    border-top: 1px solid #00000000;
-  }
-
-  :not(:last-child) {
-    border-bottom: 1px solid #ffffff15;
-  }
-
-  > i {
-    margin-right: 5px;
-    font-size: 16px;
-  }
-`;

+ 0 - 10
dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx

@@ -4,7 +4,6 @@ import { Context } from "shared/Context";
 import { Dashboard } from "./Dashboard";
 import IncidentPage from "./incidents/IncidentPage";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
-import EnvironmentDetail from "./preview-environments/EnvironmentDetail";
 
 export const Routes = () => {
   const { url } = useRouteMatch();
@@ -18,15 +17,6 @@ export const Routes = () => {
         <Route path={`${url}/node-view/:nodeId`}>
           <ExpandedNodeView />
         </Route>
-        <Route
-          path={`${url}/pr-env-detail/:namespace`}
-          render={() => {
-            if (currentProject.preview_envs_enabled) {
-              return <EnvironmentDetail />;
-            }
-            return <Redirect to={`${url}/`} />;
-          }}
-        ></Route>
         <Route path={`${url}/`}>
           <Dashboard />
         </Route>

+ 0 - 495
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx

@@ -1,495 +0,0 @@
-import DynamicLink from "components/DynamicLink";
-import React, { useContext, useEffect, useState } from "react";
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { useHistory, useLocation, useRouteMatch } from "react-router";
-import { getQueryParam } from "shared/routing";
-import styled from "styled-components";
-import Selector from "components/Selector";
-
-import ButtonEnablePREnvironments from "./components/ButtonEnablePREnvironments";
-import ConnectNewRepo from "./components/ConnectNewRepo";
-import Loading from "components/Loading";
-
-import _, { flatMapDepth } from "lodash";
-import EnvironmentCard from "./components/EnvironmentCard";
-
-export type PRDeployment = {
-  id: number;
-  created_at: string;
-  updated_at: string;
-  subdomain: string;
-  status: string;
-  environment_id: number;
-  pull_request_id: number;
-  namespace: string;
-  gh_pr_name: string;
-  gh_repo_owner: string;
-  gh_repo_name: string;
-  gh_commit_sha: string;
-};
-
-export type Environment = {
-  id: Number;
-  project_id: number;
-  cluster_id: number;
-  git_installation_id: number;
-  name: string;
-  git_repo_owner: string;
-  git_repo_name: string;
-};
-
-const EnvironmentList = () => {
-  const [isLoading, setIsLoading] = useState(true);
-  const [hasError, setHasError] = useState(false);
-  const [hasPermissions, setHasPermissions] = useState(false);
-  const [hasPermissionsLoaded, setHasPermissionsLoaded] = useState(false);
-  const [environmentList, setEnvironmentList] = useState<Environment[]>([]);
-  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
-  const [statusSelectorVal, setStatusSelectorVal] = useState<string>("active");
-
-  const [showConnectRepoFlow, setShowConnectRepoFlow] = useState(false);
-  const { currentProject, currentCluster, setCurrentModal } = useContext(
-    Context
-  );
-
-  const { url: currentUrl } = useRouteMatch();
-
-  const location = useLocation();
-  const history = useHistory();
-
-  const getPRDeploymentList = () => {
-    let status: string[] = [];
-
-    if (statusSelectorVal == "active") {
-      status = ["creating", "created", "failed"];
-    } else if (statusSelectorVal == "inactive") {
-      status = ["inactive"];
-    }
-
-    return api.getPRDeploymentList(
-      "<token>",
-      {
-        status: status,
-      },
-      {
-        project_id: currentProject.id,
-        cluster_id: currentCluster.id,
-      }
-    );
-  };
-
-  const checkGitRepoPermissions = async () => {
-    // Get all the connected repos ids
-    let gitRepos: number[] = null;
-    try {
-      gitRepos = await api
-        .getGitRepos("<token>", {}, { project_id: currentProject.id })
-        .then((res) => res.data);
-    } catch (error) {
-      console.error(error);
-    }
-
-    if (!gitRepos) {
-      return;
-    }
-
-    // Check if all repo has enough permissions
-    try {
-      const repoPermissionsRequests = gitRepos.map((id) =>
-        api
-          .getGitRepoPermission(
-            "<token>",
-            {},
-            { project_id: currentProject.id, git_repo_id: id }
-          )
-          .then((res) => res.data)
-      );
-
-      const permissions = await Promise.all(repoPermissionsRequests);
-      let hasPermission =
-        permissions.filter((val) => {
-          return val.preview_environments;
-        }).length >= 1;
-
-      setHasPermissions(hasPermission);
-      setHasPermissionsLoaded(true);
-    } catch (error) {
-      console.error(error);
-    }
-  };
-
-  useEffect(() => {
-    let isSubscribed = true;
-    api
-      .listEnvironments(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .then(({ data }) => {
-        if (!isSubscribed) {
-          return;
-        }
-
-        if (!Array.isArray(data)) {
-          throw Error("Data is not an array");
-        }
-        setEnvironmentList(data);
-      })
-      .catch((err) => {
-        console.error(err);
-        if (isSubscribed) {
-          setHasError(true);
-        }
-      });
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [currentProject, currentCluster, location.search]);
-
-  useEffect(() => {
-    setHasPermissionsLoaded(false);
-    checkGitRepoPermissions();
-  }, [currentProject, currentCluster]);
-
-  useEffect(() => {
-    let isSubscribed = true;
-    getPRDeploymentList()
-      .then(({ data }) => {
-        if (!isSubscribed) {
-          return;
-        }
-
-        if (!Array.isArray(data)) {
-          throw Error("Data is not an array");
-        }
-
-        setDeploymentList(data);
-        setIsLoading(false);
-      })
-      .catch((err) => {
-        console.error(err);
-        if (isSubscribed) {
-          setHasError(true);
-        }
-      });
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [currentCluster, currentProject, statusSelectorVal]);
-
-  useEffect(() => {
-    const action = getQueryParam({ location }, "action");
-    if (action === "connect-repo") {
-      setShowConnectRepoFlow(true);
-    } else {
-      setShowConnectRepoFlow(false);
-    }
-  }, [location.search, history]);
-
-  const handleRefresh = () => {
-    setIsLoading(true);
-    getPRDeploymentList()
-      .then(({ data }) => {
-        if (!Array.isArray(data)) {
-          throw Error("Data is not an array");
-        }
-        setDeploymentList(data);
-      })
-      .catch((err) => {
-        setHasError(true);
-        console.error(err);
-      })
-      .finally(() => setIsLoading(false));
-  };
-
-  if (showConnectRepoFlow) {
-    return (
-      <Container>
-        <ConnectNewRepo />
-      </Container>
-    );
-  }
-
-  if (hasPermissionsLoaded && !hasPermissions) {
-    return (
-      <Placeholder>
-        Github App permissions are not up to date. Please review any pending
-        requests to update Github App permissions.
-      </Placeholder>
-    );
-  }
-
-  if (!hasPermissionsLoaded) {
-    return (
-      <Placeholder>
-        <Loading />
-      </Placeholder>
-    );
-  }
-
-  if (hasError) {
-    return <Placeholder>Error</Placeholder>;
-  }
-
-  if (!environmentList.length) {
-    return (
-      <Placeholder>
-        <Header>Preview environments are not enabled on this cluster</Header>
-        <Subheader>
-          In order to use preview environments, you must enable preview
-          environments on this cluster.
-        </Subheader>
-        <ButtonEnablePREnvironments />
-      </Placeholder>
-    );
-  }
-
-  let renderDeploymentList = () => {
-    if (isLoading) {
-      return (
-        <Placeholder>
-          <Loading />
-        </Placeholder>
-      );
-    }
-
-    if (!deploymentList.length) {
-      return (
-        <Placeholder>
-          No preview apps have been found. Open a PR to create a new preview
-          app.
-        </Placeholder>
-      );
-    }
-
-    return deploymentList.map((d) => {
-      const environment = environmentList?.find((e) => {
-        return e.id === d.environment_id;
-      });
-      return (
-        <EnvironmentCard
-          deployment={d}
-          environment={environment}
-          onDelete={handleRefresh}
-        />
-      );
-    });
-  };
-
-  return (
-    <Container>
-      <ControlRow>
-        <Button
-          to={`${currentUrl}?selected_tab=preview_environments&action=connect-repo`}
-          onClick={() => console.log("launch repo")}
-        >
-          <i className="material-icons">add</i> Add Repository
-        </Button>
-
-        <ActionsWrapper>
-          <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
-            <i className="material-icons">refresh</i>
-          </RefreshButton>
-          <StyledStatusSelector>
-            <Selector
-              activeValue={statusSelectorVal}
-              setActiveValue={setStatusSelectorVal}
-              options={[
-                {
-                  value: "active",
-                  label: "Active",
-                },
-                {
-                  value: "inactive",
-                  label: "Inactive",
-                },
-              ]}
-              dropdownLabel="Status"
-              width="150px"
-              dropdownWidth="230px"
-              closeOverlay={true}
-            />
-          </StyledStatusSelector>
-
-          <SettingsButton
-            onClick={() => {
-              setCurrentModal("PreviewEnvSettingsModal", {});
-            }}
-          >
-            <i className="material-icons-outlined">settings</i>
-            Configure
-          </SettingsButton>
-        </ActionsWrapper>
-      </ControlRow>
-      <EventsGrid>{renderDeploymentList()}</EventsGrid>
-    </Container>
-  );
-};
-
-export default EnvironmentList;
-
-const ActionsWrapper = styled.div`
-  display: flex;
-`;
-
-const RefreshButton = styled.button`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: ${(props: { color: string }) => props.color};
-  cursor: pointer;
-  border: none;
-  background: none;
-  border-radius: 50%;
-  margin-right: 10px;
-  > i {
-    font-size: 20px;
-  }
-  :hover {
-    background-color: rgb(97 98 102 / 44%);
-    color: white;
-  }
-`;
-
-const SettingsButton = styled.div`
-  font-size: 12px;
-  padding: 8px 10px;
-  margin-left: 10px;
-  border-radius: 5px;
-  color: white;
-  display: flex;
-  align-items: center;
-  background: #ffffff08;
-  cursor: pointer;
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    color: white;
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const Placeholder = styled.div`
-  padding: 30px;
-  margin-top: 35px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 50vh;
-  background: #ffffff11;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const Container = styled.div`
-  margin-top: 33px;
-  padding-bottom: 120px;
-`;
-
-const ControlRow = styled.div`
-  display: flex;
-  margin-left: auto;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-  padding-left: 0px;
-`;
-
-const Button = styled(DynamicLink)`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const EventsGrid = styled.div`
-  display: grid;
-  grid-row-gap: 20px;
-  grid-template-columns: 1;
-`;
-
-const Label = styled.div`
-  display: flex;
-  align-items: center;
-  margin-right: 12px;
-
-  > i {
-    margin-right: 8px;
-    font-size: 18px;
-  }
-`;
-
-const StyledStatusSelector = styled.div`
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-`;
-
-const Header = styled.div`
-  font-weight: 500;
-  color: #aaaabb;
-  font-size: 16px;
-  margin-bottom: 15px;
-  width: 50%;
-`;
-
-const Subheader = styled.div`
-  width: 50%;
-`;

+ 0 - 353
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx

@@ -1,353 +0,0 @@
-import React, { useState } from "react";
-import styled, { keyframes } from "styled-components";
-import { Environment, PRDeployment } from "../EnvironmentList";
-import pr_icon from "assets/pull_request_icon.svg";
-import { integrationList } from "shared/common";
-import { useRouteMatch } from "react-router";
-import DynamicLink from "components/DynamicLink";
-import { capitalize, readableDate } from "shared/string_utils";
-import api from "shared/api";
-import { useContext } from "react";
-import { Context } from "shared/Context";
-
-const EnvironmentCard: React.FC<{
-  deployment: PRDeployment;
-  environment: Environment;
-  onDelete: () => void;
-}> = ({ deployment, environment, onDelete }) => {
-  const { setCurrentOverlay } = useContext(Context);
-  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
-  const [isDeleting, setIsDeleting] = useState(false);
-  const { url: currentUrl } = useRouteMatch();
-
-  let repository = `${deployment.gh_repo_owner}/${deployment.gh_repo_name}`;
-
-  const deleteDeployment = () => {
-    setIsDeleting(true);
-
-    api
-      .deletePRDeployment(
-        "<token>",
-        {
-          namespace: deployment.namespace,
-        },
-        {
-          cluster_id: environment.cluster_id,
-          project_id: environment.project_id,
-          git_installation_id: environment.git_installation_id,
-          git_repo_owner: environment.git_repo_owner,
-          git_repo_name: environment.git_repo_name,
-        }
-      )
-      .then(() => {
-        setIsDeleting(false);
-        onDelete();
-        setCurrentOverlay(null);
-      });
-  };
-
-  return (
-    <EnvironmentCardWrapper key={deployment.id}>
-      <DataContainer>
-        <PRName>
-          <PRIcon src={pr_icon} alt="pull request icon" />
-          {deployment.gh_pr_name}
-        </PRName>
-
-        <Flex>
-          <StatusContainer>
-            <Status>
-              <StatusDot status={deployment.status} />
-              {capitalize(deployment.status)}
-            </Status>
-          </StatusContainer>
-          <DeploymentImageContainer>
-            <DeploymentTypeIcon src={integrationList.repo.icon} />
-            <RepositoryName
-              onMouseOver={() => {
-                setShowRepoTooltip(true);
-              }}
-              onMouseOut={() => {
-                setShowRepoTooltip(false);
-              }}
-            >
-              {repository}
-            </RepositoryName>
-            {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
-            <InfoWrapper>
-              <LastDeployed>
-                Last updated {readableDate(deployment.updated_at)}
-              </LastDeployed>
-            </InfoWrapper>
-          </DeploymentImageContainer>
-        </Flex>
-      </DataContainer>
-      <Flex>
-        {!isDeleting ? (
-          <>
-            {deployment.status !== "creating" && (
-              <>
-                <RowButton
-                  to={`${currentUrl}/pr-env-detail/${deployment.namespace}?environment_id=${deployment.environment_id}`}
-                  key={deployment.id}
-                >
-                  <i className="material-icons-outlined">info</i>
-                  Details
-                </RowButton>
-                <RowButton
-                  to={deployment.subdomain}
-                  key={deployment.subdomain}
-                  target="_blank"
-                >
-                  <i className="material-icons">open_in_new</i>
-                  View Live
-                </RowButton>
-              </>
-            )}
-            <RowButton
-              to={"#"}
-              key={deployment.subdomain}
-              onClick={() =>
-                setCurrentOverlay({
-                  message: `Are you sure you want to delete this deployment?`,
-                  onYes: deleteDeployment,
-                  onNo: () => setCurrentOverlay(null),
-                })
-              }
-            >
-              <i className="material-icons">delete</i>
-              Delete
-            </RowButton>
-          </>
-        ) : (
-          <DeleteMessage>
-            Deleting
-            <Dot delay="0s" />
-            <Dot delay="0.1s" />
-            <Dot delay="0.2s" />
-          </DeleteMessage>
-        )}
-      </Flex>
-    </EnvironmentCardWrapper>
-  );
-};
-
-export default EnvironmentCard;
-
-const DeleteMessage = styled.div`
-  display: flex;
-  align-items: flex-end;
-  justify-content: center;
-`;
-
-export const DissapearAnimation = keyframes`
-  0% { 
-    background-color: #ffffff; 
-  }
-
-  25% {
-    background-color: #ffffff50;
-  }
-
-  50% { 
-    background-color: none;
-  }
-
-  75% {
-    background-color: #ffffff50;
-  }
-
-  100% { 
-    background-color: #ffffff;
-  }
-`;
-
-const Dot = styled.div`
-  background-color: black;
-  border-radius: 50%;
-  width: 5px;
-  height: 5px;
-  margin: 0 0.25rem;
-  margin-bottom: 2px;
-  //Animation
-  animation: ${DissapearAnimation} 0.5s linear infinite;
-  animation-delay: ${(props: { delay: string }) => props.delay};
-`;
-
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const PRName = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-  display: flex;
-  align-items: center;
-  margin-bottom: 10px;
-`;
-
-const EnvironmentCardWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  border: 1px solid #ffffff44;
-  background: #ffffff08;
-  margin-bottom: 5px;
-  border-radius: 10px;
-  padding: 14px;
-  overflow: hidden;
-  height: 80px;
-  font-size: 13px;
-  animation: fadeIn 0.5s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const DataContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: space-between;
-`;
-
-const StatusContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: flex-start;
-  height: 100%;
-`;
-
-const PRIcon = styled.img`
-  font-size: 20px;
-  height: 17px;
-  margin-right: 10px;
-  color: #aaaabb;
-  opacity: 50%;
-`;
-
-const RowButton = styled(DynamicLink)`
-  font-size: 12px;
-  padding: 8px 10px;
-  margin-left: 10px;
-  border-radius: 5px;
-  color: #ffffff;
-  border: 1px solid #aaaabb;
-  display: flex;
-  align-items: center;
-  background: #ffffff08;
-  cursor: pointer;
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    font-size: 14px;
-    margin-right: 8px;
-  }
-`;
-
-const Status = styled.span`
-  font-size: 13px;
-  display: flex;
-  align-items: center;
-  min-height: 17px;
-  color: #a7a6bb;
-`;
-
-const StatusDot = styled.div`
-  width: 8px;
-  height: 8px;
-  margin-right: 15px;
-  background: ${(props: { status: string }) =>
-    props.status === "created"
-      ? "#4797ff"
-      : props.status === "failed"
-      ? "#ed5f85"
-      : props.status === "completed"
-      ? "#00d12a"
-      : "#f5cb42"};
-  border-radius: 20px;
-  margin-left: 3px;
-`;
-
-const DeploymentImageContainer = styled.div`
-  height: 20px;
-  font-size: 13px;
-  position: relative;
-  display: flex;
-  margin-left: 15px;
-  align-items: center;
-  font-weight: 400;
-  justify-content: center;
-  color: #ffffff66;
-  padding-left: 5px;
-`;
-
-const Icon = styled.img`
-  width: 100%;
-`;
-
-const DeploymentTypeIcon = styled(Icon)`
-  width: 20px;
-  margin-right: 10px;
-`;
-
-const RepositoryName = styled.div`
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  max-width: 390px;
-  position: relative;
-  margin-right: 3px;
-`;
-
-const Tooltip = styled.div`
-  position: absolute;
-  left: -20px;
-  top: 10px;
-  min-height: 18px;
-  max-width: calc(700px);
-  padding: 5px 7px;
-  background: #272731;
-  z-index: 999;
-  color: white;
-  font-size: 12px;
-  font-family: "Work Sans", sans-serif;
-  outline: 1px solid #ffffff55;
-  opacity: 0;
-  animation: faded-in 0.2s 0.15s;
-  animation-fill-mode: forwards;
-  @keyframes faded-in {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const InfoWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-right: 8px;
-`;
-
-const LastDeployed = styled.div`
-  font-size: 13px;
-  margin-left: 14px;
-  margin-top: -1px;
-  display: flex;
-  align-items: center;
-  color: #aaaabb66;
-`;

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

@@ -101,7 +101,7 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       )
       .then((res) => {
         this.setState({ submitStatus: "successful" });
-        console.log(res);
+        // console.log(res);
         this.props.goBack();
       })
       .catch((err) => {

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -334,7 +334,7 @@ export const ExpandedEnvGroupFC = ({
         };
       }, {});
 
-      console.log({ normalObject, secretObject });
+      // console.log({ normalObject, secretObject });
 
       try {
         const updatedEnvGroup = await api
@@ -824,7 +824,7 @@ const StyledCard = styled.div`
   font-size: 13px;
   animation: ${fadeIn} 0.5s;
 
-  background: #2b2e36;
+  background: #2b2e3699;
   margin-bottom: 15px;
   overflow: hidden;
   border: 1px solid #ffffff0a;

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/utils.ts

@@ -37,9 +37,11 @@ export const parseStringToEnvObject = (src: any, options: any) => {
 
         obj[key] = val;
       } else if (debug) {
+        /*
         console.log(
           `did not match key and value when parsing line ${idx + 1}: ${line}`
         );
+        */
       }
     });
 

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -221,7 +221,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   const onSubmit = async (rawValues: any) => {
-    console.log("raw", rawValues);
+    // console.log("raw", rawValues);
     // Convert dotted keys to nested objects
     let values: any = {};
 
@@ -318,7 +318,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
     setSaveValueStatus("loading");
 
-    console.log("valuesYaml", valuesYaml);
+    // console.log("valuesYaml", valuesYaml);
     try {
       await api.upgradeChartValues(
         "<token>",
@@ -421,7 +421,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const renderTabContents = (currentTab: string) => {
     let { setSidebar } = props;
     let chart = currentChart;
-    console.log("CONTROLLERS", controllers);
+    // console.log("CONTROLLERS", controllers);
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -89,7 +89,7 @@ class RevisionSection extends Component<PropsType, StateType> {
     const ws = new WebSocket(`${url}${apiPath}`);
 
     ws.onopen = () => {
-      console.log("connected to chart live updates websocket");
+      // console.log("connected to chart live updates websocket");
     };
 
     ws.onmessage = (evt: MessageEvent) => {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -90,7 +90,7 @@ const SettingsSection: React.FC<PropsType> = ({
   const handleSubmit = async () => {
     setSaveValuesStatus("loading");
 
-    console.log(selectedImageUrl);
+    // console.log(selectedImageUrl);
 
     let values = {};
     if (selectedTag) {

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

@@ -478,7 +478,7 @@ const StartedText = styled.div`
 const StyledJob = styled.div`
   display: flex;
   flex-direction: column;
-  background: #2b2e36;
+  background: #2b2e3699;
   margin-bottom: 20px;
   border-radius: 5px;
   overflow: hidden;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts

@@ -336,7 +336,7 @@ export const useJobs = (chart: ChartType) => {
         jobsRef.current = [...jobsRef.current, data];
       },
       onclose: (event) => {
-        console.log(event);
+        // console.log(event);
         closeWebsocket(websocketId);
       },
       onerror: (error) => {

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -229,7 +229,7 @@ const LogsFC: React.FC<{
         <Refresh
           onClick={() => {
             // this.refreshLogs();
-            console.log("Refresh logs");
+            // console.log("Refresh logs");
             refresh();
           }}
         >
@@ -400,7 +400,7 @@ const useLogs = (
   };
 
   useEffect(() => {
-    console.log("Selected pod updated");
+    // console.log("Selected pod updated");
     if (currentPod?.metadata?.name === currentPodName.current) {
       return () => {};
     }

+ 115 - 43
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ConnectNewRepo.tsx → dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx

@@ -1,6 +1,5 @@
 import DynamicLink from "components/DynamicLink";
 import Heading from "components/form-components/Heading";
-import Helper from "components/form-components/Helper";
 import RepoList from "components/repo-selector/RepoList";
 import SaveButton from "components/SaveButton";
 import DocsHelper from "components/DocsHelper";
@@ -12,13 +11,19 @@ import styled from "styled-components";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
-import { Environment } from "../EnvironmentList";
+import { Environment } from "./types";
+import DashboardHeader from "../DashboardHeader";
+import PullRequestIcon from "assets/pull_request_icon.svg";
+import CheckboxRow from "components/form-components/CheckboxRow";
 
 const ConnectNewRepo: React.FC = () => {
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
   const [repo, setRepo] = useState(null);
+  const [enableAutomaticDeployments, setEnableAutomaticDeployments] = useState(
+    false
+  );
   const [filteredRepos, setFilteredRepos] = useState<string[]>([]);
 
   const [status, setStatus] = useState(null);
@@ -47,7 +52,7 @@ const ConnectNewRepo: React.FC = () => {
         }
       )
       .then(({ data }) => {
-        console.log("github account", data);
+        // console.log("github account", data);
 
         if (!Array.isArray(data)) {
           throw Error("Data is not an array");
@@ -67,7 +72,8 @@ const ConnectNewRepo: React.FC = () => {
       .createEnvironment(
         "<token>",
         {
-          name: "Preview",
+          name: `preview`,
+          mode: enableAutomaticDeployments ? "auto" : "manual",
         },
         {
           project_id: currentProject.id,
@@ -79,9 +85,7 @@ const ConnectNewRepo: React.FC = () => {
       )
       .then(() => {
         setStatus("successful");
-        pushFiltered(`${url}`, [], {
-          selected_tab: "preview_environments",
-        });
+        pushFiltered(`/preview-environments`, []);
       })
       .catch((err) => {
         err = JSON.stringify(err);
@@ -91,13 +95,20 @@ const ConnectNewRepo: React.FC = () => {
   };
 
   return (
-    <div>
-      <ControlRow>
-        <BackButton to={`${url}?selected_tab=preview_environments`}>
-          <i className="material-icons">close</i>
-        </BackButton>
-        <Title>Enable Preview Environments</Title>
-      </ControlRow>
+    <>
+      <DashboardHeader
+        image={PullRequestIcon}
+        title="Preview Environments"
+        description="Create full-stack preview environments for your pull requests."
+      />
+
+      <HeaderSection>
+        <Button to={`/preview-environments`}>
+          <i className="material-icons">keyboard_backspace</i>
+          Back
+        </Button>
+        <Title>Enable Preview Environments on a Repository</Title>
+      </HeaderSection>
 
       <Heading>Select a Repository</Heading>
       <br />
@@ -114,11 +125,27 @@ const ConnectNewRepo: React.FC = () => {
         Note: you will need to add a <CodeBlock>porter.yaml</CodeBlock> file to
         create a preview environment.
         <DocsHelper
+          disableMargin
           tooltipText="A Porter YAML file is a declarative set of resources that Porter uses to build and update your preview environment deployments."
           link="https://docs.porter.run/preview-environments/porter-yaml-reference"
         />
       </HelperContainer>
 
+      <FlexWrap>
+        <CheckboxRow
+          label="Enable automatic deployments"
+          checked={enableAutomaticDeployments}
+          toggle={() => setEnableAutomaticDeployments((prev) => !prev)}
+        />
+        <Div>
+          <DocsHelper
+            disableMargin
+            tooltipText="Automatically create a Preview Environment for each new pull request in the repository. By default, preview environments must be manually created per-PR."
+            placement="top-start"
+          />
+        </Div>
+      </FlexWrap>
+
       <ActionContainer>
         <SaveButton
           text="Add Repository"
@@ -130,54 +157,66 @@ const ConnectNewRepo: React.FC = () => {
           statusPosition={"left"}
         ></SaveButton>
       </ActionContainer>
-    </div>
+    </>
   );
 };
 
 export default ConnectNewRepo;
 
-const ControlRow = styled.div`
+const Div = styled.div`
+  margin-bottom: -7px;
+`;
+
+const FlexWrap = styled.div`
   display: flex;
-  margin-left: auto;
   align-items: center;
-  margin-bottom: 35px;
-  padding-left: 0px;
 `;
 
-const BackButton = styled(DynamicLink)`
+const Button = styled(DynamicLink)`
   display: flex;
-  width: 37px;
-  z-index: 1;
-  cursor: pointer;
-  height: 37px;
+  flex-direction: row;
   align-items: center;
-  justify-content: center;
-  border: 1px solid #ffffff55;
-  border-radius: 100px;
-  background: #ffffff11;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
   color: white;
-  > i {
-    font-size: 20px;
-  }
-
+  height: 35px;
+  margin-left: -2px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: pointer;
+  border: 2px solid #969fbbaa;
   :hover {
-    background: #ffffff22;
-    > img {
-      opacity: 1;
-    }
+    background: #ffffff11;
   }
-`;
 
-const Title = styled(TitleSection)`
-  margin-left: 10px;
-  margin-bottom: 0;
-  font-size: 18px;
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    color: #969fbbaa;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
 `;
 
 const ActionContainer = styled.div`
   display: flex;
   justify-content: flex-end;
-  margin-top: 50px;
+  margin-top: 20px;
+  padding-bottom: 100px;
 `;
 
 const CodeBlock = styled.span`
@@ -193,7 +232,7 @@ const CodeBlock = styled.span`
 
 const HelperContainer = styled.div`
   margin-top: 24px;
-  width: 600px;
+  width: 555px;
   display: flex;
   justify-content: start;
   align-items: center;
@@ -201,3 +240,36 @@ const HelperContainer = styled.div`
   line-height: 1.6em;
   font-size: 13px;
 `;
+
+const Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 15px;
+  border-radius: 2px;
+  color: #ffffff;
+`;
+
+const HeaderSection = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 40px;
+
+  > i {
+    cursor: pointer;
+    font-size 20px;
+    color: #969Fbbaa;
+    padding: 2px;
+    border: 2px solid #969fbbaa;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+
+  > img {
+    width: 20px;
+    margin-left: 17px;
+    margin-right: 7px;
+  }
+`;

+ 229 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/PreviewEnvironmentsHome.tsx

@@ -0,0 +1,229 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import { useHistory, useLocation } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import ButtonEnablePREnvironments from "./components/ButtonEnablePREnvironments";
+import DashboardHeader from "../DashboardHeader";
+import PullRequestIcon from "assets/pull_request_icon.svg";
+import DeploymentList from "./deployments/DeploymentList";
+import EnvironmentsList from "./environments/EnvironmentsList";
+import { environments } from "./mocks";
+
+const AvailableTabs = ["repositories", "pull_requests"];
+
+type TabEnum = typeof AvailableTabs[number];
+
+const PreviewEnvironmentsHome = () => {
+  const { currentCluster, currentProject } = useContext(Context);
+
+  const [hasGHAccountsLinked, setHasGHAccountsLinked] = useState(false);
+  const [hasEnvironments, setHasEnvironments] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const [environments, setEnvironments] = useState([]);
+  const [selectedRepo, setSelectedRepo] = useState("");
+
+  const { getQueryParam, pushQueryParams } = useRouting();
+  const location = useLocation();
+  const history = useHistory();
+
+  const getAccounts = async () => {
+    try {
+      const res = await api.getGithubAccounts("<token>", {}, {});
+      if (res.status !== 200) {
+        throw new Error("Not authorized");
+      }
+
+      return res.data;
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const getEnvironments = async () => {
+    try {
+      const { data } = await api.listEnvironments(
+        "<token>",
+        {},
+        {
+          project_id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+        }
+      );
+
+      return data;
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const checkPreviewEnvironmentsEnabling = async (subscribeStauts: {
+    subscribed: boolean;
+  }) => {
+    try {
+      await getAccounts();
+
+      const envs = await getEnvironments();
+      // const envs = await mockRequest();
+
+      if (!subscribeStauts.subscribed) {
+        return;
+      }
+
+      if (!Array.isArray(envs)) {
+        setHasGHAccountsLinked(true);
+        return;
+      }
+
+      setHasGHAccountsLinked(true);
+      setHasEnvironments(true);
+      setEnvironments(envs);
+    } catch (error) {
+      setHasGHAccountsLinked(false);
+    }
+  };
+
+  useEffect(() => {
+    let subscribedStatus = { subscribed: true };
+
+    setIsLoading(true);
+
+    checkPreviewEnvironmentsEnabling(subscribedStatus).finally(() => {
+      if (subscribedStatus.subscribed) {
+        setIsLoading(false);
+      }
+    });
+
+    return () => {
+      subscribedStatus.subscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  useEffect(() => {
+    const current_repo = getQueryParam("repository");
+    setSelectedRepo(current_repo);
+  }, [location.search, history]);
+
+  const renderMain = () => {
+    if (isLoading) {
+      return (
+        <Placeholder>
+          <Loading />
+        </Placeholder>
+      );
+    }
+  
+    if (hasError) {
+      return <Placeholder>Something went wrong, please try again</Placeholder>;
+    }
+  
+    if (!hasGHAccountsLinked) {
+      return (
+        <Placeholder>
+          <Title>There are no repositories linked</Title>
+          <Subtitle>
+            In order to use preview environments, you must install the porter
+            app in at least one repository.
+          </Subtitle>
+          <ButtonEnablePREnvironments />
+        </Placeholder>
+      );
+    }
+  
+    if (!hasEnvironments) {
+      return (
+        <Placeholder>
+          <Title>Preview environments are not enabled on this cluster</Title>
+          <Subtitle>
+            In order to use preview environments, you must enable preview
+            environments on this cluster.
+          </Subtitle>
+          <ButtonEnablePREnvironments />
+        </Placeholder>
+      );
+    }
+
+    if (!selectedRepo) {
+      return (
+        <EnvironmentsList
+          environments={environments}
+          setEnvironments={setEnvironments}
+        />
+      );
+    }
+
+    return (
+      <DeploymentList
+        // selectedRepo={selectedRepo}
+        environments={environments}
+      />
+    );
+  }
+
+  return (
+    <>
+      <DashboardHeader
+        image={PullRequestIcon}
+        title="Preview Environments"
+        description="Create full-stack preview environments for your pull requests."
+      />
+      {renderMain()}
+    </>
+  );
+};
+
+/*
+<DeploymentList environments={environments} />
+*/
+export default PreviewEnvironmentsHome;
+
+const mockRequest = () =>
+  new Promise((res) => {
+    setTimeout(() => {
+      res({ data: environments });
+    }, 1000);
+  });
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  border-radius: 8px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const Title = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+  width: 50%;
+`;
+
+const Subtitle = styled.div`
+  width: 50%;
+`;

+ 58 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/ActionButton.tsx

@@ -0,0 +1,58 @@
+import styled, { css, keyframes } from "styled-components";
+
+const Shake = keyframes`
+10%, 90% {
+  transform: translate3d(-0.5px, 0, 0);
+}
+
+20%, 80% {
+  transform: translate3d(1px, 0, 0);
+}
+
+30%, 50%, 70% {
+  transform: translate3d(-2px, 0, 0);
+}
+
+40%, 60% {
+  transform: translate3d(2px, 0, 0);
+}
+`;
+
+const ShakeAnimation = css`
+  animation: ${Shake} 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
+  transform: translate3d(0, 0, 0);
+  backface-visibility: hidden;
+  perspective: 1000px;
+`;
+
+export const ActionButton = styled.button`
+  font-size: 12px;
+  padding: 8px 10px;
+  margin-left: 10px;
+  border-radius: 5px;
+  color: #ffffff;
+  border: 1px solid
+    ${(props: { disabled: boolean; hasError: boolean }) =>
+      props.hasError ? "#dd4b4b" : "#aaaabb"};
+  display: flex;
+  align-items: center;
+  background: ${(props: { disabled: boolean; hasError: boolean }) =>
+    props.disabled ? "#ffffff22" : "#ffffff08"};
+  cursor: pointer;
+  min-height: 32px;
+  min-width: 220px;
+  :hover {
+    background: #ffffff22;
+  }
+
+  ${(props: { disabled: boolean; hasError: boolean }) => {
+    if (props.hasError) {
+      return ShakeAnimation;
+    }
+  }}
+
+  > i {
+    font-size: 14px;
+    margin-right: 8px;
+  }
+`;

+ 15 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx → dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx

@@ -69,8 +69,7 @@ const ButtonEnablePREnvironments = () => {
       };
     }
     return {
-      to:
-        "/cluster-dashboard?selected_tab=preview_environments&action=connect-repo",
+      to: "/preview-environments/connect-repo",
     };
   };
 
@@ -81,6 +80,20 @@ const ButtonEnablePREnvironments = () => {
       </Container>
     );
   }
+
+  if (!hasGHAccountConnected) {
+    return (
+      <>
+        <Container>
+          <Button {...getButtonProps()}>
+            <img src={pr_icon} alt="Pull request icon" />
+            Connect repositories
+          </Button>
+        </Container>
+      </>
+    );
+  }
+
   return (
     <>
       <Container>

+ 73 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx

@@ -0,0 +1,73 @@
+import React from "react";
+import TitleSection from "components/TitleSection";
+import styled from "styled-components";
+
+export const PreviewEnvironmentsHeader = () => (
+  <>
+    <TitleSection>
+      <DashboardIcon>
+        <i className="material-icons">device_hub</i>
+      </DashboardIcon>
+      Preview environments
+    </TitleSection>
+    <InfoSection>
+      <TopRow>
+        <InfoLabel>
+          <i className="material-icons">info</i> Info
+        </InfoLabel>
+      </TopRow>
+      <Description>
+        Create preview environments for your pull requests
+      </Description>
+    </InfoSection>
+  </>
+);
+
+const DashboardIcon = styled.div`
+  height: 45px;
+  min-width: 45px;
+  width: 45px;
+  border-radius: 5px;
+  margin-right: 17px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const TopRow = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Description = styled.div`
+  color: #aaaabb;
+  margin-top: 13px;
+  margin-left: 2px;
+  font-size: 13px;
+`;
+
+const InfoLabel = styled.div`
+  width: 72px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  color: #7a838f;
+  font-size: 13px;
+  > i {
+    color: #8b949f;
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const InfoSection = styled.div`
+  margin-top: 36px;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 0px;
+  margin-bottom: 35px;
+`;

+ 124 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/RecreateWorkflowFilesModal.tsx

@@ -0,0 +1,124 @@
+import Modal from "main/home/modals/Modal";
+import React from "react";
+import styled from "styled-components";
+
+type Props = {
+  hide: boolean;
+  isReEnable: boolean;
+  onClose: () => void;
+};
+
+const RecreateWorkflowFilesModal = (props: Props) => {
+  const createNewWorkflows = () => {};
+
+  if (props.hide) {
+    return null;
+  }
+
+  return (
+    <Modal title="Workflow files not found">
+      <div>
+        <div>
+          We couldn't find any workflow files to process the{" "}
+          {props.isReEnable
+            ? "re enabling of this preview environment"
+            : "creation of this preview environment"}
+          .
+          <HighlightText>
+            Do you want to create the workflow files? Or Remove the repository?
+          </HighlightText>
+          <Warning highlight>
+            ⚠️ If the workflow files don't exist, Porter will not be able to
+            create any preview environment for this repository.
+          </Warning>
+        </div>
+
+        <ActionWrapper>
+          <DeleteButton onClick={() => props.onClose()}>Close</DeleteButton>
+          <CancelButton onClick={() => createNewWorkflows()}>
+            Create new workflows
+          </CancelButton>
+        </ActionWrapper>
+      </div>
+    </Modal>
+  );
+};
+
+export default RecreateWorkflowFilesModal;
+
+const Button = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 10px;
+  color: white;
+  height: 35px;
+  padding: 10px 16px;
+  font-weight: 500;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: pointer;
+  border: none;
+  :not(:last-child) {
+    margin-right: 10px;
+  }
+`;
+
+const DeleteButton = styled(Button)`
+  ${({ disabled }: { disabled?: boolean }) => {
+    if (disabled) {
+      return `
+      background: #aaaabbee;
+      :hover {
+        background: #aaaabbee;
+      }    
+      `;
+    }
+
+    return `
+      background: #dd4b4b;
+      :hover {
+        background: #b13d3d;
+      }`;
+  }}
+`;
+
+const CancelButton = styled(Button)`
+  background: #616feecc;
+  :hover {
+    background: #505edddd;
+  }
+`;
+
+const ActionWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Warning = styled.div`
+  font-size: 13px;
+  display: flex;
+  width: 100%;
+  margin-top: 10px;
+  line-height: 1.4em;
+  align-items: center;
+  color: white;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+  color: ${(props: { highlight: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+`;
+
+const HighlightText = styled.div`
+  font-size: 16px;
+  font-weight: bold;
+  color: #ffffff;
+`;

+ 493 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx

@@ -0,0 +1,493 @@
+import React, { useState } from "react";
+import styled, { css, keyframes } from "styled-components";
+import { Environment, PRDeployment } from "../types";
+import pr_icon from "assets/pull_request_icon.svg";
+import { integrationList } from "shared/common";
+import { useRouteMatch } from "react-router";
+import DynamicLink from "components/DynamicLink";
+import { capitalize, readableDate } from "shared/string_utils";
+import api from "shared/api";
+import { useContext } from "react";
+import { Context } from "shared/Context";
+import Loading from "components/Loading";
+import { ActionButton } from "../components/ActionButton";
+
+const DeploymentCard: React.FC<{
+  deployment: PRDeployment;
+  onDelete: () => void;
+  onReEnable: () => void;
+}> = ({ deployment, onDelete, onReEnable }) => {
+  const {
+    setCurrentOverlay,
+    currentProject,
+    currentCluster,
+    setCurrentError,
+  } = useContext(Context);
+  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+  const [hasErrorOnReEnabling, setHasErrorOnReEnabling] = useState(false);
+  const [showMergeInfoTooltip, setShowMergeInfoTooltip] = useState(false);
+  const { url: currentUrl } = useRouteMatch();
+
+  let repository = `${deployment.gh_repo_owner}/${deployment.gh_repo_name}`;
+
+  const deleteDeployment = () => {
+    setIsDeleting(true);
+
+    api
+      .deletePRDeployment(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          environment_id: deployment.environment_id,
+          repo_owner: deployment.gh_repo_owner,
+          repo_name: deployment.gh_repo_name,
+          pr_number: deployment.pull_request_id,
+        }
+      )
+      .then(() => {
+        setIsDeleting(false);
+        onDelete();
+        setCurrentOverlay(null);
+      });
+  };
+
+  const reEnablePreviewEnvironment = () => {
+    setIsLoading(true);
+
+    api
+      .reenablePreviewEnvironmentDeployment(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          deployment_id: deployment.id,
+        }
+      )
+      .then(() => {
+        setIsLoading(false);
+        onReEnable();
+      })
+      .catch((err) => {
+        setHasErrorOnReEnabling(true);
+        setIsLoading(false);
+        setCurrentError(err);
+        setTimeout(() => {
+          setHasErrorOnReEnabling(false);
+        }, 500);
+      });
+  };
+
+  return (
+    <DeploymentCardWrapper>
+      <DataContainer>
+        <PRName>
+          <PRIcon src={pr_icon} alt="pull request icon" />
+          {deployment.gh_pr_name}
+          {deployment.gh_pr_branch_from && deployment.gh_pr_branch_into ? (
+            <MergeInfoWrapper>
+              <MergeInfo
+                onMouseOver={() => setShowMergeInfoTooltip(true)}
+                onMouseOut={() => setShowMergeInfoTooltip(false)}
+              >
+                {deployment.gh_pr_branch_from}
+                <i className="material-icons">arrow_forward</i>
+                {deployment.gh_pr_branch_into}
+              </MergeInfo>
+              {showMergeInfoTooltip && (
+                <Tooltip>
+                  {deployment.gh_pr_branch_from} {"->"} {deployment.gh_pr_branch_into}
+                </Tooltip>
+              )}
+            </MergeInfoWrapper>
+          ) : null}
+          <RepoLink
+            onClick={e => {
+              e.stopPropagation();
+              window.open(`https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`, "_blank")
+            }}
+          >
+            <i className="material-icons">open_in_new</i>
+            View PR
+          </RepoLink>
+        </PRName>
+
+        <Flex>
+          <StatusContainer>
+            <Status>
+              <StatusDot status={deployment.status} />
+              {capitalize(deployment.status)}
+            </Status>
+          </StatusContainer>
+          <DeploymentImageContainer>
+            <InfoWrapper>
+              <SepDot>•</SepDot>
+              <LastDeployed>
+                Last updated {readableDate(deployment.updated_at)}
+              </LastDeployed>
+            </InfoWrapper>
+          </DeploymentImageContainer>
+        </Flex>
+      </DataContainer>
+      <Flex>
+        {!isDeleting ? (
+          <>
+            {deployment.status !== "creating" &&
+              deployment.status !== "inactive" && (
+                <>
+                  <RowButton
+                    to={`/preview-environments/details/${deployment.namespace}?environment_id=${deployment.environment_id}`}
+                    key={deployment.id}
+                  >
+                    <i className="material-icons-outlined">info</i>
+                    Details
+                  </RowButton>
+                  <RowButton
+                    to={deployment.subdomain}
+                    key={deployment.subdomain}
+                    target="_blank"
+                  >
+                    <i className="material-icons">open_in_new</i>
+                    View Live
+                  </RowButton>
+                </>
+              )}
+            {deployment.status === "inactive" ? (
+              <ActionButton
+                onClick={reEnablePreviewEnvironment}
+                disabled={isLoading}
+                hasError={hasErrorOnReEnabling}
+              >
+                {isLoading ? (
+                  <Loading width="198px" height="14px" />
+                ) : (
+                  <>
+                    <i className="material-icons">play_arrow</i>
+                    Activate Preview Environment
+                  </>
+                )}
+              </ActionButton>
+            ) : (
+              <Button
+                onClick={() => {
+                  setCurrentOverlay({
+                    message: `Are you sure you want to delete this deployment?`,
+                    onYes: deleteDeployment,
+                    onNo: () => setCurrentOverlay(null),
+                  })
+                }}
+              >
+                <i className="material-icons">delete</i>
+                Delete
+              </Button>
+            )}
+          </>
+        ) : (
+          <DeleteMessage>
+            Deleting
+            <Dot delay="0s" />
+            <Dot delay="0.1s" />
+            <Dot delay="0.2s" />
+          </DeleteMessage>
+        )}
+      </Flex>
+    </DeploymentCardWrapper>
+  );
+};
+
+export default DeploymentCard;
+
+const RepoLink = styled.div`
+  height: 22px;
+  border-radius: 50px;
+  margin-left: 6px;
+  display: flex;
+  font-size: 12px;
+  cursor: pointer;
+  color: #a7a6bb;
+  align-items: center;
+  justify-content: center;
+  :hover {
+    color: #ffffff;
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    margin-right: 5px;
+    color: #a7a6bb;
+    font-size: 16px;
+  }
+`;
+
+const SepDot = styled.div`
+  color: #aaaabb66;
+`;
+
+const DeleteMessage = styled.div`
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+`;
+
+export const DissapearAnimation = keyframes`
+  0% {
+    background-color: #ffffff;
+  }
+
+  25% {
+    background-color: #ffffff50;
+  }
+
+  50% {
+    background-color: none;
+  }
+
+  75% {
+    background-color: #ffffff50;
+  }
+
+  100% {
+    background-color: #ffffff;
+  }
+`;
+
+const Dot = styled.div`
+  background-color: black;
+  border-radius: 50%;
+  width: 5px;
+  height: 5px;
+  margin: 0 0.25rem;
+  margin-bottom: 2px;
+  //Animation
+  animation: ${DissapearAnimation} 0.5s linear infinite;
+  animation-delay: ${(props: { delay: string }) => props.delay};
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const PRName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  font-size: 14px;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const DeploymentCardWrapper = styled.div`
+  display: flex;
+  background: #2b2e3699;
+  justify-content: space-between;
+  border-radius: 5px;
+  font-size: 13px;
+  height: 75px;
+  padding: 12px;
+  padding-left: 14px;
+  border: 1px solid #ffffff0f;
+
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const DataContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+`;
+
+const StatusContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  height: 100%;
+`;
+
+const PRIcon = styled.img`
+  font-size: 20px;
+  height: 17px;
+  margin-right: 10px;
+  color: #aaaabb;
+  opacity: 50%;
+`;
+
+const RowButton = styled(DynamicLink)`
+  font-size: 12px;
+  padding: 8px 10px;
+  margin-left: 10px;
+  border-radius: 5px;
+  color: #ffffff;
+  border: 1px solid #aaaabb;
+  display: flex;
+  align-items: center;
+  background: #ffffff08;
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 14px;
+    margin-right: 8px;
+  }
+`;
+
+const Button = styled.div`
+  font-size: 12px;
+  padding: 8px 10px;
+  margin-left: 10px;
+  border-radius: 5px;
+  color: #ffffff;
+  border: 1px solid #aaaabb;
+  display: flex;
+  align-items: center;
+  background: #ffffff08;
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 14px;
+    margin-right: 8px;
+  }
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  margin-right: 10px;
+  background: ${(props: { status: string }) =>
+    props.status === "created"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+  margin-left: 3px;
+`;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 10px;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const DeploymentTypeIcon = styled(Icon)`
+  width: 20px;
+  margin-right: 10px;
+`;
+
+const RepositoryName = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 390px;
+  position: relative;
+  margin-right: 3px;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: -20px;
+  top: 10px;
+  min-height: 18px;
+  max-width: calc(700px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  color: white;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-top: -1px;
+  margin-left: 10px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const MergeInfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 8px;
+  position: relative;
+`;
+
+const MergeInfo = styled.div`
+  font-size: 13px;
+  margin-left: 14px;
+  align-items: center;
+  color: #aaaabb66;
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 300px;
+
+  > i {
+    font-size: 16px;
+    margin: 0 2px;
+  }
+`;

+ 29 - 38
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentDetail.tsx → dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx

@@ -5,7 +5,7 @@ import TitleSection from "components/TitleSection";
 import pr_icon from "assets/pull_request_icon.svg";
 import { useRouteMatch, useLocation } from "react-router";
 import DynamicLink from "components/DynamicLink";
-import { PRDeployment, Environment } from "./EnvironmentList";
+import { PRDeployment } from "../types";
 import Loading from "components/Loading";
 import { Context } from "shared/Context";
 import api from "shared/api";
@@ -14,17 +14,13 @@ import github from "assets/github-white.png";
 import { integrationList } from "shared/common";
 import { capitalize } from "shared/string_utils";
 
-const EnvironmentDetail = () => {
+const DeploymentDetail = () => {
   const { params } = useRouteMatch<{ namespace: string }>();
   const context = useContext(Context);
   const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
-  const [hasError, setHasError] = useState(false);
-  const [isLoading, setIsLoading] = useState(false);
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
 
-  const { currentProject, currentCluster, setCurrentError } = useContext(
-    Context
-  );
+  const { currentProject, currentCluster } = useContext(Context);
 
   const { search } = useLocation();
   let searchParams = new URLSearchParams(search);
@@ -32,7 +28,6 @@ const EnvironmentDetail = () => {
   useEffect(() => {
     let isSubscribed = true;
     let environment_id = parseInt(searchParams.get("environment_id"));
-
     api
       .getPRDeploymentByCluster(
         "<token>",
@@ -55,14 +50,8 @@ const EnvironmentDetail = () => {
       .catch((err) => {
         console.error(err);
         if (isSubscribed) {
-          setHasError(true);
           setPRDeployment(null);
         }
-      })
-      .finally(() => {
-        if (isSubscribed) {
-          setIsLoading(false);
-        }
       });
   }, [params]);
 
@@ -75,28 +64,11 @@ const EnvironmentDetail = () => {
   return (
     <StyledExpandedChart>
       <HeaderWrapper>
-        <BackButton to={"/cluster-dashboard?selected_tab=preview_environments"}>
+        <BackButton to={"/preview-environments"}>
           <BackButtonImg src={backArrow} />
         </BackButton>
         <Title icon={pr_icon} iconWidth="25px">
           {prDeployment.gh_pr_name}
-          <DeploymentImageContainer>
-            <DeploymentTypeIcon src={integrationList.repo.icon} />
-            <RepositoryName
-              onMouseOver={() => {
-                setShowRepoTooltip(true);
-              }}
-              onMouseOut={() => {
-                setShowRepoTooltip(false);
-              }}
-            >
-              {repository}
-            </RepositoryName>
-            {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
-          </DeploymentImageContainer>
-          <TagWrapper>
-            Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
-          </TagWrapper>
         </Title>
         <InfoWrapper>
           {prDeployment.subdomain && (
@@ -105,6 +77,9 @@ const EnvironmentDetail = () => {
               {prDeployment.subdomain}
             </PRLink>
           )}
+          <TagWrapper>
+            Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
+          </TagWrapper>
         </InfoWrapper>
         <Flex>
           <Status>
@@ -112,11 +87,26 @@ const EnvironmentDetail = () => {
             {capitalize(prDeployment.status)}
           </Status>
           <Dot>•</Dot>
+          <DeploymentImageContainer>
+            <DeploymentTypeIcon src={integrationList.repo.icon} />
+            <RepositoryName
+              onMouseOver={() => {
+                setShowRepoTooltip(true);
+              }}
+              onMouseOut={() => {
+                setShowRepoTooltip(false);
+              }}
+            >
+              {repository}
+            </RepositoryName>
+            {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+          </DeploymentImageContainer>
+          <Dot>•</Dot>
           <GHALink
             to={`https://github.com/${repository}/pull/${prDeployment.pull_request_id}`}
             target="_blank"
           >
-            <img src={github} /> GitHub
+            <img src={github} /> GitHub PR
             <i className="material-icons">open_in_new</i>
           </GHALink>
         </Flex>
@@ -137,7 +127,7 @@ const EnvironmentDetail = () => {
   );
 };
 
-export default EnvironmentDetail;
+export default DeploymentDetail;
 
 const Flex = styled.div`
   display: flex;
@@ -163,7 +153,8 @@ const GHALink = styled(DynamicLink)`
     margin-right: 9px;
     margin-left: 5px;
 
-    :text-decoration: none;
+    text-decoration: none;
+
     :hover {
       text-decoration: underline;
       color: white;
@@ -212,6 +203,7 @@ const BackButtonImg = styled.img`
 
 const HeaderWrapper = styled.div`
   position: relative;
+  padding-right: 40px;
 `;
 
 const Dot = styled.div`
@@ -223,8 +215,7 @@ const Dot = styled.div`
 const InfoWrapper = styled.div`
   display: flex;
   align-items: center;
-  width: auto;
-  justify-content: space-between;
+  margin-top: 20px;
 `;
 
 const TagWrapper = styled.div`
@@ -345,7 +336,7 @@ const DeploymentImageContainer = styled.div`
   font-size: 13px;
   position: relative;
   display: flex;
-  margin-left: 15px;
+  margin-left: 5px;
   margin-bottom: -3px;
   align-items: center;
   font-weight: 400;

+ 438 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -0,0 +1,438 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+import Selector from "components/Selector";
+
+import Loading from "components/Loading";
+
+import _ from "lodash";
+import DeploymentCard from "./DeploymentCard";
+import { Environment, PRDeployment, PullRequest } from "../types";
+import { useRouting } from "shared/routing";
+import { useHistory, useLocation } from "react-router";
+import { deployments, pull_requests } from "../mocks";
+import PullRequestCard from "./PullRequestCard";
+
+const AvailableStatusFilters = [
+  "all",
+  "created",
+  "failed",
+  "active",
+  "inactive",
+  "not_deployed",
+];
+
+type AvailableStatusFiltersType = typeof AvailableStatusFilters[number];
+
+const DeploymentList = ({ environments }: { environments: Environment[] }) => {
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
+  const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
+
+  const [
+    statusSelectorVal,
+    setStatusSelectorVal,
+  ] = useState<AvailableStatusFiltersType>("active");
+  const [selectedRepo, setSelectedRepo] = useState("");
+
+  const { currentProject, currentCluster } = useContext(Context);
+  const { getQueryParam, pushQueryParams } = useRouting();
+  const location = useLocation();
+  const history = useHistory();
+
+  const getPRDeploymentList = () => {
+    return api.getPRDeploymentList(
+      "<token>",
+      {},
+      {
+        project_id: currentProject.id,
+        cluster_id: currentCluster.id,
+      }
+    );
+    // return mockRequest();
+  };
+
+  useEffect(() => {
+    const selected_repo = getQueryParam("repository");
+
+    const repo = environments.find(
+      env => `${env.git_repo_owner}/${env.git_repo_name}` === selected_repo
+    );
+
+    if (repo && true) {
+      setSelectedRepo(`${repo.git_repo_owner}/${repo.git_repo_name}`);
+    }
+  }, [location.search, history]);
+
+  useEffect(() => {
+    const status_filter = getQueryParam("status_filter");
+
+    if (!AvailableStatusFilters.includes(status_filter)) {
+      pushQueryParams({}, ["status_filter"]);
+      return;
+    }
+
+    if (status_filter !== statusSelectorVal) {
+      setStatusSelectorVal(status_filter);
+    }
+  }, [location.search, history]);
+
+  useEffect(() => {
+    pushQueryParams({}, ["status_filter"]);
+  }, []);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    getPRDeploymentList()
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setDeploymentList(data.deployments || []);
+        setPullRequests(data.pull_requests || []);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+        if (isSubscribed) {
+          setHasError(true);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject, statusSelectorVal]);
+
+  const handleRefresh = () => {
+    setIsLoading(true);
+    getPRDeploymentList()
+      .then(({ data }) => {
+        setDeploymentList(data.deployments || []);
+        setPullRequests(data.pull_requests || []);
+      })
+      .catch((err) => {
+        setHasError(true);
+        console.error(err);
+      })
+      .finally(() => setIsLoading(false));
+  };
+
+  const handlePreviewEnvironmentManualCreation = (pullRequest: PullRequest) => {
+    setPullRequests((prev) => {
+      return prev.filter((pr) => {
+        return (
+          pr.pr_title === pullRequest.pr_title &&
+          `${pr.repo_owner}/${pr.repo_name}` ===
+            `${pullRequest.repo_owner}/${pullRequest.repo_name}`
+        );
+      });
+    });
+    handleRefresh();
+  };
+
+  const filteredDeployments = useMemo(() => {
+    if (statusSelectorVal === "not_deployed") {
+      return [];
+    }
+
+    if (statusSelectorVal === "all" && selectedRepo === "all") {
+      return deploymentList;
+    }
+
+    let tmpDeploymentList = [...deploymentList];
+
+    if (selectedRepo !== "all") {
+      tmpDeploymentList = tmpDeploymentList.filter((deployment) => {
+        return (
+          `${deployment.gh_repo_owner}/${deployment.gh_repo_name}` ===
+          selectedRepo
+        );
+      });
+    }
+
+    // Only filter out inactive when status filter is "active"
+    if (statusSelectorVal === "active") {
+      tmpDeploymentList = tmpDeploymentList.filter((d) => {
+        return d.status !== "inactive";
+      });
+    } else if (statusSelectorVal === "inactive") {
+      tmpDeploymentList = tmpDeploymentList.filter((d) => {
+        return d.status === "inactive";
+      });      
+    }
+
+    return tmpDeploymentList;
+  }, [selectedRepo, statusSelectorVal, deploymentList]);
+
+  const filteredPullRequests = useMemo(() => {
+    if (statusSelectorVal !== "not_deployed" && statusSelectorVal !== "inactive") {
+      return [];
+    }
+
+    if (selectedRepo === "inactive") {
+      return pullRequests;
+    }
+
+    return pullRequests.filter((pr) => {
+      return `${pr.repo_owner}/${pr.repo_name}` === selectedRepo;
+    });
+  }, [selectedRepo, pullRequests]);
+
+  const renderDeploymentList = () => {
+    if (isLoading) {
+      return (
+        <Placeholder>
+          <Loading />
+        </Placeholder>
+      );
+    }
+
+    if (!deploymentList.length && !pullRequests.length) {
+      return (
+        <Placeholder>
+          No preview apps have been found. Open a PR to create a new preview
+          app.
+        </Placeholder>
+      );
+    }
+
+    if (!filteredDeployments.length && !filteredPullRequests.length) {
+      return (
+        <Placeholder>
+          No preview apps have been found with the given filter.
+        </Placeholder>
+      );
+    }
+
+    return (
+      <>
+        {filteredPullRequests.map((pr) => {
+          return (
+            <PullRequestCard
+              key={pr.pr_title}
+              pullRequest={pr}
+              onCreation={handlePreviewEnvironmentManualCreation}
+            />
+          );
+        })}
+        {filteredDeployments.map((d) => {
+          return (
+            <DeploymentCard
+              key={d.id}
+              deployment={d}
+              onDelete={handleRefresh}
+              onReEnable={handleRefresh}
+            />
+          );
+        })}
+      </>
+    );
+  };
+
+  const handleStatusFilterChange = (value: string) => {
+    setIsLoading(true);
+    pushQueryParams({ status_filter: value });
+    setStatusSelectorVal(value);
+  };
+
+  const renderMain = () => {
+    return (
+      <Container>
+        <EventsGrid>{renderDeploymentList()}</EventsGrid>
+      </Container>
+    );
+  }
+
+  return (
+    <>
+      <Flex>
+        <i
+          className="material-icons"
+          onClick={() => pushQueryParams({}, ["status_filter", "repository"])}
+        >
+          keyboard_backspace
+        </i>
+        <Icon
+          src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png"
+          alt="git repository icon"
+        />
+        <Title>{selectedRepo}</Title>
+
+        <ActionsWrapper>
+          <StyledStatusSelector>
+            <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
+              <i className="material-icons">refresh</i>
+            </RefreshButton>
+            <Selector
+              activeValue={statusSelectorVal}
+              setActiveValue={handleStatusFilterChange}
+              options={[
+                {
+                  value: "active",
+                  label: "Active",
+                },
+                {
+                  value: "inactive",
+                  label: "Inactive",
+                }
+              ]}
+              dropdownLabel="Status"
+              width="150px"
+              dropdownWidth="230px"
+              closeOverlay={true}
+            />
+          </StyledStatusSelector>
+        </ActionsWrapper>
+      </Flex>
+      {renderMain()}
+    </>
+  )
+};
+
+export default DeploymentList;
+
+const mockRequest = () =>
+  new Promise((res) => {
+    setTimeout(
+      () =>
+        res({
+          data: { deployments: deployments, pull_requests: pull_requests },
+        }),
+      1000
+    );
+  });
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    padding: 3px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const Icon = styled.img`
+  width: 25px;
+  height: 25px;
+  margin-right: 6px;
+  margin-left: 14px;
+`;
+
+const Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 10px;
+  border-radius: 2px;
+  color: #ffffff;
+`;
+
+const ActionsWrapper = styled.div`
+  display: flex;
+  margin-left: auto;
+`;
+
+const RefreshButton = styled.button`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: ${(props: { color: string }) => props.color};
+  cursor: pointer;
+  border: none;
+  width: 30px;
+  height: 30px;
+  margin-right: 15px;
+  background: none;
+  border-radius: 50%;
+  margin-left: 10px;
+  > i {
+    font-size: 20px;
+  }
+  :hover {
+    background-color: rgb(97 98 102 / 44%);
+    color: white;
+  }
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 40vh;
+  border-radius: 8px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const Container = styled.div`
+  margin-top: 33px;
+  padding-bottom: 120px;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 20px;
+  grid-template-columns: 1;
+`;
+
+const StyledStatusSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  :not(:first-child) {
+    margin-left: 15px;
+  }
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+  width: 50%;
+`;
+
+const Subheader = styled.div`
+  width: 50%;
+`;
+
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;

+ 303 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx

@@ -0,0 +1,303 @@
+import React, { useState, useContext } from "react";
+import styled from "styled-components";
+import pr_icon from "assets/pull_request_icon.svg";
+import { PullRequest } from "../types";
+import { integrationList } from "shared/common";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ActionButton } from "../components/ActionButton";
+import Loading from "components/Loading";
+import DynamicLink from "components/DynamicLink";
+import RecreateWorkflowFilesModal from "../components/RecreateWorkflowFilesModal";
+
+const PullRequestCard = ({
+  pullRequest,
+  onCreation,
+}: {
+  pullRequest: PullRequest;
+  onCreation: (pullRequest: PullRequest) => void;
+}) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+  const [showMergeInfoTooltip, setShowMergeInfoTooltip] = useState(false);
+  const [
+    openRecreateWorkflowFilesModal,
+    setOpenRecreateWorkflowFilesModal,
+  ] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+  const [hasError, setHasError] = useState(false);
+
+  const repository = `${pullRequest.repo_owner}/${pullRequest.repo_name}`;
+
+  const createPreviewEnvironment = async () => {
+    setIsLoading(true);
+    try {
+      await api.createPreviewEnvironmentDeployment("<token>", pullRequest, {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+      });
+      onCreation(pullRequest);
+    } catch (error) {
+      debugger;
+      setCurrentError(error);
+      setHasError(true);
+      setTimeout(() => {
+        setHasError(false);
+      }, 500);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  return (
+    <>
+      <RecreateWorkflowFilesModal
+        hide={!openRecreateWorkflowFilesModal}
+        onClose={() => setOpenRecreateWorkflowFilesModal(false)}
+        isReEnable={false}
+      />
+      <DeploymentCardWrapper>
+        <DataContainer>
+          <PRName>
+            <PRIcon src={pr_icon} alt="pull request icon" />
+            {pullRequest.pr_title}
+            <InfoWrapper>
+              <MergeInfo
+                onMouseOver={() => setShowMergeInfoTooltip(true)}
+                onMouseOut={() => setShowMergeInfoTooltip(false)}
+              >
+                {pullRequest.branch_from}
+                <i className="material-icons">arrow_forward</i>
+                {pullRequest.branch_into}
+              </MergeInfo>
+              {showMergeInfoTooltip && (
+                <Tooltip>
+                  From: {pullRequest.branch_from} Into:{" "}
+                  {pullRequest.branch_into}
+                </Tooltip>
+              )}
+            </InfoWrapper>
+            <RepoLink
+              onClick={e => {
+                e.stopPropagation();
+                window.open(`https://github.com/${pullRequest.repo_owner}/${pullRequest.repo_name}/pull/${pullRequest.pr_number}`, "_blank")
+              }}
+            >
+              <i className="material-icons">open_in_new</i>
+              View PR
+            </RepoLink>
+          </PRName>
+
+          <Flex>
+            <StatusContainer>
+              <Status>
+                <StatusDot />
+                Not deployed
+              </Status>
+            </StatusContainer>
+          </Flex>
+        </DataContainer>
+        <Flex>
+          <ActionButton
+            onClick={createPreviewEnvironment}
+            disabled={isLoading}
+            hasError={hasError}
+          >
+            {isLoading ? (
+              <Loading width="198px" height="14px" />
+            ) : (
+              <>
+                <i className="material-icons">play_arrow</i>
+                Activate Preview Environment
+              </>
+            )}
+          </ActionButton>
+        </Flex>
+      </DeploymentCardWrapper>
+    </>
+  );
+};
+
+export default PullRequestCard;
+
+const RepoLink = styled.div`
+  height: 22px;
+  border-radius: 50px;
+  margin-left: 6px;
+  display: flex;
+  font-size: 12px;
+  cursor: pointer;
+  color: #a7a6bb;
+  align-items: center;
+  justify-content: center;
+  :hover {
+    color: #ffffff;
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    margin-right: 5px;
+    color: #a7a6bb;
+    font-size: 16px;
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const PRName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const DeploymentCardWrapper = styled.div`
+  display: flex;
+  background: #2b2e3699;
+  justify-content: space-between;
+  border-radius: 5px;
+  font-size: 13px;
+  height: 75px;
+  padding: 12px;
+  padding-left: 14px;
+  border: 1px solid #ffffff0f;
+
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const DataContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+`;
+
+const StatusContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  height: 100%;
+`;
+
+const PRIcon = styled.img`
+  font-size: 20px;
+  height: 17px;
+  margin-right: 10px;
+  color: #aaaabb;
+  opacity: 50%;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  margin-right: 10px;
+  background: #ffffff88;
+  border-radius: 20px;
+  margin-left: 3px;
+`;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  margin-left: 15px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 5px;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const DeploymentTypeIcon = styled(Icon)`
+  width: 20px;
+  margin-right: 10px;
+`;
+
+const RepositoryName = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 390px;
+  position: relative;
+  margin-right: 3px;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 14px;
+  top: 20px;
+  min-height: 18px;
+  max-width: calc(700px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  color: white;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 8px;
+  position: relative;
+`;
+
+const MergeInfo = styled.div`
+  font-size: 13px;
+  margin-left: 14px;
+  align-items: center;
+  color: #aaaabb66;
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 300px;
+
+  > i {
+    font-size: 16px;
+    margin: 0 2px;
+  }
+`;

+ 348 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx

@@ -0,0 +1,348 @@
+import React, {
+  FormEvent,
+  FormEventHandler,
+  useContext,
+  useState,
+} from "react";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import { Environment } from "../types";
+import Options from "components/OptionsDropdown";
+import { useRouting } from "shared/routing";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import Modal from "main/home/modals/Modal";
+import InputRow from "components/form-components/InputRow";
+import DynamicLink from "components/DynamicLink";
+
+type Props = {
+  environment: Environment;
+  onDelete: (env: Environment) => void;
+};
+
+const EnvironmentCard = ({ environment, onDelete }: Props) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const { pushFiltered } = useRouting();
+
+  const [showDeleteModal, setShowDeleteModal] = useState(false);
+  const [deleteConfirmationRepoName, setDeleteConfirmationRepoName] = useState(
+    ""
+  );
+
+  const {
+    id,
+    name,
+    deployment_count,
+    git_repo_owner,
+    git_repo_name,
+    git_installation_id,
+    last_deployment_status,
+  } = environment;
+
+  const showOpenPrs = () => {
+    pushFiltered("/preview-environments", [], {
+      repository: `${git_repo_owner}/${git_repo_name}`,
+    });
+  };
+
+  const handleDelete = () => {
+    if (!canDelete()) {
+      return;
+    }
+    api
+      .deleteEnvironment(
+        "<token>",
+        {
+          name: name,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: git_installation_id,
+          git_repo_owner: git_repo_owner,
+          git_repo_name: git_repo_name,
+        }
+      )
+      .then(() => {
+        onDelete(environment);
+        closeForm();
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+      });
+  };
+
+  const closeForm = () => {
+    setShowDeleteModal(false);
+    setDeleteConfirmationRepoName("");
+  };
+
+  const canDelete = () => {
+    const repoName = deleteConfirmationRepoName;
+    return repoName === `${git_repo_owner}/${git_repo_name}`;
+  };
+
+  return (
+    <>
+      {showDeleteModal ? (
+        <Modal
+          title={`Remove Preview Envs for ${git_repo_owner}/${git_repo_name}`}
+          width="800px"
+          height="260px"
+          onRequestClose={closeForm}
+        >
+          <Warning highlight>
+            ⚠️ All Preview Environment deployments associated with this repo will be deleted.
+          </Warning>
+          <InputRow
+            type="text"
+            label="Enter the full name of the repository to delete Preview Environments:"
+            value={deleteConfirmationRepoName}
+            placeholder={`${git_repo_owner}/${git_repo_name}`}
+            setValue={(x: string) => setDeleteConfirmationRepoName(x)}
+            width={"500px"}
+          />
+          <ActionWrapper>
+            <DeleteButton
+              onClick={() => handleDelete()}
+              disabled={!canDelete()}
+            >
+              Delete
+            </DeleteButton>
+          </ActionWrapper>
+        </Modal>
+      ) : null}
+      <EnvironmentCardWrapper onClick={showOpenPrs}>
+        <DataContainer>
+          <RepoName>
+            <Icon
+              src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png"
+              alt="git repository icon"
+            />
+            {git_repo_owner}/{git_repo_name}
+            <RepoLink
+              onClick={e => {
+                e.stopPropagation();
+                window.open(`https://github.com/${git_repo_owner}/${git_repo_name}`, "_blank")
+              }}
+            >
+              <i className="material-icons">open_in_new</i>
+              View Repo
+            </RepoLink>
+          </RepoName>
+          <Status>
+            {deployment_count > 0 ? (
+              <>
+                <StatusDot status={last_deployment_status} />
+                Last PR status was "{capitalize(last_deployment_status || "")}"
+                <Dot>•</Dot>
+              </>
+            ) : null}
+            {deployment_count > 0 ? (
+              <Span>
+                {deployment_count || 0}{" "}
+                pull {deployment_count > 1 ? "requests" : "request"} deployed
+              </Span>
+            ) : (
+              <Span>
+                There is no pull request deployed for this environment
+              </Span>
+            )}
+          </Status>
+        </DataContainer>
+        <OptionWrapper>
+          <Options.Dropdown expandIcon="more_vert" shrinkIcon="more_vert">
+            <Options.Option onClick={() => setShowDeleteModal(true)}>
+              <i className="material-icons">delete</i> Delete
+            </Options.Option>
+          </Options.Dropdown>
+        </OptionWrapper>
+      </EnvironmentCardWrapper>
+    </>
+  );
+};
+
+export default EnvironmentCard;
+
+const Span = styled.span`
+  color: #aaaabb66;
+`;
+
+const OptionWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const RepoLink = styled.div`
+  height: 22px;
+  border-radius: 50px;
+  margin-left: 10px;
+  display: flex;
+  font-size: 12px;
+  color: #a7a6bb;
+  align-items: center;
+  justify-content: center;
+  :hover {
+    color: #ffffff;
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    margin-right: 5px;
+    color: #a7a6bb;
+    font-size: 16px;
+  }
+`;
+
+const EnvironmentCardWrapper = styled.div`
+  display: flex;
+  background: #2b2e3699;
+  justify-content: space-between;
+  border-radius: 5px;
+  cursor: pointer;
+  height: 75px;
+  padding: 12px;
+  padding-left: 14px;
+  border: 1px solid #ffffff0f;
+
+  :hover {
+    border: 1px solid #ffffff3c;
+  }
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const DataContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+`;
+
+const RepoName = styled.div`
+  display: flex;
+  font-size: 14px;
+  font-weight: 500;
+  align-items: center;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  min-height: 17px;
+  color: #a7a6bb;
+  margin-top: 10px;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  margin-right: 15px;
+  background: ${(props: { status: string }) =>
+    props.status === "created"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+  margin-left: 4px;
+`;
+
+const Icon = styled.img`
+  width: 18px;
+  height: 18px;
+  margin-right: 12px;
+`;
+
+const Button = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  margin-top: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  color: white;
+  height: 35px;
+  padding: 10px 16px;
+  font-weight: 500;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: pointer;
+  border: none;
+  :not(:last-child) {
+    margin-right: 10px;
+  }
+`;
+
+const DeleteButton = styled(Button)`
+  ${({ disabled }: { disabled: boolean }) => {
+    if (disabled) {
+      return `
+      background: #aaaabbee;
+      :hover {
+        background: #aaaabbee;
+      }    
+      `;
+    }
+
+    return `
+      background: #dd4b4b;
+      :hover {
+        background: #b13d3d;
+      }`;
+  }}
+`;
+
+const CancelButton = styled(Button)`
+  background: #616feecc;
+  :hover {
+    background: #505edddd;
+  }
+`;
+
+const ActionWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Warning = styled.div`
+  font-size: 13px;
+  display: flex;
+  border-radius: 3px;
+  width: calc(100%);
+  margin-top: 18px;
+  margin-left: 2px;
+  line-height: 1.4em;
+  align-items: center;
+  color: white;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+`;
+
+const Dot = styled.div`
+  margin-right: 9px;
+  color: #aaaabb66;
+  margin-left: 9px;
+`;

+ 105 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx

@@ -0,0 +1,105 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import { deployments, environments } from "../mocks";
+import { Environment } from "../types";
+import EnvironmentCard from "./EnvironmentCard";
+
+type Props = {
+  environments: Environment[];
+  setEnvironments: (
+    setFunction: (prev: Environment[]) => Environment[]
+  ) => void;
+};
+
+const EnvironmentsList = ({ environments, setEnvironments }: Props) => {
+  const removeEnvironmentFromList = (deletedEnv: Environment) => {
+    setEnvironments((prev) => {
+      return prev.filter((env) => env.id !== deletedEnv.id);
+    });
+  };
+
+  return (
+    <>
+      <ControlRow>
+        <Button to={`/preview-environments/connect-repo`}>
+          <i className="material-icons">add</i> Add Repository
+        </Button>
+      </ControlRow>
+      <EnvironmentsGrid>
+        {environments.map((env) => (
+          <EnvironmentCard
+            key={env.id}
+            environment={env}
+            onDelete={removeEnvironmentFromList}
+          />
+        ))}
+      </EnvironmentsGrid>
+    </>
+  );
+};
+
+export default EnvironmentsList;
+
+const EnvironmentsGrid = styled.div`
+  margin-top: 32px;
+  padding-bottom: 150px;
+  display: grid;
+  grid-row-gap: 25px;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin: 35px 0;
+  padding-left: 0px;
+`;
+
+const Button = styled(DynamicLink)`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 165 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/mocks.ts

@@ -0,0 +1,165 @@
+export const environments = [
+  {
+    id: 29,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 22158312,
+    git_repo_owner: "porter-dev",
+    git_repo_name: "porter-docs",
+    name: "Preview",
+  },
+  {
+    id: 36,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 21704327,
+    git_repo_owner: "jnfrati",
+    git_repo_name: "angular-todo-app",
+    name: "Preview",
+  },
+  {
+    id: 37,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 21704327,
+    git_repo_owner: "jnfrati",
+    git_repo_name: "porter-docs",
+    name: "Preview",
+    deployment_count: 3,
+    last_deployment_status: "failed",
+  },
+  {
+    id: 38,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 21704327,
+    git_repo_owner: "jnfrati",
+    git_repo_name: "porter-docs",
+    name: "Preview",
+  },
+  {
+    id: 39,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 21704327,
+    git_repo_owner: "jnfrati",
+    git_repo_name: "multi-tenant-blog",
+    name: "Preview",
+  },
+  {
+    id: 40,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 18424822,
+    git_repo_owner: "sunguroku",
+    git_repo_name: "node",
+    name: "Preview",
+  },
+  {
+    id: 41,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 18424822,
+    git_repo_owner: "sunguroku",
+    git_repo_name: "code-server",
+    name: "Preview",
+  },
+  {
+    id: 42,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 22158312,
+    git_repo_owner: "porter-dev",
+    git_repo_name: "preview-env",
+    name: "Preview",
+  },
+  {
+    id: 43,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 22158312,
+    git_repo_owner: "porter-dev",
+    git_repo_name: "preview",
+    name: "Preview",
+  },
+  {
+    id: 44,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 22158312,
+    git_repo_owner: "porter-dev",
+    git_repo_name: "preview-env-test",
+    name: "Preview",
+  },
+  {
+    id: 45,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 22158312,
+    git_repo_owner: "porter-dev",
+    git_repo_name: "ptrtr",
+    name: "Preview",
+  },
+];
+
+export const deployments = [
+  {
+    gh_deployment_id: 534980099,
+    gh_pr_name: "Update porter.yaml",
+    gh_repo_name: "preview",
+    gh_repo_owner: "porter-dev",
+    gh_commit_sha: "74a1191",
+    id: 43,
+    created_at: "2022-03-28T19:28:11.012729Z",
+    updated_at: "2022-03-28T19:31:53.871666Z",
+    git_installation_id: 0,
+    environment_id: 43,
+    namespace: "pr-3-preview",
+    status: "failed",
+    subdomain: "",
+    pull_request_id: 3,
+  },
+  {
+    gh_deployment_id: 532608734,
+    gh_pr_name: "Testing pr preview",
+    gh_repo_name: "porter-docs",
+    gh_repo_owner: "jnfrati",
+    gh_commit_sha: "6a4b67e",
+    id: 41,
+    created_at: "2022-03-24T20:24:17.103471Z",
+    updated_at: "2022-03-24T20:45:06.684096Z",
+    git_installation_id: 0,
+    environment_id: 37,
+    namespace: "pr-1-porter-docs",
+    status: "inactive",
+    subdomain: "https://docs-web-7b93751b98e68139.staging-onporter.run",
+    pull_request_id: 1,
+  },
+  {
+    gh_deployment_id: 514002155,
+    gh_pr_name: "Testing PR with job run",
+    gh_repo_name: "porter-docs",
+    gh_repo_owner: "porter-dev",
+    gh_commit_sha: "443d930",
+    id: 32,
+    created_at: "2022-01-30T11:04:14.496147Z",
+    updated_at: "2022-02-24T22:02:27.17928Z",
+    git_installation_id: 0,
+    environment_id: 29,
+    namespace: "pr-20-porter-docs",
+    status: "created",
+    subdomain: "https://docs-web-78a048205ac7869b.staging-onporter.run",
+    pull_request_id: 20,
+  },
+];
+
+export const pull_requests = [
+  {
+    pr_title: "Testing PR with job run",
+    pr_number: 1,
+    repo_owner: "porter-docs",
+    repo_name: "porter-dev",
+    branch_from: "some_branch",
+    branch_into: "main",
+  },
+];

+ 33 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx

@@ -0,0 +1,33 @@
+import React, { useContext } from "react";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
+import { Context } from "shared/Context";
+import ConnectNewRepo from "./ConnectNewRepo";
+import DeploymentDetail from "./deployments/DeploymentDetail";
+import PreviewEnvironmentsHome from "./PreviewEnvironmentsHome";
+
+export const Routes = () => {
+  const { url } = useRouteMatch();
+  const { currentProject } = useContext(Context);
+
+  if (!currentProject?.preview_envs_enabled) {
+    return <Redirect to={`/`} />;
+  }
+
+  return (
+    <>
+      <Switch>
+        <Route path={`${url}/connect-repo`}>
+          <ConnectNewRepo />
+        </Route>
+        <Route path={`${url}/details/:namespace?`}>
+          <DeploymentDetail />
+        </Route>
+        <Route path={`${url}/:selected_tab?`}>
+          <PreviewEnvironmentsHome />
+        </Route>
+      </Switch>
+    </>
+  );
+};
+
+export default Routes;

+ 38 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts

@@ -0,0 +1,38 @@
+export type PRDeployment = {
+  id: number;
+  created_at: string;
+  updated_at: string;
+  subdomain: string;
+  status: "creating" | "failed" | "created" | "inactive";
+  environment_id: number;
+  pull_request_id: number;
+  namespace: string;
+  gh_pr_name: string;
+  gh_repo_owner: string;
+  gh_repo_name: string;
+  gh_commit_sha: string;
+  gh_pr_branch_from?: string;
+  gh_pr_branch_into?: string;
+};
+
+export type Environment = {
+  id: number;
+  project_id: number;
+  cluster_id: number;
+  git_installation_id: number;
+  name: string;
+  git_repo_owner: string;
+  git_repo_name: string;
+  last_deployment_status: "failed" | "created" | "inactive" | "disabled";
+  deployment_count: number;
+  mode: "manual" | "auto";
+};
+
+export type PullRequest = {
+  pr_title: string;
+  pr_number: number;
+  repo_owner: string;
+  repo_name: string;
+  branch_from: string;
+  branch_into: string;
+};

+ 0 - 2
dashboard/src/main/home/infrastructure/ExpandedInfra.tsx

@@ -115,8 +115,6 @@ const ExpandedInfra: React.FunctionComponent = () => {
   };
 
   const setLatestOperation = (operation: Operation) => {
-    console.log("SETTING LATEST OPERATION", operation);
-
     let newInfra = infra;
     newInfra.latest_operation = operation;
     setInfra(newInfra);

+ 0 - 1
dashboard/src/main/home/integrations/edit-integration/GCRForm.tsx

@@ -71,7 +71,6 @@ export default class GCRForm extends Component<PropsType, StateType> {
         )
       )
       .then((res) => {
-        console.log(res.data);
         this.props.closeForm();
       })
       .catch(this.catchError);

+ 1 - 1
dashboard/src/main/home/launch/Launch.tsx

@@ -118,7 +118,7 @@ class Templates extends Component<PropsType, StateType> {
           (v: any) => v.name === template_name
         );
 
-        console.log(currentTemplate);
+        // console.log(currentTemplate);
         if (currentTemplate.versions.find((v: any) => v === version)) {
           currentTemplate.currentVersion = version;
         }

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

@@ -153,7 +153,7 @@ class SettingsPage extends Component<PropsType, StateType> {
               !this.props.isAuthorized("namespace", "", ["get", "create"])
             }
             onSubmit={(val) => {
-              console.log(val);
+              // console.log(val);
               onSubmit(val);
             }}
             hideBottomSpacer={!!this.props.fullActionConfig?.git_repo}

+ 1 - 1
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -153,7 +153,7 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
   };
 
   potentiallyOverriddenKeys(incoming: Record<string, string>): KeyValue[] {
-    console.log(incoming, this.props.existingValues);
+    // console.log(incoming, this.props.existingValues);
     return Object.entries(incoming)
       .filter(([key]) => this.props.existingValues[key])
       .map(([key, value]) => ({ key, value }));

+ 1 - 1
dashboard/src/main/home/modals/PreviewEnvSettingsModal.tsx

@@ -41,7 +41,7 @@ const PreviewEnvSettingsModal = () => {
         }
       )
       .then(({ data }) => {
-        console.log("github account", data);
+        // console.log("github account", data);
 
         if (!Array.isArray(data)) {
           throw Error("Data is not an array");

+ 1 - 1
dashboard/src/main/home/onboarding/components/RegistryImageList.tsx

@@ -28,7 +28,7 @@ const RegistryImageList: React.FC<{
         if (!res?.data) {
           throw new Error("No data found");
         }
-        console.log(res.data);
+        // console.log(res.data);
         setImageList(res.data);
       })
       .catch(console.error);

+ 1 - 1
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_AWSRegistryForm.tsx

@@ -321,7 +321,7 @@ export const TestRegistryConnection: React.FC<{ nextFormStep: () => void }> = ({
   nextFormStep,
 }) => {
   const snap = useSnapshot(StateHandler);
-  console.log(snap.connected_registry.settings);
+  // console.log(snap.connected_registry.settings);
   return (
     <>
       <RegistryImageList

+ 1 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_AWSProvisionerForm.tsx

@@ -361,7 +361,7 @@ export const SettingsForm: React.FC<{
   };
 
   const provisionECR = async (awsIntegrationId: number) => {
-    console.log("Started provision ECR");
+    // console.log("Started provision ECR");
 
     // See if there's an infra for EKS that is in an errored state and the last operation
     // was an attempt at creation. If so, re-use that infra.

+ 2 - 2
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_DOProvisionerForm.tsx

@@ -249,7 +249,7 @@ export const SettingsForm: React.FC<{
   };
 
   const provisionDOCR = async (integrationId: number, tier: string) => {
-    console.log("Provisioning DOCR...");
+    // console.log("Provisioning DOCR...");
 
     // See if there's an infra for DOKS that is in an errored state and the last operation
     // was an attempt at creation. If so, re-use that infra.
@@ -302,7 +302,7 @@ export const SettingsForm: React.FC<{
     region: string,
     clusterName: string
   ) => {
-    console.log("Provisioning DOKS...");
+    // console.log("Provisioning DOKS...");
 
     // See if there's an infra for DOKS that is in an errored state and the last operation
     // was an attempt at creation. If so, re-use that infra.

+ 2 - 2
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx

@@ -388,7 +388,7 @@ export const SettingsForm: React.FC<{
   };
 
   const provisionGCR = async (id: number) => {
-    console.log("Provisioning GCR");
+    // console.log("Provisioning GCR");
 
     // See if there's an infra for GKE that is in an errored state and the last operation
     // was an attempt at creation. If so, re-use that infra.
@@ -428,7 +428,7 @@ export const SettingsForm: React.FC<{
   };
 
   const provisionGKE = async (id: number) => {
-    console.log("Provisioning GKE");
+    // console.log("Provisioning GKE");
 
     // See if there's an infra for GKE that is in an errored state and the last operation
     // was an attempt at creation. If so, re-use that infra.

+ 2 - 2
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -186,7 +186,7 @@ const AWSFormSectionFC: React.FC<PropsType> = (props) => {
   };
 
   const provisionECR = async (awsIntegrationId: number) => {
-    console.log("Started provision ECR");
+    // console.log("Started provision ECR");
     const { currentProject } = context;
     try {
       await api.provisionInfra(
@@ -405,7 +405,7 @@ const AWSFormSectionFC: React.FC<PropsType> = (props) => {
           selected={selectedInfras}
           setSelected={(x: { value: string; label: string }[]) => {
             setIsFormDirty(true);
-            console.log(x);
+            // console.log(x);
             setSelectedInfras(x);
           }}
         />

+ 53 - 4
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -7,6 +7,7 @@ import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
 import settings from "assets/settings.svg";
 import sliders from "assets/sliders.svg";
+import PullRequestIcon from "assets/pull_request_icon.svg";
 
 import { Context } from "shared/Context";
 
@@ -32,6 +33,7 @@ type StateType = {
   pressingCtrl: boolean;
   showTooltip: boolean;
   forceCloseDrawer: boolean;
+  showLinkTooltip: { [linkKey: string]: boolean };
 };
 
 class Sidebar extends Component<PropsType, StateType> {
@@ -42,6 +44,9 @@ class Sidebar extends Component<PropsType, StateType> {
     pressingCtrl: false,
     showTooltip: false,
     forceCloseDrawer: false,
+    showLinkTooltip: {
+      prev_envs: false,
+    },
   };
 
   componentDidMount() {
@@ -172,6 +177,35 @@ class Sidebar extends Component<PropsType, StateType> {
                 Databases
               </NavButton>
             )}
+          {currentProject?.preview_envs_enabled && (
+            <NavButton to="/preview-environments">
+              <InlineSVGWrapper id="Flat" fill="#FFFFFF" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
+                <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z"/>
+              </InlineSVGWrapper>
+              <EllipsisTextWrapper
+                onMouseOver={() => {
+                  this.setState((prev) => ({
+                    ...prev,
+                    showLinkTooltip: {
+                      ...prev.showLinkTooltip,
+                      prev_envs: true,
+                    },
+                  }));
+                }}
+                onMouseOut={() => {
+                  this.setState((prev) => ({
+                    ...prev,
+                    showLinkTooltip: {
+                      ...prev.showLinkTooltip,
+                      prev_envs: false,
+                    },
+                  }));
+                }}
+              >
+                Preview Envs
+              </EllipsisTextWrapper>
+            </NavButton>
+          )}
         </>
       );
     }
@@ -318,9 +352,6 @@ const NavButton = styled(NavLink)`
   font-size: 14px;
   font-family: "Work Sans", sans-serif;
   color: #ffffff;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 
@@ -353,6 +384,24 @@ const Img = styled.img<{ enlarge?: boolean }>`
   margin-right: 10px;
 `;
 
+const InlineSVGWrapper = styled.svg`
+  width: 32px;
+  height: 32px;
+  padding: 8px;
+  padding-left: 0;
+
+  > path {
+    fill: #ffffff;
+  }
+`;
+
+const EllipsisTextWrapper = styled.span`
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`;
+
 const SidebarBg = styled.div`
   position: absolute;
   top: 0;
@@ -402,7 +451,7 @@ const Tooltip = styled.div`
   position: absolute;
   right: -60px;
   top: 34px;
-  width: 67px;
+  min-width: 67px;
   height: 18px;
   padding-bottom: 2px;
   background: #383842dd;

+ 53 - 26
dashboard/src/shared/api.tsx

@@ -1,3 +1,4 @@
+import { PullRequest } from "main/home/cluster-dashboard/preview-environments/types";
 import { release } from "process";
 import { baseApi } from "./baseApi";
 
@@ -88,6 +89,7 @@ const createEmailVerification = baseApi<{}, {}>("POST", (pathParams) => {
 const createEnvironment = baseApi<
   {
     name: string;
+    mode: "auto" | "manual";
   },
   {
     project_id: number;
@@ -129,6 +131,28 @@ const deleteEnvironment = baseApi<
   return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/environment`;
 });
 
+const createPreviewEnvironmentDeployment = baseApi<
+  PullRequest,
+  { project_id: number; cluster_id: number }
+>(
+  "POST",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/deployments/pull_request`
+);
+
+const reenablePreviewEnvironmentDeployment = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    deployment_id: number;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, deployment_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${deployment_id}/reenable`
+);
+
 const listEnvironments = baseApi<
   {},
   {
@@ -339,25 +363,18 @@ const getPRDeployment = baseApi<
 });
 
 const deletePRDeployment = baseApi<
-  {
-    namespace: string;
-  },
+  {},
   {
     cluster_id: number;
     project_id: number;
-    git_installation_id: number;
-    git_repo_owner: string;
-    git_repo_name: string;
+    environment_id: number;
+    repo_owner: string;
+    repo_name: string;
+    pr_number: number;
   }
 >("DELETE", (pathParams) => {
-  const {
-    cluster_id,
-    project_id,
-    git_installation_id,
-    git_repo_owner,
-    git_repo_name,
-  } = pathParams;
-  return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/deployment`;
+  const { cluster_id, project_id, environment_id, repo_owner, repo_name, pr_number } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${environment_id}/${repo_owner}/${repo_name}/${pr_number}`;
 });
 
 const getNotificationConfig = baseApi<
@@ -445,9 +462,11 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const getBranchContents = baseApi<
@@ -463,9 +482,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -481,9 +502,11 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getBranches = baseApi<
@@ -1215,9 +1238,11 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
-    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
-    }`;
+  return `/api/projects/${pathParams.id}/clusters/${
+    pathParams.cluster_id
+  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
+    pathParams.version ? "&version=" + pathParams.version : ""
+  }`;
 });
 
 const getConfigMap = baseApi<
@@ -1680,6 +1705,8 @@ export default {
   createEmailVerification,
   createEnvironment,
   deleteEnvironment,
+  createPreviewEnvironmentDeployment,
+  reenablePreviewEnvironmentDeployment,
   listEnvironments,
   createGCPIntegration,
   createInvite,

+ 0 - 6
dashboard/src/shared/baseApi.ts

@@ -25,15 +25,10 @@ const buildAxiosConfig: BuildAxiosConfigFunction = (
     url: typeof endpoint === "function" ? endpoint(pathParams) : endpoint,
   };
 
-  const AuthHeaders = {
-    Authorization: `Bearer ${token}`,
-  };
-
   if (method.toUpperCase() === "POST") {
     return {
       ...config,
       data: params,
-      headers: AuthHeaders,
     };
   }
 
@@ -41,7 +36,6 @@ const buildAxiosConfig: BuildAxiosConfigFunction = (
     return {
       ...config,
       data: params,
-      headers: AuthHeaders,
     };
   }
 

+ 0 - 38
dashboard/src/shared/baseApi.tsx

@@ -1,38 +0,0 @@
-import axios from "axios";
-import qs from "qs";
-
-// axios.defaults.timeout = 10000;
-
-// Partial function that accepts a generic params type and returns an api method
-export const baseApi = <T extends {}, S = {}>(
-  requestType: string,
-  endpoint: ((pathParams: S) => string) | string
-) => {
-  return (token: string, params: T, pathParams: S) => {
-    // Generate endpoint literal
-    let endpointString: ((pathParams: S) => string) | string;
-    if (typeof endpoint === "string") {
-      endpointString = endpoint;
-    } else {
-      endpointString = endpoint(pathParams);
-    }
-
-    // Handle request type (can refactor)
-    if (requestType === "POST") {
-      return axios.post(endpointString, params, {});
-    } else if (requestType === "PUT") {
-      return axios.put(endpointString, params, {});
-    } else if (requestType === "DELETE") {
-      return axios.delete(
-        endpointString + "?" + qs.stringify(params, { arrayFormat: "repeat" })
-      );
-    } else {
-      return axios.get(endpointString, {
-        params,
-        paramsSerializer: function (params) {
-          return qs.stringify(params, { arrayFormat: "repeat" });
-        },
-      });
-    }
-  };
-};

+ 1 - 1
dashboard/src/shared/hooks/useEffectDebugger.ts

@@ -27,7 +27,7 @@ export const useEffectDebugger = (
   );
 
   if (Object.keys(changedDeps).length) {
-    console.log("[use-effect-debugger] ", changedDeps);
+    // console.log("[use-effect-debugger] ", changedDeps);
   }
 
   useEffect(effectHook, dependencies);

+ 4 - 4
dashboard/src/shared/hooks/useWebsockets.ts

@@ -36,12 +36,12 @@ export const useWebsockets = () => {
     options: NewWebsocketOptions
   ): WebsocketConfig => {
     if (!id) {
-      console.log("Id cannot be empty");
+      // console.log("Id cannot be empty");
       return;
     }
 
     if (!apiEndpoint) {
-      console.log("Api endpoint string cannot be empty");
+      // console.log("Api endpoint string cannot be empty");
       return;
     }
 
@@ -75,7 +75,7 @@ export const useWebsockets = () => {
 
     // Prevent calling openWebsocket before newWebsocket
     if (!wsConfig) {
-      console.log("Couldn't find ws config");
+      // console.log("Couldn't find ws config");
       return;
     }
     // In case of having a previous websocket opened with the same ID, close the previous one
@@ -103,7 +103,7 @@ export const useWebsockets = () => {
     const ws = websocketMap.current[id];
 
     if (!ws) {
-      console.log(`Couldn't find websocket to close for id: ${id}`);
+      // console.log(`Couldn't find websocket to close for id: ${id}`);
       return;
     }
 

+ 3 - 1
dashboard/src/shared/routing.tsx

@@ -12,7 +12,8 @@ export type PorterUrl =
   | "env-groups"
   | "jobs"
   | "onboarding"
-  | "databases";
+  | "databases"
+  | "preview-environments";
 
 export const PorterUrls = [
   "dashboard",
@@ -27,6 +28,7 @@ export const PorterUrls = [
   "jobs",
   "onboarding",
   "databases",
+  "preview-environments",
 ];
 
 // TODO: consolidate with pushFiltered

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

@@ -472,7 +472,7 @@ func deleteGithubFile(
 		}
 	}
 
-	_, _, err := client.Repositories.DeleteFile(
+	_, response, err := client.Repositories.DeleteFile(
 		context.TODO(),
 		gitRepoOwner,
 		gitRepoName,
@@ -480,6 +480,10 @@ func deleteGithubFile(
 		opts,
 	)
 
+	if response.StatusCode == 404 {
+		return nil
+	}
+
 	if err != nil {
 		return err
 	}

+ 52 - 6
internal/integrations/ci/actions/preview.go

@@ -229,13 +229,39 @@ func getPreviewApplyActionYAML(opts *EnvOpts) ([]byte, error) {
 			opts.ProjectID,
 			opts.ClusterID,
 			opts.GitInstallationID,
+			opts.GitRepoOwner,
 			opts.GitRepoName,
-			"v0.1.0",
+			"v0.2.0",
 		),
 	}
 
 	actionYAML := GithubActionYAML{
-		On:   []string{"pull_request"},
+		On: map[string]interface{}{
+			"workflow_dispatch": map[string]interface{}{
+				"inputs": map[string]interface{}{
+					"pr_number": map[string]interface{}{
+						"description": "Pull request number",
+						"type":        "number",
+						"required":    true,
+					},
+					"pr_title": map[string]interface{}{
+						"description": "Pull request title",
+						"type":        "string",
+						"required":    true,
+					},
+					"pr_branch_from": map[string]interface{}{
+						"description": "Pull request head branch",
+						"type":        "string",
+						"required":    true,
+					},
+					"pr_branch_into": map[string]interface{}{
+						"description": "Pull request base branch",
+						"type":        "string",
+						"required":    true,
+					},
+				},
+			},
+		},
 		Name: "Porter Preview Environment",
 		Jobs: map[string]GithubActionYAMLJob{
 			"porter-preview": {
@@ -255,16 +281,36 @@ func getPreviewDeleteActionYAML(opts *EnvOpts) ([]byte, error) {
 			getPorterTokenSecretName(opts.ProjectID),
 			opts.ProjectID,
 			opts.ClusterID,
-			opts.GitInstallationID,
 			opts.GitRepoName,
-			"v0.1.0",
+			"v0.2.0",
 		),
 	}
 
 	actionYAML := GithubActionYAML{
 		On: map[string]interface{}{
-			"pull_request": map[string]interface{}{
-				"types": []string{"closed"},
+			"workflow_dispatch": map[string]interface{}{
+				"inputs": map[string]interface{}{
+					"environment_id": map[string]interface{}{
+						"description": "Environment ID",
+						"type":        "number",
+						"required":    true,
+					},
+					"repo_owner": map[string]interface{}{
+						"description": "Repository owner",
+						"type":        "string",
+						"required":    true,
+					},
+					"repo_name": map[string]interface{}{
+						"description": "Repository name",
+						"type":        "string",
+						"required":    true,
+					},
+					"pr_number": map[string]interface{}{
+						"description": "Pull request number",
+						"type":        "number",
+						"required":    true,
+					},
+				},
 			},
 		},
 		Name: "Porter Preview Environment",

+ 27 - 20
internal/integrations/ci/actions/steps.go

@@ -2,6 +2,7 @@ package actions
 
 import (
 	"fmt"
+	"strings"
 )
 
 const updateAppActionName = "porter-dev/porter-update-action"
@@ -40,41 +41,47 @@ func getUpdateAppStep(serverURL, porterTokenSecretName string, projectID uint, c
 	}
 }
 
-func getCreatePreviewEnvStep(serverURL, porterTokenSecretName string, projectID, clusterID, gitInstallationID uint, repoName, actionVersion string) GithubActionYAMLStep {
+func getCreatePreviewEnvStep(
+	serverURL, porterTokenSecretName string,
+	projectID, clusterID, gitInstallationID uint,
+	repoOwner, repoName, actionVersion string,
+) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Create Porter preview env",
 		Uses: fmt.Sprintf("%s@%s", createPreviewActionName, actionVersion),
 		With: map[string]string{
-			"cluster":         fmt.Sprintf("%d", clusterID),
-			"host":            serverURL,
-			"project":         fmt.Sprintf("%d", projectID),
-			"token":           fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
-			"namespace":       fmt.Sprintf("pr-${{ github.event.pull_request.number }}-%s", repoName),
-			"pr_id":           "${{ github.event.pull_request.number }}",
-			"pr_name":         "${{ github.event.pull_request.title }}",
+			"cluster": fmt.Sprintf("%d", clusterID),
+			"host":    serverURL,
+			"project": fmt.Sprintf("%d", projectID),
+			"token":   fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"namespace": fmt.Sprintf("pr-${{ github.event.inputs.pr_number }}-%s",
+				strings.ReplaceAll(repoName, "_", "-")),
+			"pr_id":           "${{ github.event.inputs.pr_number }}",
+			"pr_name":         "${{ github.event.inputs.pr_title }}",
 			"installation_id": fmt.Sprintf("%d", gitInstallationID),
-			"branch":          "${{ github.head_ref }}",
+			"pr_branch_from":  "${{ github.event.inputs.pr_branch_from }}",
+			"pr_branch_into":  "${{ github.event.inputs.pr_branch_into }}",
 			"action_id":       "${{ github.run_id }}",
-			"repo_owner":      "${{ github.repository_owner }}",
-			"repo_name":       fmt.Sprintf("%s", repoName),
+			"repo_owner":      repoOwner,
+			"repo_name":       repoName,
 		},
 		Timeout: 30,
 	}
 }
 
-func getDeletePreviewEnvStep(serverURL, porterTokenSecretName string, projectID, clusterID, gitInstallationID uint, repoName, actionVersion string) GithubActionYAMLStep {
+func getDeletePreviewEnvStep(serverURL, porterTokenSecretName string, projectID, clusterID uint, repoName, actionVersion string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Delete Porter preview env",
 		Uses: fmt.Sprintf("%s@%s", deletePreviewActionName, actionVersion),
 		With: map[string]string{
-			"cluster":         fmt.Sprintf("%d", clusterID),
-			"host":            serverURL,
-			"project":         fmt.Sprintf("%d", projectID),
-			"token":           fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
-			"namespace":       fmt.Sprintf("pr-${{ github.event.pull_request.number }}-%s", repoName),
-			"installation_id": fmt.Sprintf("%d", gitInstallationID),
-			"repo_owner":      "${{ github.repository_owner }}",
-			"repo_name":       fmt.Sprintf("%s", repoName),
+			"cluster":        fmt.Sprintf("%d", clusterID),
+			"host":           serverURL,
+			"project":        fmt.Sprintf("%d", projectID),
+			"token":          fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"environment_id": "${{ github.event.inputs.environment_id }}",
+			"repo_owner":     "${{ github.repository_owner }}",
+			"repo_name":      repoName,
+			"pr_number":      "${{ github.event.inputs.pr_number }}",
 		},
 		Timeout: 30,
 	}

+ 12 - 0
internal/kubernetes/agent.go

@@ -642,6 +642,18 @@ func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
 
 // DeleteNamespace deletes the namespace given the name.
 func (a *Agent) DeleteNamespace(name string) error {
+	// check if namespace exists
+	_, err := a.Clientset.CoreV1().Namespaces().Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	// if the namespace is not found, don't return an error.
+	if err != nil && errors.IsNotFound(err) {
+		return nil
+	}
+
 	return a.Clientset.CoreV1().Namespaces().Delete(
 		context.TODO(),
 		name,

+ 14 - 1
internal/models/environment.go

@@ -5,6 +5,8 @@ import (
 	"gorm.io/gorm"
 )
 
+type EnvironmentMode uint
+
 type Environment struct {
 	gorm.Model
 
@@ -15,6 +17,11 @@ type Environment struct {
 	GitRepoName       string
 
 	Name string
+	Mode string
+
+	// WebhookID uniquely identifies the environment when other fields (project, cluster)
+	// aren't present
+	WebhookID string `gorm:"unique"`
 }
 
 func (e *Environment) ToEnvironmentType() *types.Environment {
@@ -25,7 +32,9 @@ func (e *Environment) ToEnvironmentType() *types.Environment {
 		GitInstallationID: e.GitInstallationID,
 		GitRepoOwner:      e.GitRepoOwner,
 		GitRepoName:       e.GitRepoName,
-		Name:              e.Name,
+
+		Name: e.Name,
+		Mode: e.Mode,
 	}
 }
 
@@ -42,6 +51,8 @@ type Deployment struct {
 	RepoName       string
 	RepoOwner      string
 	CommitSHA      string
+	PRBranchFrom   string
+	PRBranchInto   string
 }
 
 func (d *Deployment) ToDeploymentType() *types.Deployment {
@@ -52,6 +63,8 @@ func (d *Deployment) ToDeploymentType() *types.Deployment {
 		RepoName:     d.RepoName,
 		RepoOwner:    d.RepoOwner,
 		CommitSHA:    d.CommitSHA,
+		PRBranchFrom: d.PRBranchFrom,
+		PRBranchInto: d.PRBranchInto,
 	}
 
 	return &types.Deployment{

+ 4 - 0
internal/repository/environment.go

@@ -6,11 +6,15 @@ type EnvironmentRepository interface {
 	CreateEnvironment(env *models.Environment) (*models.Environment, error)
 	ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error)
 	ReadEnvironmentByID(projectID, clusterID, envID uint) (*models.Environment, error)
+	ReadEnvironmentByOwnerRepoName(projectID, clusterID uint, owner, repo string) (*models.Environment, error)
+	ReadEnvironmentByWebhookIDOwnerRepoName(webhookID, owner, repo string) (*models.Environment, error)
 	ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error)
 	DeleteEnvironment(env *models.Environment) (*models.Environment, error)
 	CreateDeployment(deployment *models.Deployment) (*models.Deployment, error)
 	ReadDeployment(environmentID uint, namespace string) (*models.Deployment, error)
+	ReadDeploymentByID(projectID, clusterID, id uint) (*models.Deployment, error)
 	ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error)
+	ReadDeploymentByGitDetails(environmentID uint, owner, repo string, prNumber uint) (*models.Deployment, error)
 	ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error)
 	ListDeployments(environmentID uint, states ...string) ([]*models.Deployment, error)
 	UpdateDeployment(deployment *models.Deployment) (*models.Deployment, error)

+ 53 - 0
internal/repository/gorm/environment.go

@@ -51,6 +51,31 @@ func (repo *EnvironmentRepository) ReadEnvironmentByID(projectID, clusterID, env
 	return env, nil
 }
 
+func (repo *EnvironmentRepository) ReadEnvironmentByOwnerRepoName(
+	projectID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+) (*models.Environment, error) {
+	env := &models.Environment{}
+	if err := repo.db.Order("id desc").Where("project_id = ? AND cluster_id = ? AND git_repo_owner = ? AND git_repo_name = ?",
+		projectID, clusterID, gitRepoOwner, gitRepoName,
+	).First(&env).Error; err != nil {
+		return nil, err
+	}
+	return env, nil
+}
+
+func (repo *EnvironmentRepository) ReadEnvironmentByWebhookIDOwnerRepoName(
+	webhookID, gitRepoOwner, gitRepoName string,
+) (*models.Environment, error) {
+	env := &models.Environment{}
+	if err := repo.db.Order("id desc").Where("webhook_id = ? AND git_repo_owner = ? AND git_repo_name = ?",
+		webhookID, gitRepoOwner, gitRepoName,
+	).First(&env).Error; err != nil {
+		return nil, err
+	}
+	return env, nil
+}
+
 func (repo *EnvironmentRepository) ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error) {
 	envs := make([]*models.Environment, 0)
 
@@ -91,6 +116,19 @@ func (repo *EnvironmentRepository) ReadDeployment(environmentID uint, namespace
 	return depl, nil
 }
 
+func (repo *EnvironmentRepository) ReadDeploymentByID(projectID, clusterID, id uint) (*models.Deployment, error) {
+	depl := &models.Deployment{}
+
+	if err := repo.db.
+		Order("deployments.updated_at desc").
+		Joins("INNER JOIN environments ON environments.id = deployments.environment_id").
+		Where("environments.project_id = ? AND environments.cluster_id = ? AND id = ?", projectID, clusterID, id).First(&depl).Error; err != nil {
+		return nil, err
+	}
+
+	return depl, nil
+}
+
 func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error) {
 	depl := &models.Deployment{}
 
@@ -105,6 +143,21 @@ func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID
 	return depl, nil
 }
 
+func (repo *EnvironmentRepository) ReadDeploymentByGitDetails(
+	environmentID uint, gitRepoOwner, gitRepoName string, prNumber uint,
+) (*models.Deployment, error) {
+	depl := &models.Deployment{}
+
+	if err := repo.db.Order("id asc").
+		Where("environment_id = ? AND repo_owner = ? AND repo_name = ? AND pull_request_id = ?",
+			environmentID, gitRepoOwner, gitRepoName, prNumber).
+		First(&depl).Error; err != nil {
+		return nil, err
+	}
+
+	return depl, nil
+}
+
 func (repo *EnvironmentRepository) ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error) {
 	query := repo.db.
 		Order("deployments.updated_at desc").

+ 19 - 0
internal/repository/test/environment.go

@@ -27,6 +27,17 @@ func (repo *EnvironmentRepository) ReadEnvironmentByID(projectID, clusterID, env
 	panic("unimplemented")
 }
 
+func (repo *EnvironmentRepository) ReadEnvironmentByOwnerRepoName(
+	projectID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+) (*models.Environment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) ReadEnvironmentByWebhookIDOwnerRepoName(webhookID, owner, repoName string) (*models.Environment, error) {
+	panic("unimplemented")
+}
+
 func (repo *EnvironmentRepository) ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error) {
 	panic("unimplemented")
 }
@@ -47,10 +58,18 @@ func (repo *EnvironmentRepository) ReadDeployment(environmentID uint, namespace
 	panic("unimplemented")
 }
 
+func (repo *EnvironmentRepository) ReadDeploymentByID(projectID, clusterID, id uint) (*models.Deployment, error) {
+	panic("unimplemented")
+}
+
 func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error) {
 	panic("unimplemented")
 }
 
+func (repo *EnvironmentRepository) ReadDeploymentByGitDetails(environmentID uint, owner, repoName string, prNumber uint) (*models.Deployment, error) {
+	panic("unimplemented")
+}
+
 func (repo *EnvironmentRepository) ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error) {
 	panic("unimplemented")
 }