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

Merge branch 'nico/por-596-update-build-config-clearing-out' of github.com:porter-dev/porter into dev

jnfrati 3 лет назад
Родитель
Сommit
d476e4112f
26 измененных файлов с 824 добавлено и 100 удалено
  1. 29 18
      api/server/handlers/environment/create.go
  2. 16 0
      api/server/handlers/environment/create_deployment.go
  3. 28 0
      api/server/handlers/environment/delete.go
  4. 8 2
      api/server/handlers/environment/enable_pull_request.go
  5. 144 9
      api/server/handlers/environment/finalize_deployment.go
  6. 26 19
      api/server/handlers/environment/finalize_deployment_with_errors.go
  7. 55 0
      api/server/handlers/environment/get_environment.go
  8. 28 8
      api/server/handlers/environment/reenable_deployment.go
  9. 74 0
      api/server/handlers/environment/toggle_new_comment.go
  10. 20 0
      api/server/handlers/environment/trigger_deployment_workflow.go
  11. 18 0
      api/server/handlers/environment/update_deployment.go
  12. 27 0
      api/server/handlers/environment/update_deployment_status.go
  13. 25 13
      api/server/handlers/status/github.go
  14. 6 0
      api/server/handlers/webhook/github_incoming.go
  15. 57 0
      api/server/router/cluster.go
  16. 5 0
      api/types/environment.go
  17. 6 0
      api/types/status.go
  18. 9 5
      cli/cmd/apply.go
  19. 72 21
      dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx
  20. 83 3
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  21. 28 0
      dashboard/src/shared/api.tsx
  22. 40 2
      internal/kubernetes/agent.go
  23. 7 0
      internal/models/environment.go
  24. 1 0
      internal/repository/environment.go
  25. 8 0
      internal/repository/gorm/environment.go
  26. 4 0
      internal/repository/test/environment.go

+ 29 - 18
api/server/handlers/environment/create.go

@@ -63,34 +63,30 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	env, err := c.Repo().Environment().CreateEnvironment(&models.Environment{
-		ProjectID:         project.ID,
-		ClusterID:         cluster.ID,
-		GitInstallationID: uint(ga.InstallationID),
-		Name:              request.Name,
-		GitRepoOwner:      owner,
-		GitRepoName:       name,
-		Mode:              request.Mode,
-		WebhookID:         string(webhookUID),
-	})
-
-	if err != nil {
-		c.deleteEnvAndReportError(w, r, env, err)
-		return
+	env := &models.Environment{
+		ProjectID:           project.ID,
+		ClusterID:           cluster.ID,
+		GitInstallationID:   uint(ga.InstallationID),
+		Name:                request.Name,
+		GitRepoOwner:        owner,
+		GitRepoName:         name,
+		Mode:                request.Mode,
+		WebhookID:           string(webhookUID),
+		NewCommentsDisabled: false,
 	}
 
 	// write Github actions files to the repo
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	if err != nil {
-		c.deleteEnvAndReportError(w, r, env, err)
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	webhookURL := fmt.Sprintf("%s/api/github/incoming_webhook/%s", c.Config().ServerConf.ServerURL, string(webhookUID))
+	webhookURL := getGithubWebhookURLFromUID(c.Config().ServerConf.ServerURL, string(webhookUID))
 
 	// create incoming webhook
-	_, _, err = client.Repositories.CreateHook(
+	hook, _, err := client.Repositories.CreateHook(
 		r.Context(), owner, name, &github.Hook{
 			Config: map[string]interface{}{
 				"url":          webhookURL,
@@ -103,7 +99,18 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	)
 
 	if err != nil && !strings.Contains(err.Error(), "already exists on this repository") {
-		c.deleteEnvAndReportError(w, r, env, err)
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error trying to create a new github repository webhook: %w", err), http.StatusConflict),
+		)
+		return
+	}
+
+	env.GithubWebhookID = hook.GetID()
+
+	env, err = c.Repo().Environment().CreateEnvironment(env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
@@ -187,3 +194,7 @@ func getGithubClientFromEnvironment(config *config.Config, env *models.Environme
 
 	return github.NewClient(&http.Client{Transport: itr}), nil
 }
+
+func getGithubWebhookURLFromUID(serverURL, webhookUID string) string {
+	return fmt.Sprintf("%s/api/github/incoming_webhook/%s", serverURL, string(webhookUID))
+}

+ 16 - 0
api/server/handlers/environment/create_deployment.go

@@ -66,6 +66,22 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	// add a check for Github PR status
+	prClosed, err := isGithubPRClosed(client, owner, name, int(request.PullRequestID))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error fetching details of github PR. Error: %w", err), http.StatusConflict,
+		))
+		return
+	}
+
+	if prClosed {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+			http.StatusConflict))
+		return
+	}
+
 	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 	if err != nil {

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

@@ -1,10 +1,12 @@
 package environment
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
 
+	"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"
@@ -79,6 +81,9 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		agent.DeleteNamespace(depl.Namespace)
 	}
 
+	ghWebhookID := env.GithubWebhookID
+	webhookUID := env.WebhookID
+
 	// delete the environment
 	env, err = c.Repo().Environment().DeleteEnvironment(env)
 
@@ -87,6 +92,29 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	// FIXME: ignore the return status codes for now, should be fixed when we start returning all non-fatal errors
+	if ghWebhookID != 0 {
+		client.Repositories.DeleteHook(context.Background(), owner, name, ghWebhookID)
+	} else {
+		webhookURL := getGithubWebhookURLFromUID(c.Config().ServerConf.ServerURL, string(webhookUID))
+
+		// FIXME: should be cycling through all webhooks if pagination is needed
+		hooks, _, err := client.Repositories.ListHooks(context.Background(), owner, name, &github.ListOptions{})
+
+		if err == nil {
+			for _, hook := range hooks {
+				if hookURL, ok := hook.Config["url"]; ok {
+					if hookURLStr, ok := hookURL.(string); ok {
+						if hookURLStr == webhookURL {
+							client.Repositories.DeleteHook(context.Background(), owner, name, hook.GetID())
+							break
+						}
+					}
+				}
+			}
+		}
+	}
+
 	err = actions.DeleteEnv(&actions.EnvOpts{
 		Client:            client,
 		ServerURL:         c.Config().ServerConf.ServerURL,

+ 8 - 2
api/server/handlers/environment/enable_pull_request.go

@@ -56,14 +56,20 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	}
 
 	// 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))
+	pr, _, 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(
+	if pr.GetState() == "closed" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+			http.StatusConflict))
+		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,

+ 144 - 9
api/server/handlers/environment/finalize_deployment.go

@@ -4,6 +4,8 @@ import (
 	"context"
 	"fmt"
 	"net/http"
+	"net/url"
+	"strings"
 
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -14,6 +16,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
 )
 
 type FinalizeDeploymentHandler struct {
@@ -104,6 +107,23 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
+	// add a check for the PR to be open before creating a comment
+	prClosed, err := isGithubPRClosed(client, owner, name, int(depl.PullRequestID))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error fetching details of github PR for deployment ID: %d. Error: %w",
+				depl.ID, err), http.StatusConflict,
+		))
+		return
+	}
+
+	if prClosed {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+			http.StatusConflict))
+		return
+	}
+
 	workflowRun, err := commonutils.GetLatestWorkflowRun(client, depl.RepoOwner, depl.RepoName,
 		fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
 
@@ -125,9 +145,9 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 			"| Latest SHA | [`%s`](https://github.com/%s/%s/commit/%s) |\n"+
 			"| Live URL | %s |\n"+
 			"| Build Logs | %s |\n"+
-			"| Porter Deployments URL | %s/preview-environments/details/%s?environment_id=%d |",
+			"| Porter Deployments URL | %s/preview-environments/details/%s?environment_id=%d&project_id=%d&cluster=%s |",
 		depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, depl.Subdomain, workflowRun.GetHTMLURL(),
-		c.Config().ServerConf.ServerURL, depl.Namespace, depl.EnvironmentID,
+		c.Config().ServerConf.ServerURL, depl.Namespace, depl.EnvironmentID, project.ID, url.QueryEscape(cluster.Name),
 	)
 
 	if len(request.SuccessfulResources) > 0 {
@@ -146,20 +166,135 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		}
 	}
 
-	_, _, err = client.Issues.CreateComment(
+	err = createOrUpdateComment(client, c.Repo(), env.NewCommentsDisabled, depl, github.String(commentBody))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}
+
+func createOrUpdateComment(
+	client *github.Client,
+	repo repository.Repository,
+	newCommentsDisabled bool,
+	depl *models.Deployment,
+	commentBody *string,
+) error {
+	// when updating a PR comment, we have to handle several cases:
+	//   1. when a Porter environment has deployment status repeat-comments enabled
+	//      - nothing special here, simply create a new comment in the PR
+	//   2. when a Porter environment has deployment status repeat-comments disabled
+	//      - when a Porter deployment has Github comment ID saved in the DB
+	//        - try to update the comment using the Github comment ID
+	//        - if the above fails, try creating a new comment and save the new comment ID in the DB
+	//      - when a Porter deployment does not have a Github comment ID saved in the DB
+	//        - create a new comment and save the Github comment ID in the DB
+
+	if newCommentsDisabled {
+		if depl.GHPRCommentID == 0 {
+			// create a new comment
+			err := createGithubComment(client, repo, depl, commentBody)
+
+			if err != nil {
+				return err
+			}
+		} else {
+			err := updateGithubComment(
+				client, depl.RepoOwner, depl.RepoName, depl.GHPRCommentID, commentBody,
+			)
+
+			if err != nil {
+				if strings.Contains(err.Error(), "404") {
+					// perhaps a deleted comment?
+					// create a new comment
+					err := createGithubComment(client, repo, depl, commentBody)
+
+					if err != nil {
+						return fmt.Errorf("invalid github comment ID for deployment with ID: %d. Error creating "+
+							"new comment: %w", depl.ID, err)
+					}
+				}
+
+				return err
+			}
+		}
+	} else {
+		err := createGithubComment(client, repo, depl, commentBody)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func createGithubComment(
+	client *github.Client,
+	repo repository.Repository,
+	depl *models.Deployment,
+	body *string,
+) error {
+	ghResp, _, err := client.Issues.CreateComment(
 		context.Background(),
-		env.GitRepoOwner,
-		env.GitRepoName,
+		depl.RepoOwner,
+		depl.RepoName,
 		int(depl.PullRequestID),
 		&github.IssueComment{
-			Body: github.String(commentBody),
+			Body: body,
 		},
 	)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+		return fmt.Errorf("error creating new github comment for owner: %s repo %s prNumber: %d. Error: %w",
+			depl.RepoOwner, depl.RepoName, depl.PullRequestID, err)
 	}
 
-	c.WriteResult(w, r, depl.ToDeploymentType())
+	depl.GHPRCommentID = ghResp.GetID()
+
+	_, err = repo.Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		return fmt.Errorf("error updating deployment with ID: %d. Error: %w", depl.ID, err)
+	}
+
+	return nil
+}
+
+func updateGithubComment(
+	client *github.Client,
+	owner, repo string,
+	commentID int64,
+	body *string,
+) error {
+	_, _, err := client.Issues.EditComment(
+		context.Background(),
+		owner,
+		repo,
+		commentID,
+		&github.IssueComment{
+			Body: body,
+		},
+	)
+
+	return err
+}
+
+func isGithubPRClosed(
+	client *github.Client,
+	owner, name string,
+	prNumber int,
+) (bool, error) {
+	ghPR, _, err := client.PullRequests.Get(
+		context.Background(), owner, name, prNumber,
+	)
+
+	if err != nil {
+		return false, err
+	}
+
+	return ghPR.GetState() == "closed", nil
 }

+ 26 - 19
api/server/handlers/environment/finalize_deployment_with_errors.go

@@ -82,12 +82,6 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		return
 	}
 
-	depl.Status = types.DeploymentStatusFailed
-
-	// we do not care of the error in this case because the list deployments endpoint
-	// talks to the github API to fetch the deployment status correctly
-	c.Repo().Environment().UpdateDeployment(depl)
-
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	if err != nil {
@@ -97,7 +91,30 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		return
 	}
 
-	// FIXME: ignore the status of thie API call for now
+	// add a check for the PR to be open before creating a comment
+	prClosed, err := isGithubPRClosed(client, owner, name, int(depl.PullRequestID))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error fetching details of github PR for deployment ID: %d. Error: %w",
+				depl.ID, err), http.StatusConflict,
+		))
+		return
+	}
+
+	if prClosed {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+			http.StatusConflict))
+		return
+	}
+
+	depl.Status = types.DeploymentStatusFailed
+
+	// we do not care of the error in this case because the list deployments endpoint
+	// talks to the github API to fetch the deployment status correctly
+	c.Repo().Environment().UpdateDeployment(depl)
+
+	// FIXME: ignore the status of this API call for now
 	client.Repositories.CreateDeploymentStatus(
 		context.Background(), owner, name, depl.GHDeploymentID, &github.DeploymentStatusRequest{
 			State:       github.String("failure"),
@@ -145,20 +162,10 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		commentBody += fmt.Sprintf("<details>\n  <summary><code>%s</code></summary>\n\n  **Error:** %s\n</details>\n", res, err)
 	}
 
-	_, _, err = client.Issues.CreateComment(
-		context.Background(),
-		env.GitRepoOwner,
-		env.GitRepoName,
-		int(depl.PullRequestID),
-		&github.IssueComment{
-			Body: github.String(commentBody),
-		},
-	)
+	err = createOrUpdateComment(client, c.Repo(), env.NewCommentsDisabled, depl, github.String(commentBody))
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("error creating github comment: %w", err), http.StatusConflict,
-		))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 

+ 55 - 0
api/server/handlers/environment/get_environment.go

@@ -0,0 +1,55 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type GetEnvironmentHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewGetEnvironmentHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetEnvironmentHandler {
+	return &GetEnvironmentHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *GetEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	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.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error reading environment with ID: %d. Error: %w", envID, err)))
+		return
+	}
+
+	c.WriteResult(w, r, env.ToEnvironmentType())
+}

+ 28 - 8
api/server/handlers/environment/reenable_deployment.go

@@ -51,41 +51,61 @@ func (c *ReenableDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	}
 
 	if depl.Status != types.DeploymentStatusInactive {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("trying to re-enable deployment which is not marked \"inactive\""), http.StatusPreconditionFailed,
+		))
 		return
 	}
 
-	depl.Status = types.DeploymentStatusCreating
-
-	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, depl.EnvironmentID)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	// create the backing namespace
-	agent, err := c.GetAgent(r, cluster, "")
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	_, err = agent.CreateNamespace(depl.Namespace)
+	// add a check for the PR to be open before creating a comment
+	prClosed, err := isGithubPRClosed(client, depl.RepoOwner, depl.RepoName, int(depl.PullRequestID))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error fetching details of github PR for deployment ID: %d. Error: %w",
+				depl.ID, err), http.StatusConflict,
+		))
+		return
+	}
+
+	if prClosed {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+			http.StatusConflict))
+		return
+	}
+
+	depl.Status = types.DeploymentStatusCreating
+
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, depl.EnvironmentID)
+	// create the backing namespace
+	agent, err := c.GetAgent(r, cluster, "")
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	client, err := getGithubClientFromEnvironment(c.Config(), env)
+	_, err = agent.CreateNamespace(depl.Namespace)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 74 - 0
api/server/handlers/environment/toggle_new_comment.go

@@ -0,0 +1,74 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/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"
+	"gorm.io/gorm"
+)
+
+type ToggleNewCommentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewToggleNewCommentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ToggleNewCommentHandler {
+	return &ToggleNewCommentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ToggleNewCommentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	environmentID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	request := &types.ToggleNewCommentRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, environmentID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", environmentID)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if env.NewCommentsDisabled != request.Disable {
+		env.NewCommentsDisabled = request.Disable
+
+		_, err = c.Repo().Environment().UpdateEnvironment(env)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 20 - 0
api/server/handlers/environment/trigger_deployment_workflow.go

@@ -52,6 +52,9 @@ func (c *TriggerDeploymentWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *h
 	}
 
 	if depl.Status == types.DeploymentStatusInactive {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("trying to trigger workflow run for inactive deployment"), http.StatusConflict,
+		))
 		return
 	}
 
@@ -69,6 +72,23 @@ func (c *TriggerDeploymentWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *h
 		return
 	}
 
+	// add a check for the PR to be open before creating a comment
+	prClosed, err := isGithubPRClosed(client, depl.RepoOwner, depl.RepoName, int(depl.PullRequestID))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error fetching details of github PR for deployment ID: %d. Error: %w",
+				depl.ID, err), http.StatusConflict,
+		))
+		return
+	}
+
+	if prClosed {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+			http.StatusConflict))
+		return
+	}
+
 	latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
 		fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
 

+ 18 - 0
api/server/handlers/environment/update_deployment.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/authz"
@@ -71,6 +72,23 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	// add a check for the PR to be open before creating a comment
+	prClosed, err := isGithubPRClosed(client, owner, name, int(depl.PullRequestID))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error fetching details of github PR for deployment ID: %d. Error: %w",
+				depl.ID, err), http.StatusConflict,
+		))
+		return
+	}
+
+	if prClosed {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+			http.StatusConflict))
+		return
+	}
+
 	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 	if err != nil {

+ 27 - 0
api/server/handlers/environment/update_deployment_status.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/authz"
@@ -63,6 +64,32 @@ func (c *UpdateDeploymentStatusHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 	}
 
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("unable to get github client: %w", err), http.StatusConflict,
+		))
+		return
+	}
+
+	// add a check for the PR to be open before creating a comment
+	prClosed, err := isGithubPRClosed(client, depl.RepoOwner, depl.RepoName, int(depl.PullRequestID))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error fetching details of github PR for deployment ID: %d. Error: %w",
+				depl.ID, err), http.StatusConflict,
+		))
+		return
+	}
+
+	if prClosed {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+			http.StatusConflict))
+		return
+	}
+
 	if depl.Status == types.DeploymentStatusInactive && request.Status != string(types.DeploymentStatusCreating) {
 		// a deployment from "inactive" state can only transition to "creating"
 		c.WriteResult(w, r, depl.ToDeploymentType())

+ 25 - 13
api/server/handlers/status/github.go

@@ -1,15 +1,16 @@
 package status
 
 import (
+	"encoding/json"
 	"fmt"
+	"io"
 	"net/http"
-	"strings"
 
-	"github.com/mmcdole/gofeed"
 	"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"
 )
 
 type GetGithubStatusHandler struct {
@@ -26,23 +27,34 @@ func NewGetGithubStatusHandler(
 }
 
 func (c *GetGithubStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	fp := gofeed.NewParser()
-	feed, err := fp.ParseURL("https://www.githubstatus.com/history.rss")
+	resp, err := http.Get("https://www.githubstatus.com/api/v2/incidents/unresolved.json")
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error fetching github status RSS: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error fetching github incidents: %w", err)))
 		return
 	}
 
-	if len(feed.Items) > 0 {
-		description := feed.Items[0].Description
-		link := feed.Items[0].Link
+	defer resp.Body.Close()
 
-		if !strings.Contains(description, "This incident has been resolved") {
-			// ongoing incident
-			c.WriteResult(w, r, link)
-			return
-		}
+	data, err := io.ReadAll(resp.Body)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error reading github incidents: %w", err)))
+		return
+	}
+
+	var incidents types.GithubUnresolvedIncidents
+
+	err = json.Unmarshal(data, &incidents)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error unmarshalling json: %w", err)))
+		return
+	}
+
+	if len(incidents.Incidents) > 0 {
+		c.WriteResult(w, r, fmt.Sprintf("https://www.githubstatus.com/incidents/%s", incidents.Incidents[0].ID))
+		return
 	}
 
 	c.WriteResult(w, r, "no active incidents")

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

@@ -2,6 +2,7 @@ package webhook
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 	"strconv"
@@ -18,6 +19,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
 )
 
 type GithubIncomingWebhookHandler struct {
@@ -75,6 +77,10 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 	env, err := c.Repo().Environment().ReadEnvironmentByWebhookIDOwnerRepoName(webhookID, owner, repo)
 
 	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil
+		}
+
 		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s] error reading environment: %w", webhookID, owner, repo, err)
 	}
 

+ 57 - 0
api/server/router/cluster.go

@@ -319,6 +319,63 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/environments/{environment_id} -> environment.NewGetEnvironmentHandler
+		getEnvEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/environments/{environment_id}",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		getEnvHandler := environment.NewGetEnvironmentHandler(
+			config,
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: getEnvEndpoint,
+			Handler:  getEnvHandler,
+			Router:   r,
+		})
+
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/environment/{environment_id}/toggle_new_comment -> environment.NewToggleNewCommentHandler
+		toggleNewCommentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPatch,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/environments/{environment_id}/toggle_new_comment",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		toggleNewCommentHandler := environment.NewToggleNewCommentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: toggleNewCommentEndpoint,
+			Handler:  toggleNewCommentHandler,
+			Router:   r,
+		})
+
 		// GET /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewListDeploymentsByClusterHandler
 		listDeploymentsEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{

+ 5 - 0
api/types/environment.go

@@ -14,6 +14,7 @@ type Environment struct {
 	Mode                 string `json:"mode"`
 	DeploymentCount      uint   `json:"deployment_count"`
 	LastDeploymentStatus string `json:"last_deployment_status"`
+	NewCommentsDisabled  bool   `json:"new_comments_disabled"`
 }
 
 type CreateEnvironmentRequest struct {
@@ -123,4 +124,8 @@ type PullRequest struct {
 	BranchInto string `json:"branch_into"`
 }
 
+type ToggleNewCommentRequest struct {
+	Disable bool `json:"disable"`
+}
+
 type ListEnvironmentsResponse []*Environment

+ 6 - 0
api/types/status.go

@@ -7,3 +7,9 @@ const (
 type StreamStatusRequest struct {
 	Selectors string `schema:"selectors"`
 }
+
+type GithubUnresolvedIncidents struct {
+	Incidents []*struct {
+		ID string `json:"id"`
+	} `json:"incidents"`
+}

+ 9 - 5
cli/cmd/apply.go

@@ -265,7 +265,7 @@ func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shoul
 	}
 
 	if shouldCreate {
-		err = client.DeployAddon(
+		err := client.DeployAddon(
 			context.Background(),
 			d.target.Project,
 			d.target.Cluster,
@@ -280,6 +280,10 @@ func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shoul
 				},
 			},
 		)
+
+		if err != nil {
+			return nil, err
+		}
 	} else {
 		bytes, err := json.Marshal(addonConfig)
 
@@ -297,17 +301,17 @@ func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shoul
 				Values: string(bytes),
 			},
 		)
-	}
 
-	if err != nil {
-		return nil, err
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	if err = d.assignOutput(resource, client); err != nil {
 		return nil, err
 	}
 
-	return resource, err
+	return resource, nil
 }
 
 func (d *Driver) applyApplication(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {

+ 72 - 21
dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx

@@ -5,7 +5,15 @@ import SelectRow from "components/form-components/SelectRow";
 import Loading from "components/Loading";
 import MultiSaveButton from "components/MultiSaveButton";
 import _, { differenceBy, unionBy } from "lodash";
-import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+import React, {
+  forwardRef,
+  useContext,
+  useEffect,
+  useImperativeHandle,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import {
@@ -62,7 +70,6 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
     Context
   );
 
-  const [buildConfig, setBuildConfig] = useState<BuildConfig>(null);
   const [envVariables, setEnvVariables] = useState(
     chart.config?.container?.env?.build || null
   );
@@ -79,6 +86,11 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
     () => chart?.git_action_config?.git_branch
   );
 
+  const buildpackConfigRef = useRef<{
+    isLoading: boolean;
+    getBuildConfig: () => BuildConfig;
+  }>(null);
+
   const saveNewBranch = async (newBranch: string) => {
     if (!newBranch?.length) {
       return;
@@ -235,14 +247,30 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
     }
   };
 
-  const clearButtonStatus = () => {
+  const clearButtonStatus = (time: number = 800) => {
     setTimeout(() => {
       setButtonStatus("");
-    }, 800);
+    }, time);
+  };
+
+  const getBuildConfig = () => {
+    if (buildpackConfigRef.current?.isLoading) {
+      return null;
+    }
+    return buildpackConfigRef.current?.getBuildConfig();
   };
 
   const handleSave = async () => {
     setButtonStatus("loading");
+
+    const buildConfig = getBuildConfig();
+
+    if (!buildConfig && !chart.git_action_config.dockerfile_path) {
+      setButtonStatus("Can't save until buildpack config is loaded.");
+      clearButtonStatus(1500);
+      return;
+    }
+
     try {
       await saveBuildConfig(buildConfig);
       await saveNewBranch(currentBranch);
@@ -258,6 +286,15 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
 
   const handleSaveAndReDeploy = async () => {
     setButtonStatus("loading");
+
+    const buildConfig = getBuildConfig();
+
+    if (!buildConfig && !chart.git_action_config.dockerfile_path) {
+      setButtonStatus("Can't save until buildpack config is loaded.");
+      clearButtonStatus();
+      return;
+    }
+
     try {
       await saveBuildConfig(buildConfig);
       await saveNewBranch(currentBranch);
@@ -354,9 +391,9 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
           <>
             <Heading>Buildpack Settings</Heading>
             <BuildpackConfigSection
+              ref={buildpackConfigRef}
               currentChart={chart}
               actionConfig={currentActionConfig}
-              onChange={(buildConfig) => setBuildConfig(buildConfig)}
             />
           </>
         ) : null}
@@ -392,11 +429,16 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
 
 export default BuildSettingsTab;
 
-const BuildpackConfigSection: React.FC<{
-  actionConfig: FullActionConfigType;
-  currentChart: ChartTypeWithExtendedConfig;
-  onChange: (buildConfig: BuildConfig) => void;
-}> = ({ actionConfig, currentChart, onChange }) => {
+const BuildpackConfigSection = forwardRef<
+  {
+    isLoading: boolean;
+    getBuildConfig: () => BuildConfig;
+  },
+  {
+    actionConfig: FullActionConfigType;
+    currentChart: ChartTypeWithExtendedConfig;
+  }
+>(({ actionConfig, currentChart }, ref) => {
   const { currentProject } = useContext(Context);
 
   const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
@@ -559,16 +601,25 @@ const BuildpackConfigSection: React.FC<{
       });
   }, [currentProject, actionConfig, currentChart]);
 
-  useEffect(() => {
-    let buildConfig: BuildConfig = {} as BuildConfig;
-
-    buildConfig.builder = selectedStack;
-    buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
-      return buildpack.buildpack;
-    });
-
-    onChange(buildConfig);
-  }, [selectedBuilder, selectedBuildpacks, selectedStack]);
+  useImperativeHandle(
+    ref,
+    () => {
+      const isLoading = !stackOptions?.length || !builderOptions?.length;
+      return {
+        isLoading,
+        getBuildConfig: () => {
+          let buildConfig: BuildConfig = {} as BuildConfig;
+
+          buildConfig.builder = selectedStack;
+          buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
+            return buildpack.buildpack;
+          });
+          return buildConfig;
+        },
+      };
+    },
+    [selectedBuilder, selectedBuildpacks, selectedStack]
+  );
 
   useEffect(() => {
     populateState(
@@ -763,7 +814,7 @@ const BuildpackConfigSection: React.FC<{
       </>
     </BuildpackConfigurationContainer>
   );
-};
+});
 
 const DisabledOverlay = styled.div`
   position: absolute;

+ 83 - 3
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -16,6 +16,8 @@ import PullRequestCard from "./PullRequestCard";
 import DynamicLink from "components/DynamicLink";
 import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHeader";
 import SearchBar from "components/SearchBar";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import DocsHelper from "components/DocsHelper";
 
 const AvailableStatusFilters = [
   "all",
@@ -34,6 +36,7 @@ const DeploymentList = () => {
   const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [searchValue, setSearchValue] = useState("");
+  const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
 
   const [
     statusSelectorVal,
@@ -66,6 +69,18 @@ const DeploymentList = () => {
     // return mockRequest();
   };
 
+  const getEnvironment = () => {
+    return api.getEnvironment(
+      "<token>",
+      {},
+      {
+        project_id: currentProject.id,
+        cluster_id: currentCluster.id,
+        environment_id: Number(environment_id),
+      }
+    );
+  };
+
   useEffect(() => {
     const status_filter = getQueryParam("status_filter");
 
@@ -93,7 +108,6 @@ const DeploymentList = () => {
 
         setDeploymentList(data.deployments || []);
         setPullRequests(data.pull_requests || []);
-        setIsLoading(false);
       })
       .catch((err) => {
         console.error(err);
@@ -101,6 +115,21 @@ const DeploymentList = () => {
           setHasError(true);
         }
       });
+    getEnvironment()
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setNewCommentsDisabled(data.new_comments_disabled || false);
+      })
+      .catch((err) => {
+        console.error(err);
+        if (isSubscribed) {
+          setHasError(true);
+        }
+      });
+    setIsLoading(false);
 
     return () => {
       isSubscribed = false;
@@ -116,9 +145,15 @@ const DeploymentList = () => {
     } catch (error) {
       setHasError(true);
       console.error(error);
-    } finally {
-      setIsLoading(false);
     }
+    try {
+      const { data } = await getEnvironment();
+      setNewCommentsDisabled(data.new_comments_disabled || false);
+    } catch (error) {
+      setHasError(true);
+      console.error(error);
+    }
+    setIsLoading(false);
   };
 
   const handlePreviewEnvironmentManualCreation = (pullRequest: PullRequest) => {
@@ -232,6 +267,24 @@ const DeploymentList = () => {
     setStatusSelectorVal(value);
   };
 
+  const handleToggleCommentStatus = (currentlyDisabled: boolean) => {
+    api
+      .toggleNewCommentForEnvironment(
+        "<token>",
+        {
+          disable: !currentlyDisabled,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: Number(environment_id),
+        }
+      )
+      .then(() => {
+        setNewCommentsDisabled(!currentlyDisabled);
+      });
+  };
+
   return (
     <>
       <PreviewEnvironmentsHeader />
@@ -282,6 +335,24 @@ const DeploymentList = () => {
           </StyledStatusSelector>
         </ActionsWrapper>
       </Flex>
+      <Flex>
+        <ActionsWrapper>
+          <FlexWrap>
+            <CheckboxRow
+              label="Disable new comments for deployments"
+              checked={newCommentsDisabled}
+              toggle={() => handleToggleCommentStatus(newCommentsDisabled)}
+            />
+            <Div>
+              <DocsHelper
+                disableMargin
+                tooltipText="When checked, comments for every new deployment are disabled. Instead, the most recent comment is updated each time."
+                placement="top-end"
+              />
+            </Div>
+          </FlexWrap>
+        </ActionsWrapper>
+      </Flex>
       <Container>
         <EventsGrid>{renderDeploymentList()}</EventsGrid>
       </Container>
@@ -307,6 +378,15 @@ const Flex = styled.div`
   align-items: center;
 `;
 
+const Div = styled.div`
+  margin-bottom: -7px;
+`;
+
+const FlexWrap = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
 const BackButton = styled(DynamicLink)`
   cursor: pointer;
   font-size: 24px;

+ 28 - 0
dashboard/src/shared/api.tsx

@@ -201,6 +201,32 @@ const listEnvironments = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments`;
 });
 
+const getEnvironment = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    environment_id: number;
+  }
+>("GET", (pathParams) => {
+  let { project_id, cluster_id, environment_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}`;
+});
+
+const toggleNewCommentForEnvironment = baseApi<
+  {
+    disable: boolean;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    environment_id: number;
+  }
+>("PATCH", (pathParams) => {
+  let { project_id, cluster_id, environment_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/toggle_new_comment`;
+});
+
 const createGCPIntegration = baseApi<
   {
     gcp_key_data: string;
@@ -2073,6 +2099,8 @@ export default {
   createPreviewEnvironmentDeployment,
   reenablePreviewEnvironmentDeployment,
   listEnvironments,
+  getEnvironment,
+  toggleNewCommentForEnvironment,
   createGCPIntegration,
   createInvite,
   createNamespace,

+ 40 - 2
internal/kubernetes/agent.go

@@ -624,7 +624,39 @@ func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
 	)
 
 	if err == nil && checkNS != nil {
-		return checkNS, nil
+		if checkNS.Status.Phase == v1.NamespaceTerminating {
+			// edge case for when the same namespace was previously created
+			// but was deleted and is currently in the "Terminating" phase
+
+			// let us wait for a maximum of 10 seconds
+			timeNow := time.Now().Add(10 * time.Second)
+			stillTerminating := true
+			for {
+				_, err := a.Clientset.CoreV1().Namespaces().Get(
+					context.TODO(),
+					name,
+					metav1.GetOptions{},
+				)
+
+				if err != nil && errors.IsNotFound(err) {
+					stillTerminating = false
+					break
+				}
+
+				time.Sleep(time.Second)
+
+				if time.Now().After(timeNow) {
+					break
+				}
+			}
+
+			if stillTerminating {
+				// the namespace has been in the "Terminating" phase
+				return nil, fmt.Errorf("cannot create namespace %s, stuck in \"Terminating\" phase", name)
+			}
+		} else {
+			return checkNS, nil
+		}
 	}
 
 	namespace := v1.Namespace{
@@ -658,7 +690,7 @@ func (a *Agent) GetNamespace(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(
+	checkNS, err := a.Clientset.CoreV1().Namespaces().Get(
 		context.TODO(),
 		name,
 		metav1.GetOptions{},
@@ -669,6 +701,12 @@ func (a *Agent) DeleteNamespace(name string) error {
 		return nil
 	}
 
+	// if the namespace was found but is in the "Terminating" phase
+	// we should ignore it and not return an error
+	if checkNS != nil && checkNS.Status.Phase == v1.NamespaceTerminating {
+		return nil
+	}
+
 	return a.Clientset.CoreV1().Namespaces().Delete(
 		context.TODO(),
 		name,

+ 7 - 0
internal/models/environment.go

@@ -19,9 +19,13 @@ type Environment struct {
 	Name string
 	Mode string
 
+	NewCommentsDisabled bool
+
 	// WebhookID uniquely identifies the environment when other fields (project, cluster)
 	// aren't present
 	WebhookID string `gorm:"unique"`
+
+	GithubWebhookID int64
 }
 
 func (e *Environment) ToEnvironmentType() *types.Environment {
@@ -33,6 +37,8 @@ func (e *Environment) ToEnvironmentType() *types.Environment {
 		GitRepoOwner:      e.GitRepoOwner,
 		GitRepoName:       e.GitRepoName,
 
+		NewCommentsDisabled: e.NewCommentsDisabled,
+
 		Name: e.Name,
 		Mode: e.Mode,
 	}
@@ -47,6 +53,7 @@ type Deployment struct {
 	Subdomain      string
 	PullRequestID  uint
 	GHDeploymentID int64
+	GHPRCommentID  int64
 	PRName         string
 	RepoName       string
 	RepoOwner      string

+ 1 - 0
internal/repository/environment.go

@@ -9,6 +9,7 @@ type EnvironmentRepository interface {
 	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)
+	UpdateEnvironment(environment *models.Environment) (*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)

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

@@ -119,6 +119,14 @@ func (repo *EnvironmentRepository) ListEnvironments(projectID, clusterID uint) (
 	return envs, nil
 }
 
+func (repo *EnvironmentRepository) UpdateEnvironment(environment *models.Environment) (*models.Environment, error) {
+	if err := repo.db.Save(environment).Error; err != nil {
+		return nil, err
+	}
+
+	return environment, nil
+}
+
 func (repo *EnvironmentRepository) DeleteEnvironment(env *models.Environment) (*models.Environment, error) {
 	if err := repo.db.Delete(&env).Error; err != nil {
 		return nil, err

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

@@ -42,6 +42,10 @@ func (repo *EnvironmentRepository) ListEnvironments(projectID, clusterID uint) (
 	panic("unimplemented")
 }
 
+func (repo *EnvironmentRepository) UpdateEnvironment(environment *models.Environment) (*models.Environment, error) {
+	panic("unimplemented")
+}
+
 func (repo *EnvironmentRepository) DeleteEnvironment(env *models.Environment) (*models.Environment, error) {
 	panic("unimplemented")
 }