Browse Source

change implementation to use new webhook table

Ian Edwards 2 years ago
parent
commit
98150554a5

+ 10 - 10
api/server/handlers/porter_app/create_app_template.go

@@ -3,7 +3,6 @@ package porter_app
 import (
 	"context"
 	"encoding/base64"
-	"fmt"
 	"net/http"
 	"time"
 
@@ -208,15 +207,16 @@ 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(),
+	err = porter_app.CreateAppWebhook(ctx, porter_app.CreateAppWebhookInput{
+		PorterAppName:           appName,
+		ProjectID:               project.ID,
+		ClusterID:               cluster.ID,
+		GithubAppSecret:         c.Config().ServerConf.GithubAppSecret,
+		GithubAppID:             c.Config().ServerConf.GithubAppID,
+		GithubWebhookSecret:     c.Config().ServerConf.GithubIncomingWebhookSecret,
+		ServerURL: 						 c.Config().ServerConf.ServerURL,
+		PorterAppRepository:     c.Repo().PorterApp(),
+		GithubWebhookRepository: c.Repo().GithubWebhook(),
 	})
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "unable to set repo webhook")

+ 21 - 39
api/server/handlers/webhook/app_v2_github.go

@@ -58,61 +58,43 @@ func (c *GithubWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	webhookID, reqErr := requestutils.GetURLParamString(r, types.URLParamWebhookID)
 	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})
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "webhook-id", Value: webhookID})
 
-	clusterID, reqErr := requestutils.GetURLParamUint(r, types.URLParamClusterID)
-	if reqErr != nil {
-		err := telemetry.Error(ctx, span, nil, "error parsing cluster id")
+	webhookUUID, err := uuid.Parse(webhookID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing webhook 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")
+	if webhookUUID == uuid.Nil {
+		err := telemetry.Error(ctx, span, err, "webhook id is nil")
 		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)
+	webhook, err := c.Repo().GithubWebhook().Get(ctx, webhookUUID)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting porter app from repo")
+		err := telemetry.Error(ctx, span, err, "error getting github webhook")
 		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))
+	if webhook.ID == uuid.Nil {
+		err := telemetry.Error(ctx, span, err, "github webhook id is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-id", Value: app.ID})
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "porter-app-id", Value: webhook.PorterAppID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: webhook.ClusterID},
+		telemetry.AttributeKV{Key: "project-id", Value: webhook.ProjectID},
+	)
 
 	switch event := event.(type) {
 	case *github.PullRequestEvent:
@@ -126,8 +108,8 @@ func (c *GithubWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "event-branch", Value: branch})
 
 		deploymentTarget, err := c.Repo().DeploymentTarget().DeploymentTargetBySelectorAndSelectorType(
-			projectID,
-			clusterID,
+			uint(webhook.ProjectID),
+			uint(webhook.ClusterID),
 			branch,
 			string(models.DeploymentTargetSelectorType_Namespace),
 		)
@@ -143,14 +125,14 @@ func (c *GithubWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTarget.ID.String()})
 
-		if deploymentTarget.ClusterID != int(clusterID) {
+		if deploymentTarget.ClusterID != webhook.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),
+			ProjectId:          int64(webhook.ProjectID),
 			DeploymentTargetId: deploymentTarget.ID.String(),
 		})
 

+ 2 - 2
api/server/router/base.go

@@ -541,14 +541,14 @@ func GetBaseRoutes(
 			Router:   r,
 		})
 
-		// POST /api/webhooks/github/{project_id}/{cluster_id}/{porter_app_name} -> webhook.NewGithubWebhookHandler
+		// POST /api/webhooks/github/{webhook_id} -> 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),
+					RelativePath: fmt.Sprintf("/webhooks/github/{%s}", types.URLParamWebhookID),
 				},
 				Scopes: []types.PermissionScope{},
 			},

+ 1 - 0
api/types/request.go

@@ -54,6 +54,7 @@ const (
 	URLParamPorterAppEventID      URLParam = "porter_app_event_id"
 	URLParamAppRevisionID         URLParam = "app_revision_id"
 	URLParamDeploymentTargetID    URLParam = "deployment_target_id"
+	URLParamWebhookID             URLParam = "webhook_id"
 )
 
 type Path struct {

+ 31 - 21
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -385,6 +385,36 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     return "";
   }, [isSubmitting, errorMessagesDeep]);
 
+  const tabs = useMemo(() => {
+    const base = [
+      { label: "Activity", value: "activity" },
+      { label: "Overview", value: "overview" },
+      { label: "Logs", value: "logs" },
+      { label: "Metrics", value: "metrics" },
+      { label: "Environment", value: "environment" },
+    ];
+
+    if (deploymentTarget.preview) {
+      return base;
+    }
+
+    if (latestProto.build) {
+      base.push({
+        label: "Build Settings",
+        value: "build-settings",
+      });
+      base.push({ label: "Settings", value: "settings" });
+      return base;
+    }
+
+    base.push({
+      label: "Image Settings",
+      value: "image-settings",
+    });
+    base.push({ label: "Settings", value: "settings" });
+    return base;
+  }, [deploymentTarget.preview, latestProto.build]);
+
   useEffect(() => {
     const newProto = previewRevision
       ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto), {
@@ -473,27 +503,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         </AnimateHeight>
         <TabSelector
           noBuffer
-          options={[
-            { label: "Activity", value: "activity" },
-            { label: "Overview", value: "overview" },
-            { label: "Logs", value: "logs" },
-            { label: "Metrics", value: "metrics" },
-            { label: "Environment", value: "environment" },
-            ...(latestProto.build
-              ? [
-                  {
-                    label: "Build Settings",
-                    value: "build-settings",
-                  },
-                ]
-              : [
-                  {
-                    label: "Image Settings",
-                    value: "image-settings",
-                  },
-                ]),
-            { label: "Settings", value: "settings" },
-          ]}
+          options={tabs}
           currentTab={currentTab}
           setCurrentTab={(tab) => {
             if (deploymentTarget.preview) {

+ 6 - 4
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppTemplateForm.tsx

@@ -30,6 +30,7 @@ import { useAppValidation } from "lib/hooks/useAppValidation";
 import { PorterApp } from "@porter-dev/api-contracts";
 import axios from "axios";
 import GithubActionModal from "main/home/app-dashboard/new-app-flow/GithubActionModal";
+import { useClusterResourceLimits } from "lib/hooks/useClusterResourceLimits";
 
 const AppTemplateForm: React.FC = () => {
   const [step, setStep] = useState(0);
@@ -55,10 +56,7 @@ const AppTemplateForm: React.FC = () => {
     projectId,
     deploymentTarget,
   } = useLatestRevision();
-  const { validateApp } = useAppValidation({
-    deploymentTargetID: deploymentTarget.id,
-    creating: true,
-  });
+  const { maxCPU, maxRAM } = useClusterResourceLimits({ projectId, clusterId });
 
   const { data: baseEnvGroups = [] } = useQuery(
     ["getAllEnvGroups", projectId, clusterId],
@@ -240,6 +238,8 @@ const AppTemplateForm: React.FC = () => {
               <ServiceList
                 addNewText={"Add a new service"}
                 fieldArrayName={"app.services"}
+                maxCPU={maxCPU}
+                maxRAM={maxRAM}
               />
             </>,
             <>
@@ -272,6 +272,8 @@ const AppTemplateForm: React.FC = () => {
                 }
                 isPredeploy
                 fieldArrayName={"app.predeploy"}
+                maxCPU={maxCPU}
+                maxRAM={maxRAM}
               />
             </>,
             <Button type="submit" loadingText={"Deploying..."} width={"150px"}>

+ 1 - 21
dashboard/src/main/home/sidebar/Clusters.tsx

@@ -148,27 +148,7 @@ class Clusters extends Component<PropsType, StateType> {
     let { currentCluster, setCurrentCluster, currentProject } = this.context;
 
     if (currentProject?.simplified_view_enabled) {
-      const cluster = clusters[0];
-      return currentProject?.preview_envs_enabled ? (
-        <NavButton
-          path="/preview-environments"
-          targetClusterName={cluster?.name}
-          active={
-            currentCluster?.id === cluster?.id &&
-            window.location.pathname.startsWith("/preview-environments")
-          }
-        >
-          <InlineSVGWrapper
-            id="Flat"
-            fill="#FFFFFF"
-            xmlns="http://www.w3.org/2000/svg"
-            viewBox="0 0 256 256"
-          >
-            <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z" />
-          </InlineSVGWrapper>
-          Preview envs
-        </NavButton>
-      ) : null;
+      return null;
     }
 
     if (

+ 80 - 80
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -8,9 +8,10 @@ import settings from "assets/settings.svg";
 import applications from "assets/applications.svg";
 import infra from "assets/cluster.svg";
 import sliders from "assets/env-groups.svg";
-import addOns from "assets/add-ons.svg"
+import addOns from "assets/add-ons.svg";
 import database from "assets/database.svg";
 import collapseSidebar from "assets/collapse-sidebar.svg";
+import pr_icon from "assets/pull_request_icon.svg";
 
 import { Context } from "shared/Context";
 
@@ -113,11 +114,16 @@ class Sidebar extends Component<PropsType, StateType> {
 
   renderProjectContents = () => {
     let { currentView } = this.props;
-    let { currentProject, user, currentCluster, hasFinishedOnboarding } = this.context;
+    let {
+      currentProject,
+      user,
+      currentCluster,
+      hasFinishedOnboarding,
+    } = this.context;
     if (!currentProject?.simplified_view_enabled) {
       return (
         <ScrollWrapper>
-          <Spacer y={.5} />
+          <Spacer y={0.5} />
           <SidebarLabel>Home</SidebarLabel>
           <NavButton path={"/dashboard"}>
             <Img src={category} />
@@ -142,30 +148,28 @@ class Sidebar extends Component<PropsType, StateType> {
             "update",
             "delete",
           ]) && (
-              <NavButton path={"/integrations"}>
-                <Img src={integrations} />
-                Integrations
-              </NavButton>
-            )}
+            <NavButton path={"/integrations"}>
+              <Img src={integrations} />
+              Integrations
+            </NavButton>
+          )}
           {this.props.isAuthorized("settings", "", [
             "get",
             "update",
             "delete",
           ]) && (
-              <NavButton path={"/project-settings"}>
-                <Img src={settings} />
-                Project settings
-              </NavButton>
-            )}
+            <NavButton path={"/project-settings"}>
+              <Img src={settings} />
+              Project settings
+            </NavButton>
+          )}
 
           <br />
 
           <SidebarLabel>
-            {currentProject?.capi_provisioner_enabled ? (
-              "Your team"
-            ) : (
-              "Clusters"
-            )}
+            {currentProject?.capi_provisioner_enabled
+              ? "Your team"
+              : "Clusters"}
           </SidebarLabel>
           <Clusters
             setWelcome={this.props.setWelcome}
@@ -185,30 +189,28 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
-                <NavButton path={"/project-settings"}
-                >
-                  <Img src={settings} />
-                  Project settings
-                </NavButton>
-              )}
+              <NavButton path={"/project-settings"}>
+                <Img src={settings} />
+                Project settings
+              </NavButton>
+            )}
             {this.props.isAuthorized("integrations", "", [
               "get",
               "create",
               "update",
               "delete",
             ]) && (
-                <NavButton path={"/integrations"}
-                >
-                  <Img src={integrations} />
-                  Integrations
-                </NavButton>
-              )}
-            {currentCluster &&
+              <NavButton path={"/integrations"}>
+                <Img src={integrations} />
+                Integrations
+              </NavButton>
+            )}
+            {currentCluster && (
               <>
-                <Spacer y={.5} />
+                <Spacer y={0.5} />
                 <ClusterListContainer />
               </>
-            }
+            )}
             <NavButton
               path="/apps"
               active={window.location.pathname.startsWith("/apps")}
@@ -225,9 +227,7 @@ class Sidebar extends Component<PropsType, StateType> {
             </NavButton>
             <NavButton
               path="/env-groups"
-              active={
-                window.location.pathname.startsWith("/env-groups")
-              }
+              active={window.location.pathname.startsWith("/env-groups")}
             >
               <Img src={sliders} />
               Env groups
@@ -237,16 +237,21 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
-                <NavButton
-                  path={"/cluster-dashboard"}
-                  active={
-                    window.location.pathname.startsWith("/cluster-dashboard")
-                  }
-                >
-                  <Img src={settings} />
-                  Infrastructure
-                </NavButton>
-              )}
+              <NavButton
+                path={"/cluster-dashboard"}
+                active={window.location.pathname.startsWith(
+                  "/cluster-dashboard"
+                )}
+              >
+                <Img src={settings} />
+                Infrastructure
+              </NavButton>
+            )}
+
+            <NavButton path="/preview-environments">
+              <Img src={pr_icon} />
+              Preview environments
+            </NavButton>
 
             {/* Hacky workaround for setting currentCluster with legacy method */}
             <Clusters
@@ -259,10 +264,9 @@ class Sidebar extends Component<PropsType, StateType> {
           </ScrollWrapper>
         );
       } else {
-
         return (
           <ScrollWrapper>
-            <Spacer y={.4} />
+            <Spacer y={0.4} />
             <NavButton
               path="/apps"
               active={window.location.pathname.startsWith("/apps")}
@@ -288,9 +292,7 @@ class Sidebar extends Component<PropsType, StateType> {
             </NavButton>
             <NavButton
               path="/env-groups"
-              active={
-                window.location.pathname.startsWith("/env-groups")
-              }
+              active={window.location.pathname.startsWith("/env-groups")}
             >
               <Img src={sliders} />
               Env groups
@@ -300,17 +302,21 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
+              <NavButton
+                path={"/cluster-dashboard"}
+                active={window.location.pathname.startsWith(
+                  "/cluster-dashboard"
+                )}
+              >
+                <Img src={infra} />
+                Infrastructure
+              </NavButton>
+            )}
 
-                <NavButton
-                  path={"/cluster-dashboard"}
-                  active={
-                    window.location.pathname.startsWith("/cluster-dashboard")
-                  }
-                >
-                  <Img src={infra} />
-                  Infrastructure
-                </NavButton>
-              )}
+            <NavButton path="/preview-environments">
+              <Img src={pr_icon} />
+              Preview environments
+            </NavButton>
 
             {this.props.isAuthorized("integrations", "", [
               "get",
@@ -318,25 +324,22 @@ class Sidebar extends Component<PropsType, StateType> {
               "update",
               "delete",
             ]) && (
-                <NavButton path={"/integrations"}
-                >
-                  <Img src={integrations} />
-                  Integrations
-                </NavButton>
-              )}
+              <NavButton path={"/integrations"}>
+                <Img src={integrations} />
+                Integrations
+              </NavButton>
+            )}
 
             {this.props.isAuthorized("settings", "", [
               "get",
               "update",
               "delete",
             ]) && (
-                <NavButton path={"/project-settings"}
-                >
-                  <Img src={settings} />
-                  Project settings
-                </NavButton>
-
-              )}
+              <NavButton path={"/project-settings"}>
+                <Img src={settings} />
+                Project settings
+              </NavButton>
+            )}
 
             {/* Hacky workaround for setting currentCluster with legacy method */}
             <Clusters
@@ -348,14 +351,11 @@ class Sidebar extends Component<PropsType, StateType> {
             />
           </ScrollWrapper>
         );
-
       }
     }
 
     // Render placeholder if no project exists
     return <ProjectPlaceholder>No projects found.</ProjectPlaceholder>;
-
-
   };
 
   // SidebarBg is separate to cover retracted drawer
@@ -455,14 +455,14 @@ const SidebarBg = styled.div`
   top: 0;
   left: 0;
   width: 100%;
-  background-color: ${props => props.theme.bg};
+  background-color: ${(props) => props.theme.bg};
   height: 100%;
   z-index: -1;
   border-right: 1px solid #383a3f;
 `;
 
 const SidebarLabel = styled.div`
-  color: ${props => props.theme.text.primary};
+  color: ${(props) => props.theme.text.primary};
   padding: 5px 23px;
   margin-bottom: 5px;
   font-size: 13px;
@@ -492,7 +492,7 @@ const PullTab = styled.div`
   }
 
   :hover {
-    border: 1px solid ${props => props.theme.text.primary};
+    border: 1px solid ${(props) => props.theme.text.primary};
     border-left: none;
     > img {
       opacity: 0.9;

+ 43 - 21
internal/porter_app/github.go

@@ -2,37 +2,45 @@ package porter_app
 
 import (
 	"context"
+	"fmt"
 	"net/http"
 	"strconv"
 	"strings"
 
 	"github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v39/github"
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
 	"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
+// CreateAppWebhookInput is the input to the CreateAppWebhook function
+type CreateAppWebhookInput struct {
+	ProjectID           uint
 	ClusterID           uint
+	PorterAppName       string
 	GithubAppSecret     []byte
 	GithubAppID         string
 	GithubWebhookSecret string
-	WebhookURL          string
+	ServerURL           string
 
-	PorterAppRepository repository.PorterAppRepository
+	PorterAppRepository     repository.PorterAppRepository
+	GithubWebhookRepository repository.GithubWebhookRepository
 }
 
-// SetRepoWebhook creates or updates a github webhook for a porter app associated with a given repo
+// CreateAppWebhook creates or updates a github webhook for a porter app associated with a given project / cluster / app
 // 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")
+func CreateAppWebhook(ctx context.Context, inp CreateAppWebhookInput) error {
+	ctx, span := telemetry.NewSpan(ctx, "porter-app-create-app-webhook")
 	defer span.End()
 
 	if inp.PorterAppName == "" {
 		return telemetry.Error(ctx, span, nil, "porter app name is empty")
 	}
+	if inp.ProjectID == 0 {
+		return telemetry.Error(ctx, span, nil, "project id is empty")
+	}
 	if inp.ClusterID == 0 {
 		return telemetry.Error(ctx, span, nil, "cluster id is empty")
 	}
@@ -48,6 +56,9 @@ func SetRepoWebhook(ctx context.Context, inp SetRepoWebhookInput) error {
 	if inp.PorterAppRepository == nil {
 		return telemetry.Error(ctx, span, nil, "porter app repository is nil")
 	}
+	if inp.GithubWebhookRepository == nil {
+		return telemetry.Error(ctx, span, nil, "github webhook repository is nil")
+	}
 
 	porterApp, err := inp.PorterAppRepository.ReadPorterAppByName(inp.ClusterID, inp.PorterAppName)
 	if err != nil {
@@ -75,7 +86,6 @@ func SetRepoWebhook(ctx context.Context, inp SetRepoWebhookInput) error {
 
 	hook := &github.Hook{
 		Config: map[string]interface{}{
-			"url":          inp.WebhookURL,
 			"content_type": "json",
 			"secret":       inp.GithubWebhookSecret,
 		},
@@ -83,29 +93,41 @@ func SetRepoWebhook(ctx context.Context, inp SetRepoWebhookInput) error {
 		Active: github.Bool(true),
 	}
 
-	if porterApp.GithubWebhookID != 0 {
-		_, _, err := githubClient.Repositories.EditHook(
-			context.Background(), repoDetails[0], repoDetails[1], porterApp.GithubWebhookID, hook,
-		)
+	// check if the webhook already exists
+	webhook, err := inp.GithubWebhookRepository.GetByClusterAndAppID(ctx, inp.ClusterID, porterApp.ID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error getting github webhook")
+	}
+
+	if webhook.ID != uuid.Nil {
+		hook.Config["url"] = fmt.Sprintf("%s/api/webhooks/github/%s", inp.ServerURL, webhook.ID.String())
+		_, _, err := githubClient.Repositories.EditHook(ctx, repoDetails[0], repoDetails[1], webhook.GithubWebhookID, hook)
 		if err != nil {
-			return telemetry.Error(ctx, span, err, "could not edit hook")
+			return telemetry.Error(ctx, span, err, "error editing github webhook")
 		}
 
 		return nil
 	}
 
-	hook, _, err = githubClient.Repositories.CreateHook(
-		context.Background(), repoDetails[0], repoDetails[1], hook,
-	)
+	webhookID := uuid.New()
+
+	hook.Config["url"] = fmt.Sprintf("%s/api/webhooks/github/%s", inp.ServerURL, webhookID)
+	hook, _, err = githubClient.Repositories.CreateHook(ctx, repoDetails[0], repoDetails[1], hook)
 	if err != nil {
-		return telemetry.Error(ctx, span, err, "could not create hook")
+		return telemetry.Error(ctx, span, err, "error creating github webhook")
 	}
 
-	porterApp.GithubWebhookID = hook.GetID()
+	webhook = &models.GithubWebhook{
+		ID:              webhookID,
+		ProjectID:       int(porterApp.ProjectID),
+		ClusterID:       int(porterApp.ClusterID),
+		PorterAppID:     int(porterApp.ID),
+		GithubWebhookID: hook.GetID(),
+	}
 
-	_, err = inp.PorterAppRepository.UpdatePorterApp(porterApp)
+	webhook, err = inp.GithubWebhookRepository.Insert(ctx, webhook)
 	if err != nil {
-		return telemetry.Error(ctx, span, err, "could not update porter app")
+		return telemetry.Error(ctx, span, err, "error saving github webhook")
 	}
 
 	return nil

+ 15 - 0
internal/repository/github_webhook.go

@@ -0,0 +1,15 @@
+package repository
+
+import (
+	"context"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// GithubWebhookRepository represents the set of queries on the GithubWebhook model
+type GithubWebhookRepository interface {
+	Insert(ctx context.Context, webhook *models.GithubWebhook) (*models.GithubWebhook, error)
+	Get(ctx context.Context, id uuid.UUID) (*models.GithubWebhook, error)
+	GetByClusterAndAppID(ctx context.Context, clusterID, appID uint) (*models.GithubWebhook, error)
+}

+ 86 - 0
internal/repository/gorm/github_webhook.go

@@ -0,0 +1,86 @@
+package gorm
+
+import (
+	"context"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"gorm.io/gorm"
+)
+
+// GithubWebhookRepository uses gorm.DB for querying the database
+type GithubWebhookRepository struct {
+	db *gorm.DB
+}
+
+// NewGithubWebhookRepository returns a GithubWebhookRepository which uses
+// gorm.DB for querying the database
+func NewGithubWebhookRepository(db *gorm.DB) repository.GithubWebhookRepository {
+	return &GithubWebhookRepository{db}
+}
+
+// Insert inserts a new GithubWebhook into the db
+func (repo *GithubWebhookRepository) Insert(ctx context.Context, webhook *models.GithubWebhook) (*models.GithubWebhook, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-insert-github-webhook")
+	defer span.End()
+
+	if webhook == nil {
+		return nil, telemetry.Error(ctx, span, nil, "github webhook is nil")
+	}
+	if webhook.ClusterID == 0 {
+		return nil, telemetry.Error(ctx, span, nil, "cluster id is empty")
+	}
+	if webhook.ProjectID == 0 {
+		return nil, telemetry.Error(ctx, span, nil, "project id is empty")
+	}
+	if webhook.PorterAppID == 0 {
+		return nil, telemetry.Error(ctx, span, nil, "porter app id is empty")
+	}
+
+	if webhook.ID == uuid.Nil {
+		webhook.ID = uuid.New()
+	}
+	if webhook.CreatedAt.IsZero() {
+		webhook.CreatedAt = time.Now().UTC()
+	}
+	if webhook.UpdatedAt.IsZero() {
+		webhook.UpdatedAt = time.Now().UTC()
+	}
+
+	if err := repo.db.Save(webhook).Error; err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error saving webhook")
+	}
+
+	return webhook, nil
+}
+
+// GetByClusterAndAppID finds a GithubWebhook by clusterID and appID
+func (repo *GithubWebhookRepository) GetByClusterAndAppID(ctx context.Context, clusterID uint, appID uint) (*models.GithubWebhook, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-get-github-webhook")
+	defer span.End()
+
+	webhook := &models.GithubWebhook{}
+
+	if err := repo.db.Where("cluster_id = ? AND porter_app_id = ?", clusterID, appID).Limit(1).Find(&webhook).Error; err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error finding webhook")
+	}
+
+	return webhook, nil
+}
+
+// Get finds a GithubWebhook by id
+func (repo *GithubWebhookRepository) Get(ctx context.Context, id uuid.UUID) (*models.GithubWebhook, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-get-github-webhook")
+	defer span.End()
+
+	webhook := &models.GithubWebhook{}
+
+	if err := repo.db.Where("id = ?", id).Limit(1).Find(&webhook).Error; err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error finding webhook")
+	}
+
+	return webhook, nil
+}

+ 7 - 0
internal/repository/gorm/repository.go

@@ -55,6 +55,7 @@ type GormRepository struct {
 	porterAppEvent            repository.PorterAppEventRepository
 	deploymentTarget          repository.DeploymentTargetRepository
 	appTemplate               repository.AppTemplateRepository
+	githubWebhook             repository.GithubWebhookRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -251,6 +252,11 @@ func (t *GormRepository) AppTemplate() repository.AppTemplateRepository {
 	return t.appTemplate
 }
 
+// GithubWebhook returns the GithubWebhookRepository interface implemented by gorm
+func (t *GormRepository) GithubWebhook() repository.GithubWebhookRepository {
+	return t.githubWebhook
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -303,5 +309,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		porterAppEvent:            NewPorterAppEventRepository(db),
 		deploymentTarget:          NewDeploymentTargetRepository(db),
 		appTemplate:               NewAppTemplateRepository(db),
+		githubWebhook:             NewGithubWebhookRepository(db),
 	}
 }

+ 1 - 0
internal/repository/repository.go

@@ -49,4 +49,5 @@ type Repository interface {
 	PorterAppEvent() PorterAppEventRepository
 	DeploymentTarget() DeploymentTargetRepository
 	AppTemplate() AppTemplateRepository
+	GithubWebhook() GithubWebhookRepository
 }

+ 35 - 0
internal/repository/test/github_webhook.go

@@ -0,0 +1,35 @@
+package test
+
+import (
+	"context"
+	"errors"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// GithubWebhookRepository is a test repository that implements repository.GithubWebhookRepository
+type GithubWebhookRepository struct {
+	canQuery bool
+}
+
+// NewGithubWebhookRepository returns the test GithubWebhookRepository
+func NewGithubWebhookRepository() repository.GithubWebhookRepository {
+	return &GithubWebhookRepository{canQuery: false}
+}
+
+// Insert inserts a new GithubWebhook into the db
+func (repo *GithubWebhookRepository) Insert(ctx context.Context, webhook *models.GithubWebhook) (*models.GithubWebhook, error) {
+	return nil, errors.New("cannot write database")
+}
+
+// GetByClusterAndAppID finds a GithubWebhook by clusterID and appID
+func (repo *GithubWebhookRepository) GetByClusterAndAppID(ctx context.Context, clusterID, appID uint) (*models.GithubWebhook, error) {
+	return nil, errors.New("cannot read database")
+}
+
+// Get finds a GithubWebhook by id
+func (repo *GithubWebhookRepository) Get(ctx context.Context, id uuid.UUID) (*models.GithubWebhook, error) {
+	return nil, errors.New("cannot read database")
+}

+ 8 - 0
internal/repository/test/repository.go

@@ -53,6 +53,7 @@ type TestRepository struct {
 	porterAppEvent            repository.PorterAppEventRepository
 	deploymentTarget          repository.DeploymentTargetRepository
 	appTemplate               repository.AppTemplateRepository
+	githubWebhook 					 repository.GithubWebhookRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -249,6 +250,12 @@ func (t *TestRepository) AppTemplate() repository.AppTemplateRepository {
 	return t.appTemplate
 }
 
+// GithubWebhook returns a test GithubWebhookRepository
+func (t *TestRepository) GithubWebhook() repository.GithubWebhookRepository {
+	return t.githubWebhook
+}
+
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -301,5 +308,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		porterAppEvent:            NewPorterAppEventRepository(canQuery),
 		deploymentTarget:          NewDeploymentTargetRepository(),
 		appTemplate:               NewAppTemplateRepository(),
+		githubWebhook:             NewGithubWebhookRepository(),
 	}
 }