Quellcode durchsuchen

POR-1851 delete preview deployment targets on pr close (#3757)

ianedwards vor 2 Jahren
Ursprung
Commit
e4b9575d66
35 geänderte Dateien mit 1268 neuen und 296 gelöschten Zeilen
  1. 69 0
      api/server/handlers/deployment_target/delete.go
  2. 85 0
      api/server/handlers/deployment_target/get.go
  3. 17 0
      api/server/handlers/porter_app/create_app_template.go
  4. 3 3
      api/server/handlers/porter_app/get_app_template.go
  5. 151 0
      api/server/handlers/webhook/app_v2_github.go
  6. 25 0
      api/server/router/base.go
  7. 60 0
      api/server/router/deployment_target.go
  8. 2 2
      api/server/router/porter_app.go
  9. 2 0
      api/types/request.go
  10. 35 59
      dashboard/src/lib/hooks/useGithubWorkflow.ts
  11. 31 21
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  12. 31 17
      dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx
  13. 47 2
      dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx
  14. 2 27
      dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx
  15. 86 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/preview-environments/PreviewEnvironmentSettings.tsx
  16. 86 7
      dashboard/src/main/home/app-dashboard/apps/Apps.tsx
  17. 5 1
      dashboard/src/main/home/app-dashboard/new-app-flow/utils.ts
  18. 6 2
      dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/GHStatusBanner.tsx
  19. 6 4
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppTemplateForm.tsx
  20. 1 21
      dashboard/src/main/home/sidebar/Clusters.tsx
  21. 84 80
      dashboard/src/main/home/sidebar/Sidebar.tsx
  22. 83 46
      dashboard/src/shared/api.tsx
  23. 23 0
      dashboard/src/shared/icons/PullRequest.tsx
  24. 1 1
      go.mod
  25. 2 2
      go.sum
  26. 2 0
      internal/deployment_target/get.go
  27. 1 0
      internal/integrations/ci/actions/actions.go
  28. 5 1
      internal/integrations/ci/actions/stack.go
  29. 166 0
      internal/porter_app/github.go
  30. 15 0
      internal/repository/github_webhook.go
  31. 86 0
      internal/repository/gorm/github_webhook.go
  32. 7 0
      internal/repository/gorm/repository.go
  33. 1 0
      internal/repository/repository.go
  34. 35 0
      internal/repository/test/github_webhook.go
  35. 7 0
      internal/repository/test/repository.go

+ 69 - 0
api/server/handlers/deployment_target/delete.go

@@ -0,0 +1,69 @@
+package deployment_target
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// DeleteDeploymentTargetHandler is the handler for DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets/{deployment_target_id}
+type DeleteDeploymentTargetHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewDeleteDeploymentTargetHandler creates a new DeleteDeploymentTargetHandler
+func NewDeleteDeploymentTargetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteDeploymentTargetHandler {
+	return &DeleteDeploymentTargetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// ServeHTTP deletes the deployment target from the cluster
+func (c *DeleteDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "server-delete-deployment-target-by-id")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	deploymentTargetID, reqErr := requestutils.GetURLParamString(r, types.URLParamDeploymentTargetID)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error parsing deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if deploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "deployment target id cannot be empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	deleteReq := connect.NewRequest(&porterv1.DeleteDeploymentTargetRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: deploymentTargetID,
+	})
+
+	_, err := c.Config().ClusterControlPlaneClient.DeleteDeploymentTarget(ctx, deleteReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error deleting deployment target")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	c.WriteResult(w, r, nil)
+}

+ 85 - 0
api/server/handlers/deployment_target/get.go

@@ -0,0 +1,85 @@
+package deployment_target
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/deployment_target"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// GetDeploymentTargetHandler is the handler for the /deployment-targets/{deployment_target_id} endpoint
+type GetDeploymentTargetHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewGetDeploymentTargetHandler handles GET requests to the endpoint /deployment-targets/{deployment_target_id}
+func NewGetDeploymentTargetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetDeploymentTargetHandler {
+	return &GetDeploymentTargetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// GetDeploymentTargetRequest is the request object for the /deployment-targets/{deployment_target_id} GET endpoint
+type GetDeploymentTargetRequest struct {
+	Preview bool `json:"preview"`
+}
+
+// GetDeploymentTargetResponse is the response object for the /deployment-targets/{deployment_target_id} GET endpoint
+type GetDeploymentTargetResponse struct {
+	DeploymentTarget deployment_target.DeploymentTarget `json:"deployment_target"`
+}
+
+func (c *GetDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-deployment-target")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) {
+		err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled")
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	deploymentTargetID, reqErr := requestutils.GetURLParamString(r, types.URLParamDeploymentTargetID)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error parsing deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if deploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "deployment target id cannot be empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+		ProjectID:          int64(project.ID),
+		ClusterID:          int64(cluster.ID),
+		DeploymentTargetID: deploymentTargetID,
+		CCPClient:          c.Config().ClusterControlPlaneClient,
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	res := &GetDeploymentTargetResponse{
+		DeploymentTarget: deploymentTarget,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 17 - 0
api/server/handlers/porter_app/create_app_template.go

@@ -207,6 +207,23 @@ func (c *CreateAppTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	err = porter_app.CreateAppWebhook(ctx, porter_app.CreateAppWebhookInput{
+		PorterAppName:           appName,
+		ProjectID:               project.ID,
+		ClusterID:               cluster.ID,
+		GithubAppSecret:         c.Config().ServerConf.GithubAppSecret,
+		GithubAppID:             c.Config().ServerConf.GithubAppID,
+		GithubWebhookSecret:     c.Config().ServerConf.GithubIncomingWebhookSecret,
+		ServerURL:               c.Config().ServerConf.ServerURL,
+		PorterAppRepository:     c.Repo().PorterApp(),
+		GithubWebhookRepository: c.Repo().GithubWebhook(),
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to set repo webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	res := &CreateAppTemplateResponse{
 		AppTemplateID: updatedAppTemplate.ID.String(),
 	}

+ 3 - 3
api/server/handlers/porter_app/get_app_template.go

@@ -14,12 +14,12 @@ import (
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
-// GetAppTemplateHandler is the handler for the /app-template endpoint
+// GetAppTemplateHandler is the handler for the /apps/{porter_app_name}/templates endpoint
 type GetAppTemplateHandler struct {
 	handlers.PorterHandlerReadWriter
 }
 
-// NewGetAppTemplateHandler handles GET requests to the endpoint /apps/{porter_app_name}/app-template
+// NewGetAppTemplateHandler handles GET requests to the endpoint /apps/{porter_app_name}/templates
 func NewGetAppTemplateHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
@@ -30,7 +30,7 @@ func NewGetAppTemplateHandler(
 	}
 }
 
-// GetAppTemplateResponse is the response object for the /apps/{porter_app_name}/app-template GET endpoint
+// GetAppTemplateResponse is the response object for the /apps/{porter_app_name}/templates GET endpoint
 type GetAppTemplateResponse struct {
 	TemplateB64AppProto string `json:"template_b64_app_proto"`
 }

+ 151 - 0
api/server/handlers/webhook/app_v2_github.go

@@ -0,0 +1,151 @@
+package webhook
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/google/go-github/v41/github"
+	"github.com/google/uuid"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// GithubPRStatus_Closed is the status for a closed PR (closed, merged)
+const GithubPRStatus_Closed = "closed"
+
+// GithubWebhookHandler handles webhooks sent to /api/webhooks/github/{project_id}/{cluster_id}/{porter_app_name}
+type GithubWebhookHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewGithubWebhookHandler returns a GithubWebhookHandler
+func NewGithubWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubWebhookHandler {
+	return &GithubWebhookHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// ServeHTTP handles the webhook and deletes the deployment target if a PR has been closed
+func (c *GithubWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-github-webhook")
+	defer span.End()
+
+	payload, err := github.ValidatePayload(r, []byte(c.Config().ServerConf.GithubIncomingWebhookSecret))
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "could not validate payload")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	event, err := github.ParseWebHook(github.WebHookType(r), payload)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "could not parse webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	webhookID, reqErr := requestutils.GetURLParamString(r, types.URLParamWebhookID)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "webhook-id", Value: webhookID})
+
+	webhookUUID, err := uuid.Parse(webhookID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing webhook id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if webhookUUID == uuid.Nil {
+		err := telemetry.Error(ctx, span, err, "webhook id is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	webhook, err := c.Repo().GithubWebhook().Get(ctx, webhookUUID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting github webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if webhook.ID == uuid.Nil {
+		err := telemetry.Error(ctx, span, err, "github webhook id is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "porter-app-id", Value: webhook.PorterAppID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: webhook.ClusterID},
+		telemetry.AttributeKV{Key: "project-id", Value: webhook.ProjectID},
+	)
+
+	switch event := event.(type) {
+	case *github.PullRequestEvent:
+		if event.GetAction() != GithubPRStatus_Closed {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "event-processed", Value: false})
+			c.WriteResult(w, r, nil)
+			return
+		}
+
+		branch := event.GetPullRequest().GetHead().GetRef()
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "event-branch", Value: branch})
+
+		deploymentTarget, err := c.Repo().DeploymentTarget().DeploymentTargetBySelectorAndSelectorType(
+			uint(webhook.ProjectID),
+			uint(webhook.ClusterID),
+			branch,
+			string(models.DeploymentTargetSelectorType_Namespace),
+		)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting deployment target")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		if deploymentTarget.ID == uuid.Nil || !deploymentTarget.Preview {
+			c.WriteResult(w, r, nil)
+			return
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTarget.ID.String()})
+
+		if deploymentTarget.ClusterID != webhook.ClusterID {
+			err := telemetry.Error(ctx, span, err, "deployment target cluster id does not match")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+
+		deleteTargetReq := connect.NewRequest(&porterv1.DeleteDeploymentTargetRequest{
+			ProjectId:          int64(webhook.ProjectID),
+			DeploymentTargetId: deploymentTarget.ID.String(),
+		})
+
+		_, err = c.Config().ClusterControlPlaneClient.DeleteDeploymentTarget(ctx, deleteTargetReq)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error deleting deployment target")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "event-processed", Value: true})
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pr-id", Value: event.GetPullRequest().GetID()})
+	}
+
+	c.WriteResult(w, r, nil)
+}

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

@@ -540,6 +540,31 @@ func GetBaseRoutes(
 			Handler:  githubIncomingWebhookHandler,
 			Router:   r,
 		})
+
+		// POST /api/webhooks/github/{webhook_id} -> webhook.NewGithubWebhookHandler
+		githubWebhookEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: fmt.Sprintf("/webhooks/github/{%s}", types.URLParamWebhookID),
+				},
+				Scopes: []types.PermissionScope{},
+			},
+		)
+
+		githubWebhookHandler := webhook.NewGithubWebhookHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: githubWebhookEndpoint,
+			Handler:  githubWebhookHandler,
+			Router:   r,
+		})
 	}
 
 	return routes

+ 60 - 0
api/server/router/deployment_target.go

@@ -1,6 +1,8 @@
 package router
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -114,5 +116,63 @@ func getDeploymentTargetRoutes(
 		Router:   r,
 	})
 
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets/{deployment_target_id} -> deployment_target.DeleteDeploymentTargetHandler
+	deleteDeploymentTargetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamDeploymentTargetID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	deleteDeploymentTargetHandler := deployment_target.NewDeleteDeploymentTargetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteDeploymentTargetEndpoint,
+		Handler:  deleteDeploymentTargetHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets/{deployment_target_id} -> deployment_target.GetDeploymentTargetHandler
+	getDeploymentTargetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamDeploymentTargetID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getDeploymentTargetHandler := deployment_target.NewGetDeploymentTargetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getDeploymentTargetEndpoint,
+		Handler:  getDeploymentTargetHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

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

@@ -1241,7 +1241,7 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/app-templates -> porter_app.NewGetAppTemplateHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/templates -> porter_app.NewGetAppTemplateHandler
 	getAppTemplateEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -1270,7 +1270,7 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/app-templates -> porter_app.NewCreateAppTemplateHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/templates -> porter_app.NewCreateAppTemplateHandler
 	createAppTemplateEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,

+ 2 - 0
api/types/request.go

@@ -53,6 +53,8 @@ const (
 	URLParamPorterAppName         URLParam = "porter_app_name"
 	URLParamPorterAppEventID      URLParam = "porter_app_event_id"
 	URLParamAppRevisionID         URLParam = "app_revision_id"
+	URLParamDeploymentTargetID    URLParam = "deployment_target_id"
+	URLParamWebhookID             URLParam = "webhook_id"
 )
 
 type Path struct {

+ 35 - 59
dashboard/src/lib/hooks/useGithubWorkflow.ts

@@ -6,10 +6,15 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import { z } from "zod";
 
-export const useGithubWorkflow = (
-  porterApp: PorterAppRecord,
-  previouslyBuilt: boolean
-) => {
+export const useGithubWorkflow = ({
+  porterApp,
+  fileNames,
+  previouslyBuilt = false,
+}: {
+  porterApp: PorterAppRecord;
+  fileNames: string[];
+  previouslyBuilt?: boolean;
+}) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [githubWorkflowFilename, setGithubWorkflowName] = useState<string>("");
   const [userHasGithubAccess, setUserHasGithubAccess] = useState<boolean>(true);
@@ -83,71 +88,42 @@ export const useGithubWorkflow = (
     !!currentCluster &&
     githubWorkflowFilename === "";
 
-  const [
-    {
-      data: applicationWorkflowCheck,
-      isLoading: isLoadingApplicationWorkflow,
-    },
-    { data: defaultWorkflowCheck, isLoading: isLoadingDefaultWorkflow },
-  ] = useQueries({
-    queries: [
-      {
-        queryKey: [
-          `checkForApplicationWorkflow_porter_stack_${porterApp.name}`,
-          currentProject?.id,
-          currentCluster?.id,
-          githubWorkflowFilename,
-          previouslyBuilt,
-        ],
-        queryFn: () =>
-          fetchGithubWorkflow(`porter_stack_${porterApp.name}.yml`),
-        enabled,
-        refetchInterval: 5000,
-        retry: (_failureCount: number, error: unknown) => {
-          if (axios.isAxiosError(error) && error.response?.status === 403) {
-            setUserHasGithubAccess(false);
-            return false;
-          }
-
-          return true;
-        },
-        refetchOnWindowFocus: false,
-      },
-      {
-        queryKey: [
-          `checkForApplicationWorkflow_porter`,
-          currentProject?.id,
-          currentCluster?.id,
-          githubWorkflowFilename,
-          previouslyBuilt,
-        ],
-        queryFn: () => fetchGithubWorkflow("porter.yml"),
-        enabled,
-        refetchInterval: 5000,
-        retry: (_failureCount: number, error: unknown) => {
-          if (axios.isAxiosError(error) && error.response?.status === 403) {
-            setUserHasGithubAccess(false);
-            return false;
-          }
+  const results = useQueries({
+    queries: fileNames.map((fn) => ({
+      queryKey: [
+        `checkForApplicationWorkflow_${fn}`,
+        currentProject?.id,
+        currentCluster?.id,
+        fn,
+        previouslyBuilt,
+      ],
+      queryFn: () => fetchGithubWorkflow(fn),
+      enabled,
+      refetchInterval: 5000,
+      retry: (_failureCount: number, error: unknown) => {
+        if (axios.isAxiosError(error) && error.response?.status === 403) {
+          setUserHasGithubAccess(false);
+          return false;
+        }
 
-          return true;
-        },
-        refetchOnWindowFocus: false,
+        return true;
       },
-    ],
+      refetchOnWindowFocus: false,
+    })),
   });
 
   useEffect(() => {
-    if (!!applicationWorkflowCheck) {
+    const applicationWorkflowCheck = results
+      .map(({ data }) => data)
+      .find((d) => !!d);
+    if (applicationWorkflowCheck) {
       setGithubWorkflowName(applicationWorkflowCheck);
-    } else if (!!defaultWorkflowCheck) {
-      setGithubWorkflowName(defaultWorkflowCheck);
     }
-  }, [applicationWorkflowCheck, defaultWorkflowCheck]);
+  }, [results]);
 
   return {
     githubWorkflowFilename,
-    isLoading: isLoadingApplicationWorkflow || isLoadingDefaultWorkflow,
+    isLoading: results.some((r) => r.isLoading),
     userHasGithubAccess,
   };
 };

+ 31 - 21
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -385,6 +385,36 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     return "";
   }, [isSubmitting, errorMessagesDeep]);
 
+  const tabs = useMemo(() => {
+    const base = [
+      { label: "Activity", value: "activity" },
+      { label: "Overview", value: "overview" },
+      { label: "Logs", value: "logs" },
+      { label: "Metrics", value: "metrics" },
+      { label: "Environment", value: "environment" },
+    ];
+
+    if (deploymentTarget.preview) {
+      return base;
+    }
+
+    if (latestProto.build) {
+      base.push({
+        label: "Build Settings",
+        value: "build-settings",
+      });
+      base.push({ label: "Settings", value: "settings" });
+      return base;
+    }
+
+    base.push({
+      label: "Image Settings",
+      value: "image-settings",
+    });
+    base.push({ label: "Settings", value: "settings" });
+    return base;
+  }, [deploymentTarget.preview, latestProto.build]);
+
   useEffect(() => {
     const newProto = previewRevision
       ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
@@ -473,27 +503,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         </AnimateHeight>
         <TabSelector
           noBuffer
-          options={[
-            { label: "Activity", value: "activity" },
-            { label: "Overview", value: "overview" },
-            { label: "Logs", value: "logs" },
-            { label: "Metrics", value: "metrics" },
-            { label: "Environment", value: "environment" },
-            ...(latestProto.build
-              ? [
-                  {
-                    label: "Build Settings",
-                    value: "build-settings",
-                  },
-                ]
-              : [
-                  {
-                    label: "Image Settings",
-                    value: "image-settings",
-                  },
-                ]),
-            { label: "Settings", value: "settings" },
-          ]}
+          options={tabs}
           currentTab={currentTab}
           setCurrentTab={(tab) => {
             if (deploymentTarget.preview) {

+ 31 - 17
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -15,6 +15,7 @@ import styled from "styled-components";
 import { useLatestRevision } from "./LatestRevisionContext";
 import { prefixSubdomain } from "lib/porter-apps/services";
 import { readableDate } from "shared/string_utils";
+import PullRequestIcon from "shared/icons/PullRequest";
 
 // Buildpack icons
 const icons = [
@@ -26,7 +27,12 @@ const icons = [
 ];
 
 const AppHeader: React.FC = () => {
-  const { latestProto, porterApp, latestRevision } = useLatestRevision();
+  const {
+    latestProto,
+    porterApp,
+    latestRevision,
+    deploymentTarget,
+  } = useLatestRevision();
 
   const gitData = useMemo(() => {
     if (
@@ -109,11 +115,20 @@ const AppHeader: React.FC = () => {
               </A>
             </Container>
             <Spacer inline x={1} />
-            <TagWrapper>
-              Branch
-              <BranchTag>
-                <BranchIcon src={pr_icon} />
-                {gitData.branch}
+            <TagWrapper preview={deploymentTarget.preview}>
+              {deploymentTarget.preview ? "Preview" : "Branch"}
+              <BranchTag preview={deploymentTarget.preview}>
+                <PullRequestIcon
+                  styles={{
+                    height: "14px",
+                    opacity: "0.65",
+                    marginRight: "5px",
+                    fill: deploymentTarget.preview ? "" : "#fff",
+                  }}
+                />
+                {deploymentTarget.preview
+                  ? deploymentTarget.namespace
+                  : gitData.branch}
               </BranchTag>
             </TagWrapper>
           </>
@@ -164,27 +179,26 @@ const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
   opacity: ${(props) => props.opacity || 1};
   margin-right: 10px;
 `;
-const BranchIcon = styled.img`
-  height: 14px;
-  opacity: 0.65;
-  margin-right: 5px;
-`;
-const TagWrapper = styled.div`
+
+const TagWrapper = styled.div<{ preview?: boolean }>`
   height: 20px;
   font-size: 12px;
   display: flex;
   align-items: center;
   justify-content: center;
-  color: #ffffff44;
-  border: 1px solid #ffffff44;
+  background: ${(props) => (props.preview ? "#fefce8" : "")};
+  color: ${(props) => (props.preview ? "#ca8a04" : "#ffffff44")};
+  border: 1px solid ${(props) => (props.preview ? "#ca8a04" : "#ffffff44")};
   border-radius: 3px;
   padding-left: 6px;
 `;
-const BranchTag = styled.div`
+
+const BranchTag = styled.div<{ preview?: boolean }>`
   height: 20px;
   margin-left: 6px;
-  color: #aaaabb;
-  background: #ffffff22;
+  color: ${(props) => (props.preview ? "#ca8a04" : "#aaaabb")};
+  background: ${(props) => (props.preview ? "#fefce8" : "#ffffff22")};
+  border: 1px solid ${(props) => (props.preview ? "#ca8a04" : "#ffffff44")};
   border-radius: 3px;
   font-size: 12px;
   display: flex;

+ 47 - 2
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -33,7 +33,7 @@ export const LatestRevisionContext = createContext<{
   servicesFromYaml: DetectedServices | null;
   clusterId: number;
   projectId: number;
-  deploymentTarget: DeploymentTarget;
+  deploymentTarget: DeploymentTarget & { namespace: string };
   previewRevision: AppRevision | null;
   attachedEnvGroups: PopulatedEnvGroup[];
   appEnv?: PopulatedEnvGroup;
@@ -132,6 +132,46 @@ export const LatestRevisionProvider = ({
     }
   );
 
+  const { data, status: deploymentTargetStatus } = useQuery(
+    [
+      "getDeploymentTarget",
+      {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+        deployment_target_id: currentDeploymentTarget?.id,
+      },
+    ],
+    async () => {
+      if (!currentCluster || !currentProject || !currentDeploymentTarget) {
+        return;
+      }
+      const res = await api.getDeploymentTarget(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          deployment_target_id: currentDeploymentTarget.id,
+        }
+      );
+
+      const { deployment_target } = await z
+        .object({
+          deployment_target: z.object({
+            cluster_id: z.number(),
+            namespace: z.string(),
+            preview: z.boolean(),
+          }),
+        })
+        .parseAsync(res.data);
+
+      return deployment_target;
+    },
+    {
+      enabled: !!currentCluster && !!currentProject,
+    }
+  );
+
   const revisionId = previewRevision?.id ?? latestRevision?.id;
   const { data: { attachedEnvGroups = [], appEnv } = {} } = useQuery(
     ["getAttachedEnvGroups", appName, revisionId],
@@ -221,6 +261,7 @@ export const LatestRevisionProvider = ({
   if (
     status === "loading" ||
     porterAppStatus === "loading" ||
+    deploymentTargetStatus === "loading" ||
     !appParamsExist ||
     porterYamlLoading
   ) {
@@ -230,6 +271,7 @@ export const LatestRevisionProvider = ({
   if (
     status === "error" ||
     porterAppStatus === "error" ||
+    deploymentTargetStatus === "error" ||
     !latestRevision ||
     !latestProto ||
     !porterApp
@@ -256,7 +298,10 @@ export const LatestRevisionProvider = ({
         porterApp,
         clusterId: currentCluster.id,
         projectId: currentProject.id,
-        deploymentTarget: currentDeploymentTarget,
+        deploymentTarget: {
+          ...currentDeploymentTarget,
+          namespace: data?.namespace ?? "",
+        },
         servicesFromYaml: detectedServices,
         attachedEnvGroups,
         appEnv,

+ 2 - 27
dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx

@@ -11,8 +11,8 @@ import { useLatestRevision } from "../LatestRevisionContext";
 import api from "shared/api";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { useQueryClient } from "@tanstack/react-query";
-import { Link } from "react-router-dom";
 import { Context } from "shared/Context";
+import PreviewEnvironmentSettings from "./preview-environments/PreviewEnvironmentSettings";
 
 const Settings: React.FC = () => {
   const { currentProject } = useContext(Context);
@@ -139,32 +139,7 @@ const Settings: React.FC = () => {
 
   return (
     <StyledSettingsTab>
-      {currentProject?.preview_envs_enabled && (
-        <>
-          <Text size={16}>
-            Enable preview environments for "{porterApp.name}"
-          </Text>
-          <Spacer y={0.5} />
-          <Text color="helper">
-            Setup your application to automatically create preview environments
-            for each pull request.
-          </Text>
-          <Spacer y={0.5} />
-          <Link
-            to={`/preview-environments/configure?app_name=${porterApp.name}`}
-          >
-            <Button
-              type="button"
-              onClick={() => {
-                setIsDeleteModalOpen(true);
-              }}
-            >
-              Enable
-            </Button>
-          </Link>
-          <Spacer y={1} />
-        </>
-      )}
+      {currentProject?.preview_envs_enabled && <PreviewEnvironmentSettings />}
       <Text size={16}>Delete "{porterApp.name}"</Text>
       <Spacer y={0.5} />
       <Text color="helper">

+ 86 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/preview-environments/PreviewEnvironmentSettings.tsx

@@ -0,0 +1,86 @@
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import React from "react";
+import { Link } from "react-router-dom";
+import { useLatestRevision } from "../../LatestRevisionContext";
+import { useQuery } from "@tanstack/react-query";
+import api from "shared/api";
+import { useGithubWorkflow } from "lib/hooks/useGithubWorkflow";
+import styled from "styled-components";
+import healthy from "assets/status-healthy.png";
+import Icon from "components/porter/Icon";
+
+type Props = {};
+
+const PreviewEnvironmentSettings: React.FC<Props> = ({}) => {
+  const { porterApp, clusterId, projectId } = useLatestRevision();
+
+  const { data: templateExists, status } = useQuery(
+    ["getAppTemplate", projectId, clusterId, porterApp.name],
+    async () => {
+      try {
+        await api.getAppTemplate(
+          "<token>",
+          {},
+          {
+            project_id: projectId,
+            cluster_id: clusterId,
+            porter_app_name: porterApp.name,
+          }
+        );
+
+        return true;
+      } catch (err) {
+        return false;
+      }
+    }
+  );
+
+  const { githubWorkflowFilename, isLoading } = useGithubWorkflow({
+    porterApp,
+    fileNames: [`porter_preview_${porterApp.name}.yml`],
+  });
+
+  if (status === "loading" || isLoading) {
+    return null;
+  }
+
+  return (
+    <>
+      {templateExists && githubWorkflowFilename ? (
+        <EnabledContainer>
+          <Text size={16}>Preview Environments Enabled</Text>
+          <Icon src={healthy} />
+        </EnabledContainer>
+      ) : (
+        <Text size={16}>
+          Enable preview environments for "{porterApp.name}"
+        </Text>
+      )}
+      <Spacer y={0.5} />
+      <Text color="helper">
+        {templateExists && githubWorkflowFilename
+          ? "Preview environments are enabled for this app"
+          : "Setup your app to automatically create preview environments for each pull request."}
+      </Text>
+      <Spacer y={0.5} />
+      <Link to={`/preview-environments/configure?app_name=${porterApp.name}`}>
+        <Button type="button">
+          {templateExists && githubWorkflowFilename
+            ? "Update Settings"
+            : "Enable"}
+        </Button>
+      </Link>
+      <Spacer y={1} />
+    </>
+  );
+};
+
+export default PreviewEnvironmentSettings;
+
+const EnabledContainer = styled.div`
+  display: flex;
+  align-items: center;
+  column-gap: 0.75rem;
+`;

+ 86 - 7
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -7,6 +7,7 @@ import grid from "assets/grid.png";
 import list from "assets/list.png";
 import letter from "assets/vector.svg";
 import calendar from "assets/calendar-number.svg";
+import pull_request from "assets/pull_request_icon.svg";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
@@ -83,12 +84,58 @@ const Apps: React.FC<Props> = ({ }) => {
     }
   );
 
+  const { data, status: deploymentTargetStatus } = useQuery(
+    [
+      "getDeploymentTarget",
+      {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+        deployment_target_id: currentDeploymentTarget?.id,
+      },
+    ],
+    async () => {
+      if (!currentCluster || !currentProject || !currentDeploymentTarget) {
+        return;
+      }
+      const res = await api.getDeploymentTarget(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          deployment_target_id: currentDeploymentTarget.id,
+        }
+      );
+
+      const { deployment_target } = await z
+        .object({
+          deployment_target: z.object({
+            cluster_id: z.number(),
+            namespace: z.string(),
+            preview: z.boolean(),
+          }),
+        })
+        .parseAsync(res.data);
+
+      return deployment_target;
+    },
+    {
+      enabled:
+        !!currentCluster &&
+        !!currentProject &&
+        currentDeploymentTarget?.preview,
+    }
+  );
+
   const renderContents = () => {
     if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
       return <ClusterProvisioningPlaceholder />;
     }
 
-    if (status === "loading") {
+    if (
+      status === "loading" ||
+      (currentDeploymentTarget?.preview && deploymentTargetStatus === "loading")
+    ) {
       return <Loading offset="-150px" />;
     }
 
@@ -121,6 +168,26 @@ const Apps: React.FC<Props> = ({ }) => {
 
     return (
       <>
+        {currentDeploymentTarget?.preview && (
+          <DashboardHeader
+            image={pull_request}
+            title={
+              <div
+                style={{
+                  display: "flex",
+                  columnGap: "0.75rem",
+                  alignItems: "center",
+                }}
+              >
+                <div>{data?.namespace ?? "Preview Apps"}</div>
+                <Badge>Preview</Badge>
+              </div>
+            }
+            description={"Apps deployed to this preview environment"}
+            disableLineBreak
+            capitalize={false}
+          />
+        )}
         <Container row spaced>
           <SearchBar
             value={searchValue}
@@ -188,12 +255,14 @@ const Apps: React.FC<Props> = ({ }) => {
 
   return (
     <StyledAppDashboard>
-      <DashboardHeader
-        image={web}
-        title="Applications"
-        description="Web services, workers, and jobs for this project."
-        disableLineBreak
-      />
+      {!currentDeploymentTarget?.preview && (
+        <DashboardHeader
+          image={web}
+          title="Applications"
+          description="Web services, workers, and jobs for this project."
+          disableLineBreak
+        />
+      )}
       {renderContents()}
       <Spacer y={5} />
     </StyledAppDashboard>
@@ -228,3 +297,13 @@ const CentralContainer = styled.div`
   justify-content: left;
   align-items: left;
 `;
+
+const Badge = styled.div`
+  border: 1px solid #ca8a04;
+  background-color: #fefce8;
+  color: #ca8a04;
+  padding: 0.15rem 0.3rem;
+  text-align: center;
+  border-radius: 3px;
+  font-size: 12px;
+`;

+ 5 - 1
dashboard/src/main/home/app-dashboard/new-app-flow/utils.ts

@@ -57,12 +57,16 @@ export const getPreviewGithubAction = (
   projectID: number,
   clusterId: number,
   stackName: string,
+  branchName: string,
   porterYamlPath: string = "porter.yaml"
 ) => {
   return `on:
   pull_request:
+    paths:
+    - *
+    - '!./github/workflows/porter-**'
     branches:
-    - '!porter-**'
+    - ${branchName}
     types:
     - opened
     - synchronize

+ 6 - 2
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/GHStatusBanner.tsx

@@ -36,7 +36,11 @@ const GHStatusBanner: React.FC<GHStatusBannerProps> = ({ revisions }) => {
     githubWorkflowFilename,
     userHasGithubAccess,
     isLoading,
-  } = useGithubWorkflow(porterApp, previouslyBuilt);
+  } = useGithubWorkflow({
+    porterApp,
+    previouslyBuilt,
+    fileNames: ["porter.yml", `porter_stack_${porterApp.name}.yml`],
+  });
 
   if (previouslyBuilt) {
     return null;
@@ -87,7 +91,7 @@ const GHStatusBanner: React.FC<GHStatusBannerProps> = ({ revisions }) => {
     );
   }
 
-  return null
+  return null;
 };
 
 export default GHStatusBanner;

+ 6 - 4
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppTemplateForm.tsx

@@ -30,6 +30,7 @@ import { useAppValidation } from "lib/hooks/useAppValidation";
 import { PorterApp } from "@porter-dev/api-contracts";
 import axios from "axios";
 import GithubActionModal from "main/home/app-dashboard/new-app-flow/GithubActionModal";
+import { useClusterResourceLimits } from "lib/hooks/useClusterResourceLimits";
 
 const AppTemplateForm: React.FC = () => {
   const [step, setStep] = useState(0);
@@ -55,10 +56,7 @@ const AppTemplateForm: React.FC = () => {
     projectId,
     deploymentTarget,
   } = useLatestRevision();
-  const { validateApp } = useAppValidation({
-    deploymentTargetID: deploymentTarget.id,
-    creating: true,
-  });
+  const { maxCPU, maxRAM } = useClusterResourceLimits({ projectId, clusterId });
 
   const { data: baseEnvGroups = [] } = useQuery(
     ["getAllEnvGroups", projectId, clusterId],
@@ -240,6 +238,8 @@ const AppTemplateForm: React.FC = () => {
               <ServiceList
                 addNewText={"Add a new service"}
                 fieldArrayName={"app.services"}
+                maxCPU={maxCPU}
+                maxRAM={maxRAM}
               />
             </>,
             <>
@@ -272,6 +272,8 @@ const AppTemplateForm: React.FC = () => {
                 }
                 isPredeploy
                 fieldArrayName={"app.predeploy"}
+                maxCPU={maxCPU}
+                maxRAM={maxRAM}
               />
             </>,
             <Button type="submit" loadingText={"Deploying..."} width={"150px"}>

+ 1 - 21
dashboard/src/main/home/sidebar/Clusters.tsx

@@ -148,27 +148,7 @@ class Clusters extends Component<PropsType, StateType> {
     let { currentCluster, setCurrentCluster, currentProject } = this.context;
 
     if (currentProject?.simplified_view_enabled) {
-      const cluster = clusters[0];
-      return currentProject?.preview_envs_enabled ? (
-        <NavButton
-          path="/preview-environments"
-          targetClusterName={cluster?.name}
-          active={
-            currentCluster?.id === cluster?.id &&
-            window.location.pathname.startsWith("/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>
-          Preview envs
-        </NavButton>
-      ) : null;
+      return null;
     }
 
     if (

+ 84 - 80
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -8,9 +8,10 @@ import settings from "assets/settings.svg";
 import applications from "assets/applications.svg";
 import infra from "assets/cluster.svg";
 import sliders from "assets/env-groups.svg";
-import addOns from "assets/add-ons.svg"
+import addOns from "assets/add-ons.svg";
 import database from "assets/database.svg";
 import collapseSidebar from "assets/collapse-sidebar.svg";
+import pr_icon from "assets/pull_request_icon.svg";
 
 import { Context } from "shared/Context";
 
@@ -113,11 +114,16 @@ class Sidebar extends Component<PropsType, StateType> {
 
   renderProjectContents = () => {
     let { currentView } = this.props;
-    let { currentProject, user, currentCluster, hasFinishedOnboarding } = this.context;
+    let {
+      currentProject,
+      user,
+      currentCluster,
+      hasFinishedOnboarding,
+    } = this.context;
     if (!currentProject?.simplified_view_enabled) {
       return (
         <ScrollWrapper>
-          <Spacer y={.5} />
+          <Spacer y={0.5} />
           <SidebarLabel>Home</SidebarLabel>
           <NavButton path={"/dashboard"}>
             <Img src={category} />
@@ -142,30 +148,28 @@ class Sidebar extends Component<PropsType, StateType> {
             "update",
             "delete",
           ]) && (
-              <NavButton path={"/integrations"}>
-                <Img src={integrations} />
-                Integrations
-              </NavButton>
-            )}
+            <NavButton path={"/integrations"}>
+              <Img src={integrations} />
+              Integrations
+            </NavButton>
+          )}
           {this.props.isAuthorized("settings", "", [
             "get",
             "update",
             "delete",
           ]) && (
-              <NavButton path={"/project-settings"}>
-                <Img src={settings} />
-                Project settings
-              </NavButton>
-            )}
+            <NavButton path={"/project-settings"}>
+              <Img src={settings} />
+              Project settings
+            </NavButton>
+          )}
 
           <br />
 
           <SidebarLabel>
-            {currentProject?.capi_provisioner_enabled ? (
-              "Your team"
-            ) : (
-              "Clusters"
-            )}
+            {currentProject?.capi_provisioner_enabled
+              ? "Your team"
+              : "Clusters"}
           </SidebarLabel>
           <Clusters
             setWelcome={this.props.setWelcome}
@@ -185,30 +189,28 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
-                <NavButton path={"/project-settings"}
-                >
-                  <Img src={settings} />
-                  Project settings
-                </NavButton>
-              )}
+              <NavButton path={"/project-settings"}>
+                <Img src={settings} />
+                Project settings
+              </NavButton>
+            )}
             {this.props.isAuthorized("integrations", "", [
               "get",
               "create",
               "update",
               "delete",
             ]) && (
-                <NavButton path={"/integrations"}
-                >
-                  <Img src={integrations} />
-                  Integrations
-                </NavButton>
-              )}
-            {currentCluster &&
+              <NavButton path={"/integrations"}>
+                <Img src={integrations} />
+                Integrations
+              </NavButton>
+            )}
+            {currentCluster && (
               <>
-                <Spacer y={.5} />
+                <Spacer y={0.5} />
                 <ClusterListContainer />
               </>
-            }
+            )}
             <NavButton
               path="/apps"
               active={window.location.pathname.startsWith("/apps")}
@@ -225,9 +227,7 @@ class Sidebar extends Component<PropsType, StateType> {
             </NavButton>
             <NavButton
               path="/env-groups"
-              active={
-                window.location.pathname.startsWith("/env-groups")
-              }
+              active={window.location.pathname.startsWith("/env-groups")}
             >
               <Img src={sliders} />
               Env groups
@@ -237,16 +237,23 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
-                <NavButton
-                  path={"/cluster-dashboard"}
-                  active={
-                    window.location.pathname.startsWith("/cluster-dashboard")
-                  }
-                >
-                  <Img src={settings} />
-                  Infrastructure
-                </NavButton>
-              )}
+              <NavButton
+                path={"/cluster-dashboard"}
+                active={window.location.pathname.startsWith(
+                  "/cluster-dashboard"
+                )}
+              >
+                <Img src={settings} />
+                Infrastructure
+              </NavButton>
+            )}
+
+            {currentProject.preview_envs_enabled && (
+              <NavButton path="/preview-environments">
+                <Img src={pr_icon} />
+                Preview environments
+              </NavButton>
+            )}
 
             {/* Hacky workaround for setting currentCluster with legacy method */}
             <Clusters
@@ -259,10 +266,9 @@ class Sidebar extends Component<PropsType, StateType> {
           </ScrollWrapper>
         );
       } else {
-
         return (
           <ScrollWrapper>
-            <Spacer y={.4} />
+            <Spacer y={0.4} />
             <NavButton
               path="/apps"
               active={window.location.pathname.startsWith("/apps")}
@@ -288,9 +294,7 @@ class Sidebar extends Component<PropsType, StateType> {
             </NavButton>
             <NavButton
               path="/env-groups"
-              active={
-                window.location.pathname.startsWith("/env-groups")
-              }
+              active={window.location.pathname.startsWith("/env-groups")}
             >
               <Img src={sliders} />
               Env groups
@@ -300,17 +304,23 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
+              <NavButton
+                path={"/cluster-dashboard"}
+                active={window.location.pathname.startsWith(
+                  "/cluster-dashboard"
+                )}
+              >
+                <Img src={infra} />
+                Infrastructure
+              </NavButton>
+            )}
 
-                <NavButton
-                  path={"/cluster-dashboard"}
-                  active={
-                    window.location.pathname.startsWith("/cluster-dashboard")
-                  }
-                >
-                  <Img src={infra} />
-                  Infrastructure
-                </NavButton>
-              )}
+            {currentProject.preview_envs_enabled && (
+              <NavButton path="/preview-environments">
+                <Img src={pr_icon} />
+                Preview environments
+              </NavButton>
+            )}
 
             {this.props.isAuthorized("integrations", "", [
               "get",
@@ -318,25 +328,22 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
-                <NavButton path={"/integrations"}
-                >
-                  <Img src={integrations} />
-                  Integrations
-                </NavButton>
-              )}
+              <NavButton path={"/integrations"}>
+                <Img src={integrations} />
+                Integrations
+              </NavButton>
+            )}
 
             {this.props.isAuthorized("settings", "", [
               "get",
               "update",
               "delete",
             ]) && (
-                <NavButton path={"/project-settings"}
-                >
-                  <Img src={settings} />
-                  Project settings
-                </NavButton>
-
-              )}
+              <NavButton path={"/project-settings"}>
+                <Img src={settings} />
+                Project settings
+              </NavButton>
+            )}
 
             {/* Hacky workaround for setting currentCluster with legacy method */}
             <Clusters
@@ -348,14 +355,11 @@ class Sidebar extends Component<PropsType, StateType> {
             />
           </ScrollWrapper>
         );
-
       }
     }
 
     // Render placeholder if no project exists
     return <ProjectPlaceholder>No projects found.</ProjectPlaceholder>;
-
-
   };
 
   // SidebarBg is separate to cover retracted drawer
@@ -455,14 +459,14 @@ const SidebarBg = styled.div`
   top: 0;
   left: 0;
   width: 100%;
-  background-color: ${props => props.theme.bg};
+  background-color: ${(props) => props.theme.bg};
   height: 100%;
   z-index: -1;
   border-right: 1px solid #383a3f;
 `;
 
 const SidebarLabel = styled.div`
-  color: ${props => props.theme.text.primary};
+  color: ${(props) => props.theme.text.primary};
   padding: 5px 23px;
   margin-bottom: 5px;
   font-size: 13px;
@@ -492,7 +496,7 @@ const PullTab = styled.div`
   }
 
   :hover {
-    border: 1px solid ${props => props.theme.text.primary};
+    border: 1px solid ${(props) => props.theme.text.primary};
     border-left: none;
     > img {
       opacity: 0.9;

+ 83 - 46
dashboard/src/shared/api.tsx

@@ -352,8 +352,9 @@ const getFeedEvents = baseApi<
   }
 >("GET", (pathParams) => {
   let { project_id, cluster_id, stack_name, page } = pathParams;
-  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${page || 1
-    }`;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${
+    page || 1
+  }`;
 });
 
 const createEnvironment = baseApi<
@@ -778,9 +779,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 detectGitlabBuildpack = baseApi<
@@ -811,9 +814,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<
@@ -829,9 +834,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 getPorterYamlContents = baseApi<
@@ -847,9 +854,11 @@ const getPorterYamlContents = 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)}/porteryaml`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
 });
 
 const parsePorterYaml = baseApi<
@@ -886,9 +895,11 @@ const getBranchHead = 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)}/head`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/head`;
 });
 
 const validatePorterApp = baseApi<
@@ -914,21 +925,21 @@ const validatePorterApp = baseApi<
 
 const createApp = baseApi<
   | {
-    name: string;
-    type: "github";
-    git_repo_id: number;
-    git_branch: string;
-    git_repo_name: string;
-    porter_yaml_path: string;
-  }
+      name: string;
+      type: "github";
+      git_repo_id: number;
+      git_branch: string;
+      git_repo_name: string;
+      porter_yaml_path: string;
+    }
   | {
-    name: string;
-    type: "docker-registry";
-    image: {
-      repository: string;
-      tag: string;
-    };
-  },
+      name: string;
+      type: "docker-registry";
+      image: {
+        repository: string;
+        tag: string;
+      };
+    },
   {
     project_id: number;
     cluster_id: number;
@@ -938,18 +949,19 @@ const createApp = baseApi<
 });
 
 const createAppTemplate = baseApi<
-{
-  b64_app_proto: string;
-  variables: Record<string, string>
-  secrets: Record<string, string>
-},
-{
-  project_id: number;
-  cluster_id: number;
-  porter_app_name: string;
-}>("POST", ({ project_id, cluster_id, porter_app_name}) => {
+  {
+    b64_app_proto: string;
+    variables: Record<string, string>;
+    secrets: Record<string, string>;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+  }
+>("POST", ({ project_id, cluster_id, porter_app_name }) => {
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
-})
+});
 
 const applyApp = baseApi<
   {
@@ -1040,6 +1052,27 @@ const listDeploymentTargets = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployment-targets`;
 });
 
+const getDeploymentTarget = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    deployment_target_id: string;
+  }
+>("GET", ({ project_id, cluster_id, deployment_target_id }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/deployment-targets/${deployment_target_id}`;
+});
+
+const getAppTemplate = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+  }>("GET", ({ project_id, cluster_id, porter_app_name }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
+  })
+
 const getGitlabProcfileContents = baseApi<
   {
     path: string;
@@ -1944,9 +1977,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<
@@ -3004,7 +3039,7 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
-const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`);
+const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 const createSecretAndOpenGitHubPullRequest = baseApi<
   {
@@ -3167,6 +3202,8 @@ export default {
   listAppRevisions,
   getLatestAppRevisions,
   listDeploymentTargets,
+  getDeploymentTarget,
+  getAppTemplate,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,

+ 23 - 0
dashboard/src/shared/icons/PullRequest.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+type IconProps = {
+  className?: string;
+  styles?: React.CSSProperties;
+  fill?: string;
+};
+
+const PullRequestIcon: React.FC<IconProps> = ({ className, styles, fill }) => {
+  return (
+    <svg
+      id="Flat"
+      xmlns="http://www.w3.org/2000/svg"
+      viewBox="0 0 256 256"
+      className={className}
+      style={styles}
+      fill={fill}
+    >
+      <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" />
+    </svg>
+  );
+};
+
+export default PullRequestIcon;

+ 1 - 1
go.mod

@@ -82,7 +82,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.8
+	github.com/porter-dev/api-contracts v0.2.10
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1516,8 +1516,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.8 h1:z5BZihdZ75J5Dz3jCeX6ziE/wt6h4yhFqaYoO3BqBY8=
-github.com/porter-dev/api-contracts v0.2.8/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.10 h1:xnnV3ffaLSajHMYyn96l49vLIY/J4lvZZgwKF+8aclk=
+github.com/porter-dev/api-contracts v0.2.10/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 2 - 0
internal/deployment_target/get.go

@@ -21,6 +21,7 @@ type DeploymentTargetDetailsInput struct {
 type DeploymentTarget struct {
 	ClusterID int64  `json:"cluster_id"`
 	Namespace string `json:"namespace"`
+	Preview   bool   `json:"preview"`
 }
 
 // DeploymentTargetDetails gets the deployment target details from CCP
@@ -64,6 +65,7 @@ func DeploymentTargetDetails(ctx context.Context, inp DeploymentTargetDetailsInp
 	deploymentTarget = DeploymentTarget{
 		Namespace: deploymentTargetDetailsResp.Msg.Namespace,
 		ClusterID: deploymentTargetDetailsResp.Msg.ClusterId,
+		Preview:   deploymentTargetDetailsResp.Msg.IsPreview,
 	}
 
 	return deploymentTarget, nil

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

@@ -233,6 +233,7 @@ type GithubActionYAMLOnPullRequest struct {
 type GithubActionYAMLOnPullRequestTypes struct {
 	Branches []string `yaml:"branches,omitempty"`
 	Types    []string `yaml:"types,omitempty"`
+	Paths    []string `yaml:"paths,omitempty"`
 }
 
 type GithubActionYAMLJob struct {

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

@@ -193,8 +193,12 @@ func getStackApplyActionYAML(opts *GetStackApplyActionYAMLOpts) ([]byte, error)
 		actionYaml := GithubActionYAML{
 			On: GithubActionYAMLOnPullRequest{
 				PullRequest: GithubActionYAMLOnPullRequestTypes{
+					Paths: []string{
+						"*",
+						"!./github/workflows/porter-**",
+					},
 					Branches: []string{
-						"!porter-**",
+						opts.DefaultBranch,
 					},
 					Types: []string{
 						"opened",

+ 166 - 0
internal/porter_app/github.go

@@ -0,0 +1,166 @@
+package porter_app
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v39/github"
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// CreateAppWebhookInput is the input to the CreateAppWebhook function
+type CreateAppWebhookInput struct {
+	ProjectID           uint
+	ClusterID           uint
+	PorterAppName       string
+	GithubAppSecret     []byte
+	GithubAppID         string
+	GithubWebhookSecret string
+	ServerURL           string
+
+	PorterAppRepository     repository.PorterAppRepository
+	GithubWebhookRepository repository.GithubWebhookRepository
+}
+
+// CreateAppWebhook creates or updates a github webhook for a porter app associated with a given project / cluster / app
+// The webhook watches for pull request and push events, used for managing preview environments
+func CreateAppWebhook(ctx context.Context, inp CreateAppWebhookInput) error {
+	ctx, span := telemetry.NewSpan(ctx, "porter-app-create-app-webhook")
+	defer span.End()
+
+	if inp.PorterAppName == "" {
+		return telemetry.Error(ctx, span, nil, "porter app name is empty")
+	}
+	if inp.ProjectID == 0 {
+		return telemetry.Error(ctx, span, nil, "project id is empty")
+	}
+	if inp.ClusterID == 0 {
+		return telemetry.Error(ctx, span, nil, "cluster id is empty")
+	}
+	if inp.GithubAppSecret == nil {
+		return telemetry.Error(ctx, span, nil, "github app secret is nil")
+	}
+	if inp.GithubAppID == "" {
+		return telemetry.Error(ctx, span, nil, "github app id is empty")
+	}
+	if inp.GithubWebhookSecret == "" {
+		return telemetry.Error(ctx, span, nil, "github webhook secret is empty")
+	}
+	if inp.PorterAppRepository == nil {
+		return telemetry.Error(ctx, span, nil, "porter app repository is nil")
+	}
+	if inp.GithubWebhookRepository == nil {
+		return telemetry.Error(ctx, span, nil, "github webhook repository is nil")
+	}
+
+	porterApp, err := inp.PorterAppRepository.ReadPorterAppByName(inp.ClusterID, inp.PorterAppName)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "could not read porter app by name")
+	}
+	if porterApp.ID == 0 {
+		return telemetry.Error(ctx, span, nil, "porter app not found")
+	}
+	if porterApp.GitRepoID == 0 {
+		return telemetry.Error(ctx, span, nil, "porter app git repo id is empty")
+	}
+
+	githubClient, err := getGithubClientByRepoID(ctx, porterApp.GitRepoID, inp.GithubAppSecret, inp.GithubAppID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error creating github client")
+	}
+
+	repoDetails := strings.Split(porterApp.RepoName, "/")
+	if len(repoDetails) != 2 {
+		return telemetry.Error(ctx, span, nil, "repo name is not in the format <org>/<repo>")
+	}
+	if _, _, err := githubClient.Repositories.Get(ctx, repoDetails[0], repoDetails[1]); err != nil {
+		return telemetry.Error(ctx, span, err, "error getting github repo")
+	}
+
+	hook := &github.Hook{
+		Config: map[string]interface{}{
+			"content_type": "json",
+			"secret":       inp.GithubWebhookSecret,
+		},
+		Events: []string{"pull_request", "push"},
+		Active: github.Bool(true),
+	}
+
+	// check if the webhook already exists
+	webhook, err := inp.GithubWebhookRepository.GetByClusterAndAppID(ctx, inp.ClusterID, porterApp.ID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error getting github webhook")
+	}
+
+	if webhook.ID != uuid.Nil {
+		hook.Config["url"] = fmt.Sprintf("%s/api/webhooks/github/%s", inp.ServerURL, webhook.ID.String())
+		_, _, err := githubClient.Repositories.EditHook(ctx, repoDetails[0], repoDetails[1], webhook.GithubWebhookID, hook)
+		if err != nil {
+			return telemetry.Error(ctx, span, err, "error editing github webhook")
+		}
+
+		return nil
+	}
+
+	webhookID := uuid.New()
+
+	hook.Config["url"] = fmt.Sprintf("%s/api/webhooks/github/%s", inp.ServerURL, webhookID)
+	hook, _, err = githubClient.Repositories.CreateHook(ctx, repoDetails[0], repoDetails[1], hook)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error creating github webhook")
+	}
+
+	webhook = &models.GithubWebhook{
+		ID:              webhookID,
+		ProjectID:       int(porterApp.ProjectID),
+		ClusterID:       int(porterApp.ClusterID),
+		PorterAppID:     int(porterApp.ID),
+		GithubWebhookID: hook.GetID(),
+	}
+
+	_, err = inp.GithubWebhookRepository.Insert(ctx, webhook)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error saving github webhook")
+	}
+
+	return nil
+}
+
+func getGithubClientByRepoID(ctx context.Context, repoID uint, githubAppSecret []byte, githubAppID string) (*github.Client, error) {
+	ctx, span := telemetry.NewSpan(ctx, "get-github-client-by-repo-id")
+	defer span.End()
+
+	if githubAppSecret == nil {
+		return nil, telemetry.Error(ctx, span, nil, "github app secret is nil")
+	}
+	if githubAppID == "" {
+		return nil, telemetry.Error(ctx, span, nil, "github app id is empty")
+	}
+
+	appID, err := strconv.Atoi(githubAppID)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "could not convert github app id to int")
+	}
+
+	itr, err := ghinstallation.New(
+		http.DefaultTransport,
+		int64(appID),
+		int64(repoID),
+		githubAppSecret,
+	)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "could not create github app client")
+	}
+	if itr == nil {
+		return nil, telemetry.Error(ctx, span, nil, "github app client is nil")
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}

+ 15 - 0
internal/repository/github_webhook.go

@@ -0,0 +1,15 @@
+package repository
+
+import (
+	"context"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// GithubWebhookRepository represents the set of queries on the GithubWebhook model
+type GithubWebhookRepository interface {
+	Insert(ctx context.Context, webhook *models.GithubWebhook) (*models.GithubWebhook, error)
+	Get(ctx context.Context, id uuid.UUID) (*models.GithubWebhook, error)
+	GetByClusterAndAppID(ctx context.Context, clusterID, appID uint) (*models.GithubWebhook, error)
+}

+ 86 - 0
internal/repository/gorm/github_webhook.go

@@ -0,0 +1,86 @@
+package gorm
+
+import (
+	"context"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"gorm.io/gorm"
+)
+
+// GithubWebhookRepository uses gorm.DB for querying the database
+type GithubWebhookRepository struct {
+	db *gorm.DB
+}
+
+// NewGithubWebhookRepository returns a GithubWebhookRepository which uses
+// gorm.DB for querying the database
+func NewGithubWebhookRepository(db *gorm.DB) repository.GithubWebhookRepository {
+	return &GithubWebhookRepository{db}
+}
+
+// Insert inserts a new GithubWebhook into the db
+func (repo *GithubWebhookRepository) Insert(ctx context.Context, webhook *models.GithubWebhook) (*models.GithubWebhook, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-insert-github-webhook")
+	defer span.End()
+
+	if webhook == nil {
+		return nil, telemetry.Error(ctx, span, nil, "github webhook is nil")
+	}
+	if webhook.ClusterID == 0 {
+		return nil, telemetry.Error(ctx, span, nil, "cluster id is empty")
+	}
+	if webhook.ProjectID == 0 {
+		return nil, telemetry.Error(ctx, span, nil, "project id is empty")
+	}
+	if webhook.PorterAppID == 0 {
+		return nil, telemetry.Error(ctx, span, nil, "porter app id is empty")
+	}
+
+	if webhook.ID == uuid.Nil {
+		webhook.ID = uuid.New()
+	}
+	if webhook.CreatedAt.IsZero() {
+		webhook.CreatedAt = time.Now().UTC()
+	}
+	if webhook.UpdatedAt.IsZero() {
+		webhook.UpdatedAt = time.Now().UTC()
+	}
+
+	if err := repo.db.Save(webhook).Error; err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error saving webhook")
+	}
+
+	return webhook, nil
+}
+
+// GetByClusterAndAppID finds a GithubWebhook by clusterID and appID
+func (repo *GithubWebhookRepository) GetByClusterAndAppID(ctx context.Context, clusterID uint, appID uint) (*models.GithubWebhook, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-get-github-webhook")
+	defer span.End()
+
+	webhook := &models.GithubWebhook{}
+
+	if err := repo.db.Where("cluster_id = ? AND porter_app_id = ?", clusterID, appID).Limit(1).Find(&webhook).Error; err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error finding webhook")
+	}
+
+	return webhook, nil
+}
+
+// Get finds a GithubWebhook by id
+func (repo *GithubWebhookRepository) Get(ctx context.Context, id uuid.UUID) (*models.GithubWebhook, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-get-github-webhook")
+	defer span.End()
+
+	webhook := &models.GithubWebhook{}
+
+	if err := repo.db.Where("id = ?", id).Limit(1).Find(&webhook).Error; err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error finding webhook")
+	}
+
+	return webhook, nil
+}

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

@@ -55,6 +55,7 @@ type GormRepository struct {
 	porterAppEvent            repository.PorterAppEventRepository
 	deploymentTarget          repository.DeploymentTargetRepository
 	appTemplate               repository.AppTemplateRepository
+	githubWebhook             repository.GithubWebhookRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -251,6 +252,11 @@ func (t *GormRepository) AppTemplate() repository.AppTemplateRepository {
 	return t.appTemplate
 }
 
+// GithubWebhook returns the GithubWebhookRepository interface implemented by gorm
+func (t *GormRepository) GithubWebhook() repository.GithubWebhookRepository {
+	return t.githubWebhook
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -303,5 +309,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		porterAppEvent:            NewPorterAppEventRepository(db),
 		deploymentTarget:          NewDeploymentTargetRepository(db),
 		appTemplate:               NewAppTemplateRepository(db),
+		githubWebhook:             NewGithubWebhookRepository(db),
 	}
 }

+ 1 - 0
internal/repository/repository.go

@@ -49,4 +49,5 @@ type Repository interface {
 	PorterAppEvent() PorterAppEventRepository
 	DeploymentTarget() DeploymentTargetRepository
 	AppTemplate() AppTemplateRepository
+	GithubWebhook() GithubWebhookRepository
 }

+ 35 - 0
internal/repository/test/github_webhook.go

@@ -0,0 +1,35 @@
+package test
+
+import (
+	"context"
+	"errors"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// GithubWebhookRepository is a test repository that implements repository.GithubWebhookRepository
+type GithubWebhookRepository struct {
+	canQuery bool
+}
+
+// NewGithubWebhookRepository returns the test GithubWebhookRepository
+func NewGithubWebhookRepository() repository.GithubWebhookRepository {
+	return &GithubWebhookRepository{canQuery: false}
+}
+
+// Insert inserts a new GithubWebhook into the db
+func (repo *GithubWebhookRepository) Insert(ctx context.Context, webhook *models.GithubWebhook) (*models.GithubWebhook, error) {
+	return nil, errors.New("cannot write database")
+}
+
+// GetByClusterAndAppID finds a GithubWebhook by clusterID and appID
+func (repo *GithubWebhookRepository) GetByClusterAndAppID(ctx context.Context, clusterID, appID uint) (*models.GithubWebhook, error) {
+	return nil, errors.New("cannot read database")
+}
+
+// Get finds a GithubWebhook by id
+func (repo *GithubWebhookRepository) Get(ctx context.Context, id uuid.UUID) (*models.GithubWebhook, error) {
+	return nil, errors.New("cannot read database")
+}

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

@@ -53,6 +53,7 @@ type TestRepository struct {
 	porterAppEvent            repository.PorterAppEventRepository
 	deploymentTarget          repository.DeploymentTargetRepository
 	appTemplate               repository.AppTemplateRepository
+	githubWebhook             repository.GithubWebhookRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -249,6 +250,11 @@ func (t *TestRepository) AppTemplate() repository.AppTemplateRepository {
 	return t.appTemplate
 }
 
+// GithubWebhook returns a test GithubWebhookRepository
+func (t *TestRepository) GithubWebhook() repository.GithubWebhookRepository {
+	return t.githubWebhook
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -301,5 +307,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		porterAppEvent:            NewPorterAppEventRepository(canQuery),
 		deploymentTarget:          NewDeploymentTargetRepository(),
 		appTemplate:               NewAppTemplateRepository(),
+		githubWebhook:             NewGithubWebhookRepository(),
 	}
 }