Przeglądaj źródła

POR-1851 delete preview deployment targets on pr close (#3725)

ianedwards 2 lat temu
rodzic
commit
0867535253

+ 69 - 0
api/server/handlers/deployment_target/delete.go

@@ -0,0 +1,69 @@
+package deployment_target
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// DeleteDeploymentTargetHandler is the handler for DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets/{deployment_target_id}
+type DeleteDeploymentTargetHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewDeleteDeploymentTargetHandler creates a new DeleteDeploymentTargetHandler
+func NewDeleteDeploymentTargetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteDeploymentTargetHandler {
+	return &DeleteDeploymentTargetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// ServeHTTP deletes the deployment target from the cluster
+func (c *DeleteDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "server-delete-deployment-target-by-id")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	deploymentTargetID, reqErr := requestutils.GetURLParamString(r, types.URLParamDeploymentTargetID)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error parsing deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if deploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "deployment target id cannot be empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	deleteReq := connect.NewRequest(&porterv1.DeleteDeploymentTargetRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: deploymentTargetID,
+	})
+
+	_, err := c.Config().ClusterControlPlaneClient.DeleteDeploymentTarget(ctx, deleteReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error deleting deployment target")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	c.WriteResult(w, r, nil)
+}

+ 17 - 0
api/server/handlers/porter_app/create_app_template.go

@@ -3,6 +3,7 @@ package porter_app
 import (
 	"context"
 	"encoding/base64"
+	"fmt"
 	"net/http"
 	"time"
 
@@ -207,6 +208,22 @@ func (c *CreateAppTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	webhookURL := fmt.Sprintf("%s/api/webhooks/github/%d/%d/%s", c.Config().ServerConf.ServerURL, project.ID, cluster.ID, appName)
+	err = porter_app.SetRepoWebhook(ctx, porter_app.SetRepoWebhookInput{
+		PorterAppName:       appName,
+		ClusterID:           cluster.ID,
+		GithubAppSecret:     c.Config().ServerConf.GithubAppSecret,
+		GithubAppID:         c.Config().ServerConf.GithubAppID,
+		GithubWebhookSecret: c.Config().ServerConf.GithubIncomingWebhookSecret,
+		WebhookURL:          webhookURL,
+		PorterAppRepository: c.Repo().PorterApp(),
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to set repo webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	res := &CreateAppTemplateResponse{
 		AppTemplateID: updatedAppTemplate.ID.String(),
 	}

+ 169 - 0
api/server/handlers/webhook/app_v2_github.go

@@ -0,0 +1,169 @@
+package webhook
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/google/go-github/v41/github"
+	"github.com/google/uuid"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// GithubPRStatus_Closed is the status for a closed PR (closed, merged)
+const GithubPRStatus_Closed = "closed"
+
+// GithubWebhookHandler handles webhooks sent to /api/webhooks/github/{project_id}/{cluster_id}/{porter_app_name}
+type GithubWebhookHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewGithubWebhookHandler returns a GithubWebhookHandler
+func NewGithubWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubWebhookHandler {
+	return &GithubWebhookHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// ServeHTTP handles the webhook and deletes the deployment target if a PR has been closed
+func (c *GithubWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-github-webhook")
+	defer span.End()
+
+	payload, err := github.ValidatePayload(r, []byte(c.Config().ServerConf.GithubIncomingWebhookSecret))
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "could not validate payload")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	event, err := github.ParseWebHook(github.WebHookType(r), payload)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "could not parse webhook")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		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: "application-name", Value: appName})
+
+	clusterID, reqErr := requestutils.GetURLParamUint(r, types.URLParamClusterID)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing cluster id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	projectID, reqErr := requestutils.GetURLParamUint(r, types.URLParamProjectID)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing project id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "project-id", Value: projectID})
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: clusterID})
+
+	porterApps, err := c.Repo().PorterApp().ReadPorterAppsByProjectIDAndName(projectID, appName)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting porter app from repo")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if len(porterApps) == 0 {
+		err := telemetry.Error(ctx, span, err, "error getting porter app from repo")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if len(porterApps) > 1 {
+		err := telemetry.Error(ctx, span, err, "multiple porter apps returned; unable to determine which one to use")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	app := porterApps[0]
+	if app.ID == 0 {
+		err := telemetry.Error(ctx, span, err, "porter app id is missing")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if app.ClusterID != clusterID {
+		err := telemetry.Error(ctx, span, err, "porter app cluster id does not match")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-id", Value: app.ID})
+
+	switch event := event.(type) {
+	case *github.PullRequestEvent:
+		if event.GetAction() != GithubPRStatus_Closed {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "event-processed", Value: false})
+			c.WriteResult(w, r, nil)
+			return
+		}
+
+		branch := event.GetPullRequest().GetHead().GetRef()
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "event-branch", Value: branch})
+
+		deploymentTarget, err := c.Repo().DeploymentTarget().DeploymentTargetBySelectorAndSelectorType(
+			projectID,
+			clusterID,
+			branch,
+			string(models.DeploymentTargetSelectorType_Namespace),
+		)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting deployment target")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		if deploymentTarget.ID == uuid.Nil || !deploymentTarget.Preview {
+			c.WriteResult(w, r, nil)
+			return
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTarget.ID.String()})
+
+		if deploymentTarget.ClusterID != int(clusterID) {
+			err := telemetry.Error(ctx, span, err, "deployment target cluster id does not match")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+
+		deleteTargetReq := connect.NewRequest(&porterv1.DeleteDeploymentTargetRequest{
+			ProjectId:          int64(projectID),
+			DeploymentTargetId: deploymentTarget.ID.String(),
+		})
+
+		_, err = c.Config().ClusterControlPlaneClient.DeleteDeploymentTarget(ctx, deleteTargetReq)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error deleting deployment target")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "event-processed", Value: true})
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pr-id", Value: event.GetPullRequest().GetID()})
+	}
+
+	c.WriteResult(w, r, nil)
+}

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

@@ -540,6 +540,31 @@ func GetBaseRoutes(
 			Handler:  githubIncomingWebhookHandler,
 			Router:   r,
 		})
+
+		// POST /api/webhooks/github/{project_id}/{cluster_id}/{porter_app_name} -> webhook.NewGithubWebhookHandler
+		githubWebhookEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: fmt.Sprintf("/webhooks/github/{%s}/{%s}/{%s}", types.URLParamProjectID, types.URLParamClusterID, types.URLParamPorterAppName),
+				},
+				Scopes: []types.PermissionScope{},
+			},
+		)
+
+		githubWebhookHandler := webhook.NewGithubWebhookHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: githubWebhookEndpoint,
+			Handler:  githubWebhookHandler,
+			Router:   r,
+		})
 	}
 
 	return routes

+ 31 - 0
api/server/router/deployment_target.go

@@ -1,6 +1,8 @@
 package router
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -114,5 +116,34 @@ func getDeploymentTargetRoutes(
 		Router:   r,
 	})
 
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets/{deployment_target_id} -> deployment_target.DeleteDeploymentTargetHandler
+	deleteDeploymentTargetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamDeploymentTargetID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	deleteDeploymentTargetHandler := deployment_target.NewDeleteDeploymentTargetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteDeploymentTargetEndpoint,
+		Handler:  deleteDeploymentTargetHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 1 - 0
api/types/request.go

@@ -53,6 +53,7 @@ const (
 	URLParamPorterAppName         URLParam = "porter_app_name"
 	URLParamPorterAppEventID      URLParam = "porter_app_event_id"
 	URLParamAppRevisionID         URLParam = "app_revision_id"
+	URLParamDeploymentTargetID    URLParam = "deployment_target_id"
 )
 
 type Path struct {

+ 5 - 1
dashboard/src/main/home/app-dashboard/new-app-flow/utils.ts

@@ -57,12 +57,16 @@ export const getPreviewGithubAction = (
   projectID: number,
   clusterId: number,
   stackName: string,
+  branchName: string,
   porterYamlPath: string = "porter.yaml"
 ) => {
   return `on:
   pull_request:
+    paths:
+    - *
+    - '!./github/workflows/porter-**'
     branches:
-    - '!porter-**'
+    - ${branchName}
     types:
     - opened
     - synchronize

+ 1 - 1
go.mod

@@ -82,7 +82,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.8
+	github.com/porter-dev/api-contracts v0.2.10
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1516,8 +1516,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.8 h1:z5BZihdZ75J5Dz3jCeX6ziE/wt6h4yhFqaYoO3BqBY8=
-github.com/porter-dev/api-contracts v0.2.8/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.10 h1:xnnV3ffaLSajHMYyn96l49vLIY/J4lvZZgwKF+8aclk=
+github.com/porter-dev/api-contracts v0.2.10/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

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

@@ -233,6 +233,7 @@ type GithubActionYAMLOnPullRequest struct {
 type GithubActionYAMLOnPullRequestTypes struct {
 	Branches []string `yaml:"branches,omitempty"`
 	Types    []string `yaml:"types,omitempty"`
+	Paths    []string `yaml:"paths,omitempty"`
 }
 
 type GithubActionYAMLJob struct {

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

@@ -193,8 +193,12 @@ func getStackApplyActionYAML(opts *GetStackApplyActionYAMLOpts) ([]byte, error)
 		actionYaml := GithubActionYAML{
 			On: GithubActionYAMLOnPullRequest{
 				PullRequest: GithubActionYAMLOnPullRequestTypes{
+					Paths: []string{
+						"*",
+						"!./github/workflows/porter-**",
+					},
 					Branches: []string{
-						"!porter-**",
+						opts.DefaultBranch,
 					},
 					Types: []string{
 						"opened",

+ 1 - 0
internal/models/porter_app.go

@@ -21,6 +21,7 @@ type PorterApp struct {
 	GitRepoID uint
 	RepoName  string
 	GitBranch string
+	GithubWebhookID int64
 
 	BuildContext   string
 	Builder        string

+ 144 - 0
internal/porter_app/github.go

@@ -0,0 +1,144 @@
+package porter_app
+
+import (
+	"context"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v39/github"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// SetRepoWebhookInput is the input to the SetRepoWebhook function
+type SetRepoWebhookInput struct {
+	PorterAppName       string
+	ClusterID           uint
+	GithubAppSecret     []byte
+	GithubAppID         string
+	GithubWebhookSecret string
+	WebhookURL          string
+
+	PorterAppRepository repository.PorterAppRepository
+}
+
+// SetRepoWebhook creates or updates a github webhook for a porter app associated with a given repo
+// The webhook watches for pull request and push events, used for managing preview environments
+func SetRepoWebhook(ctx context.Context, inp SetRepoWebhookInput) error {
+	ctx, span := telemetry.NewSpan(ctx, "porter-app-set-repo-webhook")
+	defer span.End()
+
+	if inp.PorterAppName == "" {
+		return telemetry.Error(ctx, span, nil, "porter app name is empty")
+	}
+	if inp.ClusterID == 0 {
+		return telemetry.Error(ctx, span, nil, "cluster id is empty")
+	}
+	if inp.GithubAppSecret == nil {
+		return telemetry.Error(ctx, span, nil, "github app secret is nil")
+	}
+	if inp.GithubAppID == "" {
+		return telemetry.Error(ctx, span, nil, "github app id is empty")
+	}
+	if inp.GithubWebhookSecret == "" {
+		return telemetry.Error(ctx, span, nil, "github webhook secret is empty")
+	}
+	if inp.PorterAppRepository == nil {
+		return telemetry.Error(ctx, span, nil, "porter app repository is nil")
+	}
+
+	porterApp, err := inp.PorterAppRepository.ReadPorterAppByName(inp.ClusterID, inp.PorterAppName)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "could not read porter app by name")
+	}
+	if porterApp.ID == 0 {
+		return telemetry.Error(ctx, span, nil, "porter app not found")
+	}
+	if porterApp.GitRepoID == 0 {
+		return telemetry.Error(ctx, span, nil, "porter app git repo id is empty")
+	}
+
+	githubClient, err := getGithubClientByRepoID(ctx, porterApp.GitRepoID, inp.GithubAppSecret, inp.GithubAppID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error creating github client")
+	}
+
+	repoDetails := strings.Split(porterApp.RepoName, "/")
+	if len(repoDetails) != 2 {
+		return telemetry.Error(ctx, span, nil, "repo name is not in the format <org>/<repo>")
+	}
+	if _, _, err := githubClient.Repositories.Get(ctx, repoDetails[0], repoDetails[1]); err != nil {
+		return telemetry.Error(ctx, span, err, "error getting github repo")
+	}
+
+	hook := &github.Hook{
+		Config: map[string]interface{}{
+			"url":          inp.WebhookURL,
+			"content_type": "json",
+			"secret":       inp.GithubWebhookSecret,
+		},
+		Events: []string{"pull_request", "push"},
+		Active: github.Bool(true),
+	}
+
+	if porterApp.GithubWebhookID != 0 {
+		_, _, err := githubClient.Repositories.EditHook(
+			context.Background(), repoDetails[0], repoDetails[1], porterApp.GithubWebhookID, hook,
+		)
+		if err != nil {
+			return telemetry.Error(ctx, span, err, "could not edit hook")
+		}
+
+		return nil
+	}
+
+	hook, _, err = githubClient.Repositories.CreateHook(
+		context.Background(), repoDetails[0], repoDetails[1], hook,
+	)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "could not create hook")
+	}
+
+	porterApp.GithubWebhookID = hook.GetID()
+
+	_, err = inp.PorterAppRepository.UpdatePorterApp(porterApp)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "could not update porter app")
+	}
+
+	return nil
+}
+
+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()
+
+	if githubAppSecret == nil {
+		return nil, telemetry.Error(ctx, span, nil, "github app secret is nil")
+	}
+	if githubAppID == "" {
+		return nil, telemetry.Error(ctx, span, nil, "github app id is empty")
+	}
+
+	appID, err := strconv.Atoi(githubAppID)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "could not convert github app id to int")
+	}
+
+	itr, err := ghinstallation.New(
+		http.DefaultTransport,
+		int64(appID),
+		int64(repoID),
+		githubAppSecret,
+	)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "could not create github app client")
+	}
+	if itr == nil {
+		return nil, telemetry.Error(ctx, span, nil, "github app client is nil")
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}