Sfoglia il codice sorgente

Merge branch 'master' into nafees/hotfixes

Mohammed Nafees 4 anni fa
parent
commit
397d908ebf

+ 4 - 13
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -9,6 +9,7 @@ import (
 	"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/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -147,20 +148,10 @@ func updateDeploymentWithGithubWorkflowRunStatus(
 	client, err := getGithubClientFromEnvironment(config, env)
 
 	if err == nil {
-		workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
-			ctx, deployment.RepoOwner, deployment.RepoName,
-			fmt.Sprintf("porter_%s_env.yml", env.Name), &github.ListWorkflowRunsOptions{
-				Branch: deployment.PRBranchFrom,
-				ListOptions: github.ListOptions{
-					Page:    1,
-					PerPage: 1,
-				},
-			},
-		)
-
-		if err == nil && workflowRuns.GetTotalCount() > 0 {
-			latestWorkflowRun := workflowRuns.WorkflowRuns[0]
+		latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
+			fmt.Sprintf("porter_%s_env.yml", env.Name), deployment.PRBranchFrom)
 
+		if err == nil {
 			deployment.LastWorkflowRunURL = latestWorkflowRun.GetHTMLURL()
 
 			if (latestWorkflowRun.GetStatus() == "in_progress" ||

+ 2 - 24
api/server/handlers/environment/trigger_deployment_workflow.go

@@ -1,7 +1,6 @@
 package environment
 
 import (
-	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -11,6 +10,7 @@ import (
 	"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/commonutils"
 	"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"
@@ -69,7 +69,7 @@ func (c *TriggerDeploymentWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *h
 		return
 	}
 
-	latestWorkflowRun, err := getLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
+	latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
 		fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
 
 	if err != nil && errors.Is(err, ErrNoWorkflowRuns) {
@@ -118,25 +118,3 @@ func (c *TriggerDeploymentWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *h
 		return
 	}
 }
-
-func getLatestWorkflowRun(client *github.Client, owner, repo, filename, branch string) (*github.WorkflowRun, error) {
-	workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
-		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
-			Branch: branch,
-			ListOptions: github.ListOptions{
-				Page:    1,
-				PerPage: 1,
-			},
-		},
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	if workflowRuns.GetTotalCount() == 0 {
-		return nil, ErrNoWorkflowRuns
-	}
-
-	return workflowRuns.WorkflowRuns[0], nil
-}

+ 29 - 30
api/server/handlers/gitinstallation/rerun_workflow.go

@@ -1,20 +1,18 @@
 package gitinstallation
 
 import (
-	"context"
 	"errors"
 	"fmt"
 	"net/http"
+	"strings"
 
-	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 )
 
-var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
-
 type RerunWorkflowHandler struct {
 	handlers.PorterHandlerReadWriter
 }
@@ -37,10 +35,28 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	filename := r.URL.Query().Get("filename")
+	// if branch is empty then the latest workflow run is rerun, meaning that if
+	// there were multiple workflow runs for the same file but for different branches
+	// only the very latest of the workflow runs will be rerun
+	branch := r.URL.Query().Get("branch")
+	releaseName := r.URL.Query().Get("release_name")
+
+	if filename == "" && releaseName == "" {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("filename and release name are both empty")))
+		return
+	}
 
 	if filename == "" {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("filename query param not set")))
-		return
+		if c.Config().ServerConf.InstanceName != "" {
+			filename = fmt.Sprintf("porter_%s_%s.yml", strings.Replace(
+				strings.ToLower(releaseName), "-", "_", -1),
+				strings.ToLower(c.Config().ServerConf.InstanceName),
+			)
+		} else {
+			filename = fmt.Sprintf("porter_%s.yml", strings.Replace(
+				strings.ToLower(releaseName), "-", "_", -1),
+			)
+		}
 	}
 
 	client, err := GetGithubAppClientFromRequest(c.Config(), r)
@@ -50,11 +66,15 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	latestWorkflowRun, err := getLatestWorkflowRun(client, owner, name, filename)
+	latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, owner, name, filename, branch)
 
-	if err != nil && errors.Is(err, ErrNoWorkflowRuns) {
+	if err != nil && errors.Is(err, commonutils.ErrNoWorkflowRuns) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 400))
 		return
+	} else if err != nil && errors.Is(err, commonutils.ErrWorkflowNotFound) {
+		w.WriteHeader(http.StatusNotFound)
+		c.WriteResult(w, r, filename)
+		return
 	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
@@ -73,7 +93,7 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	latestWorkflowRun, err = getLatestWorkflowRun(client, owner, name, filename)
+	latestWorkflowRun, err = commonutils.GetLatestWorkflowRun(client, owner, name, filename, branch)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -82,24 +102,3 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	c.WriteResult(w, r, latestWorkflowRun.GetHTMLURL())
 }
-
-func getLatestWorkflowRun(client *github.Client, owner, repo, filename string) (*github.WorkflowRun, error) {
-	workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
-		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
-			ListOptions: github.ListOptions{
-				Page:    1,
-				PerPage: 1,
-			},
-		},
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	if workflowRuns.GetTotalCount() == 0 {
-		return nil, ErrNoWorkflowRuns
-	}
-
-	return workflowRuns.WorkflowRuns[0], nil
-}

+ 38 - 0
api/server/shared/commonutils/git_utils.go

@@ -0,0 +1,38 @@
+package commonutils
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/google/go-github/v41/github"
+)
+
+var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
+var ErrWorkflowNotFound = errors.New("no workflow found, file missing")
+
+func GetLatestWorkflowRun(client *github.Client, owner, repo, filename, branch string) (*github.WorkflowRun, error) {
+	workflowRuns, ghResponse, err := client.Actions.ListWorkflowRunsByFileName(
+		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
+			Branch: branch,
+			ListOptions: github.ListOptions{
+				Page:    1,
+				PerPage: 1,
+			},
+		},
+	)
+
+	if ghResponse.StatusCode == http.StatusNotFound {
+		return nil, ErrWorkflowNotFound
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	if workflowRuns.GetTotalCount() == 0 {
+		return nil, ErrNoWorkflowRuns
+	}
+
+	return workflowRuns.WorkflowRuns[0], nil
+}

+ 18 - 2
cli/cmd/apply.go

@@ -527,8 +527,9 @@ func (d *Driver) updateApplication(resource *models.Resource, client *api.Client
 	// if the build method is registry, we do not trigger a build
 	if appConf.Build.Method != "registry" {
 		buildEnv, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{
-			UseNewConfig: true,
-			NewConfig:    appConf.Values,
+			UseNewConfig:    true,
+			NewConfig:       appConf.Values,
+			IncludeBuildEnv: true,
 		})
 
 		if err != nil {
@@ -565,6 +566,21 @@ func (d *Driver) updateApplication(resource *models.Resource, client *api.Client
 		}
 	}
 
+	buildEnv, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{
+		UseNewConfig: true,
+		NewConfig:    appConf.Values,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = updateAgent.SetBuildEnv(buildEnv)
+
+	if err != nil {
+		return nil, err
+	}
+
 	err = updateAgent.UpdateImageAndValues(appConf.Values)
 
 	if err != nil {

+ 6 - 3
cli/cmd/deploy.go

@@ -497,8 +497,9 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 	}
 
 	buildEnv, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{
-		UseNewConfig: true,
-		NewConfig:    valuesObj,
+		UseNewConfig:    true,
+		NewConfig:       valuesObj,
+		IncludeBuildEnv: true,
 	})
 
 	if err != nil {
@@ -638,7 +639,9 @@ func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 		return err
 	}
 
-	env, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{UseNewConfig: false})
+	env, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{
+		UseNewConfig: false,
+	})
 
 	if err == nil && len(env) > 0 {
 		valuesObj = templaterUtils.CoalesceValues(valuesObj, map[string]interface{}{

+ 13 - 2
cli/cmd/deploy/deploy.go

@@ -146,8 +146,9 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 }
 
 type GetBuildEnvOpts struct {
-	UseNewConfig bool
-	NewConfig    map[string]interface{}
+	UseNewConfig    bool
+	NewConfig       map[string]interface{}
+	IncludeBuildEnv bool
 }
 
 // GetBuildEnv retrieves the build env from the release config and returns it
@@ -166,6 +167,7 @@ func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, err
 		return nil, err
 	}
 
+<<<<<<< nafees/hotfixes
 	envConfig, err := GetNestedMap(conf, "container", "env")
 
 	if err == nil {
@@ -179,6 +181,15 @@ func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, err
 					if valStr, ok := val.(string); ok {
 						env[key] = valStr
 					}
+=======
+	if opts.IncludeBuildEnv {
+		buildEnv, err := GetNestedMap(conf, "container", "env", "build")
+
+		if err == nil {
+			for key, val := range buildEnv {
+				if valStr, ok := val.(string); ok {
+					env[key] = valStr
+>>>>>>> master
 				}
 			}
 		}

+ 27 - 21
dashboard/src/components/MultiSaveButton.tsx

@@ -1,7 +1,6 @@
-import React, { Component, useState } from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
 import loading from "assets/loading.gif";
-import MultiSelect from "./porter-form/field-components/MultiSelect";
 import Description from "./Description";
 
 type MultiSelectOption = {
@@ -27,12 +26,11 @@ type Props = {
   // Provide the classname to modify styles from other components
   className?: string;
   successText?: string;
+  expandTo?: OptionsWrapperProps["expandTo"];
 };
 
 const MultiSaveButton: React.FC<Props> = (props) => {
-  const [currOption, setCurrOption] = useState<MultiSelectOption>(
-    props.options[0]
-  );
+  const [currOptionIndex, setCurrOptionIndex] = useState<number>(0);
 
   const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
 
@@ -86,6 +84,7 @@ const MultiSaveButton: React.FC<Props> = (props) => {
         <>
           <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
           <OptionWrapper
+            expandTo={props.expandTo || "right"}
             dropdownWidth="400px"
             dropdownMaxHeight="300px"
             onClick={() => setIsDropdownExpanded(false)}
@@ -102,8 +101,8 @@ const MultiSaveButton: React.FC<Props> = (props) => {
       return (
         <Option
           key={i}
-          selected={option.text === currOption.text}
-          onClick={() => setCurrOption(option)}
+          selected={option.text === originalArray[currOptionIndex]?.text}
+          onClick={() => setCurrOptionIndex(i)}
           lastItem={i === originalArray.length - 1}
         >
           {option.text}
@@ -126,10 +125,10 @@ const MultiSaveButton: React.FC<Props> = (props) => {
         <Button
           rounded={props.rounded}
           disabled={props.disabled}
-          onClick={currOption.onClick}
+          onClick={props.options[currOptionIndex]?.onClick}
           color={props.color || "#5561C0"}
         >
-          {currOption.text}
+          {props.options[currOptionIndex]?.text}
         </Button>
         <DropdownButton
           disabled={props.disabled}
@@ -165,12 +164,13 @@ const StatusTextWrapper = styled.p`
   margin: 0;
 `;
 
-// TODO: prevent status re-render on form refresh to allow animation
-// animation: statusFloatIn 0.5s;
-const StatusWrapper = styled.div<{
+type StatusWrapperProps = {
   successful: boolean;
   position: "right" | "left";
-}>`
+};
+// TODO: prevent status re-render on form refresh to allow animation
+// animation: statusFloatIn 0.5s;
+const StatusWrapper = styled.div<StatusWrapperProps>`
   display: flex;
   align-items: center;
   font-family: "Work Sans", sans-serif;
@@ -239,11 +239,13 @@ const ButtonWrapper = styled.div`
   }}
 `;
 
-const Button = styled.button<{
+type ButtonProps = {
   disabled: boolean;
   color: string;
   rounded: boolean;
-}>`
+};
+
+const Button = styled.button<ButtonProps>`
   height: 35px;
   font-size: 13px;
   font-weight: 500;
@@ -321,15 +323,19 @@ const DropdownOverlay = styled.div`
   cursor: default;
 `;
 
-const OptionWrapper = styled.div`
+type OptionsWrapperProps = {
+  expandTo: "left" | "right";
+  dropdownWidth: string;
+  dropdownMaxHeight: string;
+};
+
+const OptionWrapper = styled.div<OptionsWrapperProps>`
   position: absolute;
-  left: 0;
+  ${(props) => (props.expandTo === "right" ? "left: 0" : "right: 0")};
   top: calc(100% + 10px);
   background: #26282f;
-  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
-    props.dropdownWidth};
-  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
-    props.dropdownMaxHeight || "300px"};
+  width: ${(props) => props.dropdownWidth};
+  max-height: ${(props) => props.dropdownMaxHeight || "300px"};
   border-radius: 3px;
   z-index: 999;
   overflow-y: auto;

+ 9 - 4
dashboard/src/components/form-components/KeyValueArray.tsx

@@ -130,7 +130,9 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                 }}
-                disabled={this.props.disabled || value.includes("PORTERSECRET")}
+                disabled={
+                  this.props.disabled || value?.includes("PORTERSECRET")
+                }
                 spellCheck={false}
               />
               <Spacer />
@@ -145,12 +147,14 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                 }}
-                disabled={this.props.disabled || value.includes("PORTERSECRET")}
-                type={value.includes("PORTERSECRET") ? "password" : "text"}
+                disabled={
+                  this.props.disabled || value?.includes("PORTERSECRET")
+                }
+                type={value?.includes("PORTERSECRET") ? "password" : "text"}
                 spellCheck={false}
               />
               {this.renderDeleteButton(i)}
-              {this.renderHiddenOption(value.includes("PORTERSECRET"), i)}
+              {this.renderHiddenOption(value?.includes("PORTERSECRET"), i)}
             </InputWrapper>
           );
         })}
@@ -176,6 +180,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
               this.props.setValues(newValues);
               this.setState({ values: this.objectToValues(newValues) });
             }}
+            normalEnvVarsOnly
           />
         </Modal>
       );

+ 5 - 1
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -31,7 +31,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
       initState: () => {
         let values = props.value[0];
         const normalValues = Object.entries(values?.normal || {});
-        values = omit(values, ["normal", "synced"]);
+        values = omit(values, ["normal", "synced", "build"]);
         return {
           values: hasSetValue(props)
             ? ([...Object.entries(values), ...normalValues]?.map(([k, v]) => {
@@ -511,6 +511,10 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
       }
     });
 
+    if (Array.isArray(props.value) && props.value[0]?.build) {
+      obj.build = props.value[0].build;
+    }
+
     if (state.synced_env_groups?.length) {
       obj.synced = state.synced_env_groups.map((envGroup) => ({
         name: envGroup?.name,

+ 774 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx

@@ -0,0 +1,774 @@
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import KeyValueArray from "components/form-components/KeyValueArray";
+import SelectRow from "components/form-components/SelectRow";
+import Loading from "components/Loading";
+import MultiSaveButton from "components/MultiSaveButton";
+import { unionBy } from "lodash";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import {
+  BuildConfig,
+  ChartTypeWithExtendedConfig,
+  FullActionConfigType,
+} from "shared/types";
+import styled, { keyframes } from "styled-components";
+import yaml from "js-yaml";
+import DynamicLink from "components/DynamicLink";
+import { AxiosError } from "axios";
+
+const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
+const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
+
+type Buildpack = {
+  name: string;
+  buildpack: string;
+  config: {
+    [key: string]: string;
+  };
+};
+
+type DetectedBuildpack = {
+  name: string;
+  builders: string[];
+  detected: Buildpack[];
+  others: Buildpack[];
+};
+
+type DetectBuildpackResponse = DetectedBuildpack[];
+
+type UpdateBuildconfigResponse = {
+  CreatedAt: string;
+  DeletedAt: { Time: string; Valid: boolean };
+  Time: string;
+  Valid: boolean;
+  ID: number;
+  UpdatedAt: string;
+  builder: string;
+  buildpacks: string;
+  config: string;
+  name: string;
+};
+
+type Props = {
+  chart: ChartTypeWithExtendedConfig;
+  isPreviousVersion: boolean;
+};
+
+const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const [buildConfig, setBuildConfig] = useState<BuildConfig>(null);
+  const [envVariables, setEnvVariables] = useState(
+    chart.config?.container?.env?.build || null
+  );
+  const [runningWorkflowURL, setRunningWorkflowURL] = useState("");
+  const [reRunError, setReRunError] = useState<{
+    title: string;
+    description: string;
+  }>(null);
+  const [buttonStatus, setButtonStatus] = useState<
+    "loading" | "successful" | string
+  >("");
+
+  const saveBuildConfig = async (config: BuildConfig) => {
+    if (config === null) {
+      return;
+    }
+
+    if (!config.builder.length || !config.buildpacks.length) {
+      throw new Error("You have to select at least one buildpack");
+    }
+
+    try {
+      await api.updateBuildConfig<UpdateBuildconfigResponse>(
+        "<token>",
+        { ...config },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: chart.namespace,
+          release_name: chart.name,
+        }
+      );
+    } catch (err) {
+      throw err;
+    }
+  };
+
+  const saveEnvVariables = async (envs: { [key: string]: string }) => {
+    let values = { ...chart.config };
+    if (envs === null) {
+      return;
+    }
+
+    values.container.env.build = { ...envs };
+    const valuesYaml = yaml.dump({ ...values });
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          values: valuesYaml,
+        },
+        {
+          id: currentProject.id,
+          namespace: chart.namespace,
+          name: chart.name,
+          cluster_id: currentCluster.id,
+        }
+      );
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const triggerWorkflow = async () => {
+    try {
+      await api.reRunGHWorkflow(
+        "",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: chart.git_action_config?.git_repo_id,
+          owner: chart.git_action_config?.git_repo?.split("/")[0],
+          name: chart.git_action_config?.git_repo?.split("/")[1],
+          branch: chart.git_action_config?.git_branch,
+          release_name: chart.name,
+        }
+      );
+    } catch (error) {
+      if (!error?.response) {
+        throw error;
+      }
+
+      let tmpError: AxiosError = error;
+
+      /**
+       * @smell
+       * Currently the expanded chart is clearing all the state when a chart update is triggered (saveEnvVariables).
+       * Temporary usage of setCurrentError until a context is applied to keep the state of the ReRunError during re renders.
+       */
+
+      if (tmpError.response.status === 400) {
+        // setReRunError({
+        //   title: "No previous run found",
+        //   description:
+        //     "There are no previous runs for this workflow, please trigger manually a run before changing the build settings.",
+        // });
+        setCurrentError(
+          "There are no previous runs for this workflow. Please manually trigger a run before changing build settings."
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 409) {
+        // setReRunError({
+        //   title: "The workflow is still running",
+        //   description:
+        //     'If you want to make more changes, please choose the option "Save" until the workflow finishes.',
+        // });
+
+        if (typeof tmpError.response.data === "string") {
+          setRunningWorkflowURL(tmpError.response.data);
+        }
+        setCurrentError(
+          'The workflow is still running. You can "Save" the current build settings for the next workflow run and view the current status of the workflow here: ' +
+            tmpError.response.data
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 404) {
+        let description = "No action file matching this deployment was found.";
+        if (typeof tmpError.response.data === "string") {
+          const filename = tmpError.response.data;
+          description = description.concat(
+            `Please check that the file "${filename}" exists in your repository.`
+          );
+        }
+        // setReRunError({
+        //   title: "The action doesn't seem to exist",
+        //   description,
+        // });
+
+        setCurrentError(description);
+        return;
+      }
+      throw error;
+    }
+  };
+
+  const clearButtonStatus = () => {
+    setTimeout(() => {
+      setButtonStatus("");
+    }, 800);
+  };
+
+  const handleSave = async () => {
+    setButtonStatus("loading");
+    try {
+      await saveBuildConfig(buildConfig);
+      await saveEnvVariables(envVariables);
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      setCurrentError(error);
+    } finally {
+      clearButtonStatus();
+    }
+  };
+
+  const handleSaveAndReDeploy = async () => {
+    setButtonStatus("loading");
+    try {
+      await saveBuildConfig(buildConfig);
+      await saveEnvVariables(envVariables);
+      await triggerWorkflow();
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      setCurrentError(error);
+    } finally {
+      clearButtonStatus();
+    }
+  };
+
+  return (
+    <Wrapper>
+      {isPreviousVersion ? (
+        <DisabledOverlay>
+          Build config is disabled when reviewing past versions. Please go to
+          the current revision to update your app build configuration.
+        </DisabledOverlay>
+      ) : null}
+      <StyledSettingsSection blurContent={isPreviousVersion}>
+        {/* {reRunError !== null ? (
+        <AlertCard>
+          <AlertCardIcon className="material-icons">error</AlertCardIcon>
+          <AlertCardContent className="content">
+            <AlertCardTitle className="title">
+              {reRunError.title}
+            </AlertCardTitle>
+            {reRunError.description}
+            {runningWorkflowURL.length ? (
+              <>
+                {" "}
+                To go to the workflow{" "}
+                <DynamicLink to={runningWorkflowURL} target="_blank">
+                  click here
+                </DynamicLink>
+              </>
+            ) : null}
+          </AlertCardContent>
+          <AlertCardAction
+            onClick={() => {
+              setReRunError(null);
+              setRunningWorkflowURL("");
+            }}
+          >
+            <span className="material-icons">close</span>
+          </AlertCardAction>
+        </AlertCard>
+      ) : null} */}
+        <Heading isAtTop>Build Environment Variables</Heading>
+        <KeyValueArray
+          values={envVariables}
+          envLoader
+          externalValues={{
+            namespace: chart.namespace,
+            clusterId: currentCluster.id,
+          }}
+          setValues={(values) => {
+            setEnvVariables(values);
+          }}
+        ></KeyValueArray>
+
+        {!chart.git_action_config.dockerfile_path ? (
+          <>
+            <Heading>Buildpack Settings</Heading>
+            <BuildpackConfigSection
+              currentChart={chart}
+              actionConfig={chart.git_action_config}
+              onChange={(buildConfig) => setBuildConfig(buildConfig)}
+            />
+          </>
+        ) : null}
+        <SaveButtonWrapper>
+          <MultiSaveButton
+            options={[
+              {
+                text: "Save",
+                onClick: handleSave,
+                description:
+                  "Save the build settings to be used in the next workflow run",
+              },
+              {
+                text: "Save and Redeploy",
+                onClick: handleSaveAndReDeploy,
+                description:
+                  "Immediately trigger a workflow run with updated build settings",
+              },
+            ]}
+            disabled={false}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="left"
+            expandTo="left"
+            saveText=""
+            status={buttonStatus}
+          ></MultiSaveButton>
+        </SaveButtonWrapper>
+      </StyledSettingsSection>
+    </Wrapper>
+  );
+};
+
+export default BuildSettingsTab;
+
+const BuildpackConfigSection: React.FC<{
+  actionConfig: FullActionConfigType;
+  currentChart: ChartTypeWithExtendedConfig;
+  onChange: (buildConfig: BuildConfig) => void;
+}> = ({ actionConfig, currentChart, onChange }) => {
+  const { currentProject } = useContext(Context);
+
+  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
+  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
+
+  const [stacks, setStacks] = useState<string[]>(null);
+  const [selectedStack, setSelectedStack] = useState<string>(null);
+
+  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
+  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
+    []
+  );
+
+  useEffect(() => {
+    const currentBuildConfig = currentChart?.build_config;
+
+    if (!currentBuildConfig) {
+      return;
+    }
+
+    api
+      .detectBuildpack<DetectBuildpackResponse>(
+        "<token>",
+        {
+          dir: actionConfig.folder_path || ".",
+        },
+        {
+          project_id: currentProject.id,
+          git_repo_id: actionConfig.git_repo_id,
+          kind: "github",
+          owner: actionConfig.git_repo.split("/")[0],
+          name: actionConfig.git_repo.split("/")[1],
+          branch: actionConfig.git_branch,
+        }
+      )
+      .then(({ data }) => {
+        const builders = data;
+
+        const defaultBuilder = builders.find((builder) =>
+          builder.builders.find((stack) => stack === currentBuildConfig.builder)
+        );
+
+        const availableBuildpacks = defaultBuilder.others?.filter(
+          (buildpack) => {
+            if (!currentBuildConfig.buildpacks.includes(buildpack.buildpack)) {
+              return true;
+            }
+            return false;
+          }
+        );
+
+        const userAddedBuildpacks = defaultBuilder.others?.filter(
+          (buildpack) => {
+            if (currentBuildConfig.buildpacks.includes(buildpack.buildpack)) {
+              return true;
+            }
+            return false;
+          }
+        );
+
+        const detectedBuildpacks = unionBy(
+          userAddedBuildpacks,
+          defaultBuilder.detected,
+          "buildpack"
+        );
+
+        const defaultStack = defaultBuilder.builders.find((stack) => {
+          return stack === currentBuildConfig.builder;
+        });
+
+        setBuilders(builders);
+        setSelectedBuilder(defaultBuilder.name.toLowerCase());
+
+        setStacks(defaultBuilder.builders);
+        setSelectedStack(defaultStack);
+        if (!Array.isArray(detectedBuildpacks)) {
+          setSelectedBuildpacks([]);
+        } else {
+          setSelectedBuildpacks(detectedBuildpacks);
+        }
+        if (!Array.isArray(availableBuildpacks)) {
+          setAvailableBuildpacks([]);
+        } else {
+          setAvailableBuildpacks(availableBuildpacks);
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject, actionConfig, currentChart]);
+
+  useEffect(() => {
+    let buildConfig: BuildConfig = {} as BuildConfig;
+
+    buildConfig.builder = selectedStack;
+    buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
+      return buildpack.buildpack;
+    });
+
+    onChange(buildConfig);
+  }, [selectedBuilder, selectedBuildpacks, selectedStack]);
+
+  const builderOptions = useMemo(() => {
+    if (!Array.isArray(builders)) {
+      return;
+    }
+
+    return builders.map((builder) => ({
+      label: builder.name,
+      value: builder.name.toLowerCase(),
+    }));
+  }, [builders]);
+
+  const stackOptions = useMemo(() => {
+    if (!Array.isArray(stacks)) {
+      return;
+    }
+
+    return stacks.map((stack) => ({
+      label: stack,
+      value: stack.toLowerCase(),
+    }));
+  }, [stacks]);
+
+  const handleSelectBuilder = (builderName: string) => {
+    const builder = builders.find(
+      (b) => b.name.toLowerCase() === builderName.toLowerCase()
+    );
+    const detectedBuildpacks = builder.detected;
+    const availableBuildpacks = builder.others;
+    const defaultStack = builder.builders.find((stack) => {
+      return stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK;
+    });
+    setSelectedBuilder(builderName);
+    setBuilders(builders);
+    setSelectedBuilder(builderName.toLowerCase());
+
+    setStacks(builder.builders);
+    setSelectedStack(defaultStack);
+
+    if (!Array.isArray(detectedBuildpacks)) {
+      setSelectedBuildpacks([]);
+    } else {
+      setSelectedBuildpacks(detectedBuildpacks);
+    }
+    if (!Array.isArray(availableBuildpacks)) {
+      setAvailableBuildpacks([]);
+    } else {
+      setAvailableBuildpacks(availableBuildpacks);
+    }
+  };
+
+  const renderBuildpacksList = (
+    buildpacks: Buildpack[],
+    action: "remove" | "add"
+  ) => {
+    return buildpacks?.map((buildpack) => {
+      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
+
+      return (
+        <StyledCard>
+          <ContentContainer>
+            <Icon className={icon} />
+            <EventInformation>
+              <EventName>{buildpack?.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            {action === "add" && (
+              <DeleteButton
+                onClick={() => handleAddBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons-outlined">add</span>
+              </DeleteButton>
+            )}
+            {action === "remove" && (
+              <DeleteButton
+                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons">delete</span>
+              </DeleteButton>
+            )}
+          </ActionContainer>
+        </StyledCard>
+      );
+    });
+  };
+
+  const handleRemoveBuildpack = (buildpackToRemove: string) => {
+    setSelectedBuildpacks((selBuildpacks) => {
+      const tmpSelectedBuildpacks = [...selBuildpacks];
+
+      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToRemove
+      );
+      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
+
+      setAvailableBuildpacks((availableBuildpacks) => [
+        ...availableBuildpacks,
+        buildpack,
+      ]);
+
+      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
+
+      return [...tmpSelectedBuildpacks];
+    });
+  };
+
+  const handleAddBuildpack = (buildpackToAdd: string) => {
+    setAvailableBuildpacks((avBuildpacks) => {
+      const tmpAvailableBuildpacks = [...avBuildpacks];
+      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToAdd
+      );
+      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
+
+      setSelectedBuildpacks((selectedBuildpacks) => [
+        ...selectedBuildpacks,
+        buildpack,
+      ]);
+
+      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
+      return [...tmpAvailableBuildpacks];
+    });
+  };
+
+  if (!stackOptions?.length || !builderOptions?.length) {
+    return <Loading />;
+  }
+
+  return (
+    <BuildpackConfigurationContainer>
+      <>
+        <SelectRow
+          value={selectedBuilder}
+          width="100%"
+          options={builderOptions}
+          setActiveValue={(option) => handleSelectBuilder(option)}
+          label="Select a builder"
+        />
+
+        <SelectRow
+          value={selectedStack}
+          width="100%"
+          options={stackOptions}
+          setActiveValue={(option) => setSelectedStack(option)}
+          label="Select your stack"
+        />
+        <Helper>
+          The following buildpacks were automatically detected. You can also
+          manually add/remove buildpacks.
+        </Helper>
+
+        {!!selectedBuildpacks?.length &&
+          renderBuildpacksList(selectedBuildpacks, "remove")}
+
+        {!!availableBuildpacks?.length && (
+          <>
+            <Helper>Available buildpacks:</Helper>
+            {renderBuildpacksList(availableBuildpacks, "add")}
+          </>
+        )}
+      </>
+    </BuildpackConfigurationContainer>
+  );
+};
+
+const DisabledOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: inherit;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #00000099;
+  z-index: 1000;
+  border-radius: 8px;
+  padding: 0 35px;
+  text-align: center;
+`;
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const SaveButtonWrapper = styled.div`
+  width: 100%;
+  margin-top: 30px;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+const BuildpackConfigurationContainer = styled.div`
+  animation: ${fadeIn} 0.75s;
+`;
+
+const Wrapper = styled.div`
+  position: relative;
+  width: 100%;
+  margin-bottom: 65px;
+  height: 100%;
+`;
+
+const StyledSettingsSection = styled.div<{ blurContent: boolean }>`
+  width: 100%;
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-top: 35px;
+  padding-bottom: 15px;
+  position: relative;
+  border-radius: 8px;
+  height: calc(100% - 55px);
+  ${(props) => (props.blurContent ? "filter: blur(5px);" : "")}
+`;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff00;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 20px;
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const DeleteButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;
+
+const AlertCard = styled.div`
+  transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
+  border-radius: 4px;
+  box-shadow: none;
+  font-weight: 400;
+  font-size: 0.875rem;
+  line-height: 1.43;
+  letter-spacing: 0.01071em;
+  border: 1px solid rgb(229, 115, 115);
+  display: flex;
+  padding: 6px 16px;
+  color: rgb(244, 199, 199);
+  margin-top: 20px;
+  position: relative;
+`;
+
+const AlertCardIcon = styled.span`
+  color: rgb(239, 83, 80);
+  margin-right: 12px;
+  padding: 7px 0px;
+  display: flex;
+  font-size: 22px;
+  opacity: 0.9;
+`;
+
+const AlertCardTitle = styled.div`
+  margin: -2px 0px 0.35em;
+  font-size: 1rem;
+  line-height: 1.5;
+  letter-spacing: 0.00938em;
+  font-weight: 500;
+`;
+
+const AlertCardContent = styled.div`
+  padding: 8px 0px;
+`;
+
+const AlertCardAction = styled.button`
+  position: absolute;
+  right: 5px;
+  top: 5px;
+  border: none;
+  background-color: unset;
+  color: white;
+  :hover {
+    cursor: pointer;
+  }
+`;

+ 51 - 43
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -30,6 +30,7 @@ import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
 import { onlyInLeft } from "shared/array_utils";
 import IncidentsTab from "./incidents/IncidentsTab";
+import BuildSettingsTab from "./BuildSettingsTab";
 
 type Props = {
   namespace: string;
@@ -515,6 +516,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
             disabled={!isAuthorized("application", "", ["get", "update"])}
           />
         );
+      case "build-settings":
+        return <BuildSettingsTab chart={chart} isPreviousVersion={isPreview} />;
       default:
     }
   };
@@ -539,6 +542,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
     }
 
+    if (currentChart?.git_action_config?.git_repo) {
+      rightTabOptions.push({
+        label: "Build Settings",
+        value: "build-settings",
+      });
+    }
+
     // Settings tab is always last
     if (isAuthorized("application", "", ["get", "delete"])) {
       rightTabOptions.push({ label: "Settings", value: "settings" });
@@ -580,47 +590,47 @@ const ExpandedChart: React.FC<Props> = (props) => {
     }
   };
 
-  const chartStatus = useMemo(() => {
-    const getAvailability = (kind: string, c: any) => {
-      switch (kind?.toLowerCase()) {
-        case "deployment":
-        case "replicaset":
-          return c.status.availableReplicas == c.status.replicas;
-        case "statefulset":
-          return c.status.readyReplicas == c.status.replicas;
-        case "daemonset":
-          return c.status.numberAvailable == c.status.desiredNumberScheduled;
-      }
-    };
-
-    const chartStatus = currentChart.info.status;
-
-    if (chartStatus === "deployed") {
-      for (var uid in controllers) {
-        let value = controllers[uid];
-        let available = getAvailability(value.metadata.kind, value);
-        let progressing = true;
-
-        controllers[uid]?.status?.conditions?.forEach((condition: any) => {
-          if (
-            condition.type == "Progressing" &&
-            condition.status == "False" &&
-            condition.reason == "ProgressDeadlineExceeded"
-          ) {
-            progressing = false;
-          }
-        });
-
-        if (!available && progressing) {
-          return "loading";
-        } else if (!available && !progressing) {
-          return "failed";
-        }
-      }
-      return "deployed";
-    }
-    return chartStatus;
-  }, [currentChart, controllers]);
+  // const chartStatus = useMemo(() => {
+  //   const getAvailability = (kind: string, c: any) => {
+  //     switch (kind?.toLowerCase()) {
+  //       case "deployment":
+  //       case "replicaset":
+  //         return c.status.availableReplicas == c.status.replicas;
+  //       case "statefulset":
+  //         return c.status.readyReplicas == c.status.replicas;
+  //       case "daemonset":
+  //         return c.status.numberAvailable == c.status.desiredNumberScheduled;
+  //     }
+  //   };
+
+  //   const chartStatus = currentChart.info.status;
+
+  //   if (chartStatus === "deployed") {
+  //     for (var uid in controllers) {
+  //       let value = controllers[uid];
+  //       let available = getAvailability(value.metadata.kind, value);
+  //       let progressing = true;
+
+  //       controllers[uid]?.status?.conditions?.forEach((condition: any) => {
+  //         if (
+  //           condition.type == "Progressing" &&
+  //           condition.status == "False" &&
+  //           condition.reason == "ProgressDeadlineExceeded"
+  //         ) {
+  //           progressing = false;
+  //         }
+  //       });
+
+  //       if (!available && progressing) {
+  //         return "loading";
+  //       } else if (!available && !progressing) {
+  //         return "failed";
+  //       }
+  //     }
+  //     return "deployed";
+  //   }
+  //   return chartStatus;
+  // }, [currentChart, controllers]);
 
   const renderUrl = () => {
     if (url) {
@@ -836,7 +846,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
                 setRevision={setRevision}
                 forceRefreshRevisions={forceRefreshRevisions}
                 refreshRevisionsOff={() => setForceRefreshRevisions(false)}
-                status={chartStatus}
                 shouldUpdate={
                   currentChart.latest_version &&
                   currentChart.latest_version !==
@@ -940,7 +949,6 @@ const LineBreak = styled.div`
 
 const BodyWrapper = styled.div`
   position: relative;
-  overflow: hidden;
   margin-bottom: 120px;
 `;
 

+ 12 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -27,6 +27,7 @@ import ConnectToJobInstructionsModal from "./jobs/ConnectToJobInstructionsModal"
 import CommandLineIcon from "assets/command-line-icon";
 import CronParser from "cron-parser";
 import CronPrettifier from "cronstrue";
+import BuildSettingsTab from "./BuildSettingsTab";
 
 const readableDate = (s: string) => {
   let ts = new Date(s);
@@ -84,6 +85,13 @@ export const ExpandedJobChartFC: React.FC<{
     rightTabOptions.push({ label: "Settings", value: "settings" });
   }
 
+  if (chart?.git_action_config?.git_repo) {
+    rightTabOptions.push({
+      label: "Build Settings",
+      value: "build-settings",
+    });
+  }
+
   const leftTabOptions = [{ label: "Jobs", value: "jobs" }];
 
   const processValuesToUpdateChart = (newConfig?: any) => (
@@ -241,6 +249,10 @@ export const ExpandedJobChartFC: React.FC<{
       );
     }
 
+    if (currentTab === "build-settings") {
+      return <BuildSettingsTab chart={chart} />;
+    }
+
     if (
       currentTab === "settings" &&
       isAuthorized("job", "", ["get", "delete"])

+ 6 - 2
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -30,6 +30,7 @@ type PropsType = {
   enableSyncedEnvGroups?: boolean;
   syncedEnvGroups?: PopulatedEnvGroup[];
   setSyncedEnvGroups?: (values: PopulatedEnvGroup) => void;
+  normalEnvVarsOnly?: boolean;
 };
 
 type StateType = {
@@ -132,6 +133,9 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
     } else {
       return this.state.envGroups
         .filter((envGroup) => {
+          if (!Array.isArray(this.props.syncedEnvGroups)) {
+            return true;
+          }
           return !this.props.syncedEnvGroups.find(
             (syncedEnvGroup) => syncedEnvGroup.name === envGroup.name
           );
@@ -265,11 +269,11 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
                   />
                 </IconWrapper>
               </>
-            ) : (
+            ) : !this.props.normalEnvVarsOnly ? (
               <Helper color="#f5cb42">
                 Upgrade the job template to enable sync env groups
               </Helper>
-            )}
+            ) : null}
           </AbsoluteWrapper>
         </GroupModalSections>
 

+ 44 - 4
dashboard/src/shared/api.tsx

@@ -2,7 +2,7 @@ import { PullRequest } from "main/home/cluster-dashboard/preview-environments/ty
 import { release } from "process";
 import { baseApi } from "./baseApi";
 
-import { FullActionConfigType, StorageType } from "./types";
+import { BuildConfig, FullActionConfigType, StorageType } from "./types";
 
 /**
  * Generic api call format
@@ -1693,6 +1693,20 @@ const upgradePorterAgent = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/agent/upgrade`
 );
 
+const updateBuildConfig = baseApi<
+  BuildConfig,
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    release_name: string;
+  }
+>(
+  "POST",
+  ({ project_id, cluster_id, namespace, release_name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/buildconfig`
+);
+
 const reRunGHWorkflow = baseApi<
   {},
   {
@@ -1701,12 +1715,37 @@ const reRunGHWorkflow = baseApi<
     git_installation_id: number;
     owner: string;
     name: string;
-    filename: string;
+    branch?: string;
+    filename?: string;
+    release_name?: string;
   }
 >(
   "POST",
-  ({ project_id, git_installation_id, owner, name, cluster_id, filename }) =>
-    `/api/projects/${project_id}/gitrepos/${git_installation_id}/${owner}/${name}/clusters/${cluster_id}/rerun_workflow?filename=${filename}`
+  ({
+    project_id,
+    git_installation_id,
+    owner,
+    name,
+    cluster_id,
+    filename,
+    release_name,
+    branch,
+  }) => {
+    const queryParams = new URLSearchParams();
+
+    if (branch) {
+      queryParams.set("branch", branch);
+    }
+
+    if (release_name) {
+      queryParams.set("release_name", release_name);
+    }
+    if (filename) {
+      queryParams.set("filename", filename);
+    }
+
+    return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${owner}/${name}/clusters/${cluster_id}/rerun_workflow?${queryParams.toString()}`;
+  }
 );
 
 const triggerPreviewEnvWorkflow = baseApi<
@@ -1879,6 +1918,7 @@ export default {
   getIncidentLogsByLogId,
   upgradePorterAgent,
   deletePRDeployment,
+  updateBuildConfig,
   reRunGHWorkflow,
   triggerPreviewEnvWorkflow,
 };

+ 12 - 0
dashboard/src/shared/types.tsx

@@ -21,6 +21,7 @@ export interface DetailedIngressError {
 export interface ChartType {
   image_repo_uri: string;
   git_action_config: any;
+  build_config: BuildConfig;
   name: string;
   info: {
     last_deployed: string;
@@ -72,6 +73,9 @@ export interface ChartTypeWithExtendedConfig extends ChartType {
         normal: {
           [key: string]: string;
         };
+        build: {
+          [key: string]: string;
+        };
         synced: any;
       };
       lifecycle: { postStart: string; preStop: string };
@@ -540,3 +544,11 @@ export type InfraCredentialOptions =
 export type InfraCredentials = {
   [key in InfraCredentialOptions]?: number;
 };
+
+export type BuildConfig = {
+  builder: string;
+  buildpacks: string[];
+  config: null | {
+    [key: string]: string;
+  };
+};

+ 1 - 1
internal/models/gitrepo.go

@@ -61,7 +61,7 @@ func (r *GitActionConfig) ToGitActionConfigType() *types.GitActionConfig {
 		GitRepo:        r.GitRepo,
 		GitBranch:      r.GitBranch,
 		ImageRepoURI:   r.ImageRepoURI,
-		GitRepoID:      r.GithubInstallationID,
+		GitRepoID:      r.GitRepoID,
 		DockerfilePath: r.DockerfilePath,
 		FolderPath:     r.FolderPath,
 	}