Browse Source

Merge branch 'nafees/preview-env-new-endpoints' of github.com:porter-dev/porter into nico/preview-envs-frontend-improvements

jnfrati 4 years ago
parent
commit
e5f24f32f6

+ 48 - 0
api/server/handlers/environment/create.go

@@ -1,6 +1,7 @@
 package environment
 package environment
 
 
 import (
 import (
+	"fmt"
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
 
 
@@ -58,6 +59,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		Name:              request.Name,
 		Name:              request.Name,
 		GitRepoOwner:      owner,
 		GitRepoOwner:      owner,
 		GitRepoName:       name,
 		GitRepoName:       name,
+		Mode:              request.Mode,
 	})
 	})
 
 
 	if err != nil {
 	if err != nil {
@@ -73,6 +75,52 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 		return
 	}
 	}
 
 
+	hooks, _, err := client.Repositories.ListHooks(
+		r.Context(), owner, name, &github.ListOptions{},
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	webhookURL := fmt.Sprintf("%s/api/github/incoming_webhook", c.Config().ServerConf.ServerURL)
+
+	for _, hook := range hooks {
+		if hook.GetURL() == webhookURL {
+			// if a previous webhook exists then we should delete it
+			// this ensures that an updated webhook secret is maintained
+			_, err = client.Repositories.DeleteHook(
+				r.Context(), owner, name, hook.GetID(),
+			)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			break
+		}
+	}
+
+	// create incoming webhook
+	_, _, err = client.Repositories.CreateHook(
+		r.Context(), owner, name, &github.Hook{
+			Config: map[string]interface{}{
+				"url":          webhookURL,
+				"content_type": "json",
+				"secret":       c.Config().ServerConf.GithubIncomingWebhookSecret,
+			},
+			Events: []string{"pull_request"},
+			Active: github.Bool(false),
+		},
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	// generate porter jwt token
 	// generate porter jwt token
 	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
 	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
 
 

+ 13 - 9
api/server/handlers/environment/create_deployment.go

@@ -66,7 +66,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 		return
 	}
 	}
 
 
-	ghDeployment, err := createDeployment(client, env, request.CreateGHDeploymentRequest)
+	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -84,6 +84,8 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		RepoName:       request.GitHubMetadata.RepoName,
 		RepoName:       request.GitHubMetadata.RepoName,
 		PRName:         request.GitHubMetadata.PRName,
 		PRName:         request.GitHubMetadata.PRName,
 		CommitSHA:      request.GitHubMetadata.CommitSHA,
 		CommitSHA:      request.GitHubMetadata.CommitSHA,
+		PRBranchFrom:   request.GitHubMetadata.PRBranchFrom,
+		PRBranchInto:   request.GitHubMetadata.PRBranchInto,
 	})
 	})
 
 
 	if err != nil {
 	if err != nil {
@@ -109,16 +111,18 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	c.WriteResult(w, r, depl.ToDeploymentType())
 	c.WriteResult(w, r, depl.ToDeploymentType())
 }
 }
 
 
-func createDeployment(client *github.Client, env *models.Environment, request *types.CreateGHDeploymentRequest) (*github.Deployment, error) {
-	branch := request.Branch
-	envName := "Preview"
-	automerge := false
+func createDeployment(
+	client *github.Client,
+	env *models.Environment,
+	branchFrom string,
+	actionID uint,
+) (*github.Deployment, error) {
 	requiredContexts := []string{}
 	requiredContexts := []string{}
 
 
 	deploymentRequest := github.DeploymentRequest{
 	deploymentRequest := github.DeploymentRequest{
-		Ref:              &branch,
-		Environment:      &envName,
-		AutoMerge:        &automerge,
+		Ref:              github.String(branchFrom),
+		Environment:      github.String("Preview"),
+		AutoMerge:        github.Bool(false),
 		RequiredContexts: &requiredContexts,
 		RequiredContexts: &requiredContexts,
 	}
 	}
 
 
@@ -138,7 +142,7 @@ func createDeployment(client *github.Client, env *models.Environment, request *t
 	// Create Deployment Status to indicate it's in progress
 	// Create Deployment Status to indicate it's in progress
 
 
 	state := "in_progress"
 	state := "in_progress"
-	log_url := fmt.Sprintf("https://github.com/%s/%s/runs/%d", env.GitRepoOwner, env.GitRepoName, request.ActionID)
+	log_url := fmt.Sprintf("https://github.com/%s/%s/runs/%d", env.GitRepoOwner, env.GitRepoName, actionID)
 
 
 	deploymentStatusRequest := github.DeploymentStatusRequest{
 	deploymentStatusRequest := github.DeploymentStatusRequest{
 		State:  &state,
 		State:  &state,

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

@@ -27,6 +27,7 @@ func NewDeleteEnvironmentHandler(
 ) *DeleteEnvironmentHandler {
 ) *DeleteEnvironmentHandler {
 	return &DeleteEnvironmentHandler{
 	return &DeleteEnvironmentHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 	}
 }
 }
 
 

+ 8 - 15
api/server/handlers/environment/delete_deployment.go

@@ -8,13 +8,12 @@ import (
 	"github.com/google/go-github/v41/github"
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
-	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"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/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/models/integrations"
 )
 )
 
 
 type DeleteDeploymentHandler struct {
 type DeleteDeploymentHandler struct {
@@ -34,32 +33,26 @@ func NewDeleteDeploymentHandler(
 }
 }
 
 
 func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ga, _ := r.Context().Value(types.GitInstallationScope).(*integrations.GithubAppInstallation)
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	deplID, reqErr := requestutils.GetURLParamUint(r, "deployment_id")
 
 
-	if !ok {
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
 		return
 		return
 	}
 	}
 
 
-	request := &types.DeleteDeploymentRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	// read the environment to get the environment id
-	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
+	// read the deployment
+	depl, err := c.Repo().Environment().ReadDeploymentByID(deplID)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
 
 
-	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	// read the environment to get the environment id
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, depl.EnvironmentID)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 67 - 0
api/server/handlers/environment/enable_pull_request.go

@@ -0,0 +1,67 @@
+package environment
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/google/go-github/v41/github"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type EnablePullRequestHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewEnablePullRequestHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *EnablePullRequestHandler {
+	return &EnablePullRequestHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.PullRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(request.RepoOwner, request.RepoName)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = client.Actions.CreateWorkflowDispatchEventByFileName(
+		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
+		github.CreateWorkflowDispatchEventRequest{
+			Ref: request.BranchFrom,
+			Inputs: map[string]interface{}{
+				"pr_number":      request.Number,
+				"pr_title":       request.Title,
+				"pr_branch_from": request.BranchFrom,
+				"pr_branch_into": request.BranchInto,
+			},
+		},
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 16 - 1
api/server/handlers/environment/list.go

@@ -38,7 +38,22 @@ func (c *ListEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	res := make([]*types.Environment, 0)
 	res := make([]*types.Environment, 0)
 
 
 	for _, env := range envs {
 	for _, env := range envs {
-		res = append(res, env.ToEnvironmentType())
+		environment := env.ToEnvironmentType()
+
+		depls, err := c.Repo().Environment().ListDeployments(env.ID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		environment.DeploymentCount = uint(len(depls))
+
+		if environment.DeploymentCount > 0 {
+			environment.LastDeploymentStatus = string(depls[0].Status)
+		}
+
+		res = append(res, environment)
 	}
 	}
 
 
 	c.WriteResult(w, r, res)
 	c.WriteResult(w, r, res)

+ 1 - 1
api/server/handlers/environment/list_deployments.go

@@ -61,7 +61,7 @@ func (c *ListDeploymentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
-	depls, err := c.Repo().Environment().ListDeployments(env.ID, req.Status...)
+	depls, err := c.Repo().Environment().ListDeployments(env.ID)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 94 - 7
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -1,8 +1,10 @@
 package environment
 package environment
 
 
 import (
 import (
+	"context"
 	"net/http"
 	"net/http"
 
 
+	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/apierrors"
@@ -35,18 +37,103 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		return
 		return
 	}
 	}
 
 
-	depls, err := c.Repo().Environment().ListDeploymentsByCluster(project.ID, cluster.ID, req.Status...)
+	var deployments []*types.Deployment
+	var pullRequests []*types.PullRequest
+
+	if req.EnvironmentID == 0 {
+		depls, err := c.Repo().Environment().ListDeploymentsByCluster(project.ID, cluster.ID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		for _, depl := range depls {
+			deployments = append(deployments, depl.ToDeploymentType())
+		}
+
+		envList, err := c.Repo().Environment().ListEnvironments(project.ID, cluster.ID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		for _, env := range envList {
+			err = populateOpenPullRequests(r.Context(), c.Config(), env, pullRequests)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
+	} else {
+		depls, err := c.Repo().Environment().ListDeployments(req.EnvironmentID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		for _, depl := range depls {
+			deployments = append(deployments, depl.ToDeploymentType())
+		}
+
+		env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, req.EnvironmentID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		err = populateOpenPullRequests(r.Context(), c.Config(), env, pullRequests)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	c.WriteResult(w, r, map[string]interface{}{
+		"pull_requests": pullRequests,
+		"deployments":   deployments,
+	})
+}
+
+func populateOpenPullRequests(
+	ctx context.Context,
+	config *config.Config,
+	env *models.Environment,
+	pullRequests []*types.PullRequest,
+) error {
+	client, err := getGithubClientFromEnvironment(config, env)
 
 
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+		return err
 	}
 	}
 
 
-	res := make([]*types.Deployment, 0)
+	openPRs, _, err := client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
+		&github.PullRequestListOptions{
+			ListOptions: github.ListOptions{
+				PerPage: 50,
+			},
+		},
+	)
+
+	if err != nil {
+		return err
+	}
 
 
-	for _, depl := range depls {
-		res = append(res, depl.ToDeploymentType())
+	for _, pr := range openPRs {
+		pullRequests = append(pullRequests, &types.PullRequest{
+			Title:      pr.GetTitle(),
+			Number:     uint(pr.GetNumber()),
+			RepoOwner:  pr.GetHead().GetRepo().GetOwner().GetName(),
+			RepoName:   pr.GetHead().GetRepo().GetName(),
+			BranchFrom: pr.GetHead().GetRef(),
+			BranchInto: pr.GetBase().GetRef(),
+		})
 	}
 	}
 
 
-	c.WriteResult(w, r, res)
+	return nil
 }
 }

+ 84 - 0
api/server/handlers/environment/reenable_deployment.go

@@ -0,0 +1,84 @@
+package environment
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/google/go-github/v41/github"
+	"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"
+)
+
+type ReenableDeploymentHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewReenableDeploymentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ReenableDeploymentHandler {
+	return &ReenableDeploymentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *ReenableDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	deplID, reqErr := requestutils.GetURLParamUint(r, "deployment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	depl, err := c.Repo().Environment().ReadDeploymentByID(deplID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if depl.Status != types.DeploymentStatusInactive {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, depl.EnvironmentID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = client.Actions.CreateWorkflowDispatchEventByFileName(
+		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
+		github.CreateWorkflowDispatchEventRequest{
+			Ref: depl.PRBranchFrom,
+			Inputs: map[string]interface{}{
+				"pr_number":      depl.PullRequestID,
+				"pr_title":       depl.PRName,
+				"pr_branch_from": depl.PRBranchFrom,
+				"pr_branch_into": depl.PRBranchInto,
+			},
+		},
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 2 - 2
api/server/handlers/environment/update_deployment.go

@@ -71,7 +71,7 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 		return
 	}
 	}
 
 
-	ghDeployment, err := createDeployment(client, env, request.CreateGHDeploymentRequest)
+	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -86,7 +86,7 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 		return
 	}
 	}
 
 
-	// create the deployment
+	// update the deployment
 	depl, err = c.Repo().Environment().UpdateDeployment(depl)
 	depl, err = c.Repo().Environment().UpdateDeployment(depl)
 
 
 	if err != nil {
 	if err != nil {

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

@@ -0,0 +1,146 @@
+package webhook
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v41/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GithubIncomingWebhookHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubIncomingWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubIncomingWebhookHandler {
+	return &GithubIncomingWebhookHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GithubIncomingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	payload, err := github.ValidatePayload(r, []byte(c.Config().ServerConf.GithubIncomingWebhookSecret))
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	event, err := github.ParseWebHook(github.WebHookType(r), payload)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	switch event := event.(type) {
+	case *github.PullRequestEvent:
+		err = c.processPullRequestEvent(event, r)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}
+
+func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.PullRequestEvent, r *http.Request) error {
+	owner := event.GetOrganization().GetName()
+	repo := event.GetRepo().GetName()
+
+	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(owner, repo)
+
+	if err != nil {
+		return err
+	}
+
+	// create deployment on GitHub API
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		return err
+	}
+
+	if env.Mode == "auto" && event.GetAction() == "opened" {
+		_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+			r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
+			github.CreateWorkflowDispatchEventRequest{
+				Ref: event.PullRequest.GetHead().GetRef(),
+				Inputs: map[string]interface{}{
+					"pr_number":      event.PullRequest.GetNumber(),
+					"pr_title":       event.PullRequest.GetTitle(),
+					"pr_branch_from": event.PullRequest.GetHead().GetRef(),
+					"pr_branch_into": event.PullRequest.GetBase().GetRef(),
+				},
+			},
+		)
+
+		if err != nil {
+			return err
+		}
+	} else if event.GetAction() == "synchronize" {
+		depl, err := c.Repo().Environment().ReadDeploymentByGitDetails(
+			env.ID, owner, repo, uint(event.GetPullRequest().GetNumber()),
+		)
+
+		if err != nil {
+			return err
+		}
+
+		if depl.Status != types.DeploymentStatusInactive {
+			_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+				r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
+				github.CreateWorkflowDispatchEventRequest{
+					Ref: event.PullRequest.GetHead().GetRef(),
+					Inputs: map[string]interface{}{
+						"pr_number":      event.PullRequest.GetNumber(),
+						"pr_title":       event.PullRequest.GetTitle(),
+						"pr_branch_from": event.PullRequest.GetHead().GetRef(),
+						"pr_branch_into": event.PullRequest.GetBase().GetRef(),
+					},
+				},
+			)
+
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
+	// get the github app client
+	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// authenticate as github app installation
+	itr, err := ghinstallation.NewKeyFromFile(
+		http.DefaultTransport,
+		int64(ghAppId),
+		int64(env.GitInstallationID),
+		config.ServerConf.GithubAppSecretPath,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}

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

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/metadata"
 	"github.com/porter-dev/porter/api/server/handlers/metadata"
 	"github.com/porter-dev/porter/api/server/handlers/release"
 	"github.com/porter-dev/porter/api/server/handlers/release"
 	"github.com/porter-dev/porter/api/server/handlers/user"
 	"github.com/porter-dev/porter/api/server/handlers/user"
+	"github.com/porter-dev/porter/api/server/handlers/webhook"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
@@ -536,5 +537,34 @@ func GetBaseRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	if config.ServerConf.GithubIncomingWebhookSecret != "" {
+
+		// POST /api/github/incoming_webhook -> webhook.NewGithubIncomingWebhook
+		githubIncomingWebhookEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: "/github/incoming_webhook",
+				},
+				Scopes: []types.PermissionScope{},
+			},
+		)
+
+		githubIncomingWebhookHandler := webhook.NewGithubIncomingWebhookHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: githubIncomingWebhookEndpoint,
+			Handler:  githubIncomingWebhookHandler,
+			Router:   r,
+		})
+
+	}
+
 	return routes
 	return routes
 }
 }

+ 168 - 79
api/server/router/cluster.go

@@ -288,91 +288,180 @@ func getClusterRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/environments -> environment.NewListEnvironmentHandler
-	listEnvEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/environments",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	listEnvHandler := environment.NewListEnvironmentHandler(
-		config,
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: listEnvEndpoint,
-		Handler:  listEnvHandler,
-		Router:   r,
-	})
+	if config.ServerConf.GithubIncomingWebhookSecret != "" {
+
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/environments -> environment.NewListEnvironmentHandler
+		listEnvEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/environments",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		listEnvHandler := environment.NewListEnvironmentHandler(
+			config,
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: listEnvEndpoint,
+			Handler:  listEnvHandler,
+			Router:   r,
+		})
 
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewListDeploymentsByClusterHandler
-	listDeploymentsEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/deployments",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewListDeploymentsByClusterHandler
+		listDeploymentsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		listDeploymentsHandler := environment.NewListDeploymentsByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: listDeploymentsEndpoint,
+			Handler:  listDeploymentsHandler,
+			Router:   r,
+		})
 
 
-	listDeploymentsHandler := environment.NewListDeploymentsByClusterHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/{environment_id}/deployment -> environment.NewGetDeploymentByClusterHandler
+		getDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/{environment_id}/deployment",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		getDeploymentHandler := environment.NewGetDeploymentByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: getDeploymentEndpoint,
+			Handler:  getDeploymentHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: listDeploymentsEndpoint,
-		Handler:  listDeploymentsHandler,
-		Router:   r,
-	})
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id}/reenable -> environment.NewReenableDeploymentHandler
+		reenableDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPatch,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/{deployment_id}/reenable",
+				},
+				Scopes: []types.PermissionScope{
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		reenableDeploymentHandler := environment.NewReenableDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: reenableDeploymentEndpoint,
+			Handler:  reenableDeploymentHandler,
+			Router:   r,
+		})
 
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/{environment_id}/deployment -> environment.NewGetDeploymentByClusterHandler
-	getDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/{environment_id}/deployment",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// POST /api/projects/{project_id}/clusters/{cluster_id}/deployments/pull_request -> environment.NewEnablePullRequestHandler
+		enablePullRequestEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/pull_request",
+				},
+				Scopes: []types.PermissionScope{
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		enablePullRequestHandler := environment.NewEnablePullRequestHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: enablePullRequestEndpoint,
+			Handler:  enablePullRequestHandler,
+			Router:   r,
+		})
 
 
-	getDeploymentHandler := environment.NewGetDeploymentByClusterHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id} ->
+		// environment.NewDeleteDeploymentHandler
+		deleteDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbDelete,
+				Method: types.HTTPVerbDelete,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/{deployment_id}",
+				},
+				Scopes: []types.PermissionScope{
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		deleteDeploymentHandler := environment.NewDeleteDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: deleteDeploymentEndpoint,
+			Handler:  deleteDeploymentHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: getDeploymentEndpoint,
-		Handler:  getDeploymentHandler,
-		Router:   r,
-	})
+	}
 
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewClusterListNamespacesHandler
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewClusterListNamespacesHandler
 	listNamespacesEndpoint := factory.NewAPIEndpoint(
 	listNamespacesEndpoint := factory.NewAPIEndpoint(

+ 283 - 315
api/server/router/git_installation.go

@@ -112,329 +112,297 @@ func getGitInstallationRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id} ->
-	// environment.NewCreateEnvironmentHandler
-	createEnvironmentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	createEnvironmentHandler := environment.NewCreateEnvironmentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: createEnvironmentEndpoint,
-		Handler:  createEnvironmentHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-	// environment.NewCreateDeploymentHandler
-	createDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	createDeploymentHandler := environment.NewCreateDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: createDeploymentEndpoint,
-		Handler:  createDeploymentHandler,
-		Router:   r,
-	})
-
-	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-	// environment.NewCreateDeploymentHandler
-	getDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	getDeploymentHandler := environment.NewGetDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: getDeploymentEndpoint,
-		Handler:  getDeploymentHandler,
-		Router:   r,
-	})
-
-	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployments ->
-	// environment.NewCreateDeploymentHandler
-	listDeploymentsEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployments",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	listDeploymentsHandler := environment.NewListDeploymentsHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: listDeploymentsEndpoint,
-		Handler:  listDeploymentsHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/finalize ->
-	// environment.NewFinalizeDeploymentHandler
-	finalizeDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/finalize",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	finalizeDeploymentHandler := environment.NewFinalizeDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: finalizeDeploymentEndpoint,
-		Handler:  finalizeDeploymentHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update ->
-	// environment.NewFinalizeDeploymentHandler
-	updateDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbUpdate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	updateDeploymentHandler := environment.NewUpdateDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: updateDeploymentEndpoint,
-		Handler:  updateDeploymentHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update/status ->
-	// environment.NewUpdateDeploymentStatusHandler
-	updateDeploymentStatusEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbUpdate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update/status",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
+	if config.ServerConf.GithubIncomingWebhookSecret != "" {
+
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id} ->
+		// environment.NewCreateEnvironmentHandler
+		createEnvironmentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		createEnvironmentHandler := environment.NewCreateEnvironmentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: createEnvironmentEndpoint,
+			Handler:  createEnvironmentHandler,
+			Router:   r,
+		})
 
 
-	updateDeploymentStatusHandler := environment.NewUpdateDeploymentStatusHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
+		// environment.NewCreateDeploymentHandler
+		createDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		createDeploymentHandler := environment.NewCreateDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: createDeploymentEndpoint,
+			Handler:  createDeploymentHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: updateDeploymentStatusEndpoint,
-		Handler:  updateDeploymentStatusHandler,
-		Router:   r,
-	})
+		// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
+		// environment.NewCreateDeploymentHandler
+		getDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		getDeploymentHandler := environment.NewGetDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: getDeploymentEndpoint,
+			Handler:  getDeploymentHandler,
+			Router:   r,
+		})
 
 
-	// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/environment ->
-	// environment.NewDeleteEnvironmentHandler
-	deleteEnvironmentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbDelete,
-			Method: types.HTTPVerbDelete,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployments ->
+		// environment.NewCreateDeploymentHandler
+		listDeploymentsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployments",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		listDeploymentsHandler := environment.NewListDeploymentsHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: listDeploymentsEndpoint,
+			Handler:  listDeploymentsHandler,
+			Router:   r,
+		})
 
 
-	deleteEnvironmentHandler := environment.NewDeleteEnvironmentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/finalize ->
+		// environment.NewFinalizeDeploymentHandler
+		finalizeDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/finalize",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		finalizeDeploymentHandler := environment.NewFinalizeDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: finalizeDeploymentEndpoint,
+			Handler:  finalizeDeploymentHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: deleteEnvironmentEndpoint,
-		Handler:  deleteEnvironmentHandler,
-		Router:   r,
-	})
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update ->
+		// environment.NewFinalizeDeploymentHandler
+		updateDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		updateDeploymentHandler := environment.NewUpdateDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: updateDeploymentEndpoint,
+			Handler:  updateDeploymentHandler,
+			Router:   r,
+		})
 
 
-	// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-	// environment.NewDeleteDeploymentHandler
-	deleteDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbDelete,
-			Method: types.HTTPVerbDelete,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update/status ->
+		// environment.NewUpdateDeploymentStatusHandler
+		updateDeploymentStatusEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update/status",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		updateDeploymentStatusHandler := environment.NewUpdateDeploymentStatusHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: updateDeploymentStatusEndpoint,
+			Handler:  updateDeploymentStatusHandler,
+			Router:   r,
+		})
 
 
-	deleteDeploymentHandler := environment.NewDeleteDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/environment ->
+		// environment.NewDeleteEnvironmentHandler
+		deleteEnvironmentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbDelete,
+				Method: types.HTTPVerbDelete,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		deleteEnvironmentHandler := environment.NewDeleteEnvironmentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: deleteEnvironmentEndpoint,
+			Handler:  deleteEnvironmentHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: deleteDeploymentEndpoint,
-		Handler:  deleteDeploymentHandler,
-		Router:   r,
-	})
+	}
 
 
 	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/repos ->
 	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/repos ->
 	// gitinstallation.GithubListReposHandler
 	// gitinstallation.GithubListReposHandler

+ 2 - 0
api/server/shared/config/env/envconfs.go

@@ -37,6 +37,8 @@ type ServerConf struct {
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 
 
+	GithubIncomingWebhookSecret string `env:"GITHUB_INCOMING_WEBHOOK_SECRET"`
+
 	GithubAppClientID      string `env:"GITHUB_APP_CLIENT_ID"`
 	GithubAppClientID      string `env:"GITHUB_APP_CLIENT_ID"`
 	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
 	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
 	GithubAppName          string `env:"GITHUB_APP_NAME"`
 	GithubAppName          string `env:"GITHUB_APP_NAME"`

+ 24 - 8
api/types/environment.go

@@ -10,11 +10,15 @@ type Environment struct {
 	GitRepoOwner      string `json:"git_repo_owner"`
 	GitRepoOwner      string `json:"git_repo_owner"`
 	GitRepoName       string `json:"git_repo_name"`
 	GitRepoName       string `json:"git_repo_name"`
 
 
-	Name string `json:"name"`
+	Name                 string `json:"name"`
+	Mode                 string `json:"mode"`
+	DeploymentCount      uint   `json:"deployment_count"`
+	LastDeploymentStatus string `json:"last_deployment_status"`
 }
 }
 
 
 type CreateEnvironmentRequest struct {
 type CreateEnvironmentRequest struct {
 	Name string `json:"name" form:"required"`
 	Name string `json:"name" form:"required"`
+	Mode string `json:"mode" form:"oneof=auto manual" default:"manual"`
 }
 }
 
 
 type GitHubMetadata struct {
 type GitHubMetadata struct {
@@ -23,6 +27,8 @@ type GitHubMetadata struct {
 	RepoName     string `json:"gh_repo_name"`
 	RepoName     string `json:"gh_repo_name"`
 	RepoOwner    string `json:"gh_repo_owner"`
 	RepoOwner    string `json:"gh_repo_owner"`
 	CommitSHA    string `json:"gh_commit_sha"`
 	CommitSHA    string `json:"gh_commit_sha"`
+	PRBranchFrom string `json:"gh_pr_branch_from"`
+	PRBranchInto string `json:"gh_pr_branch_into"`
 }
 }
 
 
 type DeploymentStatus string
 type DeploymentStatus string
@@ -49,8 +55,7 @@ type Deployment struct {
 }
 }
 
 
 type CreateGHDeploymentRequest struct {
 type CreateGHDeploymentRequest struct {
-	Branch   string `json:"branch" form:"required"`
-	ActionID uint   `json:"action_id" form:"required"`
+	ActionID uint `json:"action_id" form:"required"`
 }
 }
 
 
 type CreateDeploymentRequest struct {
 type CreateDeploymentRequest struct {
@@ -69,19 +74,21 @@ type FinalizeDeploymentRequest struct {
 type UpdateDeploymentRequest struct {
 type UpdateDeploymentRequest struct {
 	*CreateGHDeploymentRequest
 	*CreateGHDeploymentRequest
 
 
-	CommitSHA string `json:"commit_sha" form:"required"`
-	Namespace string `json:"namespace" form:"required"`
+	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
+	CommitSHA    string `json:"commit_sha" form:"required"`
+	Namespace    string `json:"namespace" form:"required"`
 }
 }
 
 
 type ListDeploymentRequest struct {
 type ListDeploymentRequest struct {
-	Status []string `schema:"status"`
+	EnvironmentID uint `schema:"environment_id"`
 }
 }
 
 
 type UpdateDeploymentStatusRequest struct {
 type UpdateDeploymentStatusRequest struct {
 	*CreateGHDeploymentRequest
 	*CreateGHDeploymentRequest
 
 
-	Status    string `json:"status" form:"required,oneof=created creating inactive failed"`
-	Namespace string `json:"namespace" form:"required"`
+	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
+	Status       string `json:"status" form:"required,oneof=created creating inactive failed"`
+	Namespace    string `json:"namespace" form:"required"`
 }
 }
 
 
 type DeleteDeploymentRequest struct {
 type DeleteDeploymentRequest struct {
@@ -91,3 +98,12 @@ type DeleteDeploymentRequest struct {
 type GetDeploymentRequest struct {
 type GetDeploymentRequest struct {
 	Namespace string `schema:"namespace" form:"required"`
 	Namespace string `schema:"namespace" form:"required"`
 }
 }
+
+type PullRequest struct {
+	Title      string `json:"pr_title"`
+	Number     uint   `json:"pr_number"`
+	RepoOwner  string `json:"repo_owner"`
+	RepoName   string `json:"repo_name"`
+	BranchFrom string `json:"branch_from"`
+	BranchInto string `json:"branch_into"`
+}

+ 19 - 15
cli/cmd/apply.go

@@ -730,10 +730,10 @@ func existsInRepo(name, version, url string) (map[string]interface{}, error) {
 }
 }
 
 
 type DeploymentHook struct {
 type DeploymentHook struct {
-	client                                                    *api.Client
-	resourceGroup                                             *switchboardTypes.ResourceGroup
-	gitInstallationID, projectID, clusterID, prID, actionID   uint
-	branch, namespace, repoName, repoOwner, prName, commitSHA string
+	client                                                                    *api.Client
+	resourceGroup                                                             *switchboardTypes.ResourceGroup
+	gitInstallationID, projectID, clusterID, prID, actionID                   uint
+	branchFrom, branchInto, namespace, repoName, repoOwner, prName, commitSHA string
 }
 }
 
 
 func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.ResourceGroup, namespace string) (*DeploymentHook, error) {
 func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.ResourceGroup, namespace string) (*DeploymentHook, error) {
@@ -773,8 +773,11 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 		return nil, fmt.Errorf("cluster id must be set")
 		return nil, fmt.Errorf("cluster id must be set")
 	}
 	}
 
 
-	branchName := os.Getenv("PORTER_BRANCH_NAME")
-	res.branch = branchName
+	branchFrom := os.Getenv("PORTER_BRANCH_FROM")
+	res.branchFrom = branchFrom
+
+	branchInto := os.Getenv("PORTER_BRANCH_INTO")
+	res.branchInto = branchInto
 
 
 	actionIDStr := os.Getenv("PORTER_ACTION_ID")
 	actionIDStr := os.Getenv("PORTER_ACTION_ID")
 	actionID, err := strconv.Atoi(actionIDStr)
 	actionID, err := strconv.Atoi(actionIDStr)
@@ -827,14 +830,15 @@ func (t *DeploymentHook) PreApply() error {
 				Namespace:     t.namespace,
 				Namespace:     t.namespace,
 				PullRequestID: t.prID,
 				PullRequestID: t.prID,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 					ActionID: t.actionID,
 				},
 				},
 				GitHubMetadata: &types.GitHubMetadata{
 				GitHubMetadata: &types.GitHubMetadata{
-					PRName:    t.prName,
-					RepoName:  t.repoName,
-					RepoOwner: t.repoOwner,
-					CommitSHA: t.commitSHA,
+					PRName:       t.prName,
+					RepoName:     t.repoName,
+					RepoOwner:    t.repoOwner,
+					CommitSHA:    t.commitSHA,
+					PRBranchFrom: t.branchFrom,
+					PRBranchInto: t.branchInto,
 				},
 				},
 			},
 			},
 		)
 		)
@@ -846,10 +850,10 @@ func (t *DeploymentHook) PreApply() error {
 			&types.UpdateDeploymentRequest{
 			&types.UpdateDeploymentRequest{
 				Namespace: t.namespace,
 				Namespace: t.namespace,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 					ActionID: t.actionID,
 				},
 				},
-				CommitSHA: t.commitSHA,
+				PRBranchFrom: t.branchFrom,
+				CommitSHA:    t.commitSHA,
 			},
 			},
 		)
 		)
 	}
 	}
@@ -929,10 +933,10 @@ func (t *DeploymentHook) OnError(err error) {
 			&types.UpdateDeploymentStatusRequest{
 			&types.UpdateDeploymentStatusRequest{
 				Namespace: t.namespace,
 				Namespace: t.namespace,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 					ActionID: t.actionID,
 				},
 				},
-				Status: string(types.DeploymentStatusFailed),
+				PRBranchFrom: t.branchFrom,
+				Status:       string(types.DeploymentStatusFailed),
 			},
 			},
 		)
 		)
 	}
 	}

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

@@ -472,7 +472,7 @@ func deleteGithubFile(
 		}
 		}
 	}
 	}
 
 
-	_, _, err := client.Repositories.DeleteFile(
+	_, response, err := client.Repositories.DeleteFile(
 		context.TODO(),
 		context.TODO(),
 		gitRepoOwner,
 		gitRepoOwner,
 		gitRepoName,
 		gitRepoName,
@@ -480,6 +480,10 @@ func deleteGithubFile(
 		opts,
 		opts,
 	)
 	)
 
 
+	if response.StatusCode == 404 {
+		return nil
+	}
+
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}

+ 27 - 1
internal/integrations/ci/actions/preview.go

@@ -229,13 +229,39 @@ func getPreviewApplyActionYAML(opts *EnvOpts) ([]byte, error) {
 			opts.ProjectID,
 			opts.ProjectID,
 			opts.ClusterID,
 			opts.ClusterID,
 			opts.GitInstallationID,
 			opts.GitInstallationID,
+			opts.GitRepoOwner,
 			opts.GitRepoName,
 			opts.GitRepoName,
 			"v0.1.0",
 			"v0.1.0",
 		),
 		),
 	}
 	}
 
 
 	actionYAML := GithubActionYAML{
 	actionYAML := GithubActionYAML{
-		On:   []string{"pull_request"},
+		On: map[string]interface{}{
+			"workflow_dispatch": map[string]interface{}{
+				"inputs": map[string]interface{}{
+					"pr_number": map[string]interface{}{
+						"description": "Pull request number",
+						"type":        "number",
+						"required":    true,
+					},
+					"pr_title": map[string]interface{}{
+						"description": "Pull request title",
+						"type":        "string",
+						"required":    true,
+					},
+					"pr_branch_from": map[string]interface{}{
+						"description": "Pull request head branch",
+						"type":        "string",
+						"required":    true,
+					},
+					"pr_branch_into": map[string]interface{}{
+						"description": "Pull request base branch",
+						"type":        "string",
+						"required":    true,
+					},
+				},
+			},
+		},
 		Name: "Porter Preview Environment",
 		Name: "Porter Preview Environment",
 		Jobs: map[string]GithubActionYAMLJob{
 		Jobs: map[string]GithubActionYAMLJob{
 			"porter-preview": {
 			"porter-preview": {

+ 18 - 11
internal/integrations/ci/actions/steps.go

@@ -2,6 +2,7 @@ package actions
 
 
 import (
 import (
 	"fmt"
 	"fmt"
+	"strings"
 )
 )
 
 
 const updateAppActionName = "porter-dev/porter-update-action"
 const updateAppActionName = "porter-dev/porter-update-action"
@@ -40,23 +41,29 @@ func getUpdateAppStep(serverURL, porterTokenSecretName string, projectID uint, c
 	}
 	}
 }
 }
 
 
-func getCreatePreviewEnvStep(serverURL, porterTokenSecretName string, projectID, clusterID, gitInstallationID uint, repoName, actionVersion string) GithubActionYAMLStep {
+func getCreatePreviewEnvStep(
+	serverURL, porterTokenSecretName string,
+	projectID, clusterID, gitInstallationID uint,
+	repoOwner, repoName, actionVersion string,
+) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 	return GithubActionYAMLStep{
 		Name: "Create Porter preview env",
 		Name: "Create Porter preview env",
 		Uses: fmt.Sprintf("%s@%s", createPreviewActionName, actionVersion),
 		Uses: fmt.Sprintf("%s@%s", createPreviewActionName, actionVersion),
 		With: map[string]string{
 		With: map[string]string{
-			"cluster":         fmt.Sprintf("%d", clusterID),
-			"host":            serverURL,
-			"project":         fmt.Sprintf("%d", projectID),
-			"token":           fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
-			"namespace":       fmt.Sprintf("pr-${{ github.event.pull_request.number }}-%s", repoName),
-			"pr_id":           "${{ github.event.pull_request.number }}",
-			"pr_name":         "${{ github.event.pull_request.title }}",
+			"cluster": fmt.Sprintf("%d", clusterID),
+			"host":    serverURL,
+			"project": fmt.Sprintf("%d", projectID),
+			"token":   fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"namespace": fmt.Sprintf("pr-${{ github.event.inputs.pr_number }}-%s",
+				strings.ReplaceAll(repoName, "_", "-")),
+			"pr_id":           "${{ github.event.inputs.pr_number }}",
+			"pr_name":         "${{ github.event.inputs.pr_title }}",
 			"installation_id": fmt.Sprintf("%d", gitInstallationID),
 			"installation_id": fmt.Sprintf("%d", gitInstallationID),
-			"branch":          "${{ github.head_ref }}",
+			"pr_branch_from":  "${{ github.event.inputs.pr_branch_from }}",
+			"pr_branch_into":  "${{ github.event.inputs.pr_branch_into }}",
 			"action_id":       "${{ github.run_id }}",
 			"action_id":       "${{ github.run_id }}",
-			"repo_owner":      "${{ github.repository_owner }}",
-			"repo_name":       fmt.Sprintf("%s", repoName),
+			"repo_owner":      repoOwner,
+			"repo_name":       repoName,
 		},
 		},
 		Timeout: 30,
 		Timeout: 30,
 	}
 	}

+ 10 - 1
internal/models/environment.go

@@ -5,6 +5,8 @@ import (
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 )
 )
 
 
+type EnvironmentMode uint
+
 type Environment struct {
 type Environment struct {
 	gorm.Model
 	gorm.Model
 
 
@@ -15,6 +17,7 @@ type Environment struct {
 	GitRepoName       string
 	GitRepoName       string
 
 
 	Name string
 	Name string
+	Mode string
 }
 }
 
 
 func (e *Environment) ToEnvironmentType() *types.Environment {
 func (e *Environment) ToEnvironmentType() *types.Environment {
@@ -25,7 +28,9 @@ func (e *Environment) ToEnvironmentType() *types.Environment {
 		GitInstallationID: e.GitInstallationID,
 		GitInstallationID: e.GitInstallationID,
 		GitRepoOwner:      e.GitRepoOwner,
 		GitRepoOwner:      e.GitRepoOwner,
 		GitRepoName:       e.GitRepoName,
 		GitRepoName:       e.GitRepoName,
-		Name:              e.Name,
+
+		Name: e.Name,
+		Mode: e.Mode,
 	}
 	}
 }
 }
 
 
@@ -42,6 +47,8 @@ type Deployment struct {
 	RepoName       string
 	RepoName       string
 	RepoOwner      string
 	RepoOwner      string
 	CommitSHA      string
 	CommitSHA      string
+	PRBranchFrom   string
+	PRBranchInto   string
 }
 }
 
 
 func (d *Deployment) ToDeploymentType() *types.Deployment {
 func (d *Deployment) ToDeploymentType() *types.Deployment {
@@ -52,6 +59,8 @@ func (d *Deployment) ToDeploymentType() *types.Deployment {
 		RepoName:     d.RepoName,
 		RepoName:     d.RepoName,
 		RepoOwner:    d.RepoOwner,
 		RepoOwner:    d.RepoOwner,
 		CommitSHA:    d.CommitSHA,
 		CommitSHA:    d.CommitSHA,
+		PRBranchFrom: d.PRBranchFrom,
+		PRBranchInto: d.PRBranchInto,
 	}
 	}
 
 
 	return &types.Deployment{
 	return &types.Deployment{

+ 3 - 0
internal/repository/environment.go

@@ -6,11 +6,14 @@ type EnvironmentRepository interface {
 	CreateEnvironment(env *models.Environment) (*models.Environment, error)
 	CreateEnvironment(env *models.Environment) (*models.Environment, error)
 	ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error)
 	ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error)
 	ReadEnvironmentByID(projectID, clusterID, envID uint) (*models.Environment, error)
 	ReadEnvironmentByID(projectID, clusterID, envID uint) (*models.Environment, error)
+	ReadEnvironmentByOwnerRepoName(owner, repo string) (*models.Environment, error)
 	ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error)
 	ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error)
 	DeleteEnvironment(env *models.Environment) (*models.Environment, error)
 	DeleteEnvironment(env *models.Environment) (*models.Environment, error)
 	CreateDeployment(deployment *models.Deployment) (*models.Deployment, error)
 	CreateDeployment(deployment *models.Deployment) (*models.Deployment, error)
 	ReadDeployment(environmentID uint, namespace string) (*models.Deployment, error)
 	ReadDeployment(environmentID uint, namespace string) (*models.Deployment, error)
+	ReadDeploymentByID(id uint) (*models.Deployment, error)
 	ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error)
 	ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error)
+	ReadDeploymentByGitDetails(environmentID uint, owner, repo string, prNumber uint) (*models.Deployment, error)
 	ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error)
 	ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error)
 	ListDeployments(environmentID uint, states ...string) ([]*models.Deployment, error)
 	ListDeployments(environmentID uint, states ...string) ([]*models.Deployment, error)
 	UpdateDeployment(deployment *models.Deployment) (*models.Deployment, error)
 	UpdateDeployment(deployment *models.Deployment) (*models.Deployment, error)

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

@@ -51,6 +51,18 @@ func (repo *EnvironmentRepository) ReadEnvironmentByID(projectID, clusterID, env
 	return env, nil
 	return env, nil
 }
 }
 
 
+func (repo *EnvironmentRepository) ReadEnvironmentByOwnerRepoName(
+	gitRepoOwner, gitRepoName string,
+) (*models.Environment, error) {
+	env := &models.Environment{}
+	if err := repo.db.Order("id desc").Where("git_repo_owner = ? AND git_repo_name = ?",
+		gitRepoOwner, gitRepoName,
+	).First(&env).Error; err != nil {
+		return nil, err
+	}
+	return env, nil
+}
+
 func (repo *EnvironmentRepository) ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error) {
 func (repo *EnvironmentRepository) ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error) {
 	envs := make([]*models.Environment, 0)
 	envs := make([]*models.Environment, 0)
 
 
@@ -91,6 +103,14 @@ func (repo *EnvironmentRepository) ReadDeployment(environmentID uint, namespace
 	return depl, nil
 	return depl, nil
 }
 }
 
 
+func (repo *EnvironmentRepository) ReadDeploymentByID(id uint) (*models.Deployment, error) {
+	depl := &models.Deployment{}
+	if err := repo.db.Where("id = ?", id).First(&depl).Error; err != nil {
+		return nil, err
+	}
+	return depl, nil
+}
+
 func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error) {
 func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error) {
 	depl := &models.Deployment{}
 	depl := &models.Deployment{}
 
 
@@ -105,6 +125,21 @@ func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID
 	return depl, nil
 	return depl, nil
 }
 }
 
 
+func (repo *EnvironmentRepository) ReadDeploymentByGitDetails(
+	environmentID uint, gitRepoOwner, gitRepoName string, prNumber uint,
+) (*models.Deployment, error) {
+	depl := &models.Deployment{}
+
+	if err := repo.db.Order("id asc").
+		Where("environment_id = ? AND repo_owner = ? AND repo_name = ? AND pull_request_id = ?",
+			environmentID, gitRepoOwner, gitRepoName, prNumber).
+		First(&depl).Error; err != nil {
+		return nil, err
+	}
+
+	return depl, nil
+}
+
 func (repo *EnvironmentRepository) ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error) {
 func (repo *EnvironmentRepository) ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error) {
 	query := repo.db.
 	query := repo.db.
 		Order("deployments.updated_at desc").
 		Order("deployments.updated_at desc").

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

@@ -27,6 +27,10 @@ func (repo *EnvironmentRepository) ReadEnvironmentByID(projectID, clusterID, env
 	panic("unimplemented")
 	panic("unimplemented")
 }
 }
 
 
+func (repo *EnvironmentRepository) ReadEnvironmentByOwnerRepoName(owner, repoName string) (*models.Environment, error) {
+	panic("unimplemented")
+}
+
 func (repo *EnvironmentRepository) ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error) {
 func (repo *EnvironmentRepository) ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error) {
 	panic("unimplemented")
 	panic("unimplemented")
 }
 }
@@ -47,10 +51,18 @@ func (repo *EnvironmentRepository) ReadDeployment(environmentID uint, namespace
 	panic("unimplemented")
 	panic("unimplemented")
 }
 }
 
 
+func (repo *EnvironmentRepository) ReadDeploymentByID(id uint) (*models.Deployment, error) {
+	panic("unimplemented")
+}
+
 func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error) {
 func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error) {
 	panic("unimplemented")
 	panic("unimplemented")
 }
 }
 
 
+func (repo *EnvironmentRepository) ReadDeploymentByGitDetails(environmentID uint, owner, repoName string, prNumber uint) (*models.Deployment, error) {
+	panic("unimplemented")
+}
+
 func (repo *EnvironmentRepository) ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error) {
 func (repo *EnvironmentRepository) ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error) {
 	panic("unimplemented")
 	panic("unimplemented")
 }
 }