Przeglądaj źródła

Merge branch 'nafees/preview-env-branches' into dev

Mohammed Nafees 3 lat temu
rodzic
commit
8c1d73a8ae

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

@@ -74,6 +74,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		Mode:                request.Mode,
 		WebhookID:           string(webhookUID),
 		NewCommentsDisabled: request.DisableNewComments,
+		GitDeployBranches:   strings.Join(request.GitDeployBranches, ","),
 	}
 
 	if len(request.NamespaceLabels) > 0 {
@@ -104,7 +105,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 				"content_type": "json",
 				"secret":       c.Config().ServerConf.GithubIncomingWebhookSecret,
 			},
-			Events: []string{"pull_request"},
+			Events: []string{"pull_request", "push"},
 			Active: github.Bool(true),
 		},
 	)

+ 20 - 3
api/server/handlers/environment/enable_pull_request.go

@@ -69,8 +69,25 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 		if !found {
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-				fmt.Errorf("base branch '%s' is not enabled for this preview environment, please enable it in the settings page",
-					request.BranchInto), http.StatusBadRequest,
+				fmt.Errorf("base branch '%s' is not enabled for this preview environment, please enable it "+
+					"in the settings page to continue", request.BranchInto), http.StatusBadRequest,
+			))
+			return
+		}
+	} else if len(envType.GitDeployBranches) > 0 {
+		found := false
+
+		for _, branch := range env.ToEnvironmentType().GitDeployBranches {
+			if branch == request.BranchFrom {
+				found = true
+				break
+			}
+		}
+
+		if found {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("head branch '%s' is enabled for branch deploys for this preview environment, "+
+					"please disable it in the settings page to continue", request.BranchInto), http.StatusBadRequest,
 			))
 			return
 		}
@@ -104,7 +121,7 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 			Ref: request.BranchFrom,
 			Inputs: map[string]interface{}{
 				"pr_number":      strconv.FormatUint(uint64(request.Number), 10),
-				"pr_title":       *pr.Title,
+				"pr_title":       pr.GetTitle(),
 				"pr_branch_from": request.BranchFrom,
 				"pr_branch_into": request.BranchInto,
 			},

+ 32 - 30
api/server/handlers/environment/finalize_deployment.go

@@ -148,42 +148,44 @@ 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 !depl.IsBranchDeploy() {
+		// 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 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 prClosed {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+				http.StatusConflict))
+			return
+		}
 
-	commentBody := "## Porter Preview Environments\n"
+		commentBody := "## Porter Preview Environments\n"
 
-	if depl.Subdomain == "" {
-		commentBody += fmt.Sprintf(
-			"✅ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) has been successfully deployed.",
-			depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA,
-		)
-	} else {
-		commentBody += fmt.Sprintf(
-			"✅ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) has been successfully deployed to %s",
-			depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, depl.Subdomain,
-		)
-	}
+		if depl.Subdomain == "" {
+			commentBody += fmt.Sprintf(
+				"✅ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) has been successfully deployed.",
+				depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA,
+			)
+		} else {
+			commentBody += fmt.Sprintf(
+				"✅ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) has been successfully deployed to %s",
+				depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, depl.Subdomain,
+			)
+		}
 
-	err = createOrUpdateComment(client, c.Repo(), env.NewCommentsDisabled, depl, github.String(commentBody))
+		err = createOrUpdateComment(client, c.Repo(), env.NewCommentsDisabled, depl, github.String(commentBody))
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	}
 
 	c.WriteResult(w, r, depl.ToDeploymentType())

+ 52 - 50
api/server/handlers/environment/finalize_deployment_with_errors.go

@@ -119,20 +119,6 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		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(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
@@ -147,51 +133,67 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		},
 	)
 
-	workflowRun, err := commonutils.GetLatestWorkflowRun(client, depl.RepoOwner, depl.RepoName,
-		fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
+	if !depl.IsBranchDeploy() {
+		// 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.NewErrInternal(err))
-		return
-	}
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
+			return
+		}
 
-	commentBody := fmt.Sprintf(
-		"## Porter Preview Environments\n"+
-			"❌ Errors encountered while deploying the changes\n"+
-			"||Deployment Information|\n"+
-			"|-|-|\n"+
-			"| Latest SHA | [`%s`](https://github.com/%s/%s/commit/%s) |\n"+
-			"| Build Logs | %s |\n",
-		depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, workflowRun.GetHTMLURL(),
-	)
+		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)
 
-	if len(request.SuccessfulResources) > 0 {
-		commentBody += "#### Successfully deployed resources\n"
-
-		for _, res := range request.SuccessfulResources {
-			if res.ReleaseType == "job" {
-				commentBody += fmt.Sprintf("- [`%s`](%s/jobs/%s/%s/%s?project_id=%d)\n",
-					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
-					res.ReleaseName, project.ID)
-			} else {
-				commentBody += fmt.Sprintf("- [`%s`](%s/applications/%s/%s/%s?project_id=%d)\n",
-					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
-					res.ReleaseName, project.ID)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		commentBody := fmt.Sprintf(
+			"## Porter Preview Environments\n"+
+				"❌ Errors encountered while deploying the changes\n"+
+				"||Deployment Information|\n"+
+				"|-|-|\n"+
+				"| Latest SHA | [`%s`](https://github.com/%s/%s/commit/%s) |\n"+
+				"| Build Logs | %s |\n",
+			depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, workflowRun.GetHTMLURL(),
+		)
+
+		if len(request.SuccessfulResources) > 0 {
+			commentBody += "#### Successfully deployed resources\n"
+
+			for _, res := range request.SuccessfulResources {
+				if res.ReleaseType == "job" {
+					commentBody += fmt.Sprintf("- [`%s`](%s/jobs/%s/%s/%s?project_id=%d)\n",
+						res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+						res.ReleaseName, project.ID)
+				} else {
+					commentBody += fmt.Sprintf("- [`%s`](%s/applications/%s/%s/%s?project_id=%d)\n",
+						res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+						res.ReleaseName, project.ID)
+				}
 			}
 		}
-	}
 
-	commentBody += "#### Failed resources\n"
+		commentBody += "#### Failed resources\n"
 
-	for res, err := range request.Errors {
-		commentBody += fmt.Sprintf("<details>\n  <summary><code>%s</code></summary>\n\n  **Error:** %s\n</details>\n", res, err)
-	}
+		for res, err := range request.Errors {
+			commentBody += fmt.Sprintf("<details>\n  <summary><code>%s</code></summary>\n\n  **Error:** %s\n</details>\n", res, err)
+		}
 
-	err = createOrUpdateComment(client, c.Repo(), env.NewCommentsDisabled, depl, github.String(commentBody))
+		err = createOrUpdateComment(client, c.Repo(), env.NewCommentsDisabled, depl, github.String(commentBody))
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	}
 
 	c.WriteResult(w, r, depl.ToDeploymentType())

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

@@ -281,6 +281,10 @@ func fetchOpenPullRequests(
 			}
 		}
 
+		if isDeployBranch(pr.GetHead().GetRef(), env) {
+			continue
+		}
+
 		if _, ok := deplInfoMap[fmt.Sprintf("%s-%s-%d", env.GitRepoOwner, env.GitRepoName, pr.GetNumber())]; !ok {
 			prs = append(prs, &types.PullRequest{
 				Title:      pr.GetTitle(),
@@ -295,3 +299,13 @@ func fetchOpenPullRequests(
 
 	return prs, nil
 }
+
+func isDeployBranch(branch string, env *models.Environment) bool {
+	for _, b := range env.ToEnvironmentType().GitDeployBranches {
+		if b == branch {
+			return true
+		}
+	}
+
+	return false
+}

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

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -79,6 +80,61 @@ func (c *UpdateEnvironmentSettingsHandler) ServeHTTP(w http.ResponseWriter, r *h
 		env.GitRepoBranches = strings.Join(request.GitRepoBranches, ",")
 	}
 
+	newBranches = []string{}
+
+	for _, br := range request.GitDeployBranches {
+		name := strings.TrimSpace(br)
+
+		if len(name) > 0 {
+			newBranches = append(newBranches, name)
+		}
+	}
+
+	changed = !reflect.DeepEqual(env.ToEnvironmentType().GitDeployBranches, newBranches)
+
+	if changed {
+		// let us check if the webhook has access to the "push" event
+		client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		hook, _, err := client.Repositories.GetHook(
+			context.Background(), env.GitRepoOwner, env.GitRepoName, env.GithubWebhookID,
+		)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		found := false
+
+		for _, ev := range hook.Events {
+			if ev == "push" {
+				found = true
+				break
+			}
+		}
+
+		if !found {
+			hook.Events = append(hook.Events, "push")
+
+			_, _, err := client.Repositories.EditHook(
+				context.Background(), env.GitRepoOwner, env.GitRepoName, env.GithubWebhookID, hook,
+			)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
+
+		env.GitDeployBranches = strings.Join(request.GitDeployBranches, ",")
+	}
+
 	if request.DisableNewComments != env.NewCommentsDisabled {
 		env.NewCommentsDisabled = request.DisableNewComments
 		changed = true

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

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net/http"
 	"strconv"
+	"strings"
 	"sync"
 
 	"github.com/bradleyfalzon/ghinstallation/v2"
@@ -59,6 +60,13 @@ func (c *GithubIncomingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error processing pull request webhook event: %w", err)))
 			return
 		}
+	case *github.PushEvent:
+		err = c.processPushEvent(event, r)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error processing push webhook event: %w", err)))
+			return
+		}
 	}
 }
 
@@ -103,6 +111,21 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 		if !found {
 			return nil
 		}
+	} else if len(envType.GitDeployBranches) > 0 {
+		// if the pull request's head branch is in the list of deploy branches
+		// then we ignore it to avoid a double deploy
+		found := false
+
+		for _, br := range envType.GitDeployBranches {
+			if br == event.GetPullRequest().GetHead().GetRef() {
+				found = true
+				break
+			}
+		}
+
+		if found {
+			return nil
+		}
 	}
 
 	// create deployment on GitHub API
@@ -320,6 +343,99 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 	return nil
 }
 
+func (c *GithubIncomingWebhookHandler) processPushEvent(event *github.PushEvent, r *http.Request) error {
+	if !strings.HasPrefix(event.GetRef(), "refs/heads/") {
+		return nil
+	}
+
+	// get the webhook id from the request
+	webhookID, reqErr := requestutils.GetURLParamString(r, types.URLParamIncomingWebhookID)
+
+	if reqErr != nil {
+		return fmt.Errorf(reqErr.Error())
+	}
+
+	owner := event.GetRepo().GetOwner().GetLogin()
+	repo := event.GetRepo().GetName()
+
+	env, err := c.Repo().Environment().ReadEnvironmentByWebhookIDOwnerRepoName(webhookID, owner, repo)
+
+	if err != nil {
+		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)
+	}
+
+	envType := env.ToEnvironmentType()
+
+	branch := strings.TrimPrefix(event.GetRef(), "refs/heads/")
+
+	if len(envType.GitDeployBranches) > 0 {
+		found := false
+
+		for _, br := range envType.GitDeployBranches {
+			if br == branch {
+				found = true
+				break
+			}
+		}
+
+		if !found {
+			return nil
+		}
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s] error creating github client: %w",
+			webhookID, owner, repo, err)
+	}
+
+	depl := &models.Deployment{
+		EnvironmentID: env.ID,
+		Namespace:     "",
+		Status:        types.DeploymentStatusCreating,
+		PullRequestID: 0,
+		PRName:        fmt.Sprintf("Deployment for branch %s", branch),
+		RepoName:      repo,
+		RepoOwner:     owner,
+		CommitSHA:     event.GetHeadCommit().GetSHA()[:7],
+		PRBranchFrom:  branch,
+		PRBranchInto:  branch,
+	}
+
+	depl, err = c.Repo().Environment().CreateDeployment(depl)
+
+	if err != nil {
+		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, branch: %s] "+
+			"error creating new deployment: %w", webhookID, owner, repo, env.ID, branch, err)
+	}
+
+	// FIXME: we should case on if env mode is auto or manual
+	_, err = client.Actions.CreateWorkflowDispatchEventByFileName(
+		r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
+		github.CreateWorkflowDispatchEventRequest{
+			Ref: branch,
+			Inputs: map[string]interface{}{
+				"pr_number":      depl.ID,
+				"pr_title":       fmt.Sprintf("Deployment for branch %s", branch),
+				"pr_branch_from": branch,
+				"pr_branch_into": branch,
+			},
+		},
+	)
+
+	if err != nil {
+		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, branch: %s] "+
+			"error creating workflow dispatch event: %w", webhookID, owner, repo, env.ID, branch, err)
+	}
+
+	return nil
+}
+
 func isSystemNamespace(namespace string) bool {
 	return namespace == "cert-manager" || namespace == "ingress-nginx" ||
 		namespace == "kube-node-lease" || namespace == "kube-public" ||

+ 10 - 17
api/types/environment.go

@@ -17,6 +17,7 @@ type Environment struct {
 	LastDeploymentStatus string            `json:"last_deployment_status"`
 	NewCommentsDisabled  bool              `json:"new_comments_disabled"`
 	NamespaceLabels      map[string]string `json:"namespace_labels,omitempty"`
+	GitDeployBranches    []string          `json:"git_deploy_branches"`
 }
 
 type CreateEnvironmentRequest struct {
@@ -25,6 +26,7 @@ type CreateEnvironmentRequest struct {
 	DisableNewComments bool              `json:"disable_new_comments"`
 	GitRepoBranches    []string          `json:"git_repo_branches"`
 	NamespaceLabels    map[string]string `json:"namespace_labels"`
+	GitDeployBranches  []string          `json:"git_deploy_branches"`
 }
 
 type GitHubMetadata struct {
@@ -84,18 +86,14 @@ type FinalizeDeploymentRequest struct {
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	Subdomain           string                          `json:"subdomain"`
 	PRNumber            uint                            `json:"pr_number"`
-
-	// legacy usage for backwards compatibility
-	Namespace string `json:"namespace"`
+	Namespace           string                          `json:"namespace"`
 }
 
 type FinalizeDeploymentWithErrorsRequest struct {
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	Errors              map[string]string               `json:"errors" form:"required"`
 	PRNumber            uint                            `json:"pr_number"`
-
-	// legacy usage for backwards compatibility
-	Namespace string `json:"namespace"`
+	Namespace           string                          `json:"namespace"`
 }
 
 type UpdateDeploymentRequest struct {
@@ -104,9 +102,7 @@ type UpdateDeploymentRequest struct {
 	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
 	CommitSHA    string `json:"commit_sha" form:"required"`
 	PRNumber     uint   `json:"pr_number"`
-
-	// legacy usage for backwards compatibility
-	Namespace string `json:"namespace"`
+	Namespace    string `json:"namespace"`
 }
 
 type ListDeploymentRequest struct {
@@ -119,9 +115,7 @@ type UpdateDeploymentStatusRequest struct {
 	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
 	Status       string `json:"status" form:"required,oneof=created creating inactive failed"`
 	PRNumber     uint   `json:"pr_number"`
-
-	// legacy usage for backwards compatibility
-	Namespace string `json:"namespace"`
+	Namespace    string `json:"namespace"`
 }
 
 type DeleteDeploymentRequest struct {
@@ -129,11 +123,9 @@ type DeleteDeploymentRequest struct {
 }
 
 type GetDeploymentRequest struct {
-	DeploymentID uint `schema:"id"`
-	PRNumber     uint `schema:"pr_number"`
-
-	// legacy usage for backwards compatibility
-	Namespace string `schema:"namespace"`
+	DeploymentID uint   `schema:"id"`
+	PRNumber     uint   `schema:"pr_number"`
+	Namespace    string `schema:"namespace"`
 }
 
 type PullRequest struct {
@@ -164,4 +156,5 @@ type UpdateEnvironmentSettingsRequest struct {
 	DisableNewComments bool              `json:"disable_new_comments"`
 	GitRepoBranches    []string          `json:"git_repo_branches"`
 	NamespaceLabels    map[string]string `json:"namespace_labels"`
+	GitDeployBranches  []string          `json:"git_deploy_branches"`
 }

+ 129 - 64
cli/cmd/apply.go

@@ -754,6 +754,10 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 	return res, nil
 }
 
+func (t *DeploymentHook) isBranchDeploy() bool {
+	return t.branchFrom != "" && t.branchInto != "" && t.branchFrom == t.branchInto
+}
+
 func (t *DeploymentHook) PreApply() error {
 	if isSystemNamespace(t.namespace) {
 		color.New(color.FgYellow).Printf("attempting to deploy to system namespace '%s'\n", t.namespace)
@@ -826,51 +830,72 @@ func (t *DeploymentHook) PreApply() error {
 		}
 	}
 
-	// attempt to read the deployment -- if it doesn't exist, create it
-	_, err = t.client.GetDeployment(
-		context.Background(),
-		t.projectID, t.clusterID, t.envID,
-		&types.GetDeploymentRequest{
-			PRNumber: t.prID,
-		},
-	)
+	var deplErr error
+
+	if t.isBranchDeploy() {
+		_, deplErr = t.client.GetDeployment(
+			context.Background(),
+			t.projectID, t.clusterID, t.envID,
+			&types.GetDeploymentRequest{
+				Namespace: t.namespace,
+			},
+		)
+	} else {
+		_, deplErr = t.client.GetDeployment(
+			context.Background(),
+			t.projectID, t.clusterID, t.envID,
+			&types.GetDeploymentRequest{
+				PRNumber: t.prID,
+			},
+		)
+	}
 
-	if err != nil && strings.Contains(err.Error(), "not found") {
+	if deplErr != nil && strings.Contains(deplErr.Error(), "not found") {
 		// in this case, create the deployment
+		createReq := &types.CreateDeploymentRequest{
+			Namespace:     t.namespace,
+			PullRequestID: t.prID,
+			CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
+				ActionID: t.actionID,
+			},
+			GitHubMetadata: &types.GitHubMetadata{
+				PRName:       t.prName,
+				RepoName:     t.repoName,
+				RepoOwner:    t.repoOwner,
+				CommitSHA:    t.commitSHA,
+				PRBranchFrom: t.branchFrom,
+				PRBranchInto: t.branchInto,
+			},
+		}
+
+		if t.isBranchDeploy() {
+			createReq.PullRequestID = 0
+		}
+
 		_, err = t.client.CreateDeployment(
 			context.Background(),
 			t.projectID, t.gitInstallationID, t.clusterID,
-			t.repoOwner, t.repoName,
-			&types.CreateDeploymentRequest{
-				Namespace:     t.namespace,
-				PullRequestID: t.prID,
-				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					ActionID: t.actionID,
-				},
-				GitHubMetadata: &types.GitHubMetadata{
-					PRName:       t.prName,
-					RepoName:     t.repoName,
-					RepoOwner:    t.repoOwner,
-					CommitSHA:    t.commitSHA,
-					PRBranchFrom: t.branchFrom,
-					PRBranchInto: t.branchInto,
-				},
-			},
+			t.repoOwner, t.repoName, createReq,
 		)
 	} else if err == nil {
+		updateReq := &types.UpdateDeploymentRequest{
+			Namespace: t.namespace,
+			PRNumber:  t.prID,
+			CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
+				ActionID: t.actionID,
+			},
+			PRBranchFrom: t.branchFrom,
+			CommitSHA:    t.commitSHA,
+		}
+
+		if t.isBranchDeploy() {
+			updateReq.PRNumber = 0
+		}
+
 		_, err = t.client.UpdateDeployment(
 			context.Background(),
 			t.projectID, t.gitInstallationID, t.clusterID,
-			t.repoOwner, t.repoName,
-			&types.UpdateDeploymentRequest{
-				Namespace: t.namespace,
-				PRNumber:  t.prID,
-				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					ActionID: t.actionID,
-				},
-				PRBranchFrom: t.branchFrom,
-				CommitSHA:    t.commitSHA,
-			},
+			t.repoOwner, t.repoName, updateReq,
 		)
 	}
 
@@ -953,10 +978,15 @@ func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
 	}
 
 	req := &types.FinalizeDeploymentRequest{
-		PRNumber:  t.prID,
 		Subdomain: strings.Join(subdomains, ", "),
 	}
 
+	if t.isBranchDeploy() {
+		req.Namespace = t.namespace
+	} else {
+		req.PRNumber = t.prID
+	}
+
 	for _, res := range t.resourceGroup.Resources {
 		releaseType := getReleaseType(t.projectID, res)
 		releaseName := getReleaseName(res)
@@ -980,47 +1010,82 @@ func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
 }
 
 func (t *DeploymentHook) OnError(error) {
+	var deplErr error
+
+	if t.isBranchDeploy() {
+		_, deplErr = t.client.GetDeployment(
+			context.Background(),
+			t.projectID, t.clusterID, t.envID,
+			&types.GetDeploymentRequest{
+				Namespace: t.namespace,
+			},
+		)
+	} else {
+		_, deplErr = t.client.GetDeployment(
+			context.Background(),
+			t.projectID, t.clusterID, t.envID,
+			&types.GetDeploymentRequest{
+				PRNumber: t.prID,
+			},
+		)
+	}
+
 	// if the deployment exists, throw an error for that deployment
-	_, err := t.client.GetDeployment(
-		context.Background(),
-		t.projectID, t.clusterID, t.envID,
-		&types.GetDeploymentRequest{
-			PRNumber: t.prID,
-		},
-	)
+	if deplErr == nil {
+		req := &types.UpdateDeploymentStatusRequest{
+			CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
+				ActionID: t.actionID,
+			},
+			PRBranchFrom: t.branchFrom,
+			Status:       string(types.DeploymentStatusFailed),
+		}
+
+		if t.isBranchDeploy() {
+			req.Namespace = t.namespace
+		} else {
+			req.PRNumber = t.prID
+		}
 
-	if err == nil {
 		// FIXME: try to use the error with a custom logger
 		t.client.UpdateDeploymentStatus(
 			context.Background(),
 			t.projectID, t.gitInstallationID, t.clusterID,
-			t.repoOwner, t.repoName,
-			&types.UpdateDeploymentStatusRequest{
-				PRNumber: t.prID,
-				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					ActionID: t.actionID,
-				},
-				PRBranchFrom: t.branchFrom,
-				Status:       string(types.DeploymentStatusFailed),
-			},
+			t.repoOwner, t.repoName, req,
 		)
 	}
 }
 
 func (t *DeploymentHook) OnConsolidatedErrors(allErrors map[string]error) {
-	// if the deployment exists, throw an error for that deployment
-	_, getDeplErr := t.client.GetDeployment(
-		context.Background(),
-		t.projectID, t.clusterID, t.envID,
-		&types.GetDeploymentRequest{
-			PRNumber: t.prID,
-		},
-	)
+	var deplErr error
 
-	if getDeplErr == nil {
+	if t.isBranchDeploy() {
+		_, deplErr = t.client.GetDeployment(
+			context.Background(),
+			t.projectID, t.clusterID, t.envID,
+			&types.GetDeploymentRequest{
+				Namespace: t.namespace,
+			},
+		)
+	} else {
+		_, deplErr = t.client.GetDeployment(
+			context.Background(),
+			t.projectID, t.clusterID, t.envID,
+			&types.GetDeploymentRequest{
+				PRNumber: t.prID,
+			},
+		)
+	}
+
+	// if the deployment exists, throw an error for that deployment
+	if deplErr == nil {
 		req := &types.FinalizeDeploymentWithErrorsRequest{
-			PRNumber: t.prID,
-			Errors:   make(map[string]string),
+			Errors: make(map[string]string),
+		}
+
+		if t.isBranchDeploy() {
+			req.Namespace = t.namespace
+		} else {
+			req.PRNumber = t.prID
 		}
 
 		for _, res := range t.resourceGroup.Resources {

+ 18 - 5
dashboard/src/main/home/cluster-dashboard/preview-environments/ConnectNewRepo.tsx

@@ -40,7 +40,8 @@ const ConnectNewRepo: React.FC = () => {
   });
 
   // Branch selector data
-  const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
+  const [baseBranches, setBaseBranches] = useState<string[]>([]);
+  const [deployBranches, setDeployBranches] = useState<string[]>([]);
   const [availableBranches, setAvailableBranches] = useState<string[]>([]);
   const [isLoadingBranches, setIsLoadingBranches] = useState(false);
 
@@ -113,7 +114,7 @@ const ConnectNewRepo: React.FC = () => {
 
   const addRepo = () => {
     let [owner, repoName] = repo.split("/");
-    let labels: Record<string, string> = {};
+    const labels: Record<string, string> = {};
 
     setStatus("loading");
 
@@ -146,8 +147,9 @@ const ConnectNewRepo: React.FC = () => {
           name: `preview`,
           mode: enableAutomaticDeployments ? "auto" : "manual",
           disable_new_comments: isNewCommentsDisabled,
-          git_repo_branches: selectedBranches,
+          git_repo_branches: baseBranches,
           namespace_labels: labels,
+          git_deploy_branches: deployBranches,
         },
         {
           project_id: currentProject.id,
@@ -254,6 +256,17 @@ const ConnectNewRepo: React.FC = () => {
         />
       </CheckboxWrapper>
 
+      <Heading>Deploy from branches</Heading>
+      <Helper>
+        Choose the list of branches that you want to deploy changes from.
+      </Helper>
+      <BranchFilterSelector
+        onChange={setDeployBranches}
+        options={availableBranches}
+        value={deployBranches}
+        showLoading={isLoadingBranches}
+      />
+
       <Heading>Select allowed branches</Heading>
       <Helper>
         If the pull request has a base branch included in this list, it will be
@@ -262,9 +275,9 @@ const ConnectNewRepo: React.FC = () => {
         (Leave empty to allow all branches)
       </Helper>
       <BranchFilterSelector
-        onChange={setSelectedBranches}
+        onChange={setBaseBranches}
         options={availableBranches}
-        value={selectedBranches}
+        value={baseBranches}
         showLoading={isLoadingBranches}
       />
 

+ 29 - 17
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentSettings.tsx

@@ -30,7 +30,8 @@ const EnvironmentSettings = () => {
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
-  const [selectedBranches, setSelectedBranches] = useState([]);
+  const [baseBranches, setBaseBranches] = useState([]);
+  const [deployBranches, setDeployBranches] = useState([]);
   const [environment, setEnvironment] = useState<Environment>();
   const [saveStatus, setSaveStatus] = useState("");
   const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
@@ -64,19 +65,17 @@ const EnvironmentSettings = () => {
       );
 
       setEnvironment(environment);
-      setSelectedBranches(environment.git_repo_branches);
+      setBaseBranches(environment.git_repo_branches);
       setNewCommentsDisabled(environment.new_comments_disabled);
       setDeploymentMode(environment.mode);
 
       if (environment.namespace_labels) {
-        const labels: KeyValueType[] = [];
-
-        Object.keys(environment.namespace_labels).forEach((k) => {
-          labels.push({
-            key: k,
-            value: environment.namespace_labels[k],
-          });
-        });
+        const labels: KeyValueType[] = Object.entries(
+          environment.namespace_labels
+        ).map(([key, value]) => ({
+          key,
+          value,
+        }));
 
         setNamespaceLabels(labels);
       }
@@ -154,8 +153,9 @@ const EnvironmentSettings = () => {
         {
           mode: deploymentMode,
           disable_new_comments: newCommentsDisabled,
-          git_repo_branches: selectedBranches,
+          git_repo_branches: baseBranches,
           namespace_labels: labels,
+          git_deploy_branches: deployBranches,
         },
         {
           project_id: currentProject.id,
@@ -273,6 +273,17 @@ const EnvironmentSettings = () => {
           }
         />
         <Br />
+        <Heading>Deploy from branches</Heading>
+        <Helper>
+          Choose the list of branches that you want to deploy changes from.
+        </Helper>
+        <BranchFilterSelector
+          onChange={setDeployBranches}
+          options={availableBranches}
+          value={deployBranches}
+          showLoading={isLoadingBranches}
+        />
+        <Br />
         <Heading>Select allowed branches</Heading>
         <Helper>
           If the pull request has a base branch included in this list, it will
@@ -281,9 +292,9 @@ const EnvironmentSettings = () => {
           (Leave empty to allow all branches)
         </Helper>
         <BranchFilterSelector
-          onChange={setSelectedBranches}
+          onChange={setBaseBranches}
           options={availableBranches}
-          value={selectedBranches}
+          value={baseBranches}
           showLoading={isLoadingBranches}
         />
         <Br />
@@ -295,10 +306,11 @@ const EnvironmentSettings = () => {
         <NamespaceLabels
           values={namespaceLabels}
           setValues={(x: KeyValueType[]) => {
-            let labels: KeyValueType[] = [];
-            x.forEach((entry) => {
-              labels.push({ key: entry.key, value: entry.value });
-            });
+            const labels: KeyValueType[] = x.map((entry) => ({
+              key: entry.key,
+              value: entry.value,
+            }));
+
             setNamespaceLabels(labels);
           }}
         />

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

@@ -152,6 +152,7 @@ const createEnvironment = baseApi<
     disable_new_comments: boolean;
     git_repo_branches: string[];
     namespace_labels: Record<string, string>;
+    git_deploy_branches: string[];
   },
   {
     project_id: number;
@@ -177,6 +178,7 @@ const updateEnvironment = baseApi<
     disable_new_comments: boolean;
     git_repo_branches: string[]; // Array with branch names
     namespace_labels: Record<string, string>;
+    git_deploy_branches: string[];
   },
   {
     project_id: number;

+ 13 - 1
internal/models/environment.go

@@ -25,6 +25,7 @@ type Environment struct {
 	NewCommentsDisabled  bool
 	NamespaceLabels      []byte
 	NamespaceAnnotations []byte
+	GitDeployBranches    string
 
 	// WebhookID uniquely identifies the environment when other fields (project, cluster)
 	// aren't present
@@ -75,6 +76,14 @@ func (e *Environment) ToEnvironmentType() *types.Environment {
 		env.GitRepoBranches = []string{}
 	}
 
+	branches = getGitRepoBranches(e.GitDeployBranches)
+
+	if len(branches) > 0 {
+		env.GitDeployBranches = branches
+	} else {
+		env.GitDeployBranches = []string{}
+	}
+
 	if len(e.NamespaceLabels) > 0 {
 		env.NamespaceLabels = make(map[string]string)
 		labels := string(e.NamespaceLabels)
@@ -110,7 +119,6 @@ type Deployment struct {
 }
 
 func (d *Deployment) ToDeploymentType() *types.Deployment {
-
 	ghMetadata := &types.GitHubMetadata{
 		DeploymentID: d.GHDeploymentID,
 		PRName:       d.PRName,
@@ -133,3 +141,7 @@ func (d *Deployment) ToDeploymentType() *types.Deployment {
 		GitHubMetadata: ghMetadata,
 	}
 }
+
+func (d *Deployment) IsBranchDeploy() bool {
+	return d.PullRequestID == 0 && d.PRBranchFrom != "" && d.PRBranchInto != "" && d.PRBranchFrom == d.PRBranchInto
+}