Sfoglia il codice sorgente

POR-1852 comment deploy details with URL on first preview deploy (#3759)

ianedwards 2 anni fa
parent
commit
96d5360415

+ 34 - 0
api/client/porter_app.go

@@ -439,6 +439,40 @@ func (c *Client) GetBuildEnv(
 	return resp, err
 }
 
+// ReportRevisionStatusInput is the input struct to ReportRevisionStatus
+type ReportRevisionStatusInput struct {
+	ProjectID     uint
+	ClusterID     uint
+	AppName       string
+	AppRevisionID string
+	PRNumber      int
+	CommitSHA     string
+}
+
+// ReportRevisionStatus reports the status of an app revision to external services
+func (c *Client) ReportRevisionStatus(
+	ctx context.Context,
+	inp ReportRevisionStatusInput,
+) (*porter_app.ReportRevisionStatusResponse, error) {
+	resp := &porter_app.ReportRevisionStatusResponse{}
+
+	req := &porter_app.ReportRevisionStatusRequest{
+		PRNumber:  inp.PRNumber,
+		CommitSHA: inp.CommitSHA,
+	}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/%s/revisions/%s/status",
+			inp.ProjectID, inp.ClusterID, inp.AppName, inp.AppRevisionID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 // CreateOrUpdateAppEnvironment updates the app environment group and creates it if it doesn't exist
 func (c *Client) CreateOrUpdateAppEnvironment(
 	ctx context.Context,

+ 1 - 1
api/server/handlers/porter_app/apply.go

@@ -221,7 +221,7 @@ func addPorterSubdomainsIfNecessary(ctx context.Context, app *porterv1.PorterApp
 
 			if !webConfig.GetPrivate() && len(webConfig.Domains) == 0 {
 				if deploymentTarget.Namespace != DeploymentTargetSelector_Default {
-					createSubdomainInput.AppName = fmt.Sprintf("%s-%s", createSubdomainInput.AppName, deploymentTarget.Namespace)
+					createSubdomainInput.AppName = fmt.Sprintf("%s-%s", createSubdomainInput.AppName, deploymentTarget.ID[:6])
 				}
 
 				subdomain, err := porter_app.CreatePorterSubdomain(ctx, createSubdomainInput)

+ 261 - 0
api/server/handlers/porter_app/report_status.go

@@ -0,0 +1,261 @@
+package porter_app
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/google/go-github/v39/github"
+	"github.com/google/uuid"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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/deployment_target"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/porter_app"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"k8s.io/utils/pointer"
+)
+
+// ReportRevisionStatusHandler is the handler for the /apps/{porter_app_name}/revisions/{app_revision_id}/status endpoint
+type ReportRevisionStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewReportRevisionStatusHandler handles POST requests to the endpoint /apps/{porter_app_name}/revisions/{app_revision_id}/status
+func NewReportRevisionStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ReportRevisionStatusHandler {
+	return &ReportRevisionStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ReportRevisionStatusRequest is the request object for the /apps/{porter_app_name}/revisions/{app_revision_id}/status endpoint
+type ReportRevisionStatusRequest struct {
+	PRNumber  int    `json:"pr_number"`
+	CommitSHA string `json:"commit_sha"`
+}
+
+// ReportRevisionStatusResponse is the response object for the /apps/{porter_app_name}/revisions/{app_revision_id}/status endpoint
+type ReportRevisionStatusResponse struct{}
+
+// ServeHTTP reports the status of a revision to Github and other integrations, depending on the status and the deployment target
+func (c *ReportRevisionStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-report-revision-status")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) {
+		err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
+		return
+	}
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-name", Value: appName})
+
+	revisionID, reqErr := requestutils.GetURLParamString(r, types.URLParamAppRevisionID)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing app revision id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-revision-id", Value: revisionID})
+
+	appRevisionUuid, err := uuid.Parse(revisionID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing app revision id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if appRevisionUuid == uuid.Nil {
+		err := telemetry.Error(ctx, span, nil, "app revision id is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-revision-id", Value: appRevisionUuid.String()})
+
+	porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error reading porter app by name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if porterApp.ID == 0 {
+		err := telemetry.Error(ctx, span, nil, "porter app not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-id", Value: porterApp.ID})
+
+	request := &ReportRevisionStatusRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	revision, err := porter_app.GetAppRevision(ctx, porter_app.GetAppRevisionInput{
+		AppRevisionID: appRevisionUuid,
+		ProjectID:     project.ID,
+		CCPClient:     c.Config().ClusterControlPlaneClient,
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting app revision")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+		ProjectID:          int64(project.ID),
+		ClusterID:          int64(cluster.ID),
+		DeploymentTargetID: revision.DeploymentTargetID,
+		CCPClient:          c.Config().ClusterControlPlaneClient,
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTarget.ID})
+
+	resp := &ReportRevisionStatusResponse{}
+
+	if !deploymentTarget.Preview || request.PRNumber == 0 || revision.RevisionNumber > 1 {
+		c.WriteResult(w, r, resp)
+		return
+	}
+
+	err = writePRComment(ctx, writePRCommentInput{
+		revision:        revision,
+		porterApp:       porterApp,
+		prNumber:        request.PRNumber,
+		commitSha:       request.CommitSHA,
+		githubAppSecret: c.Config().ServerConf.GithubAppSecret,
+		githubAppID:     c.Config().ServerConf.GithubAppID,
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error writing pr comment")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}
+
+type writePRCommentInput struct {
+	revision  porter_app.Revision
+	porterApp *models.PorterApp
+	prNumber  int
+	commitSha string
+	serverURL string
+
+	githubAppSecret []byte
+	githubAppID     string
+}
+
+func writePRComment(ctx context.Context, inp writePRCommentInput) error {
+	ctx, span := telemetry.NewSpan(ctx, "write-pr-comment")
+	defer span.End()
+
+	if inp.porterApp == nil {
+		return telemetry.Error(ctx, span, nil, "porter app is nil")
+	}
+	if inp.prNumber == 0 {
+		return telemetry.Error(ctx, span, nil, "pr number is empty")
+	}
+	if inp.commitSha == "" {
+		return telemetry.Error(ctx, span, nil, "commit sha is empty")
+	}
+	if inp.githubAppSecret == nil {
+		return telemetry.Error(ctx, span, nil, "github app secret is empty")
+	}
+	if inp.githubAppID == "" {
+		return telemetry.Error(ctx, span, nil, "github app id is empty")
+	}
+
+	client, err := porter_app.GetGithubClientByRepoID(ctx, inp.porterApp.GitRepoID, inp.githubAppSecret, inp.githubAppID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error getting github client")
+	}
+
+	repoDetails := strings.Split(inp.porterApp.RepoName, "/")
+	if len(repoDetails) != 2 {
+		return telemetry.Error(ctx, span, nil, "repo name is not in the format <org>/<repo>")
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "repo-owner", Value: repoDetails[0]},
+		telemetry.AttributeKV{Key: "repo-name", Value: repoDetails[1]},
+		telemetry.AttributeKV{Key: "pr-number", Value: inp.prNumber},
+		telemetry.AttributeKV{Key: "commit-sha", Value: inp.commitSha},
+	)
+
+	decoded, err := base64.StdEncoding.DecodeString(inp.revision.B64AppProto)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error decoding base proto")
+	}
+
+	appProto := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, appProto)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error unmarshalling app proto")
+	}
+
+	body := "## Porter Preview Environments\n"
+	porterURL := fmt.Sprintf("%s/preview-environments/apps/%s?target=%s", inp.serverURL, inp.porterApp.Name, inp.revision.DeploymentTargetID)
+
+	switch inp.revision.Status {
+	case models.AppRevisionStatus_BuildFailed:
+		body = fmt.Sprintf("%s❌ The latest deploy failed to build. Check the [Porter Dashboard](%s) or [action logs](https://github.com/%s/actions/runs/) for more information.", body, porterURL, inp.porterApp.RepoName)
+	case models.AppRevisionStatus_DeployFailed:
+		body = fmt.Sprintf("%s❌ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) failed to deploy.\nCheck the [Porter Dashboard](%s) or [action logs](https://github.com/%s/actions/runs/) for more information.\nContact Porter Support if the errors persists", body, inp.commitSha, repoDetails[0], repoDetails[1], inp.commitSha, porterURL, inp.porterApp.RepoName)
+	case models.AppRevisionStatus_Deployed:
+		body = fmt.Sprintf("%s✅ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) has been successfully deployed.\nApp details available in the [Porter Dashboard](%s)", body, inp.commitSha, repoDetails[0], repoDetails[1], inp.commitSha, porterURL)
+	default:
+		return nil
+	}
+
+	for _, service := range appProto.Services {
+		webConfig := service.GetWebConfig()
+		if webConfig != nil {
+			domains := webConfig.GetDomains()
+
+			if len(domains) > 0 {
+				body = fmt.Sprintf("%s\n\n**Preview URL**: https://%s", body, domains[0].Name)
+			}
+		}
+	}
+
+	_, _, err = client.Issues.CreateComment(
+		ctx,
+		repoDetails[0],
+		repoDetails[1],
+		inp.prNumber,
+		&github.IssueComment{
+			Body: pointer.String(body),
+		},
+	)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error creating github comment")
+	}
+
+	return nil
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -1154,6 +1154,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id}/status -> porter_app.NewReportRevisionStatusHandler
+	reportRevisionStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/apps/{%s}/revisions/{%s}/status", types.URLParamPorterAppName, types.URLParamAppRevisionID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	reportRevisionStatusHandler := porter_app.NewReportRevisionStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: reportRevisionStatusEndpoint,
+		Handler:  reportRevisionStatusHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/update-environment -> porter_app.NewUpdateAppEnvironmentHandler
 	updateAppEnvironmentGroupEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 69 - 13
cli/cmd/v2/apply.go

@@ -51,6 +51,15 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 		return fmt.Errorf("error getting deployment target from config: %w", err)
 	}
 
+	var prNumber int
+	prNumberEnv := os.Getenv("PORTER_PR_NUMBER")
+	if prNumberEnv != "" {
+		prNumber, err = strconv.Atoi(prNumberEnv)
+		if err != nil {
+			return fmt.Errorf("error parsing PORTER_PR_NUMBER to int: %w", err)
+		}
+	}
+
 	porterYamlExists := len(inp.PorterYamlPath) != 0
 
 	if porterYamlExists {
@@ -174,43 +183,55 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 
 		eventID, _ := createBuildEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID)
 
+		reportBuildFailureInput := reportBuildFailureInput{
+			client:             client,
+			appName:            appName,
+			cliConf:            cliConf,
+			deploymentTargetID: deploymentTargetID,
+			appRevisionID:      applyResp.AppRevisionId,
+			eventID:            eventID,
+			buildError:         err,
+			commitSHA:          commitSHA,
+			prNumber:           prNumber,
+		}
+
 		if commitSHA == "" {
 			err := errors.New("Build is required but commit SHA cannot be identified. Please set the PORTER_COMMIT_SHA environment variable or run apply in git repository with access to the git CLI.")
-			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID, err, "")
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
 			return err
 		}
 
 		buildSettings, err := buildSettingsFromBase64AppProto(base64AppProto)
 		if err != nil {
 			err := fmt.Errorf("error getting build settings from base64 app proto: %w", err)
-			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID, err, "")
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
 			return err
 		}
 
 		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, appName, deploymentTargetID)
 		if err != nil {
 			err := fmt.Errorf("error getting current app revision: %w", err)
-			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID, err, "")
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
 			return err
 		}
 
 		if currentAppRevisionResp == nil {
 			err := errors.New("current app revision is nil")
-			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID, err, "")
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
 			return err
 		}
 
 		appRevision := currentAppRevisionResp.AppRevision
 		if appRevision.B64AppProto == "" {
 			err := errors.New("current app revision b64 app proto is empty")
-			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID, err, "")
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
 			return err
 		}
 
 		currentImageTag, err := imageTagFromBase64AppProto(appRevision.B64AppProto)
 		if err != nil {
 			err := fmt.Errorf("error getting image tag from current app revision: %w", err)
-			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID, err, "")
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
 			return err
 		}
 
@@ -220,7 +241,7 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 		buildEnv, err := client.GetBuildEnv(ctx, cliConf.Project, cliConf.Cluster, appName, appRevision.ID)
 		if err != nil {
 			err := fmt.Errorf("error getting build env: %w", err)
-			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID, err, "")
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
 			return err
 		}
 		buildSettings.Env = buildEnv.BuildEnvVariables
@@ -228,7 +249,8 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 		buildOutput := build(ctx, client, buildSettings)
 		if buildOutput.Error != nil {
 			err := fmt.Errorf("error building app: %w", buildOutput.Error)
-			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID, err, buildOutput.Logs)
+			reportBuildFailureInput.buildLogs = buildOutput.Logs
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
 			return err
 		}
 
@@ -295,6 +317,15 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 		return fmt.Errorf("unexpected CLI action: %s", applyResp.CLIAction)
 	}
 
+	_, _ = client.ReportRevisionStatus(ctx, api.ReportRevisionStatusInput{
+		ProjectID:     cliConf.Project,
+		ClusterID:     cliConf.Cluster,
+		AppName:       appName,
+		AppRevisionID: applyResp.AppRevisionId,
+		PRNumber:      prNumber,
+		CommitSHA:     commitSHA,
+	})
+
 	color.New(color.FgGreen).Printf("Successfully applied new revision %s for app %s\n", applyResp.AppRevisionId, appName) // nolint:errcheck,gosec
 	return nil
 }
@@ -516,8 +547,21 @@ func updateEnvGroupsInProto(ctx context.Context, base64AppProto string, envGroup
 	return editedB64AppProto, nil
 }
 
-func reportBuildFailure(ctx context.Context, client api.Client, appName string, cliConf config.CLIConfig, deploymentTargetID string, appRevisionID string, eventID string, buildError error, buildLogs string) error {
-	_, err := client.UpdateRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, appRevisionID, models.AppRevisionStatus_BuildFailed)
+type reportBuildFailureInput struct {
+	client             api.Client
+	appName            string
+	cliConf            config.CLIConfig
+	deploymentTargetID string
+	appRevisionID      string
+	eventID            string
+	buildError         error
+	buildLogs          string
+	commitSHA          string
+	prNumber           int
+}
+
+func reportBuildFailure(ctx context.Context, inp reportBuildFailureInput) error {
+	_, err := inp.client.UpdateRevisionStatus(ctx, inp.cliConf.Project, inp.cliConf.Cluster, inp.appName, inp.appRevisionID, models.AppRevisionStatus_BuildFailed)
 	if err != nil {
 		return err
 	}
@@ -527,16 +571,28 @@ func reportBuildFailure(ctx context.Context, client api.Client, appName string,
 
 	// the below is a temporary solution until we can report build errors via telemetry from the CLI
 	errorStringMap := make(map[string]string)
-	errorStringMap["build-error"] = fmt.Sprintf("%+v", buildError)
-	b64BuildLogs := base64.StdEncoding.EncodeToString([]byte(buildLogs))
+	errorStringMap["build-error"] = fmt.Sprintf("%+v", inp.buildError)
+	b64BuildLogs := base64.StdEncoding.EncodeToString([]byte(inp.buildLogs))
 	// the key name below must be kept the same so that reportBuildStatus in the CreateOrUpdatePorterAppEvent handler reports logs correctly
 	errorStringMap["b64-build-logs"] = b64BuildLogs
 
 	buildMetadata["errors"] = errorStringMap
 
-	err = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, types.PorterAppEventType_Build, eventID, types.PorterAppEventStatus_Failed, buildMetadata)
+	err = updateExistingEvent(ctx, inp.client, inp.appName, inp.cliConf.Project, inp.cliConf.Cluster, inp.deploymentTargetID, types.PorterAppEventType_Build, inp.eventID, types.PorterAppEventStatus_Failed, buildMetadata)
 	if err != nil {
 		return err
 	}
+	_, err = inp.client.ReportRevisionStatus(ctx, api.ReportRevisionStatusInput{
+		ProjectID:     inp.cliConf.Project,
+		ClusterID:     inp.cliConf.Cluster,
+		AppName:       inp.appName,
+		AppRevisionID: inp.appRevisionID,
+		PRNumber:      inp.prNumber,
+		CommitSHA:     inp.commitSHA,
+	})
+	if err != nil {
+		return err
+	}
+
 	return nil
 }

+ 3 - 2
dashboard/src/main/home/app-dashboard/new-app-flow/utils.ts

@@ -71,7 +71,7 @@ export const getPreviewGithubAction = (
     - opened
     - synchronize
     
-name: Deploy preview environment
+name: Deploy to Preview Environment
 jobs:
   porter-deploy:
     runs-on: ubuntu-latest
@@ -92,5 +92,6 @@ jobs:
         PORTER_PROJECT: ${projectID}
         PORTER_STACK_NAME: ${stackName}
         PORTER_TAG: \${{ steps.vars.outputs.sha_short }}
-        PORTER_TOKEN: \${{ secrets.PORTER_STACK_${projectID}_${clusterId} }}`;
+        PORTER_TOKEN: \${{ secrets.PORTER_STACK_${projectID}_${clusterId} }}
+        PORTER_PR_NUMBER: \${{ github.event.inputs.pr_number }}`;
 };

+ 2 - 0
internal/deployment_target/get.go

@@ -19,6 +19,7 @@ type DeploymentTargetDetailsInput struct {
 
 // DeploymentTarget is a struct representing the unique cluster, namespace pair for a deployment target
 type DeploymentTarget struct {
+	ID        string `json:"id"`
 	ClusterID int64  `json:"cluster_id"`
 	Namespace string `json:"namespace"`
 	Preview   bool   `json:"preview"`
@@ -63,6 +64,7 @@ func DeploymentTargetDetails(ctx context.Context, inp DeploymentTargetDetailsInp
 	}
 
 	deploymentTarget = DeploymentTarget{
+		ID:        inp.DeploymentTargetID,
 		Namespace: deploymentTargetDetailsResp.Msg.Namespace,
 		ClusterID: deploymentTargetDetailsResp.Msg.ClusterId,
 		Preview:   deploymentTargetDetailsResp.Msg.IsPreview,

+ 1 - 0
internal/integrations/ci/actions/steps.go

@@ -106,6 +106,7 @@ func getDeployStackStep(
 			"PORTER_TOKEN":      fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
 			"PORTER_TAG":        "${{ steps.vars.outputs.sha_short }}",
 			"PORTER_STACK_NAME": stackName,
+			"PORTER_PR_NUMBER":  "${{ github.event.inputs.pr_number }}",
 		},
 		Timeout: 30,
 	}

+ 3 - 2
internal/porter_app/github.go

@@ -71,7 +71,7 @@ func CreateAppWebhook(ctx context.Context, inp CreateAppWebhookInput) error {
 		return telemetry.Error(ctx, span, nil, "porter app git repo id is empty")
 	}
 
-	githubClient, err := getGithubClientByRepoID(ctx, porterApp.GitRepoID, inp.GithubAppSecret, inp.GithubAppID)
+	githubClient, err := GetGithubClientByRepoID(ctx, porterApp.GitRepoID, inp.GithubAppSecret, inp.GithubAppID)
 	if err != nil {
 		return telemetry.Error(ctx, span, err, "error creating github client")
 	}
@@ -133,7 +133,8 @@ func CreateAppWebhook(ctx context.Context, inp CreateAppWebhookInput) error {
 	return nil
 }
 
-func getGithubClientByRepoID(ctx context.Context, repoID uint, githubAppSecret []byte, githubAppID string) (*github.Client, error) {
+// GetGithubClientByRepoID creates a github client for a given repo id
+func GetGithubClientByRepoID(ctx context.Context, repoID uint, githubAppSecret []byte, githubAppID string) (*github.Client, error) {
 	ctx, span := telemetry.NewSpan(ctx, "get-github-client-by-repo-id")
 	defer span.End()
 

+ 35 - 2
internal/porter_app/revisions.go

@@ -3,6 +3,7 @@ package porter_app
 import (
 	"context"
 	"encoding/base64"
+	"fmt"
 	"time"
 
 	"connectrpc.com/connect"
@@ -13,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
@@ -24,7 +26,7 @@ type Revision struct {
 	// B64AppProto is the base64 encoded app proto definition
 	B64AppProto string `json:"b64_app_proto"`
 	// Status is the status of the revision
-	Status string `json:"status"`
+	Status models.AppRevisionStatus `json:"status"`
 	// RevisionNumber is the revision number with respect to the app and deployment target
 	RevisionNumber uint64 `json:"revision_number"`
 	// CreatedAt is the time the revision was created
@@ -105,9 +107,14 @@ func EncodedRevisionFromProto(ctx context.Context, appRevision *porterv1.AppRevi
 
 	b64 := base64.StdEncoding.EncodeToString(encoded)
 
+	status, err := appRevisionStatusFromProto(appRevision.Status)
+	if err != nil {
+		return revision, telemetry.Error(ctx, span, err, "error getting app revision status from proto")
+	}
+
 	revision = Revision{
 		B64AppProto:        b64,
-		Status:             appRevision.Status,
+		Status:             status,
 		ID:                 appRevision.Id,
 		RevisionNumber:     appRevision.RevisionNumber,
 		CreatedAt:          appRevision.CreatedAt.AsTime(),
@@ -184,3 +191,29 @@ func AttachEnvToRevision(ctx context.Context, inp AttachEnvToRevisionInput) (Rev
 
 	return revision, nil
 }
+
+func appRevisionStatusFromProto(status string) (models.AppRevisionStatus, error) {
+	var appRevisionStatus models.AppRevisionStatus
+	switch status {
+	case string(models.AppRevisionStatus_AwaitingBuild):
+		appRevisionStatus = models.AppRevisionStatus_AwaitingBuild
+	case string(models.AppRevisionStatus_AwaitingPredeploy):
+		appRevisionStatus = models.AppRevisionStatus_AwaitingPredeploy
+	case string(models.AppRevisionStatus_Deployed):
+		appRevisionStatus = models.AppRevisionStatus_Deployed
+	case string(models.AppRevisionStatus_BuildCanceled):
+		appRevisionStatus = models.AppRevisionStatus_BuildCanceled
+	case string(models.AppRevisionStatus_BuildFailed):
+		appRevisionStatus = models.AppRevisionStatus_BuildFailed
+	case string(models.AppRevisionStatus_PredeployFailed):
+		appRevisionStatus = models.AppRevisionStatus_PredeployFailed
+	case string(models.AppRevisionStatus_DeployFailed):
+		appRevisionStatus = models.AppRevisionStatus_DeployFailed
+	case string(models.AppRevisionStatus_Created):
+		appRevisionStatus = models.AppRevisionStatus_Created
+	default:
+		return appRevisionStatus, fmt.Errorf("unknown app revision status")
+	}
+
+	return appRevisionStatus, nil
+}