Ian Edwards il y a 2 ans
Parent
commit
87f1824e0b

+ 16 - 0
api/server/handlers/gitinstallation/get_contents.go

@@ -61,6 +61,22 @@ func (c *GithubGetContentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	if request.ForceDefaultBranch {
+		repo, _, err := client.Repositories.Get(ctx, owner, name)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "could not get repo")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		if repo == nil || repo.DefaultBranch == nil {
+			err := telemetry.Error(ctx, span, nil, "repo or default branch not found")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		branch = *repo.DefaultBranch
+	}
+
 	telemetry.WithAttributes(
 		span,
 		telemetry.AttributeKV{Key: "repo-owner", Value: owner},

+ 7 - 2
api/server/handlers/porter_app/create_secret_and_open_pr.go

@@ -151,12 +151,17 @@ func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if request.DeleteWorkflowFilename != "" {
 			openPRInput.PRAction = actions.GithubPRAction_DeleteAppWorkflow
 			openPRInput.WorkflowFileName = request.DeleteWorkflowFilename
-			openPRInput.PRBranch = "porter-stack-delete"
+			openPRInput.PRBranch = fmt.Sprintf("porter-stack-delete-%s", randStr)
 		}
 		if request.PreviewsWorkflowFilename != "" {
 			openPRInput.PRAction = actions.GithubPRAction_PreviewAppWorkflow
 			openPRInput.WorkflowFileName = request.PreviewsWorkflowFilename
-			openPRInput.PRBranch = "porter-stack-preview"
+			openPRInput.PRBranch = fmt.Sprintf("porter-previews-%s", randStr)
+		}
+		if request.ManualWorkflowFilename != "" {
+			openPRInput.PRAction = actions.GithubPRAction_ManualPreviewDeploy
+			openPRInput.WorkflowFileName = request.ManualWorkflowFilename
+			openPRInput.PRBranch = fmt.Sprintf("porter-manual-previews-%s", randStr)
 		}
 
 		pr, err = actions.OpenGithubPR(openPRInput)

+ 1 - 0
api/types/git_installation.go

@@ -70,6 +70,7 @@ type GetBuildpackRequest struct {
 
 type GetContentsRequest struct {
 	GithubDirectoryRequest
+	ForceDefaultBranch bool `schema:"force_default_branch,omitempty"`
 }
 
 type GithubDirectoryItem struct {

+ 1 - 0
api/types/stack.go

@@ -20,6 +20,7 @@ type CreateSecretAndOpenGHPRRequest struct {
 	PorterYamlPath           string `json:"porter_yaml_path"`
 	DeleteWorkflowFilename   string `json:"delete_workflow_filename"`
 	PreviewsWorkflowFilename string `json:"previews_workflow_filename"`
+	ManualWorkflowFilename   string `json:"manual_workflow_filename"`
 	DeploymentTargetId       string `json:"deployment_target_id"`
 }
 

+ 12 - 7
dashboard/src/lib/hooks/useAppWithPreviewOverrides.ts

@@ -38,14 +38,19 @@ export const useAppWithPreviewOverrides = ({
       ? templateEnv.secret_variables
       : appEnv?.secret_variables;
 
+    console.log("detectedServices", detectedServices);
+
+    const clientApp = clientAppFromProto({
+      proto,
+      overrides: detectedServices,
+      variables,
+      secrets,
+      lockServiceDeletions: true,
+    });
+    console.log("clientAppFromProto", clientApp);
+
     return applyPreviewOverrides({
-      app: clientAppFromProto({
-        proto,
-        overrides: detectedServices,
-        variables,
-        secrets,
-        lockServiceDeletions: true,
-      }),
+      app: clientApp,
       overrides: detectedServices?.previews,
     });
   }, [

+ 52 - 0
dashboard/src/lib/hooks/useGithubInstallation.ts

@@ -0,0 +1,52 @@
+import { useContext } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+const gitInstallationValidator = z.object({
+  provider: z.enum(["github"]),
+  name: z.string(),
+  installation_id: z.number(),
+});
+
+type TUseGithubInstallation = {
+  installation: z.infer<typeof gitInstallationValidator> | undefined;
+  status: string;
+};
+
+export const useGithubInstallation = (): TUseGithubInstallation => {
+  const { currentProject } = useContext(Context);
+
+  const { data: installation, status } = useQuery(
+    ["getGithubInstallation", currentProject?.id],
+    async () => {
+      if (!currentProject?.id) {
+        return;
+      }
+
+      const res = await api.getGitProviders(
+        "<token>",
+        {},
+        { project_id: currentProject?.id }
+      );
+
+      const installations = await z
+        .array(gitInstallationValidator)
+        .parseAsync(res.data);
+
+      return installations.find(
+        (installation) => installation.provider === "github"
+      );
+    },
+    {
+      enabled: !!currentProject?.id,
+    }
+  );
+
+  return {
+    installation,
+    status,
+  };
+};

+ 24 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppList.tsx

@@ -101,6 +101,30 @@ export const ConfigurableAppList: React.FC = () => {
       </Container>
       <Spacer y={1} />
       <List>
+        {environments.length === 0 && (
+          <DashboardPlaceholder>
+            <Text size={16}>
+              Preview environments have not been set up yet.
+            </Text>
+            <Spacer y={0.5} />
+            <Text color={"helper"}>
+              Get started by creating a new environment template - a blueprint
+              for your preview environments.
+            </Text>
+            <Spacer y={1} />
+            <Button
+              alt
+              height="35px"
+              onClick={() => {
+                history.push({
+                  pathname: "/preview-environments/configure",
+                });
+              }}
+            >
+              Create Template
+            </Button>
+          </DashboardPlaceholder>
+        )}
         {envsWithExistingAppInstance.map((ev) => (
           <ConfigurableAppRow
             key={ev.name}

+ 318 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/DeployNewModal.tsx

@@ -0,0 +1,318 @@
+import React, {
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
+import { useQuery } from "@tanstack/react-query";
+import styled from "styled-components";
+import { match, P } from "ts-pattern";
+
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Image from "components/porter/Image";
+import Input from "components/porter/Input";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import BranchSelector from "main/home/app-dashboard/build-settings/BranchSelector";
+import { BackButton } from "main/home/infrastructure-dashboard/forms/CreateClusterForm";
+import { type Environment } from "lib/environments/types";
+import { useGithubInstallation } from "lib/hooks/useGithubInstallation";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import external from "assets/external-link.svg";
+
+import { TemplateSelector } from "./setup-app/AppSelector";
+
+type Props = {
+  onClose: () => void;
+};
+
+export const DeployNewModal: React.FC<Props> = ({ onClose }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [step, setStep] = useState(0);
+  const [actionLoading, setActionLoading] = useState(false);
+  const [selectedTemplate, setSelectedTemplate] = useState<Environment | null>(
+    null
+  );
+  const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
+
+  const { installation } = useGithubInstallation();
+
+  useEffect(() => {
+    if (selectedTemplate) {
+      setStep(1);
+    }
+    if (selectedBranch) {
+      setStep(3);
+    }
+  }, [selectedTemplate, selectedBranch]);
+
+  const repo = useMemo(() => {
+    if (selectedTemplate?.apps[0]?.build?.repo) {
+      return new URL(selectedTemplate?.apps[0]?.build?.repo).pathname.substring(
+        1
+      );
+    }
+    return "";
+  }, [selectedTemplate]);
+
+  const { data: exists, isLoading } = useQuery(
+    ["getBranchContents", currentProject?.id, repo, selectedBranch],
+    async () => {
+      try {
+        if (!currentProject || !repo || !selectedBranch) {
+          return null;
+        }
+
+        const res = await api.getBranchContents(
+          "<token",
+          {
+            dir: `./.github/workflows/porter_manual_preview_${selectedTemplate?.name}.yml`,
+            force_default_branch: true,
+          },
+          {
+            project_id: currentProject.id,
+            git_repo_id: installation?.installation_id ?? 0,
+            kind: "github",
+            owner: repo.split("/")[0],
+            name: repo.split("/")[1],
+            branch: selectedBranch,
+          }
+        );
+
+        if (res.data) {
+          return true;
+        }
+
+        return false;
+      } catch (err) {
+        return false;
+      }
+    },
+    {
+      enabled: !!selectedBranch && !!repo,
+      refetchInterval: 3000,
+    }
+  );
+
+  console.log("exists", exists)
+
+  const addActionToRepo = useCallback(async () => {
+    try {
+      setActionLoading(true);
+      if (
+        !currentProject ||
+        !currentCluster ||
+        !repo ||
+        !selectedBranch ||
+        !selectedTemplate
+      ) {
+        return;
+      }
+
+      const res = await api.createSecretAndOpenGitHubPullRequest(
+        "<token>",
+        {
+          github_app_installation_id: installation?.installation_id ?? 0,
+          github_repo_owner: repo.split("/")[0],
+          github_repo_name: repo.split("/")[1],
+          branch: selectedBranch,
+          open_pr: true,
+          manual_workflow_filename: `.github/workflows/porter_preview_manual_${selectedTemplate?.name}.yml`,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          stack_name: selectedTemplate?.name,
+        }
+      );
+
+      if (res.data?.url) {
+        window.open(res.data.url, "_blank");
+      }
+    } catch (err) {
+    } finally {
+      setActionLoading(false);
+    }
+  }, [
+    currentProject,
+    currentCluster,
+    repo,
+    selectedBranch,
+    selectedTemplate,
+    installation,
+  ]);
+
+  const buttonStatus = useMemo(() => {
+    if (isLoading && selectedBranch) {
+      return "loading";
+    }
+
+    return "";
+  }, [isLoading, selectedBranch]);
+
+  return (
+    <Modal closeModal={onClose} width="750px">
+      <Text size={16}>Manually Deploy Preview</Text>
+      <Spacer height="15px" />
+      <Text color="helper">
+        Select the preview template and the branch you would like to spin up a
+        new preview environment for. The manual deploy Github Action must be set
+        up in your repository in order to proceed.
+      </Text>
+      <Spacer y={0.5} />
+      <VerticalSteps
+        currentStep={step}
+        steps={[
+          <>
+            <Text size={16}>Choose a preview template</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              Select the preview template you would like to deploy
+            </Text>
+            <Spacer y={0.5} />
+            {match(selectedTemplate)
+              .with(P.nullish, () => {
+                return (
+                  <TemplateSelector
+                    selectedTemplate={selectedTemplate}
+                    setSelectedTemplate={setSelectedTemplate}
+                  />
+                );
+              })
+              .otherwise(() => (
+                <>
+                  <Input
+                    disabled={true}
+                    label="GitHub repository:"
+                    width="100%"
+                    value={repo}
+                    setValue={() => {}}
+                    placeholder=""
+                  />
+                  <Spacer y={0.5} />
+                  <BackButton
+                    width="155px"
+                    onClick={() => {
+                      setSelectedTemplate(null);
+                      setStep(0);
+                    }}
+                  >
+                    <i className="material-icons">keyboard_backspace</i>
+                    Select template
+                  </BackButton>
+                </>
+              ))}
+          </>,
+          <>
+            <Text size={16}>Choose a branch</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              Select the branch you would like to deploy the preview environment
+              for
+            </Text>
+            <Spacer y={0.5} />
+            {selectedTemplate &&
+              match(selectedBranch)
+                .with(P.nullish, () => (
+                  <ExpandedWrapper>
+                    <BranchSelector
+                      {...(selectedBranch && {
+                        currentBranch: selectedBranch,
+                      })}
+                      setBranch={(b) => {
+                        setSelectedBranch(b);
+                      }}
+                      repo_name={
+                        selectedTemplate?.apps[0]?.build?.repo
+                          ? new URL(
+                              selectedTemplate?.apps[0]?.build?.repo
+                            ).pathname.substring(1)
+                          : ""
+                      }
+                      git_repo_id={installation?.installation_id ?? 0}
+                    />
+                  </ExpandedWrapper>
+                ))
+                .otherwise((b) => (
+                  <>
+                    <Input
+                      disabled={true}
+                      label="GitHub branch:"
+                      type="text"
+                      width="100%"
+                      value={b}
+                      setValue={() => {}}
+                      placeholder=""
+                    />
+                    <Spacer y={0.5} />
+                    <BackButton
+                      width="150px"
+                      onClick={() => {
+                        setSelectedBranch(null);
+                        setStep(1);
+                      }}
+                    >
+                      <i className="material-icons">keyboard_backspace</i>
+                      Select branch
+                    </BackButton>
+                  </>
+                ))}
+          </>,
+          <>
+            {!selectedBranch || isLoading || exists ? (
+              <Button
+                onClick={() => {
+                  console.log("Deploying");
+                }}
+                status={buttonStatus}
+                loadingText="Loading..."
+              >
+                Deploy
+              </Button>
+            ) : (
+              <Container>
+                <Text color="error">
+                  The manual deploy Github Action must be set up in your
+                  repository in order to proceed.
+                </Text>
+                <Spacer y={0.25} />
+                <Text color="helper">
+                  This action can be run against any branch in your repository
+                  when triggered
+                </Text>
+                <Spacer y={0.5} />
+                <Button
+                  onClick={() => {
+                    void addActionToRepo();
+                  }}
+                  status={actionLoading ? "loading" : ""}
+                  loadingText="Loading..."
+                >
+                  <Container row>
+                    <Image src={external} size={12} />
+                    <Spacer inline x={0.5} />
+                    <Text>Add Action</Text>
+                  </Container>
+                </Button>
+              </Container>
+            )}
+          </>,
+        ]}
+      />
+    </Modal>
+  );
+};
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  max-height: 275px;
+`;

+ 81 - 50
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx

@@ -13,20 +13,24 @@ import Button from "components/porter/Button";
 import Container from "components/porter/Container";
 import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 import Icon from "components/porter/Icon";
+import Image from "components/porter/Image";
 import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Toggle from "components/porter/Toggle";
 import type { DeploymentTarget } from "lib/hooks/useDeploymentTarget";
+import { useTemplateEnvs } from "lib/hooks/useTemplateEnvs";
 
 import { search } from "shared/search";
 import { readableDate } from "shared/string_utils";
 import calendar from "assets/calendar-number.svg";
+import deploy from "assets/deploy.png";
 import pull_request from "assets/pull_request_icon.svg";
 import healthy from "assets/status-healthy.png";
 import time from "assets/time.png";
 import letter from "assets/vector.svg";
 
+import { DeployNewModal } from "./DeployNewModal";
 import { type ValidTab } from "./PreviewEnvs";
 
 type PreviewEnvGridProps = {
@@ -40,6 +44,9 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
 }) => {
   const [searchValue, setSearchValue] = useState("");
   const [sort, setSort] = useState<"calendar" | "letter">("calendar");
+  const [showDeployNewModal, setShowDeployNewModal] = useState(false);
+
+  const { environments } = useTemplateEnvs();
 
   const filteredEnvs = useMemo(() => {
     const filteredBySearch = search(deploymentTargets ?? [], searchValue, {
@@ -55,28 +62,6 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
       .exhaustive();
   }, [deploymentTargets, searchValue, sort]);
 
-  if (deploymentTargets.length === 0) {
-    return (
-      <DashboardPlaceholder>
-        <Text size={16}>No preview environments have been deployed yet.</Text>
-        <Spacer y={0.5} />
-        <Text color={"helper"}>
-          Get started by enabling preview envs for your apps.
-        </Text>
-        <Spacer y={1} />
-        <Button
-          alt
-          height="35px"
-          onClick={() => {
-            setTab("config");
-          }}
-        >
-          Set up
-        </Button>
-      </DashboardPlaceholder>
-    );
-  }
-
   return (
     <>
       <Container row spaced>
@@ -103,36 +88,82 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
             }
           }}
         />
+        <Spacer inline x={1} />
+        <Button
+          onClick={() => {
+            if (environments.length === 0) {
+              setTab("config");
+              return;
+            }
+
+            setShowDeployNewModal(true);
+          }}
+          height="30px"
+          width="140px"
+        >
+          <Container row>
+            <Image src={deploy} size={12} />
+            <Spacer inline x={0.5} />
+            <Text>Deploy New</Text>
+          </Container>
+        </Button>
       </Container>
       <Spacer y={1} />
-      <List>
-        {(filteredEnvs ?? []).map((env) => {
-          return (
-            <Link
-              to={`/preview-environments/apps?target=${env.id}`}
-              key={env.namespace}
-            >
-              <Row>
-                <Container row>
-                  <Spacer inline width="1px" />
-                  <Icon height="18px" src={pull_request} />
-                  <Spacer inline width="12px" />
-                  <Text size={14}>{env.name}</Text>
-                  <Spacer inline x={1} />
-                  <Icon height="16px" src={healthy} />
-                </Container>
-                <Spacer height="15px" />
-                <Container row>
-                  <SmallIcon opacity="0.4" src={time} />
-                  <Text size={13} color="#ffffff44">
-                    {readableDate(env.created_at)}
-                  </Text>
-                </Container>
-              </Row>
-            </Link>
-          );
-        })}
-      </List>
+      {deploymentTargets.length === 0 ? (
+        <DashboardPlaceholder>
+          <Text size={16}>No preview environments have been deployed yet.</Text>
+          <Spacer y={0.5} />
+          <Text color={"helper"}>
+            Get started by enabling preview envs for your apps.
+          </Text>
+          <Spacer y={1} />
+          <Button
+            alt
+            height="35px"
+            onClick={() => {
+              setTab("config");
+            }}
+          >
+            Set up
+          </Button>
+        </DashboardPlaceholder>
+      ) : (
+        <List>
+          {(filteredEnvs ?? []).map((env) => {
+            return (
+              <Link
+                to={`/preview-environments/apps?target=${env.id}`}
+                key={env.namespace}
+              >
+                <Row>
+                  <Container row>
+                    <Spacer inline width="1px" />
+                    <Icon height="18px" src={pull_request} />
+                    <Spacer inline width="12px" />
+                    <Text size={14}>{env.name}</Text>
+                    <Spacer inline x={1} />
+                    <Icon height="16px" src={healthy} />
+                  </Container>
+                  <Spacer height="15px" />
+                  <Container row>
+                    <SmallIcon opacity="0.4" src={time} />
+                    <Text size={13} color="#ffffff44">
+                      {readableDate(env.created_at)}
+                    </Text>
+                  </Container>
+                </Row>
+              </Link>
+            );
+          })}
+        </List>
+      )}
+      {showDeployNewModal && (
+        <DeployNewModal
+          onClose={() => {
+            setShowDeployNewModal(false);
+          }}
+        />
+      )}
     </>
   );
 };

+ 47 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppSelector.tsx

@@ -7,14 +7,20 @@ import React, {
 } from "react";
 import { PorterApp } from "@porter-dev/api-contracts";
 import styled from "styled-components";
+import { match } from "ts-pattern";
 
+import Loading from "components/Loading";
+import Icon from "components/porter/Icon";
 import SearchBar from "components/porter/SearchBar";
 import { AppIcon } from "main/home/app-dashboard/apps/AppMeta";
 import { type AppInstance } from "main/home/app-dashboard/apps/types";
+import { type Environment } from "lib/environments/types";
 import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
+import { useTemplateEnvs } from "lib/hooks/useTemplateEnvs";
 
 import { Context } from "shared/Context";
 import { search } from "shared/search";
+import addOns from "assets/add-ons.svg";
 
 type Props = {
   selectedApp: AppInstance | null;
@@ -96,6 +102,45 @@ export const AppSelector: React.FC<Props> = ({
   );
 };
 
+type TemplateSelectorProps = {
+  selectedTemplate: Environment | null;
+  setSelectedTemplate: Dispatch<SetStateAction<Environment | null>>;
+};
+
+export const TemplateSelector: React.FC<TemplateSelectorProps> = ({
+  selectedTemplate,
+  setSelectedTemplate,
+}) => {
+  const { environments, status } = useTemplateEnvs();
+
+  return (
+    <ListContainer>
+      <ListWrapper maxHeight="156px">
+        {match(status)
+          .with("loading", () => {
+            return <Loading />;
+          })
+          .otherwise(() => {
+            return environments.map((env, i) => {
+              return (
+                <AppItem
+                  key={i}
+                  onClick={() => {
+                    setSelectedTemplate(env);
+                  }}
+                  isSelected={selectedTemplate?.name === env.name}
+                >
+                  <Icon height="18px" src={addOns} />
+                  {env.name}
+                </AppItem>
+              );
+            });
+          })}
+      </ListWrapper>
+    </ListContainer>
+  );
+};
+
 const ExpandedWrapper = styled.div`
   margin-top: 10px;
   width: 100%;
@@ -109,11 +154,11 @@ const ListContainer = styled.div`
   overflow-y: auto;
 `;
 
-const ListWrapper = styled.div`
+const ListWrapper = styled.div<{ maxHeight?: string }>`
   width: 100%;
   border-radius: 3px;
   border: 0px solid #ffffff44;
-  max-height: 221px;
+  max-height: ${(props) => props.maxHeight ?? "221px"};
   top: 40px;
 
   > i {

+ 9 - 33
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/CreateTemplate.tsx

@@ -33,10 +33,6 @@ export const CreateTemplate: React.FC = () => {
 
   const [step, setStep] = useState(0);
   const [selectedApp, setSelectedApp] = useState<AppInstance | null>(null);
-  const [detectedServices, setDetectedServices] = useState<{
-    detected: boolean;
-    count: number;
-  }>({ detected: false, count: 0 });
 
   const {
     showGHAModal,
@@ -53,36 +49,9 @@ export const CreateTemplate: React.FC = () => {
   } = useFormContext<AppTemplateFormData>();
 
   const source = watch("source");
+  const build = watch("app.build");
 
-  const { detectedServices: servicesFromYaml, detectedName } = usePorterYaml({
-    source: source.type === "github" ? source : null,
-    appName: "",
-  });
-
-  useEffect(() => {
-    if (servicesFromYaml && !detectedServices.detected) {
-      const { services, predeploy, build: detectedBuild } = servicesFromYaml;
-      setValue("app.services", services);
-      setValue("app.predeploy", [predeploy].filter(valueExists));
-
-      if (detectedBuild) {
-        setValue("app.build", detectedBuild);
-      }
-      setDetectedServices({
-        detected: true,
-        count: services.length,
-      });
-    }
-
-    if (!servicesFromYaml && detectedServices.detected) {
-      setValue("app.services", []);
-      setValue("app.predeploy", []);
-      setDetectedServices({
-        detected: false,
-        count: 0,
-      });
-    }
-  }, [servicesFromYaml, detectedName, detectedServices.detected]);
+  console.log("build", build);
 
   useEffect(() => {
     if (selectedApp?.deployment_target.id) {
@@ -100,6 +69,13 @@ export const CreateTemplate: React.FC = () => {
     }
   }, [selectedApp?.id]);
 
+  // useEffect(() => {
+  //   console.log("source", source);
+  //   if (source?.type === "github") {
+  //     setValue("app.build.repo", source.git_repo_name);
+  //   }
+  // }, [source?.type]);
+
   return (
     <>
       <Back to="/preview-environments" />

+ 10 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RevisionLoader.tsx

@@ -1,17 +1,24 @@
-import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
 import { useFormContext } from "react-hook-form";
 
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 import { useAppWithPreviewOverrides } from "lib/hooks/useAppWithPreviewOverrides";
 
+import { valueExists } from "shared/util";
+
 import { type AppTemplateFormData } from "../EnvTemplateContextProvider";
 
 export const RevisionLoader: React.FC<{
   children: React.ReactNode;
 }> = ({ children }) => {
+  const [detectedServices, setDetectedServices] = useState<{
+    detected: boolean;
+    count: number;
+  }>({ detected: false, count: 0 });
+
   const { latestProto, porterApp, latestSource, servicesFromYaml, appEnv } =
     useLatestRevision();
-  const { reset } = useFormContext<AppTemplateFormData>();
+  const { reset, setValue } = useFormContext<AppTemplateFormData>();
 
   const withPreviewOverrides = useAppWithPreviewOverrides({
     latestApp: latestProto,
@@ -46,5 +53,6 @@ export const RevisionLoader: React.FC<{
     });
   }, [withPreviewOverrides]);
 
+
   return <>{children}</>;
 };

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

@@ -901,6 +901,7 @@ const detectGitlabBuildpack = baseApi<
 const getBranchContents = baseApi<
   {
     dir: string;
+    force_default_branch?: boolean;
   },
   {
     project_id: number;
@@ -3662,6 +3663,7 @@ const createSecretAndOpenGitHubPullRequest = baseApi<
     porter_yaml_path?: string;
     delete_workflow_filename?: string;
     previews_workflow_filename?: string;
+    manual_workflow_filename?: string;
     deployment_target_id?: string;
   },
   {

+ 86 - 0
internal/integrations/ci/actions/stack.go

@@ -19,6 +19,8 @@ const (
 	GithubPRAction_DeleteAppWorkflow GithubPRAction = "delete-app-workflow"
 	// GithubPRAction_PreviewAppWorkflow is the action for creating the preview app workflow
 	GithubPRAction_PreviewAppWorkflow GithubPRAction = "preview-app-workflow"
+	// GithubPRAction_ManualPreviewDeploy is the action for manually deploying a preview environment
+	GithubPRAction_ManualPreviewDeploy GithubPRAction = "manual-preview-deploy"
 )
 
 type GithubPROpts struct {
@@ -97,6 +99,8 @@ func getPRTitle(action GithubPRAction, stackName string) string {
 		return fmt.Sprintf("Delete Porter Application %s", stackName)
 	case GithubPRAction_PreviewAppWorkflow:
 		return "Enable Preview Environments on Porter"
+	case GithubPRAction_ManualPreviewDeploy:
+		return "Deploy Preview Environments Manually"
 	default:
 		return ""
 	}
@@ -170,12 +174,94 @@ func commitChange(prBranchName string, opts GithubPROpts) error {
 			return fmt.Errorf("error committing file: %w", err)
 		}
 
+		return nil
+	case GithubPRAction_ManualPreviewDeploy:
+		manualPreviewWorkflowYaml, err := getManualPreviewActionYAML(&GetStackApplyActionYAMLOpts{
+			ServerURL:          opts.ServerURL,
+			ClusterID:          opts.ClusterID,
+			ProjectID:          opts.ProjectID,
+			StackName:          opts.StackName,
+			DefaultBranch:      opts.DefaultBranch,
+			SecretName:         opts.SecretName,
+			PorterYamlPath:     opts.PorterYamlPath,
+			DeploymentTargetId: opts.DeploymentTargetId,
+			Preview:            true,
+		})
+		if err != nil {
+			return err
+		}
+
+		_, err = commitWorkflowFile(
+			opts.Client,
+			fmt.Sprintf("porter_manual_preview_%s.yml", strings.ToLower(opts.StackName)),
+			manualPreviewWorkflowYaml, opts.GitRepoOwner,
+			opts.GitRepoName, prBranchName, false,
+		)
+		if err != nil {
+			return fmt.Errorf("error committing file: %w", err)
+		}
+
 		return nil
 	default:
 		return fmt.Errorf("invalid PR action: %s", opts.PRAction)
 	}
 }
 
+func getManualPreviewActionYAML(opts *GetStackApplyActionYAMLOpts) ([]byte, error) {
+	checkoutWithUsesRef := GithubActionYAMLStep{
+		Name: "Checkout code",
+		Uses: "actions/checkout@v3",
+		With: map[string]string{
+			"ref": "${{ github.event.inputs.branch }}",
+		},
+	}
+
+	gaSteps := []GithubActionYAMLStep{
+		checkoutWithUsesRef,
+		getSetTagStep(),
+		getSetupPorterStep(),
+		getDeployStackStep(
+			opts.ServerURL,
+			opts.SecretName,
+			opts.StackName,
+			"v0.1.0",
+			opts.PorterYamlPath,
+			opts.ProjectID,
+			opts.ClusterID,
+			opts.DeploymentTargetId,
+			true,
+		),
+	}
+
+	actionYAML := GithubActionYAML{
+		On: map[string]interface{}{
+			"workflow_dispatch": map[string]interface{}{
+				"inputs": map[string]interface{}{
+					"branch": map[string]interface{}{
+						"description": "Base branch tod deploy",
+						"type":        "string",
+						"required":    true,
+					},
+				},
+			},
+		},
+		Name: fmt.Sprintf("Deploy Preview for %s", opts.StackName),
+		Jobs: map[string]GithubActionYAMLJob{
+			"porter-deploy": {
+				RunsOn: "ubuntu-latest",
+				Steps:  gaSteps,
+			},
+		},
+	}
+
+	by, err := yaml.Marshal(actionYAML)
+	if err != nil {
+		return nil, fmt.Errorf("error marshalling yaml: %w", err)
+	}
+
+	return by, nil
+}
+
 func getStackApplyActionYAML(opts *GetStackApplyActionYAMLOpts) ([]byte, error) {
 	gaSteps := []GithubActionYAMLStep{
 		getCheckoutCodeStep(),