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

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

jnfrati 3 лет назад
Родитель
Сommit
617b023a96

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

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

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

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

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

@@ -0,0 +1,102 @@
+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,
+) *UpdateEnvironmentSettingsHandler {
+	return &UpdateEnvironmentSettingsHandler{
+		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
+	}
+
+	var newBranches []string
+
+	for _, br := range request.GitRepoBranches {
+		name := strings.TrimSpace(br)
+
+		if len(name) > 0 {
+			newBranches = append(newBranches, name)
+		}
+	}
+
+	changed := !reflect.DeepEqual(env.ToEnvironmentType().GitRepoBranches, newBranches)
+
+	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 {
+		env, err = c.Repo().Environment().UpdateEnvironment(env)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	c.WriteResult(w, r, env.ToEnvironmentType())
+}

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

@@ -89,6 +89,23 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 			webhookID, owner, repo, err)
 			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
 	// create deployment on GitHub API
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 

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

@@ -551,6 +551,36 @@ func getClusterRoutes(
 			Router:   r,
 			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
 	// 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"
 import "time"
 
 
 type Environment struct {
 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"`
 	Name                 string `json:"name"`
 	Mode                 string `json:"mode"`
 	Mode                 string `json:"mode"`
@@ -18,8 +19,10 @@ type Environment struct {
 }
 }
 
 
 type CreateEnvironmentRequest 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 {
 type GitHubMetadata struct {
@@ -129,3 +132,9 @@ type ToggleNewCommentRequest struct {
 }
 }
 
 
 type ListEnvironmentsResponse []*Environment
 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>
         <div>
           <HelperButton onClick={handleTooltipToggle}>
           <HelperButton onClick={handleTooltipToggle}>
-            <i className="material-icons">help_outline</i>
+            <Icon className="material-icons">help_outline</Icon>
           </HelperButton>
           </HelperButton>
           {open && (
           {open && (
             <Tooltip placement={placement}>
             <Tooltip placement={placement}>
               <StyledContent onClick={handleTooltipOpen}>
               <StyledContent onClick={handleTooltipOpen}>
                 {tooltipText}
                 {tooltipText}
                 {link && (
                 {link && (
-                <A target="_blank" href={link}>
-                  Documentation {">"}
-                </A>
+                  <A target="_blank" href={link}>
+                    Documentation {">"}
+                  </A>
                 )}
                 )}
               </StyledContent>
               </StyledContent>
             </Tooltip>
             </Tooltip>
@@ -167,3 +167,9 @@ const DocsHelperContainer = styled.div<{ disableMargin: boolean }>`
   }}
   }}
   position: relative;
   position: relative;
 `;
 `;
+
+const Icon = styled.i`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;

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

@@ -1,22 +1,25 @@
 import _ from "lodash";
 import _ from "lodash";
 import React, { useMemo, useState } from "react";
 import React, { useMemo, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
+import Loading from "./Loading";
 
 
-type Props = {
-  options: any[];
-  onSelect: (option: any) => void;
+type Props<T = any> = {
+  options: T[];
+  onSelect: (option: T) => void;
   label?: string;
   label?: string;
   dropdownLabel?: string;
   dropdownLabel?: string;
-  getOptionLabel?: (option: any) => string;
-  filterBy?: ((option: any) => string) | string;
+  getOptionLabel?: (option: T) => string;
+  filterBy?: ((option: T) => string) | string;
   noOptionsText?: string;
   noOptionsText?: string;
   dropdownMaxHeight?: string;
   dropdownMaxHeight?: string;
   renderAddButton?: any;
   renderAddButton?: any;
   className?: string;
   className?: string;
-  renderOptionIcon?: (option: any) => React.ReactNode;
+  renderOptionIcon?: (option: T) => React.ReactNode;
+  placeholder?: string;
+  showLoading?: boolean;
 };
 };
 
 
-const SearchSelector = ({
+function SearchSelector<O = any>({
   options,
   options,
   onSelect,
   onSelect,
   label,
   label,
@@ -28,7 +31,9 @@ const SearchSelector = ({
   renderAddButton,
   renderAddButton,
   className,
   className,
   renderOptionIcon,
   renderOptionIcon,
-}: Props) => {
+  placeholder = "Find or add a tag...", // legacy value to not break existing code
+  showLoading = false,
+}: Props<O>) {
   const [isExpanded, setIsExpanded] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
   const [filter, setFilter] = useState("");
   const [filter, setFilter] = useState("");
 
 
@@ -57,9 +62,22 @@ const SearchSelector = ({
       );
       );
     }
     }
 
 
-    return options.filter((option) => option.includes(filter));
+    return options.filter((option) =>
+      typeof option === "string" ? option.includes(filter) : true
+    );
   }, [filter, options]);
   }, [filter, options]);
 
 
+  if (showLoading) {
+    return (
+      <>
+        {label?.length ? <Label>{label}</Label> : null}
+        <InputWrapper className={className}>
+          <Loading />
+        </InputWrapper>
+      </>
+    );
+  }
+
   return (
   return (
     <>
     <>
       {label?.length ? <Label>{label}</Label> : null}
       {label?.length ? <Label>{label}</Label> : null}
@@ -71,7 +89,7 @@ const SearchSelector = ({
       >
       >
         <Input
         <Input
           value={filter}
           value={filter}
-          placeholder="Find or add a tag..."
+          placeholder={placeholder}
           onClick={(e) => {
           onClick={(e) => {
             setIsExpanded(false);
             setIsExpanded(false);
             e.stopPropagation();
             e.stopPropagation();
@@ -139,7 +157,7 @@ const SearchSelector = ({
       </InputWrapper>
       </InputWrapper>
     </>
     </>
   );
   );
-};
+}
 
 
 export default SearchSelector;
 export default SearchSelector;
 
 
@@ -152,6 +170,7 @@ const InputWrapper = styled.div`
   background: #ffffff11;
   background: #ffffff11;
   position: relative;
   position: relative;
   width: 100%;
   width: 100%;
+  min-height: 37px;
 `;
 `;
 
 
 const Input = styled.input`
 const Input = styled.input`

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

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

+ 115 - 19
dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx

@@ -3,9 +3,7 @@ import Heading from "components/form-components/Heading";
 import RepoList from "components/repo-selector/RepoList";
 import RepoList from "components/repo-selector/RepoList";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import DocsHelper from "components/DocsHelper";
 import DocsHelper from "components/DocsHelper";
-import { ActionConfigType } from "shared/types";
-import TitleSection from "components/TitleSection";
-import { useRouteMatch } from "react-router";
+import { GithubActionConfigType } from "shared/types";
 import React, { useContext, useEffect, useState } from "react";
 import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import api from "shared/api";
 import api from "shared/api";
@@ -15,6 +13,8 @@ import { Environment } from "./types";
 import DashboardHeader from "../DashboardHeader";
 import DashboardHeader from "../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import PullRequestIcon from "assets/pull_request_icon.svg";
 import CheckboxRow from "components/form-components/CheckboxRow";
 import CheckboxRow from "components/form-components/CheckboxRow";
+import BranchFilterSelector from "./components/BranchFilterSelector";
+import Helper from "components/form-components/Helper";
 
 
 const ConnectNewRepo: React.FC = () => {
 const ConnectNewRepo: React.FC = () => {
   const { currentProject, currentCluster, setCurrentError } = useContext(
   const { currentProject, currentCluster, setCurrentError } = useContext(
@@ -30,16 +30,21 @@ const ConnectNewRepo: React.FC = () => {
   const { pushFiltered } = useRouting();
   const { pushFiltered } = useRouting();
 
 
   // NOTE: git_repo_id is a misnomer as this actually refers to the github app's installation id.
   // NOTE: git_repo_id is a misnomer as this actually refers to the github app's installation id.
-  const [actionConfig, setActionConfig] = useState<ActionConfigType>({
+  const [actionConfig, setActionConfig] = useState<GithubActionConfigType>({
     git_repo: null,
     git_repo: null,
     image_repo_uri: null,
     image_repo_uri: null,
     git_branch: null,
     git_branch: null,
     git_repo_id: 0,
     git_repo_id: 0,
+    kind: "github",
   });
   });
 
 
-  useEffect(() => {}, [repo]);
+  // Branch selector data
+  const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
+  const [availableBranches, setAvailableBranches] = useState<string[]>([]);
+  const [isLoadingBranches, setIsLoadingBranches] = useState(false);
 
 
-  const { url } = useRouteMatch();
+  // Disable new comments data
+  const [isNewCommentsDisabled, setIsNewCommentsDisabled] = useState(false);
 
 
   useEffect(() => {
   useEffect(() => {
     api
     api
@@ -65,6 +70,43 @@ const ConnectNewRepo: React.FC = () => {
       .catch(() => {});
       .catch(() => {});
   }, []);
   }, []);
 
 
+  useEffect(() => {
+    if (!actionConfig.git_repo || !actionConfig.git_repo_id) {
+      return;
+    }
+
+    let isSubscribed = true;
+    const repoName = actionConfig.git_repo.split("/")[1];
+    const repoOwner = actionConfig.git_repo.split("/")[0];
+    setIsLoadingBranches(true);
+    api
+      .getBranches<string[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          kind: "github",
+          name: repoName,
+          owner: repoOwner,
+          git_repo_id: actionConfig.git_repo_id,
+        }
+      )
+      .then(({ data }) => {
+        if (isSubscribed) {
+          setIsLoadingBranches(false);
+          setAvailableBranches(data);
+        }
+      })
+      .catch(() => {
+        if (isSubscribed) {
+          setIsLoadingBranches(false);
+          setCurrentError(
+            "Couldn't load branches for this repository, using all branches by default."
+          );
+        }
+      });
+  }, [actionConfig]);
+
   const addRepo = () => {
   const addRepo = () => {
     let [owner, repoName] = repo.split("/");
     let [owner, repoName] = repo.split("/");
     setStatus("loading");
     setStatus("loading");
@@ -74,6 +116,8 @@ const ConnectNewRepo: React.FC = () => {
         {
         {
           name: `preview`,
           name: `preview`,
           mode: enableAutomaticDeployments ? "auto" : "manual",
           mode: enableAutomaticDeployments ? "auto" : "manual",
+          disable_new_comments: isNewCommentsDisabled,
+          git_repo_branches: selectedBranches,
         },
         },
         {
         {
           project_id: currentProject.id,
           project_id: currentProject.id,
@@ -114,7 +158,7 @@ const ConnectNewRepo: React.FC = () => {
       <br />
       <br />
       <RepoList
       <RepoList
         actionConfig={actionConfig}
         actionConfig={actionConfig}
-        setActionConfig={(a: ActionConfigType) => {
+        setActionConfig={(a: GithubActionConfigType) => {
           setActionConfig(a);
           setActionConfig(a);
           setRepo(a.git_repo);
           setRepo(a.git_repo);
         }}
         }}
@@ -131,25 +175,71 @@ const ConnectNewRepo: React.FC = () => {
         />
         />
       </HelperContainer>
       </HelperContainer>
 
 
-      <FlexWrap>
+      <Heading>Automatic pull request deployments</Heading>
+      <Helper>
+        If you enable this option, the new pull requests will be automatically
+        deployed.
+      </Helper>
+      <CheckboxWrapper>
         <CheckboxRow
         <CheckboxRow
-          label="Enable automatic deployments"
+          label="Enable automatic deploys"
           checked={enableAutomaticDeployments}
           checked={enableAutomaticDeployments}
-          toggle={() => setEnableAutomaticDeployments((prev) => !prev)}
+          toggle={() =>
+            setEnableAutomaticDeployments(!enableAutomaticDeployments)
+          }
+          wrapperStyles={{
+            disableMargin: true,
+          }}
         />
         />
-        <Div>
-          <DocsHelper
-            disableMargin
-            tooltipText="Automatically create a Preview Environment for each new pull request in the repository. By default, preview environments must be manually created per-PR."
-            placement="top-start"
-          />
-        </Div>
-      </FlexWrap>
+        <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={() => setIsNewCommentsDisabled(!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>
+
+      <Heading>Select allowed branches</Heading>
+      <Helper>
+        If the pull request has a base branch included in this list, it will be
+        allowed to be deployed.
+        <br />
+        (Leave empty to allow all branches)
+      </Helper>
+      <BranchFilterSelector
+        onChange={setSelectedBranches}
+        options={availableBranches}
+        value={selectedBranches}
+        showLoading={isLoadingBranches}
+      />
 
 
       <ActionContainer>
       <ActionContainer>
         <SaveButton
         <SaveButton
           text="Add Repository"
           text="Add Repository"
-          disabled={actionConfig.git_repo_id ? false : true}
+          disabled={
+            (actionConfig.git_repo_id ? false : true) ||
+            isLoadingBranches ||
+            status === "loading"
+          }
           onClick={addRepo}
           onClick={addRepo}
           makeFlush={true}
           makeFlush={true}
           clearPosition={true}
           clearPosition={true}
@@ -273,3 +363,9 @@ const HeaderSection = styled.div`
     margin-right: 7px;
     margin-right: 7px;
   }
   }
 `;
 `;
+
+const CheckboxWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 20px;
+`;

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

@@ -0,0 +1,68 @@
+import SearchSelector from "components/SearchSelector";
+import React, { useMemo } from "react";
+import styled from "styled-components";
+
+const BranchFilterSelector = ({
+  value,
+  options,
+  onChange,
+  showLoading,
+}: {
+  value: string[];
+  options: string[];
+  onChange: (value: string[]) => void;
+  showLoading?: boolean;
+}) => {
+  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);
+  };
+
+  const placeholder = options?.length
+    ? "Find or add a branch..."
+    : "No branches found for current repository.";
+
+  return (
+    <>
+      <SearchSelector
+        options={filteredBranches}
+        onSelect={(newBranch) => handleAddBranch(newBranch)}
+        getOptionLabel={(option) => option}
+        placeholder={placeholder}
+        showLoading={showLoading}
+      />
+      {/* 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 SearchBar from "components/SearchBar";
 import CheckboxRow from "components/form-components/CheckboxRow";
 import CheckboxRow from "components/form-components/CheckboxRow";
 import DocsHelper from "components/DocsHelper";
 import DocsHelper from "components/DocsHelper";
+import EnvironmentSettings from "../environments/EnvironmentSettings";
 
 
 const AvailableStatusFilters = [
 const AvailableStatusFilters = [
   "all",
   "all",
@@ -36,7 +37,6 @@ const DeploymentList = () => {
   const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
   const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [searchValue, setSearchValue] = useState("");
   const [searchValue, setSearchValue] = useState("");
-  const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
 
 
   const [
   const [
     statusSelectorVal,
     statusSelectorVal,
@@ -69,18 +69,6 @@ const DeploymentList = () => {
     // return mockRequest();
     // return mockRequest();
   };
   };
 
 
-  const getEnvironment = () => {
-    return api.getEnvironment(
-      "<token>",
-      {},
-      {
-        project_id: currentProject.id,
-        cluster_id: currentCluster.id,
-        environment_id: Number(environment_id),
-      }
-    );
-  };
-
   useEffect(() => {
   useEffect(() => {
     const status_filter = getQueryParam("status_filter");
     const status_filter = getQueryParam("status_filter");
 
 
@@ -102,29 +90,18 @@ const DeploymentList = () => {
     let isSubscribed = true;
     let isSubscribed = true;
     setIsLoading(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 () => {
     return () => {
       isSubscribed = false;
       isSubscribed = false;
@@ -141,13 +118,6 @@ const DeploymentList = () => {
       setHasError(true);
       setHasError(true);
       console.error(error);
       console.error(error);
     }
     }
-    try {
-      const { data } = await getEnvironment();
-      setNewCommentsDisabled(data.new_comments_disabled || false);
-    } catch (error) {
-      setHasError(true);
-      console.error(error);
-    }
     setIsLoading(false);
     setIsLoading(false);
   };
   };
 
 
@@ -262,24 +232,6 @@ const DeploymentList = () => {
     setStatusSelectorVal(value);
     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 (
   return (
     <>
     <>
       <PreviewEnvironmentsHeader />
       <PreviewEnvironmentsHeader />
@@ -327,27 +279,11 @@ const DeploymentList = () => {
               dropdownWidth="230px"
               dropdownWidth="230px"
               closeOverlay={true}
               closeOverlay={true}
             />
             />
+            <EnvironmentSettings environmentId={environment_id} />
           </StyledStatusSelector>
           </StyledStatusSelector>
         </ActionsWrapper>
         </ActionsWrapper>
       </Flex>
       </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>
       <Container>
         <EventsGrid>{renderDeploymentList()}</EventsGrid>
         <EventsGrid>{renderDeploymentList()}</EventsGrid>
       </Container>
       </Container>

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

@@ -74,7 +74,7 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
 
 
   return (
   return (
     <>
     <>
-      {showDeleteModal ? (
+      {/* {showDeleteModal ? (
         <Modal
         <Modal
           title={`Remove Preview Envs for ${git_repo_owner}/${git_repo_name}`}
           title={`Remove Preview Envs for ${git_repo_owner}/${git_repo_name}`}
           width="800px"
           width="800px"
@@ -102,7 +102,7 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             </DeleteButton>
             </DeleteButton>
           </ActionWrapper>
           </ActionWrapper>
         </Modal>
         </Modal>
-      ) : null}
+      ) : null} */}
       <EnvironmentCardWrapper
       <EnvironmentCardWrapper
         to={`/preview-environments/deployments/${id}/${git_repo_owner}/${git_repo_name}`}
         to={`/preview-environments/deployments/${id}/${git_repo_owner}/${git_repo_name}`}
       >
       >
@@ -141,13 +141,6 @@ const EnvironmentCard = ({ environment, onDelete }: Props) => {
             )}
             )}
           </Status>
           </Status>
         </DataContainer>
         </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>
       </EnvironmentCardWrapper>
     </>
     </>
   );
   );

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

@@ -0,0 +1,254 @@
+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.
+              <br />
+              (Leave empty to allow all branches)
+            </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 />
             <Loading />
           </FloatingPlaceholder>
           </FloatingPlaceholder>
         ) : null}
         ) : null}
-
         <ControlRow>
         <ControlRow>
           <ButtonEnablePREnvironments setIsReady={setButtonIsReady} />
           <ButtonEnablePREnvironments setIsReady={setButtonIsReady} />
         </ControlRow>
         </ControlRow>
+
         {environments.length === 0 ? (
         {environments.length === 0 ? (
           <Placeholder>
           <Placeholder>
             No repositories found with Preview Environments enabled.
             No repositories found with Preview Environments enabled.
@@ -143,6 +143,7 @@ const FloatingPlaceholder = styled(Placeholder)`
   width: 100%;
   width: 100%;
   height: 100%;
   height: 100%;
   margin-top: 0px;
   margin-top: 0px;
+  z-index: 999;
 `;
 `;
 
 
 const EnvironmentsGrid = styled.div`
 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;
   name: string;
   git_repo_owner: string;
   git_repo_owner: string;
   git_repo_name: string;
   git_repo_name: string;
+  git_repo_branches: string[];
+  disable_new_comments: boolean;
   last_deployment_status: DeploymentStatusUnion;
   last_deployment_status: DeploymentStatusUnion;
   deployment_count: number;
   deployment_count: number;
   mode: "manual" | "auto";
   mode: "manual" | "auto";

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

@@ -140,6 +140,8 @@ const createEnvironment = baseApi<
   {
   {
     name: string;
     name: string;
     mode: "auto" | "manual";
     mode: "auto" | "manual";
+    disable_new_comments: boolean;
+    git_repo_branches: string[];
   },
   },
   {
   {
     project_id: number;
     project_id: number;
@@ -159,6 +161,23 @@ const createEnvironment = baseApi<
   return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/environment`;
   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<
 const deleteEnvironment = baseApi<
   {
   {
     name: string;
     name: string;
@@ -2180,6 +2199,7 @@ export default {
   createGitlabIntegration,
   createGitlabIntegration,
   createEmailVerification,
   createEmailVerification,
   createEnvironment,
   createEnvironment,
+  updateEnvironment,
   deleteEnvironment,
   deleteEnvironment,
   createPreviewEnvironmentDeployment,
   createPreviewEnvironmentDeployment,
   reenablePreviewEnvironmentDeployment,
   reenablePreviewEnvironmentDeployment,

+ 4 - 0
dashboard/src/shared/types.tsx

@@ -310,6 +310,10 @@ export type ActionConfigType = {
     }
     }
 );
 );
 
 
+export type GithubActionConfigType = ActionConfigType & {
+  kind: "github";
+};
+
 export type FullActionConfigType = ActionConfigType & {
 export type FullActionConfigType = ActionConfigType & {
   dockerfile_path: string;
   dockerfile_path: string;
   folder_path: string;
   folder_path: string;

+ 22 - 0
internal/models/environment.go

@@ -1,6 +1,8 @@
 package models
 package models
 
 
 import (
 import (
+	"strings"
+
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 )
 )
@@ -15,6 +17,7 @@ type Environment struct {
 	GitInstallationID uint
 	GitInstallationID uint
 	GitRepoOwner      string
 	GitRepoOwner      string
 	GitRepoName       string
 	GitRepoName       string
+	GitRepoBranches   string
 
 
 	Name string
 	Name string
 	Mode string
 	Mode string
@@ -28,6 +31,24 @@ type Environment struct {
 	GithubWebhookID int64
 	GithubWebhookID int64
 }
 }
 
 
+func getGitRepoBranches(branches string) []string {
+	var branchesArr []string
+
+	if branches != "" {
+		supposedBranches := strings.Split(branches, ",")
+
+		for _, br := range supposedBranches {
+			name := strings.TrimSpace(br)
+
+			if len(name) > 0 {
+				branchesArr = append(branchesArr, name)
+			}
+		}
+	}
+
+	return branchesArr
+}
+
 func (e *Environment) ToEnvironmentType() *types.Environment {
 func (e *Environment) ToEnvironmentType() *types.Environment {
 	return &types.Environment{
 	return &types.Environment{
 		ID:                e.Model.ID,
 		ID:                e.Model.ID,
@@ -36,6 +57,7 @@ func (e *Environment) ToEnvironmentType() *types.Environment {
 		GitInstallationID: e.GitInstallationID,
 		GitInstallationID: e.GitInstallationID,
 		GitRepoOwner:      e.GitRepoOwner,
 		GitRepoOwner:      e.GitRepoOwner,
 		GitRepoName:       e.GitRepoName,
 		GitRepoName:       e.GitRepoName,
+		GitRepoBranches:   getGitRepoBranches(e.GitRepoBranches),
 
 
 		NewCommentsDisabled: e.NewCommentsDisabled,
 		NewCommentsDisabled: e.NewCommentsDisabled,