Переглянути джерело

Merge branch 'nafees/preview-env-improvements' of github.com:porter-dev/porter into dev

jnfrati 3 роки тому
батько
коміт
7f913618f2

+ 2 - 1
api/server/handlers/environment/create.go

@@ -70,9 +70,10 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		Name:                request.Name,
 		GitRepoOwner:        owner,
 		GitRepoName:         name,
+		GitRepoBranches:     strings.Join(request.GitRepoBranches, ","),
 		Mode:                request.Mode,
 		WebhookID:           string(webhookUID),
-		NewCommentsDisabled: false,
+		NewCommentsDisabled: request.DisableNewComments,
 	}
 
 	// write Github actions files to the repo

+ 13 - 0
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -235,6 +235,13 @@ func fetchOpenPullRequests(
 	env *models.Environment,
 	deplInfoMap map[string]bool,
 ) ([]*types.PullRequest, error) {
+	branchesMap := make(map[string]bool)
+	envType := env.ToEnvironmentType()
+
+	for _, br := range envType.GitRepoBranches {
+		branchesMap[br] = true
+	}
+
 	openPRs, resp, err := client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
 		&github.PullRequestListOptions{
 			ListOptions: github.ListOptions{
@@ -269,6 +276,12 @@ func fetchOpenPullRequests(
 	}
 
 	for _, pr := range openPRs {
+		if len(envType.GitRepoBranches) > 0 {
+			if _, ok := branchesMap[pr.GetHead().GetRef()]; !ok {
+				continue
+			}
+		}
+
 		if _, ok := deplInfoMap[fmt.Sprintf("%s-%s-%d", env.GitRepoOwner, env.GitRepoName, pr.GetNumber())]; !ok {
 			prs = append(prs, &types.PullRequest{
 				Title:      pr.GetTitle(),

+ 90 - 0
api/server/handlers/environment/update_environment_settings.go

@@ -0,0 +1,90 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"reflect"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type UpdateEnvironmentSettingsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateEnvironmentSettingsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateDeploymentStatusHandler {
+	return &UpdateDeploymentStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateEnvironmentSettingsHandler) 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
+	}
+
+	request := &types.UpdateEnvironmentSettingsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		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(err))
+		return
+	}
+
+	changed := !reflect.DeepEqual(env.ToEnvironmentType().GitRepoBranches, request.GitRepoBranches)
+
+	if changed {
+		env.GitRepoBranches = strings.Join(request.GitRepoBranches, ",")
+	}
+
+	if request.DisableNewComments != env.NewCommentsDisabled {
+		env.NewCommentsDisabled = request.DisableNewComments
+		changed = true
+	}
+
+	if request.Mode != env.Mode {
+		env.Mode = request.Mode
+		changed = true
+	}
+
+	if changed {
+		_, err = c.Repo().Environment().UpdateEnvironment(env)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

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

@@ -89,6 +89,23 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 			webhookID, owner, repo, err)
 	}
 
+	envType := env.ToEnvironmentType()
+
+	if len(envType.GitRepoBranches) > 0 {
+		found := false
+
+		for _, br := range envType.GitRepoBranches {
+			if br == event.GetPullRequest().GetHead().GetRef() {
+				found = true
+				break
+			}
+		}
+
+		if !found {
+			return nil
+		}
+	}
+
 	// create deployment on GitHub API
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 

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

@@ -551,6 +551,36 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/environments/{environment_id}/settings ->
+		// environment.NewUpdateEnvironmentSettingsHandler
+		updateEnvironmentSettingsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPatch,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/environments/{environment_id}/settings",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		updateEnvironmentSettingsHandler := environment.NewUpdateEnvironmentSettingsHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: updateEnvironmentSettingsEndpoint,
+			Handler:  updateEnvironmentSettingsHandler,
+			Router:   r,
+		})
+
 	}
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewClusterListNamespacesHandler

+ 17 - 8
api/types/environment.go

@@ -3,12 +3,13 @@ package types
 import "time"
 
 type Environment struct {
-	ID                uint   `json:"id"`
-	ProjectID         uint   `json:"project_id"`
-	ClusterID         uint   `json:"cluster_id"`
-	GitInstallationID uint   `json:"git_installation_id"`
-	GitRepoOwner      string `json:"git_repo_owner"`
-	GitRepoName       string `json:"git_repo_name"`
+	ID                uint     `json:"id"`
+	ProjectID         uint     `json:"project_id"`
+	ClusterID         uint     `json:"cluster_id"`
+	GitInstallationID uint     `json:"git_installation_id"`
+	GitRepoOwner      string   `json:"git_repo_owner"`
+	GitRepoName       string   `json:"git_repo_name"`
+	GitRepoBranches   []string `json:"git_repo_branches"`
 
 	Name                 string `json:"name"`
 	Mode                 string `json:"mode"`
@@ -18,8 +19,10 @@ type Environment struct {
 }
 
 type CreateEnvironmentRequest struct {
-	Name string `json:"name" form:"required"`
-	Mode string `json:"mode" form:"oneof=auto manual" default:"manual"`
+	Name               string   `json:"name" form:"required"`
+	Mode               string   `json:"mode" form:"oneof=auto manual" default:"manual"`
+	DisableNewComments bool     `json:"disable_new_comments"`
+	GitRepoBranches    []string `json:"git_repo_branches"`
 }
 
 type GitHubMetadata struct {
@@ -129,3 +132,9 @@ type ToggleNewCommentRequest struct {
 }
 
 type ListEnvironmentsResponse []*Environment
+
+type UpdateEnvironmentSettingsRequest struct {
+	Mode               string   `json:"mode" form:"oneof=auto manual"`
+	DisableNewComments bool     `json:"disable_new_comments"`
+	GitRepoBranches    []string `json:"git_repo_branches"`
+}

+ 10 - 4
dashboard/src/components/DocsHelper.tsx

@@ -39,16 +39,16 @@ const DocsHelper: React.FC<Props> = ({
       >
         <div>
           <HelperButton onClick={handleTooltipToggle}>
-            <i className="material-icons">help_outline</i>
+            <Icon className="material-icons">help_outline</Icon>
           </HelperButton>
           {open && (
             <Tooltip placement={placement}>
               <StyledContent onClick={handleTooltipOpen}>
                 {tooltipText}
                 {link && (
-                <A target="_blank" href={link}>
-                  Documentation {">"}
-                </A>
+                  <A target="_blank" href={link}>
+                    Documentation {">"}
+                  </A>
                 )}
               </StyledContent>
             </Tooltip>
@@ -167,3 +167,9 @@ const DocsHelperContainer = styled.div<{ disableMargin: boolean }>`
   }}
   position: relative;
 `;
+
+const Icon = styled.i`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;

+ 15 - 11
dashboard/src/components/SearchSelector.tsx

@@ -2,21 +2,22 @@ import _ from "lodash";
 import React, { useMemo, useState } from "react";
 import styled from "styled-components";
 
-type Props = {
-  options: any[];
-  onSelect: (option: any) => void;
+type Props<T = any> = {
+  options: T[];
+  onSelect: (option: T) => void;
   label?: string;
   dropdownLabel?: string;
-  getOptionLabel?: (option: any) => string;
-  filterBy?: ((option: any) => string) | string;
+  getOptionLabel?: (option: T) => string;
+  filterBy?: ((option: T) => string) | string;
   noOptionsText?: string;
   dropdownMaxHeight?: string;
   renderAddButton?: any;
   className?: string;
-  renderOptionIcon?: (option: any) => React.ReactNode;
+  renderOptionIcon?: (option: T) => React.ReactNode;
+  placeholder?: string;
 };
 
-const SearchSelector = ({
+function SearchSelector<O = any>({
   options,
   onSelect,
   label,
@@ -28,7 +29,8 @@ const SearchSelector = ({
   renderAddButton,
   className,
   renderOptionIcon,
-}: Props) => {
+  placeholder = "Find or add a tag...", // legacy value to not break existing code
+}: Props<O>) {
   const [isExpanded, setIsExpanded] = useState(false);
   const [filter, setFilter] = useState("");
 
@@ -57,7 +59,9 @@ const SearchSelector = ({
       );
     }
 
-    return options.filter((option) => option.includes(filter));
+    return options.filter((option) =>
+      typeof option === "string" ? option.includes(filter) : true
+    );
   }, [filter, options]);
 
   return (
@@ -71,7 +75,7 @@ const SearchSelector = ({
       >
         <Input
           value={filter}
-          placeholder="Find or add a tag..."
+          placeholder={placeholder}
           onClick={(e) => {
             setIsExpanded(false);
             e.stopPropagation();
@@ -139,7 +143,7 @@ const SearchSelector = ({
       </InputWrapper>
     </>
   );
-};
+}
 
 export default SearchSelector;
 

+ 16 - 4
dashboard/src/components/form-components/CheckboxRow.tsx

@@ -7,6 +7,9 @@ type PropsType = {
   toggle: () => void;
   isRequired?: boolean;
   disabled?: boolean;
+  wrapperStyles?: {
+    disableMargin?: boolean;
+  };
 };
 
 type StateType = {};
@@ -14,7 +17,9 @@ type StateType = {};
 export default class CheckboxRow extends Component<PropsType, StateType> {
   render() {
     return (
-      <StyledCheckboxRow>
+      <StyledCheckboxRow
+        disableMargin={this.props.wrapperStyles?.disableMargin}
+      >
         <CheckboxWrapper
           disabled={this.props.disabled}
           onClick={!this.props.disabled ? this.props.toggle : undefined}
@@ -65,9 +70,16 @@ const Checkbox = styled.div<{ checked: boolean }>`
   }
 `;
 
-const StyledCheckboxRow = styled.div`
+const StyledCheckboxRow = styled.div<{ disableMargin?: boolean }>`
   display: flex;
   align-items: center;
-  margin-bottom: 15px;
-  margin-top: 20px;
+  ${({ disableMargin }) => {
+    if (disableMargin) {
+      return "";
+    }
+    return `
+      margin-bottom: 15px;
+      margin-top: 20px;
+    `;
+  }}
 `;

+ 60 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/BranchFilterSelector.tsx

@@ -0,0 +1,60 @@
+import SearchSelector from "components/SearchSelector";
+import React, { useMemo } from "react";
+
+const BranchFilterSelector = ({
+  value,
+  options,
+  onChange,
+}: {
+  value: string[];
+  options: string[];
+  onChange: (value: string[]) => void;
+}) => {
+  const filteredBranches = useMemo(() => {
+    if (!options.length) {
+      return [];
+    }
+
+    if (value.find((branch) => branch === "")) {
+      return options;
+    }
+
+    return options.filter((branch) => !value.includes(branch));
+  }, [options, value]);
+
+  const handleAddBranch = (branch: string) => {
+    const newSelectedBranches = [...value, branch];
+
+    onChange(newSelectedBranches);
+  };
+
+  const handleDeleteBranch = (branch: string) => {
+    const newSelectedBranches = value.filter(
+      (selectedBranch) => selectedBranch !== branch
+    );
+
+    onChange(newSelectedBranches);
+  };
+
+  return (
+    <>
+      <SearchSelector
+        options={filteredBranches}
+        onSelect={(newBranch) => handleAddBranch(newBranch)}
+        getOptionLabel={(option) => option}
+        placeholder="Find or add a branch..."
+      />
+      {/* List selected branches  */}
+      <ul>
+        {value.map((branch) => (
+          <li key={branch}>
+            {branch}
+            <button onClick={() => handleDeleteBranch(branch)}>Remove</button>
+          </li>
+        ))}
+      </ul>
+    </>
+  );
+};
+
+export default BranchFilterSelector;

+ 12 - 76
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -18,6 +18,7 @@ import { PreviewEnvironmentsHeader } from "../components/PreviewEnvironmentsHead
 import SearchBar from "components/SearchBar";
 import CheckboxRow from "components/form-components/CheckboxRow";
 import DocsHelper from "components/DocsHelper";
+import EnvironmentSettings from "../environments/EnvironmentSettings";
 
 const AvailableStatusFilters = [
   "all",
@@ -36,7 +37,6 @@ const DeploymentList = () => {
   const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [searchValue, setSearchValue] = useState("");
-  const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
 
   const [
     statusSelectorVal,
@@ -69,18 +69,6 @@ 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");
 
@@ -102,29 +90,18 @@ const DeploymentList = () => {
     let isSubscribed = true;
     setIsLoading(true);
 
-    Promise.allSettled([getPRDeploymentList(), getEnvironment()]).then(
-      ([getDeploymentsResponse, getEnvironmentResponse]) => {
-        const deploymentList =
-          getDeploymentsResponse.status === "fulfilled"
-            ? getDeploymentsResponse.value.data
-            : {};
-        const environmentList =
-          getEnvironmentResponse.status === "fulfilled"
-            ? getEnvironmentResponse.value.data
-            : {};
-
-        if (!isSubscribed) {
-          return;
-        }
+    getPRDeploymentList().then(({ data }) => {
+      const deploymentList = data;
 
-        setDeploymentList(deploymentList.deployments || []);
-        setPullRequests(deploymentList.pull_requests || []);
+      if (!isSubscribed) {
+        return;
+      }
 
-        setNewCommentsDisabled(environmentList.new_comments_disabled || false);
+      setDeploymentList(deploymentList.deployments || []);
+      setPullRequests(deploymentList.pull_requests || []);
 
-        setIsLoading(false);
-      }
-    );
+      setIsLoading(false);
+    });
 
     return () => {
       isSubscribed = false;
@@ -141,13 +118,6 @@ const DeploymentList = () => {
       setHasError(true);
       console.error(error);
     }
-    try {
-      const { data } = await getEnvironment();
-      setNewCommentsDisabled(data.new_comments_disabled || false);
-    } catch (error) {
-      setHasError(true);
-      console.error(error);
-    }
     setIsLoading(false);
   };
 
@@ -262,24 +232,6 @@ 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 />
@@ -327,27 +279,11 @@ const DeploymentList = () => {
               dropdownWidth="230px"
               closeOverlay={true}
             />
+            <EnvironmentSettings environmentId={environment_id} />
           </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>

+ 2 - 9
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx

@@ -74,7 +74,7 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
 
   return (
     <>
-      {showDeleteModal ? (
+      {/* {showDeleteModal ? (
         <Modal
           title={`Remove Preview Envs for ${git_repo_owner}/${git_repo_name}`}
           width="800px"
@@ -102,7 +102,7 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             </DeleteButton>
           </ActionWrapper>
         </Modal>
-      ) : null}
+      ) : null} */}
       <EnvironmentCardWrapper
         to={`/preview-environments/deployments/${id}/${git_repo_owner}/${git_repo_name}`}
       >
@@ -141,13 +141,6 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             )}
           </Status>
         </DataContainer>
-        <OptionWrapper>
-          <Options.Dropdown expandIcon="more_vert" shrinkIcon="more_vert">
-            <Options.Option onClick={() => setShowDeleteModal(true)}>
-              <i className="material-icons">delete</i> Delete
-            </Options.Option>
-          </Options.Dropdown>
-        </OptionWrapper>
       </EnvironmentCardWrapper>
     </>
   );

+ 252 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx

@@ -0,0 +1,252 @@
+import DocsHelper from "components/DocsHelper";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import Loading from "components/Loading";
+import SaveButton from "components/SaveButton";
+import Modal from "main/home/modals/Modal";
+import React, { useContext, useReducer, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled, { css, keyframes } from "styled-components";
+import BranchFilterSelector from "../components/BranchFilterSelector";
+import { Environment } from "../types";
+
+const EnvironmentSettings = ({ environmentId }: { environmentId: string }) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const [show, toggle] = useReducer((prev) => !prev, false);
+
+  const [environment, setEnvironment] = useState<Environment>();
+
+  const [isNewCommentsDisabled, setIsNewCommentsDisabled] = useState(false);
+
+  const [deploymentMode, setDeploymentMode] = useState<Environment["mode"]>(
+    "auto"
+  );
+  const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
+  const [availableBranches, setAvailableBranches] = useState<string[]>([]);
+
+  const [saveStatus, setSaveStatus] = useState("");
+
+  const [isLoading, setIsLoading] = useState(false);
+
+  const getEnvironment = async () => {
+    return api.getEnvironment<Environment>(
+      "<token>",
+      {},
+      {
+        project_id: currentProject.id,
+        cluster_id: currentCluster.id,
+        environment_id: Number(environmentId),
+      }
+    );
+  };
+
+  const getBranches = async () => {
+    return api.getBranches<string[]>(
+      "<token>",
+      {},
+      {
+        project_id: environment.project_id,
+        git_repo_id: environment.git_installation_id,
+        kind: "github",
+        owner: environment.git_repo_owner,
+        name: environment.git_repo_name,
+      }
+    );
+  };
+
+  const handleToggleCommentStatus = async (currentlyDisabled: boolean) => {
+    setIsNewCommentsDisabled(!currentlyDisabled);
+  };
+
+  const handleOpen = async () => {
+    setIsLoading(true);
+
+    try {
+      const [environmentResponse, branchesResponse] = await Promise.allSettled([
+        getEnvironment(),
+        getBranches(),
+      ]);
+
+      if (environmentResponse.status === "fulfilled") {
+        const environment = environmentResponse.value.data;
+
+        setEnvironment(environment);
+        setIsNewCommentsDisabled(environment.disable_new_comments);
+        setDeploymentMode(environment.mode);
+        setSelectedBranches(environment.git_repo_branches.filter(Boolean));
+      } else {
+        throw new Error("Failed to get environment");
+      }
+
+      if (branchesResponse.status === "fulfilled") {
+        const branches = branchesResponse.value.data;
+        setAvailableBranches(branches);
+      } else {
+        throw new Error("Failed to get branches");
+      }
+    } catch (error) {
+      console.error(error);
+    } finally {
+      setIsLoading(false);
+      toggle();
+    }
+  };
+
+  const handleSave = () => {
+    setSaveStatus("loading");
+
+    api
+      .updateEnvironment(
+        "<token>",
+        {
+          mode: deploymentMode,
+          new_comment_enabled: !isNewCommentsDisabled,
+          git_repo_branches: selectedBranches,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: Number(environmentId),
+        }
+      )
+      .then(() => {
+        setSaveStatus("successful");
+        setTimeout(() => {
+          setSaveStatus(""), toggle();
+        }, 2000);
+        toggle();
+      })
+      .catch((error) => {
+        setCurrentError(error);
+        setSaveStatus("Couldn't update the environment, please try again.");
+      })
+      .finally(() => {
+        setSaveStatus("");
+      });
+  };
+
+  return (
+    <>
+      <SettingsButton type="button" onClick={handleOpen} isLoading={isLoading}>
+        <i className="material-icons">settings</i>
+      </SettingsButton>
+      {show && (
+        <Modal
+          height="fit-content"
+          onRequestClose={toggle}
+          title={`Settings for ${environment.git_repo_owner}/${environment.git_repo_name}`}
+        >
+          <>
+            {/* Add branch selector (probably will have to create a new component that lets the user pick multiple) */}
+            <Heading>Allowed Branches</Heading>
+            <Helper>
+              If the pull request has a base branch included in this list, it
+              will be allowed to be deployed.
+            </Helper>
+            <BranchFilterSelector
+              value={selectedBranches}
+              onChange={setSelectedBranches}
+              options={availableBranches}
+            />
+
+            <Heading>Automatic pull request deployments</Heading>
+            <Helper>
+              If you enable this option, the new pull requests will be
+              automatically deployed.
+            </Helper>
+            {/* Add checkbox to change deployment mode (auto | manaul) */}
+            <CheckboxWrapper>
+              <CheckboxRow
+                label="Enable automatic deploys"
+                checked={deploymentMode === "auto"}
+                toggle={() =>
+                  setDeploymentMode((prev) =>
+                    prev === "auto" ? "manual" : "auto"
+                  )
+                }
+                wrapperStyles={{
+                  disableMargin: true,
+                }}
+              />
+              <DocsHelper
+                disableMargin
+                tooltipText="Automatically create a Preview Environment for each new pull request in the repository. By default, preview environments must be manually created per-PR."
+              />
+            </CheckboxWrapper>
+
+            <Heading>Disable new comments for new deployments</Heading>
+            <Helper>
+              When enabled new comments will not be created for new deployments.
+              Instead the last comment will be updated.
+            </Helper>
+            <CheckboxWrapper>
+              <CheckboxRow
+                label="Disable new comments for deployments"
+                checked={isNewCommentsDisabled}
+                toggle={() => handleToggleCommentStatus(isNewCommentsDisabled)}
+                wrapperStyles={{
+                  disableMargin: true,
+                }}
+              />
+              <DocsHelper
+                disableMargin
+                tooltipText="When checked, comments for every new deployment are disabled. Instead, the most recent comment is updated each time."
+                placement="top-end"
+              />
+            </CheckboxWrapper>
+            <SubmitButton
+              onClick={handleSave}
+              clearPosition
+              text="Save"
+              statusPosition="right"
+              status={saveStatus}
+            />
+          </>
+        </Modal>
+      )}
+    </>
+  );
+};
+
+export default EnvironmentSettings;
+
+const rotatingAnimation = keyframes`
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+`;
+
+const iconAnimation = css`
+  animation: ${rotatingAnimation} 1s linear infinite;
+`;
+
+const SettingsButton = styled.button<{ isLoading: boolean }>`
+  background: none;
+  color: white;
+  border: none;
+  margin-left: 10px;
+  cursor: pointer;
+  > i {
+    font-size: 20px;
+    ${({ isLoading }) => (isLoading ? iconAnimation : "")}
+  }
+`;
+
+const CheckboxWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 20px;
+`;
+
+const SubmitButton = styled(SaveButton)`
+  margin-top: 20px;
+  align-items: flex-end;
+`;

+ 2 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx

@@ -85,10 +85,10 @@ const EnvironmentsList = () => {
             <Loading />
           </FloatingPlaceholder>
         ) : null}
-
         <ControlRow>
           <ButtonEnablePREnvironments setIsReady={setButtonIsReady} />
         </ControlRow>
+
         {environments.length === 0 ? (
           <Placeholder>
             No repositories found with Preview Environments enabled.
@@ -143,6 +143,7 @@ const FloatingPlaceholder = styled(Placeholder)`
   width: 100%;
   height: 100%;
   margin-top: 0px;
+  z-index: 999;
 `;
 
 const EnvironmentsGrid = styled.div`

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

@@ -37,6 +37,8 @@ export type Environment = {
   name: string;
   git_repo_owner: string;
   git_repo_name: string;
+  git_repo_branches: string[];
+  disable_new_comments: boolean;
   last_deployment_status: DeploymentStatusUnion;
   deployment_count: number;
   mode: "manual" | "auto";

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

@@ -159,6 +159,23 @@ const createEnvironment = baseApi<
   return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/environment`;
 });
 
+const updateEnvironment = baseApi<
+  {
+    mode: "auto" | "manual";
+    new_comment_enabled: boolean;
+    git_repo_branches: string[]; // Array with branch names
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    environment_id: number;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, environment_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/settings`
+);
+
 const deleteEnvironment = baseApi<
   {
     name: string;
@@ -2180,6 +2197,7 @@ export default {
   createGitlabIntegration,
   createEmailVerification,
   createEnvironment,
+  updateEnvironment,
   deleteEnvironment,
   createPreviewEnvironmentDeployment,
   reenablePreviewEnvironmentDeployment,

+ 4 - 0
internal/models/environment.go

@@ -1,6 +1,8 @@
 package models
 
 import (
+	"strings"
+
 	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 )
@@ -15,6 +17,7 @@ type Environment struct {
 	GitInstallationID uint
 	GitRepoOwner      string
 	GitRepoName       string
+	GitRepoBranches   string
 
 	Name string
 	Mode string
@@ -36,6 +39,7 @@ func (e *Environment) ToEnvironmentType() *types.Environment {
 		GitInstallationID: e.GitInstallationID,
 		GitRepoOwner:      e.GitRepoOwner,
 		GitRepoName:       e.GitRepoName,
+		GitRepoBranches:   strings.Split(e.GitRepoBranches, ","),
 
 		NewCommentsDisabled: e.NewCommentsDisabled,