Browse Source

Merge branch 'master' into healthChecksStacks

Soham Dessai 3 năm trước cách đây
mục cha
commit
4db3c1ec82
44 tập tin đã thay đổi với 1656 bổ sung324 xóa
  1. 47 0
      api/server/handlers/project_integration/delete_gitlab.go
  2. 89 0
      api/server/handlers/project_integration/get_gitlab_porter_yaml.go
  3. 4 18
      api/server/handlers/project_integration/get_gitlab_repo_buildpack.go
  4. 3 17
      api/server/handlers/project_integration/get_gitlab_repo_contents.go
  5. 3 16
      api/server/handlers/project_integration/get_gitlab_repo_procfile.go
  6. 29 2
      api/server/handlers/project_integration/list_gitlab.go
  7. 14 5
      api/server/handlers/project_integration/list_gitlab_repo_branches.go
  8. 13 4
      api/server/handlers/project_integration/list_gitlab_repos.go
  9. 7 8
      api/server/handlers/release/create.go
  10. 1 10
      api/server/handlers/release/delete.go
  11. 69 15
      api/server/router/project_integration.go
  12. 28 0
      api/types/git_installation.go
  13. 6 1
      api/types/project_integration.go
  14. BIN
      dashboard/src/assets/history.png
  15. 22 18
      dashboard/src/components/repo-selector/BranchList.tsx
  16. 5 5
      dashboard/src/components/repo-selector/BuildpackSelection.tsx
  17. 5 5
      dashboard/src/components/repo-selector/BuildpackStack.tsx
  18. 15 13
      dashboard/src/components/repo-selector/ContentsList.tsx
  19. 57 23
      dashboard/src/components/repo-selector/DetectContentsList.tsx
  20. 35 27
      dashboard/src/components/repo-selector/RepoList.tsx
  21. 2 2
      dashboard/src/main/auth/Login.tsx
  22. 1 0
      dashboard/src/main/home/Home.tsx
  23. 14 0
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  24. 78 29
      dashboard/src/main/home/app-dashboard/expanded-app/JobRuns.tsx
  25. 35 2
      dashboard/src/main/home/app-dashboard/expanded-app/StatusFooter.tsx
  26. 222 0
      dashboard/src/main/home/app-dashboard/expanded-app/expanded-job/ExpandedJob.tsx
  27. 559 0
      dashboard/src/main/home/app-dashboard/expanded-app/expanded-job/ExpandedJobRun.tsx
  28. 3 0
      dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx
  29. 12 1
      dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx
  30. 24 11
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  31. 5 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx
  32. 84 5
      dashboard/src/main/home/integrations/GitlabIntegrationList.tsx
  33. 7 1
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  34. 1 1
      dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx
  35. 76 40
      dashboard/src/shared/api.tsx
  36. 1 1
      internal/integrations/buildpacks/go.go
  37. 1 1
      internal/integrations/buildpacks/nodejs.go
  38. 1 1
      internal/integrations/buildpacks/python.go
  39. 6 6
      internal/integrations/buildpacks/ruby.go
  40. 1 2
      internal/integrations/buildpacks/shared.go
  41. 47 29
      internal/integrations/ci/gitlab/ci.go
  42. 8 0
      internal/repository/gorm/auth.go
  43. 1 0
      internal/repository/integrations.go
  44. 15 0
      internal/repository/test/auth.go

+ 47 - 0
api/server/handlers/project_integration/delete_gitlab.go

@@ -0,0 +1,47 @@
+package project_integration
+
+import (
+	"fmt"
+	"net/http"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type DeleteGitlabIntegration struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewDeleteGitlabIntegrationHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteGitlabIntegration {
+	return &DeleteGitlabIntegration{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *DeleteGitlabIntegration) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
+
+	metadata := p.Config().Metadata
+
+	if !metadata.Gitlab {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("gitlab integration endpoints are not enabled")))
+		return
+	}
+
+	err := p.Repo().GitlabIntegration().DeleteGitlabIntegrationByID(gi.ProjectID, gi.ID)
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deleting gitlab integration: %w", err)))
+		return
+	}
+
+	return
+}

+ 89 - 0
api/server/handlers/project_integration/get_gitlab_porter_yaml.go

@@ -0,0 +1,89 @@
+package project_integration
+
+import (
+	b64 "encoding/base64"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/xanzy/go-gitlab"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type GitlabRepoPorterYamlContentsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetGitlabRepoPorterYamlContentsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GitlabRepoPorterYamlContentsHandler {
+	return &GitlabRepoPorterYamlContentsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *GitlabRepoPorterYamlContentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
+
+	request := &types.GetGitlabProcfileRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+	if !ok {
+		return
+	}
+
+	path, err := url.QueryUnescape(request.Path)
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("malformed query param path")))
+		return
+	}
+
+	client, err := getGitlabClient(p.Repo(), user.ID, project.ID, gi, p.Config())
+	if err != nil {
+		if errors.Is(err, errUnauthorizedGitlabUser) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(errUnauthorizedGitlabUser, http.StatusUnauthorized))
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	file, resp, err := client.RepositoryFiles.GetRawFile(request.RepoPath,
+		strings.TrimPrefix(path, "./"), &gitlab.GetRawFileOptions{
+			Ref: gitlab.String(request.Branch),
+		},
+	)
+
+	if resp.StatusCode == http.StatusUnauthorized {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
+		return
+	} else if resp.StatusCode == http.StatusNotFound {
+		w.WriteHeader(http.StatusNotFound)
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("no such procfile exists")))
+		return
+	}
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	data := b64.StdEncoding.EncodeToString(file)
+
+	p.WriteResult(w, r, data)
+}

+ 4 - 18
api/server/handlers/project_integration/get_gitlab_repo_buildpack.go

@@ -11,7 +11,6 @@ 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/integrations/buildpacks"
@@ -39,22 +38,9 @@ func (p *GetGitlabRepoBuildpackHandler) ServeHTTP(w http.ResponseWriter, r *http
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
 
-	request := &types.GetBuildpackRequest{}
+	request := &types.GetGitlabBuildpackRequest{}
 
 	ok := p.DecodeAndValidate(w, r, request)
-
-	if !ok {
-		return
-	}
-
-	owner, name, ok := commonutils.GetOwnerAndNameParams(p, w, r)
-
-	if !ok {
-		return
-	}
-
-	branch, ok := commonutils.GetBranchParam(p, w, r)
-
 	if !ok {
 		return
 	}
@@ -81,9 +67,9 @@ func (p *GetGitlabRepoBuildpackHandler) ServeHTTP(w http.ResponseWriter, r *http
 		dir = "."
 	}
 
-	tree, resp, err := client.Repositories.ListTree(fmt.Sprintf("%s/%s", owner, name), &gitlab.ListTreeOptions{
+	tree, resp, err := client.Repositories.ListTree(request.RepoPath, &gitlab.ListTreeOptions{
 		Path: gitlab.String(dir),
-		Ref:  gitlab.String(branch),
+		Ref:  gitlab.String(request.Branch),
 	})
 
 	if resp.StatusCode == http.StatusUnauthorized {
@@ -111,7 +97,7 @@ func (p *GetGitlabRepoBuildpackHandler) ServeHTTP(w http.ResponseWriter, r *http
 				}
 			}()
 			buildpacks.Runtimes[idx].DetectGitlab(
-				client, tree, owner, name, dir, branch,
+				client, tree, request.RepoPath, dir, request.Branch,
 				builderInfoMap[buildpacks.PaketoBuilder], builderInfoMap[buildpacks.HerokuBuilder],
 			)
 			wg.Done()

+ 3 - 17
api/server/handlers/project_integration/get_gitlab_repo_contents.go

@@ -10,7 +10,6 @@ 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"
@@ -37,22 +36,9 @@ func (p *GetGitlabRepoContentsHandler) ServeHTTP(w http.ResponseWriter, r *http.
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
 
-	request := &types.GetContentsRequest{}
+	request := &types.GetGitlabContentsRequest{}
 
 	ok := p.DecodeAndValidate(w, r, request)
-
-	if !ok {
-		return
-	}
-
-	owner, name, ok := commonutils.GetOwnerAndNameParams(p, w, r)
-
-	if !ok {
-		return
-	}
-
-	branch, ok := commonutils.GetBranchParam(p, w, r)
-
 	if !ok {
 		return
 	}
@@ -79,9 +65,9 @@ func (p *GetGitlabRepoContentsHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		return
 	}
 
-	tree, resp, err := client.Repositories.ListTree(fmt.Sprintf("%s/%s", owner, name), &gitlab.ListTreeOptions{
+	tree, resp, err := client.Repositories.ListTree(request.RepoPath, &gitlab.ListTreeOptions{
 		Path: gitlab.String(dir),
-		Ref:  gitlab.String(branch),
+		Ref:  gitlab.String(request.Branch),
 	})
 
 	if resp.StatusCode == http.StatusUnauthorized {

+ 3 - 16
api/server/handlers/project_integration/get_gitlab_repo_procfile.go

@@ -11,7 +11,6 @@ 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"
@@ -40,7 +39,7 @@ func (p *GetGitlabRepoProcfileHandler) ServeHTTP(w http.ResponseWriter, r *http.
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
 
-	request := &types.GetProcfileRequest{}
+	request := &types.GetGitlabProcfileRequest{}
 
 	ok := p.DecodeAndValidate(w, r, request)
 
@@ -48,18 +47,6 @@ func (p *GetGitlabRepoProcfileHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		return
 	}
 
-	owner, name, ok := commonutils.GetOwnerAndNameParams(p, w, r)
-
-	if !ok {
-		return
-	}
-
-	branch, ok := commonutils.GetBranchParam(p, w, r)
-
-	if !ok {
-		return
-	}
-
 	path, err := url.QueryUnescape(request.Path)
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("malformed query param path")))
@@ -76,9 +63,9 @@ func (p *GetGitlabRepoProcfileHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		return
 	}
 
-	file, resp, err := client.RepositoryFiles.GetRawFile(fmt.Sprintf("%s/%s", owner, name),
+	file, resp, err := client.RepositoryFiles.GetRawFile(request.RepoPath,
 		strings.TrimPrefix(path, "./"), &gitlab.GetRawFileOptions{
-			Ref: gitlab.String(branch),
+			Ref: gitlab.String(request.Branch),
 		},
 	)
 

+ 29 - 2
api/server/handlers/project_integration/list_gitlab.go

@@ -3,6 +3,8 @@ package project_integration
 import (
 	"net/http"
 
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+
 	"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"
@@ -26,6 +28,7 @@ func NewListGitlabHandler(
 
 func (p *ListGitlabHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
 
 	gitlabInts, err := p.Repo().GitlabIntegration().ListGitlabIntegrationsByProjectID(project.ID)
 	if err != nil {
@@ -33,11 +36,35 @@ func (p *ListGitlabHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	var res types.ListGitlabResponse = make([]*types.GitlabIntegration, 0)
+	var res types.ListGitlabResponse = make([]*types.GitlabIntegrationWithUsername, 0)
 
 	for _, gitlabInt := range gitlabInts {
-		res = append(res, gitlabInt.ToGitlabIntegrationType())
+		username := p.getCurrentUsername(user.ID, project.ID, gitlabInt)
+		res = append(res,
+			&types.GitlabIntegrationWithUsername{
+				*gitlabInt.ToGitlabIntegrationType(),
+				username,
+			},
+		)
 	}
 
 	p.WriteResult(w, r, res)
 }
+
+func (p *ListGitlabHandler) getCurrentUsername(userID uint, projectID uint, gi *ints.GitlabIntegration) string {
+	client, err := getGitlabClient(p.Repo(), userID, projectID, gi, p.Config())
+	if err != nil {
+		return "Unable to connect"
+	}
+
+	currentUser, resp, err := client.Users.CurrentUser()
+	if resp.StatusCode == http.StatusUnauthorized {
+		return "Unable to connect"
+	}
+
+	if err != nil {
+		return "Unable to connect"
+	}
+
+	return currentUser.Username
+}

+ 14 - 5
api/server/handlers/project_integration/list_gitlab_repo_branches.go

@@ -8,7 +8,6 @@ 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"
@@ -35,12 +34,14 @@ func (p *ListGitlabRepoBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	gi, _ := r.Context().Value(types.GitlabIntegrationScope).(*ints.GitlabIntegration)
 
-	owner, name, ok := commonutils.GetOwnerAndNameParams(p, w, r)
-
-	if !ok {
+	request := &types.ListGitlabRepoBranchesRequest{}
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(errors.New("cannot decode and validate request")))
 		return
 	}
 
+	repoPath := request.RepoPath
+
 	client, err := getGitlabClient(p.Repo(), user.ID, project.ID, gi, p.Config())
 	if err != nil {
 		if errors.Is(err, errUnauthorizedGitlabUser) {
@@ -51,7 +52,15 @@ func (p *ListGitlabRepoBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 	}
 
-	branches, resp, err := client.Branches.ListBranches(fmt.Sprintf("%s/%s", owner, name), &gitlab.ListBranchesOptions{})
+	branches, resp, err := client.Branches.ListBranches(repoPath,
+		&gitlab.ListBranchesOptions{
+			ListOptions: gitlab.ListOptions{
+				Page:    1,
+				PerPage: 20,
+			},
+			Search: &request.SearchTerm,
+		},
+	)
 
 	if resp.StatusCode == http.StatusUnauthorized {
 		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))

+ 13 - 4
api/server/handlers/project_integration/list_gitlab_repos.go

@@ -50,10 +50,21 @@ func (p *ListGitlabReposHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	giProjects, resp, err := client.Projects.ListProjects(&gitlab.ListProjectsOptions{
+	searchTerm := r.URL.Query().Get("searchTerm")
+
+	opts := &gitlab.ListProjectsOptions{
 		Simple:     gitlab.Bool(true),
 		Membership: gitlab.Bool(true),
-	})
+		ListOptions: gitlab.ListOptions{
+			PerPage: 20,
+			Page:    1,
+		},
+		Search:           gitlab.String(searchTerm),
+		SearchNamespaces: gitlab.Bool(true),
+	}
+
+	var res []string
+	giProjects, resp, err := client.Projects.ListProjects(opts)
 
 	if resp.StatusCode == http.StatusUnauthorized {
 		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unauthorized gitlab user"), http.StatusUnauthorized))
@@ -65,8 +76,6 @@ func (p *ListGitlabReposHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	var res []string
-
 	for _, giProject := range giProjects {
 		res = append(res, giProject.PathWithNamespace)
 	}

+ 7 - 8
api/server/handlers/release/create.go

@@ -373,12 +373,6 @@ func createGitAction(
 
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "is-dry-run", Value: isDryRun})
 
-	repoSplit := strings.Split(request.GitRepo, "/")
-
-	if len(repoSplit) != 2 {
-		return nil, nil, fmt.Errorf("invalid formatting of repo name")
-	}
-
 	encoded := ""
 	var err error
 
@@ -397,8 +391,7 @@ func createGitAction(
 	if request.GitlabIntegrationID != 0 {
 		giRunner := &gitlab.GitlabCI{
 			ServerURL:        config.ServerConf.ServerURL,
-			GitRepoOwner:     repoSplit[0],
-			GitRepoName:      repoSplit[1],
+			GitRepoPath:      request.GitRepo,
 			GitBranch:        request.GitBranch,
 			Repo:             config.Repo,
 			ProjectID:        projectID,
@@ -414,6 +407,12 @@ func createGitAction(
 
 		gitErr = giRunner.Setup()
 	} else {
+		repoSplit := strings.Split(request.GitRepo, "/")
+
+		if len(repoSplit) != 2 {
+			return nil, nil, fmt.Errorf("invalid formatting of repo name")
+		}
+
 		// create the commit in the git repo
 		gaRunner := &actions.GithubActions{
 			InstanceName:           config.ServerConf.InstanceName,

+ 1 - 10
api/server/handlers/release/delete.go

@@ -2,9 +2,7 @@ package release
 
 import (
 	"context"
-	"fmt"
 	"net/http"
-	"strings"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -60,17 +58,10 @@ func (c *DeleteReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 			if gitAction != nil && gitAction.ID != 0 {
 				if gitAction.GitlabIntegrationID != 0 {
-					repoSplit := strings.Split(gitAction.GitRepo, "/")
-
-					if len(repoSplit) != 2 {
-						c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("invalid formatting of repo name")))
-						return
-					}
 
 					giRunner := &gitlab.GitlabCI{
 						ServerURL:        c.Config().ServerConf.ServerURL,
-						GitRepoOwner:     repoSplit[0],
-						GitRepoName:      repoSplit[1],
+						GitRepoPath:      gitAction.GitRepo,
 						Repo:             c.Repo(),
 						ProjectID:        cluster.ProjectID,
 						ClusterID:        cluster.ID,

+ 69 - 15
api/server/router/project_integration.go

@@ -416,6 +416,33 @@ func getProjectIntegrationRoutes(
 	// PATCH /api/projects/{project_id}/integrations/gitlab/{integration_id}
 
 	// DELETE /api/projects/{project_id}/integrations/gitlab/{integration_id}
+	deleteGitlabEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/gitlab/{integration_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitlabIntegrationScope,
+			},
+		},
+	)
+
+	deleteGitlabHandler := project_integration.NewDeleteGitlabIntegrationHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteGitlabEndpoint,
+		Handler:  deleteGitlabHandler,
+		Router:   r,
+	})
 
 	// GET /api/projects/{project_id}/integrations/git
 	listGitIntegrationsEndpoint := factory.NewAPIEndpoint(
@@ -474,15 +501,15 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/{owner}/{name}/branches
+	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/branches
 	listGitlabRepoBranchesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent: basePath,
-				RelativePath: fmt.Sprintf("%s/gitlab/{%s}/repos/{%s}/{%s}/branches",
-					relPath, types.URLParamIntegrationID, types.URLParamGitRepoOwner, types.URLParamGitRepoName),
+				RelativePath: fmt.Sprintf("%s/gitlab/{%s}/repos/branches",
+					relPath, types.URLParamIntegrationID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -504,16 +531,15 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/{owner}/{name}/{branch}/contents
+	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/contents
 	getGitlabRepoContentsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent: basePath,
-				RelativePath: fmt.Sprintf("%s/gitlab/{%s}/repos/{%s}/{%s}/{%s}/contents", relPath,
-					types.URLParamIntegrationID, types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName, types.URLParamGitBranch),
+				RelativePath: fmt.Sprintf("%s/gitlab/{%s}/repos/contents", relPath,
+					types.URLParamIntegrationID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -535,16 +561,15 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/{owner}/{name}/{branch}/buildpack/detect
+	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/buildpack/detect
 	getGitlabRepoBuildpackEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent: basePath,
-				RelativePath: fmt.Sprintf("%s/gitlab/{%s}/repos/{%s}/{%s}/{%s}/buildpack/detect", relPath,
-					types.URLParamIntegrationID, types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName, types.URLParamGitBranch),
+				RelativePath: fmt.Sprintf("%s/gitlab/{%s}/repos/buildpack/detect", relPath,
+					types.URLParamIntegrationID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -566,16 +591,15 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/{owner}/{name}/{branch}/procfile
+	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/procfile
 	getGitlabRepoProcfileEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent: basePath,
-				RelativePath: fmt.Sprintf("%s/gitlab/{%s}/repos/{%s}/{%s}/{%s}/procfile", relPath,
-					types.URLParamIntegrationID, types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName, types.URLParamGitBranch),
+				RelativePath: fmt.Sprintf("%s/gitlab/{%s}/repos/procfile", relPath,
+					types.URLParamIntegrationID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -597,5 +621,35 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/integrations/gitlab/{integration_id}/repos/porteryaml
+	getGitlabRepoPorterYamlContentsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf("%s/gitlab/{%s}/repos/porteryaml", relPath,
+					types.URLParamIntegrationID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitlabIntegrationScope,
+			},
+		},
+	)
+
+	getGitlabRepoPorterYamlContentsHandler := project_integration.NewGetGitlabRepoPorterYamlContentsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getGitlabRepoPorterYamlContentsEndpoint,
+		Handler:  getGitlabRepoPorterYamlContentsHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 28 - 0
api/types/git_installation.go

@@ -32,6 +32,34 @@ const (
 
 type ListRepoBranchesResponse []string
 
+type ListGitlabRepoBranchesRequest struct {
+	RepoPath   string `schema:"repo_path" form:"required"`
+	SearchTerm string `schema:"search_term"`
+}
+
+type GitlabRepoBranchRequest struct {
+	RepoPath string `schema:"repo_path" form:"required"`
+	Branch   string `schema:"branch" form:"required"`
+}
+
+type GetGitlabContentsRequest struct {
+	GitlabRepoBranchRequest
+	GetContentsRequest
+}
+type GetGitlabBuildpackRequest struct {
+	GitlabRepoBranchRequest
+	GetBuildpackRequest
+}
+type GetGitlabProcfileRequest struct {
+	GitlabRepoBranchRequest
+	GetProcfileRequest
+}
+
+type GetGitlabPorterYamlContentsRequest struct {
+	GitlabRepoBranchRequest
+	GetPorterYamlRequest
+}
+
 type GithubDirectoryRequest struct {
 	Dir string `schema:"dir" form:"required"`
 }

+ 6 - 1
api/types/project_integration.go

@@ -185,7 +185,12 @@ type GitlabIntegration struct {
 	InstanceURL string `json:"instance_url"`
 }
 
-type ListGitlabResponse []*GitlabIntegration
+type GitlabIntegrationWithUsername struct {
+	GitlabIntegration
+	Username string `json:"username"`
+}
+
+type ListGitlabResponse []*GitlabIntegrationWithUsername
 
 type CreateGitlabRequest struct {
 	InstanceURL     string `json:"instance_url"`

BIN
dashboard/src/assets/history.png


+ 22 - 18
dashboard/src/components/repo-selector/BranchList.tsx

@@ -37,7 +37,7 @@ const BranchList: React.FC<Props> = ({
   useEffect(() => {
     // Get branches
     if (!actionConfig) {
-      return () => { };
+      return () => {};
     }
 
     if (actionConfig?.kind === "github") {
@@ -67,12 +67,13 @@ const BranchList: React.FC<Props> = ({
       api
         .getGitlabBranches(
           "<token>",
-          {},
+          {
+            repo_path: actionConfig.git_repo,
+            search_term: searchFilter,
+          },
           {
             project_id: currentProject.id,
             integration_id: actionConfig.gitlab_integration_id,
-            repo_owner: actionConfig.git_repo.split("/")[0],
-            repo_name: actionConfig.git_repo.split("/")[1],
           }
         )
         .then((res) => {
@@ -86,7 +87,7 @@ const BranchList: React.FC<Props> = ({
           setError(true);
         });
     }
-  }, []);
+  }, [searchFilter]);
 
   const renderBranchList = () => {
     if (loading) {
@@ -99,19 +100,22 @@ const BranchList: React.FC<Props> = ({
       return <LoadingWrapper>Error loading branches</LoadingWrapper>;
     }
 
-    let results = searchFilter != null
-      ? branches
-        .filter((branch) => {
-          return branch.toLowerCase().includes(
-            searchFilter.toLowerCase()
-          );
-        })
-        .sort((a: string, b: string) => {
-          const aIndex = a.toLowerCase().indexOf(searchFilter.toLowerCase());
-          const bIndex = b.toLowerCase().indexOf(searchFilter.toLowerCase());
-          return aIndex - bIndex;
-        })
-      : sortBranches(branches).slice(0, 10);
+    let results =
+      searchFilter != null
+        ? branches
+            .filter((branch) => {
+              return branch.toLowerCase().includes(searchFilter.toLowerCase());
+            })
+            .sort((a: string, b: string) => {
+              const aIndex = a
+                .toLowerCase()
+                .indexOf(searchFilter.toLowerCase());
+              const bIndex = b
+                .toLowerCase()
+                .indexOf(searchFilter.toLowerCase());
+              return aIndex - bIndex;
+            })
+        : sortBranches(branches).slice(0, 10);
 
     if (results.length == 0) {
       return <LoadingWrapper>No matching Branches found.</LoadingWrapper>;

+ 5 - 5
dashboard/src/components/repo-selector/BuildpackSelection.tsx

@@ -73,14 +73,14 @@ export const BuildpackSelection: React.FC<{
     if (actionConfig.kind === "gitlab") {
       return api.detectGitlabBuildpack<DetectBuildpackResponse>(
         "<token>",
-        { dir: folderPath || "." },
+        {
+          repo_path: actionConfig.git_repo,
+          branch: branch,
+          dir: folderPath || ".",
+        },
         {
           project_id: currentProject.id,
           integration_id: actionConfig.gitlab_integration_id,
-
-          repo_owner: actionConfig.git_repo.split("/")[0],
-          repo_name: actionConfig.git_repo.split("/")[1],
-          branch: branch,
         }
       );
     }

+ 5 - 5
dashboard/src/components/repo-selector/BuildpackStack.tsx

@@ -133,14 +133,14 @@ export const BuildpackStack: React.FC<{
     if (actionConfig.kind === "gitlab") {
       return api.detectGitlabBuildpack<DetectBuildpackResponse>(
         "<token>",
-        { dir: folderPath || "." },
+        {
+          repo_path: actionConfig.git_repo,
+          branch: branch,
+          dir: folderPath || ".",
+        },
         {
           project_id: currentProject.id,
           integration_id: actionConfig.gitlab_integration_id,
-
-          repo_owner: actionConfig.git_repo.split("/")[0],
-          repo_name: actionConfig.git_repo.split("/")[1],
-          branch: branch,
         }
       );
     }

+ 15 - 13
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -71,13 +71,14 @@ export default class ContentsList extends Component<PropsType, StateType> {
       return api
         .getGitlabFolderContent(
           "<token>",
-          { dir: this.state.currentDir || "./" },
+          {
+            repo_path: actionConfig.git_repo,
+            branch: branch,
+            dir: this.state.currentDir || "./",
+          },
           {
             project_id: currentProject.id,
             integration_id: actionConfig.gitlab_integration_id,
-            repo_owner: actionConfig.git_repo.split("/")[0],
-            repo_name: actionConfig.git_repo.split("/")[1],
-            branch: branch,
           }
         )
         .then((res) => {
@@ -128,14 +129,14 @@ export default class ContentsList extends Component<PropsType, StateType> {
 
     return api.detectGitlabBuildpack(
       "<token>",
-      { dir: this.state.currentDir || "." },
+      {
+        repo_path: actionConfig.git_repo,
+        branch: branch,
+        dir: this.state.currentDir || ".",
+      },
       {
         project_id: currentProject.id,
         integration_id: actionConfig.gitlab_integration_id,
-
-        repo_owner: actionConfig.git_repo.split("/")[0],
-        repo_name: actionConfig.git_repo.split("/")[1],
-        branch: branch,
       }
     );
   };
@@ -162,13 +163,14 @@ export default class ContentsList extends Component<PropsType, StateType> {
 
     return api.getGitlabProcfileContents(
       "<token>",
-      { path: procfilePath },
+      {
+        repo_path: actionConfig.git_repo,
+        branch: branch,
+        path: procfilePath,
+      },
       {
         project_id: currentProject.id,
         integration_id: actionConfig.gitlab_integration_id,
-        owner: actionConfig.git_repo.split("/")[0],
-        name: actionConfig.git_repo.split("/")[1],
-        branch: branch,
       }
     );
   };

+ 57 - 23
dashboard/src/components/repo-selector/DetectContentsList.tsx

@@ -132,13 +132,14 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
       return api
         .getGitlabFolderContent(
           "<token>",
-          { dir: currentDir || "./" },
+          {
+            repo_path: actionConfig.git_repo,
+            branch: branch,
+            dir: currentDir || "./",
+          },
           {
             project_id: currentProject.id,
             integration_id: actionConfig.gitlab_integration_id,
-            repo_owner: actionConfig.git_repo.split("/")[0],
-            repo_name: actionConfig.git_repo.split("/")[1],
-            branch: branch,
           }
         )
         .then((res) => {
@@ -169,25 +170,44 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
   const fetchPorterYamlContent = async (porterYaml: string) => {
     let { currentProject } = context;
     let { actionConfig, branch } = props;
-
-    try {
-      const res = await api.getPorterYamlContents(
-        "<token>",
-        {
-          path: porterYaml,
-        },
-        {
-          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: branch,
-        }
-      );
-      return res;
-    } catch (err) {
-      console.log(err);
+    if (actionConfig.kind === "github") {
+      try {
+        const res = await api.getPorterYamlContents(
+          "<token>",
+          {
+            path: porterYaml,
+          },
+          {
+            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: branch,
+          }
+        );
+        return res;
+      } catch (err) {
+        console.log(err);
+      }
+    } else if (actionConfig.kind === "gitlab") {
+      try {
+        const res = await api.getGitlabPorterYamlContents(
+          "<token>",
+          {
+            repo_path: actionConfig.git_repo,
+            branch: branch,
+            path: porterYaml,
+          },
+          {
+            project_id: currentProject.id,
+            integration_id: actionConfig.gitlab_integration_id,
+          }
+        );
+        return res;
+      } catch (err) {
+        console.log(err);
+      }
     }
   };
   const detectBuildpacks = () => {
@@ -209,8 +229,22 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
           branch: branch,
         }
       );
+    } else if (actionConfig.kind === "gitlab") {
+      return api.detectGitlabBuildpack(
+        "<token>",
+        {
+          repo_path: actionConfig.git_repo,
+          branch: branch,
+          dir: currentDir || ".",
+        },
+        {
+          project_id: currentProject.id,
+          integration_id: actionConfig.gitlab_integration_id,
+        }
+      );
     }
   };
+
   const handleInputChange = (newValue: string) => {
     props.setPorterYamlPath(newValue);
     setChangedPorterYaml(newValue === "");

+ 35 - 27
dashboard/src/components/repo-selector/RepoList.tsx

@@ -11,6 +11,7 @@ import SearchBar from "../SearchBar";
 import DynamicLink from "components/DynamicLink";
 import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
 import Text from "components/porter/Text";
+import { search } from "../../shared/search";
 
 type Props = {
   actionConfig: ActionConfigType | null;
@@ -22,15 +23,15 @@ type Props = {
 
 type Provider =
   | {
-    provider: "github";
-    name: string;
-    installation_id: number;
-  }
+      provider: "github";
+      name: string;
+      installation_id: number;
+    }
   | {
-    provider: "gitlab";
-    instance_url: string;
-    integration_id: number;
-  };
+      provider: "gitlab";
+      instance_url: string;
+      integration_id: number;
+    };
 
 // Sort provider by name if it's github or instance url if it's gitlab
 const sortProviders = (providers: Provider[]) => {
@@ -69,7 +70,7 @@ const RepoList: React.FC<Props> = ({
   const [repoLoading, setRepoLoading] = useState(true);
   const [selectedRepo, setSelectedRepo] = useState(null);
   const [repoError, setRepoError] = useState(false);
-  const [searchFilter, setSearchFilter] = useState(null);
+  const [searchFilter, setSearchFilter] = useState<string>("");
   const [hasProviders, setHasProviders] = useState(true);
   const { currentProject, setCurrentError } = useContext(Context);
 
@@ -110,14 +111,16 @@ const RepoList: React.FC<Props> = ({
 
       const repos = res.data.map((repo) => ({ ...repo, GHRepoID: repoId }));
       return repos;
-    } catch (error) { }
+    } catch (error) {}
   };
 
   const loadGitlabRepos = async (integrationId: number) => {
     try {
       const res = await api.getGitlabRepos<string[]>(
         "<token>",
-        {},
+        {
+          searchTerm: searchFilter,
+        },
         { project_id: currentProject.id, integration_id: integrationId }
       );
       const repos: RepoType[] = res.data.map((repo) => ({
@@ -126,7 +129,7 @@ const RepoList: React.FC<Props> = ({
         GitIntegrationId: integrationId,
       }));
       return repos;
-    } catch (error) { }
+    } catch (error) {}
   };
 
   const loadRepos = (provider: any) => {
@@ -160,7 +163,7 @@ const RepoList: React.FC<Props> = ({
       .finally(() => {
         setRepoLoading(false);
       });
-  }, [currentProvider]);
+  }, [currentProvider, searchFilter]);
 
   // clear out actionConfig and SelectedRepository if new search is performed
   useEffect(() => {
@@ -237,19 +240,24 @@ const RepoList: React.FC<Props> = ({
     }
 
     // show 10 most recently used repos if user hasn't searched anything yet
-    let results = searchFilter != null
-      ? repos
-        .filter((repo: RepoType) => {
-          return repo.FullName.toLowerCase().includes(
-            searchFilter.toLowerCase()
-          );
-        })
-        .sort((a: RepoType, b: RepoType) => {
-          const aIndex = a.FullName.toLowerCase().indexOf(searchFilter.toLowerCase());
-          const bIndex = b.FullName.toLowerCase().indexOf(searchFilter.toLowerCase());
-          return aIndex - bIndex;
-        })
-      : repos.slice(0, 10);
+    let results =
+      searchFilter != null
+        ? repos
+            .filter((repo: RepoType) => {
+              return repo.FullName.toLowerCase().includes(
+                searchFilter.toLowerCase()
+              );
+            })
+            .sort((a: RepoType, b: RepoType) => {
+              const aIndex = a.FullName.toLowerCase().indexOf(
+                searchFilter.toLowerCase()
+              );
+              const bIndex = b.FullName.toLowerCase().indexOf(
+                searchFilter.toLowerCase()
+              );
+              return aIndex - bIndex;
+            })
+        : repos.slice(0, 10);
 
     if (results.length == 0) {
       return <LoadingWrapper>No matching Github repos found.</LoadingWrapper>;
@@ -358,7 +366,7 @@ const ConnectToGithubButton = styled.a`
     props.disabled ? "#aaaabbee" : "#2E3338"};
   :hover {
     background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "" : "#353a3e"};
+      props.disabled ? "" : "#353a3e"};
   }
 
   > i {

+ 2 - 2
dashboard/src/main/auth/Login.tsx

@@ -130,11 +130,11 @@ const Login: React.FC<Props> = ({
             <Shiny>Welcome back to Porter</Shiny>
           </Jumbotron>
           <Spacer y={2} />
-          <LinkRow to="https://docs.porter.run" target="_blank">
+          <LinkRow to="https://porter.run/docs" target="_blank">
             <img src={docs} /> Read the Porter docs
           </LinkRow>
           <Spacer y={0.5} />
-          <LinkRow to="https://blog.porter.run" target="_blank">
+          <LinkRow to="https://porter.run/blog" target="_blank">
             <img src={blog} /> See what's new with Porter
           </LinkRow>
           <Spacer y={0.5} />

+ 1 - 0
dashboard/src/main/home/Home.tsx

@@ -39,6 +39,7 @@ import Spacer from "components/porter/Spacer";
 import Button from "components/porter/Button";
 import NewAppFlow from "./app-dashboard/new-app-flow/NewAppFlow";
 import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp";
+import ExpandedJob from "./app-dashboard/expanded-app/expanded-job/ExpandedJob";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [

+ 14 - 0
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -46,6 +46,7 @@ import ActivityFeed from "./ActivityFeed";
 import JobRuns from "./JobRuns";
 import MetricsSection from "./MetricsSection";
 import StatusSectionFC from "./status/StatusSection";
+import ExpandedJob from "./expanded-job/ExpandedJob";
 
 type Props = RouteComponentProps & {};
 
@@ -85,6 +86,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [porterJson, setPorterJson] = useState<
     z.infer<typeof PorterYamlSchema> | undefined
   >(undefined);
+  const [expandedJob, setExpandedJob] = useState(null);
 
   const [services, setServices] = useState<Service[]>([]);
   const [releaseJob, setReleaseJob] = useState<ReleaseService[]>([]);
@@ -559,6 +561,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
               chart={appData.chart}
               services={services}
               addNewText={"Add a new service"}
+              setExpandedJob={(x: string) => setExpandedJob(x)}
             />
             <Spacer y={1} />
             <Button
@@ -667,6 +670,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
               lastRunStatus="all"
               namespace={appData.chart?.namespace}
               sortType="Newest"
+              releaseName={appData.app.name + "-r"}
             />
             }
           </>
@@ -676,6 +680,16 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     }
   };
 
+  if (expandedJob) {
+    return (
+      <ExpandedJob 
+        appName={appData.app.name}
+        jobName={expandedJob}
+        goBack={() => setExpandedJob(null)}
+      />
+    )
+  }
+
   return (
     <>
       {isLoading && !appData && <Loading />}

+ 78 - 29
dashboard/src/main/home/app-dashboard/expanded-app/JobRuns.tsx

@@ -15,6 +15,9 @@ type Props = {
   lastRunStatus: "failed" | "succeeded" | "active" | "all";
   namespace: string;
   sortType: "Newest" | "Oldest" | "Alphabetical";
+  releaseName?: string;
+  jobName?: string;
+  setExpandedRun?: any;
 };
 
 const runnedFor = (start: string | number, end?: string | number) => {
@@ -32,6 +35,9 @@ const JobRuns: React.FC<Props> = ({
   lastRunStatus,
   namespace,
   sortType,
+  releaseName,
+  jobName,
+  setExpandedRun,
 }) => {
   const { currentCluster, currentProject } = useContext(Context);
   const [jobRuns, setJobRuns] = useState<JobRun[]>(null);
@@ -102,35 +108,35 @@ const JobRuns: React.FC<Props> = ({
     () => [
       {
         Header: "Started",
-        accessor: (originalRow) => relativeDate(originalRow.status.startTime),
+        accessor: (originalRow) => relativeDate(originalRow?.status.startTime),
       },
       {
         Header: "Run for",
         accessor: (originalRow) => {
-          if (originalRow.status?.completionTime) {
-            return originalRow.status?.completionTime;
+          if (originalRow?.status?.completionTime) {
+            return originalRow?.status?.completionTime;
           } else if (
-            Array.isArray(originalRow.status?.conditions) &&
-            originalRow.status?.conditions[0]?.lastTransitionTime
+            Array.isArray(originalRow?.status?.conditions) &&
+            originalRow?.status?.conditions[0]?.lastTransitionTime
           ) {
-            return originalRow.status?.conditions[0]?.lastTransitionTime;
+            return originalRow?.status?.conditions[0]?.lastTransitionTime;
           } else {
             return "Still running...";
           }
         },
         Cell: ({ row }) => {
-          if (row.original.status?.completionTime) {
+          if (row.original?.status?.completionTime) {
             return runnedFor(
-              row.original.status?.startTime,
-              row.original.status?.completionTime
+              row.original?.status?.startTime,
+              row.original?.status?.completionTime
             );
           } else if (
-            Array.isArray(row.original.status?.conditions) &&
-            row.original.status?.conditions[0]?.lastTransitionTime
+            Array.isArray(row.original?.status?.conditions) &&
+            row.original?.status?.conditions[0]?.lastTransitionTime
           ) {
             return runnedFor(
-              row.original.status?.startTime,
-              row.original.status?.conditions[0]?.lastTransitionTime
+              row.original?.status?.startTime,
+              row.original?.status?.conditions[0]?.lastTransitionTime
             );
           } else {
             return "Still running...";
@@ -144,11 +150,11 @@ const JobRuns: React.FC<Props> = ({
         Header: "Status",
         id: "status",
         Cell: ({ row }: CellProps<JobRun>) => {
-          if (row.original.status?.succeeded >= 1) {
+          if (row.original?.status?.succeeded >= 1) {
             return <Status color="#38a88a">Succeeded</Status>;
           }
 
-          if (row.original.status?.failed >= 1) {
+          if (row.original?.status?.failed >= 1) {
             return <Status color="#cc3d42">Failed</Status>;
           }
 
@@ -181,17 +187,28 @@ const JobRuns: React.FC<Props> = ({
           urlParams.append("project_id", String(currentProject.id));
           urlParams.append("chart_revision", String(0));
           urlParams.append("job", row.original.metadata.name);
-
-          return (
-            <RedirectButton
-              to={{
-                pathname: `/jobs/${currentCluster.name}/${row.original?.metadata?.namespace}/${row.original?.metadata?.labels["meta.helm.sh/release-name"]}`,
-                search: `app=${row.original?.metadata?.namespace.split("porter-stack-")[1]}&` + urlParams.toString(),
-              }}
-            >
-              <i className="material-icons">open_in_new</i>
-            </RedirectButton>
-          );
+          if (!setExpandedRun) {
+            return (
+              <RedirectButton
+                to={{
+                  pathname: `/jobs/${currentCluster.name}/${row.original?.metadata?.namespace}/${row.original?.metadata?.labels["meta.helm.sh/release-name"]}`,
+                  search: `app=${row.original?.metadata?.namespace.split("porter-stack-")[1]}&` + urlParams.toString(),
+                }}
+              >
+                <i className="material-icons">open_in_new</i>
+              </RedirectButton>
+            );
+          } else {
+            return (
+              <ExpandButton
+                onClick={() => {
+                  setExpandedRun(row.original);
+                }}
+              >
+                <i className="material-icons">open_in_new</i>
+              </ExpandButton>
+            )
+          }
         },
         maxWidth: 40,
       },
@@ -216,7 +233,7 @@ const JobRuns: React.FC<Props> = ({
         tmp = filter.filterBySucceded();
         break;
       default:
-        tmp = filter.dontFilter();
+        tmp = filter.dontFilter(releaseName, jobName, namespace);
         break;
     }
 
@@ -252,7 +269,7 @@ const JobRuns: React.FC<Props> = ({
   }
 
   if (!jobRuns?.length) {
-    return <Placeholder>No pre-deploy job runs were found.</Placeholder>;
+    return <Placeholder>No job runs were found.</Placeholder>;
   }
 
   return (
@@ -329,6 +346,24 @@ const RedirectButton = styled(DynamicLink)`
   }
 `;
 
+const ExpandButton = styled.div`
+  user-select: none;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    margin: 0 5px;
+    color: #ffffff44;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
 type JobRun = {
   metadata: {
     name: string;
@@ -442,6 +477,7 @@ class JobRunsFilter {
     this.jobRuns = newJobRuns;
   }
 
+  // TODO: to support this filter, add appName filter (see dontFilter())
   filterByFailed() {
     return this.jobRuns.filter((jobRun) => jobRun?.status?.failed);
   }
@@ -459,7 +495,20 @@ class JobRunsFilter {
     );
   }
 
-  dontFilter() {
+  dontFilter(releaseName?: string, jobName?: string, namespace?: string) {
+    if (releaseName) {
+      const filteredJobs = this.jobRuns.filter(x => {
+        return releaseName === x?.metadata?.labels["meta.helm.sh/release-name"];
+      });
+      return filteredJobs;
+    } else if (jobName) {
+      const filteredJobs = this.jobRuns.filter(x => {
+        let name = x?.metadata?.name;
+        let appName = namespace.split("porter-stack-")[1];
+        return name.startsWith(`${appName}-${jobName}`) && name.split(`${appName}-${jobName}-`).length > 1 && name.split(`${appName}-${jobName}-`)[1].split("-").length === 2;
+      });
+      return filteredJobs;
+    } 
     return this.jobRuns;
   }
 }

+ 35 - 2
dashboard/src/main/home/app-dashboard/expanded-app/StatusFooter.tsx

@@ -16,14 +16,17 @@ import {
   getAvailabilityStacks,
 } from "../../cluster-dashboard/expanded-chart/deploy-status-section/util";
 import Spacer from "components/porter/Spacer";
+import { pushFiltered } from "shared/routing";
+import { RouteComponentProps, useLocation, withRouter } from "react-router";
 import { timeFormat } from "d3-time-format";
 import AnimateHeight, { Height } from "react-animate-height";
 import { ControllerTabPodType } from "./status/ControllerTab";
 import _ from "lodash";
 
-type Props = {
+type Props = RouteComponentProps & {
   chart: any;
   service: any;
+  setExpandedJob: any;
 };
 
 interface ErrorMessage {
@@ -34,12 +37,15 @@ interface ErrorMessage {
 const StatusFooter: React.FC<Props> = ({
   chart,
   service,
+  setExpandedJob,
+  ...props
 }) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [controller, setController] = React.useState<any>(null);
   const [available, setAvailable] = React.useState<number>(0);
   const [total, setTotal] = React.useState<number>(0);
   const [stale, setStale] = React.useState<number>(0);
+  const location = useLocation();
   const [unavailable, setUnavailable] = React.useState<number>(0);
   const [height, setHeight] = useState<Height>(0);
   const [expanded, setExpanded] = useState<boolean>(false);
@@ -264,6 +270,33 @@ const StatusFooter: React.FC<Props> = ({
     }
   };
 
+  if (service.type === "job") {
+    return (
+      <StyledStatusFooter>
+        {service.type === "job" && (
+          <Container row>
+            {/*
+            <Mi className="material-icons">check</Mi>
+            <Text color="helper">
+              Last run succeeded at 12:39 PM on 4/13/23
+            </Text>
+            */}
+            <Button
+              onClick={() => setExpandedJob(service.name)}
+              height="30px"
+              width="87px"
+              color="#ffffff11"
+              withBorder
+            >
+              <I className="material-icons">open_in_new</I>
+              History
+            </Button>
+          </Container>
+        )}
+    </StyledStatusFooter>
+    );
+  };
+
   return (
     <>
       {replicaSetArray != null && replicaSetArray.length > 0 && replicaSetArray.map((replicaSet, i) => {
@@ -329,7 +362,7 @@ const StatusFooter: React.FC<Props> = ({
 };
 
 
-export default StatusFooter;
+export default withRouter(StatusFooter);
 
 const StatusDot = styled.div<{ color?: string }>`
   min-width: 7px;

+ 222 - 0
dashboard/src/main/home/app-dashboard/expanded-app/expanded-job/ExpandedJob.tsx

@@ -0,0 +1,222 @@
+import React, { useEffect, useState, useContext, useCallback } from "react";
+import { RouteComponentProps, useLocation, withRouter } from "react-router";
+import styled from "styled-components";
+
+import history from "assets/history.png";
+import loadingImg from "assets/loading.gif";
+import refresh from "assets/refresh.png";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import Error from "components/porter/Error";
+
+import Banner from "components/porter/Banner";
+import Loading from "components/Loading";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Link from "components/porter/Link";
+import Back from "components/porter/Back";
+import TabSelector from "components/TabSelector";
+import RevisionSection from "main/home/cluster-dashboard/expanded-chart/RevisionSection";
+import ConfirmOverlay from "components/porter/ConfirmOverlay";
+import Fieldset from "components/porter/Fieldset";
+import JobRuns from "../JobRuns";
+import ExpandedJobRun from "./ExpandedJobRun";
+
+type Props = RouteComponentProps & {
+  appName: string;
+  jobName: string;
+  goBack: () => void;
+};
+
+const ExpandedJob: React.FC<Props> = ({ 
+  appName,
+  jobName,
+  goBack,
+  ...props 
+}) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState(null);
+  const [expandedRun, setExpandedRun] = useState(null);
+
+  return (
+    <>
+      {isLoading && <Loading />}
+      {!isLoading && expandedRun && (
+        <ExpandedJobRun
+          currentChart={null}
+          jobRun={expandedRun}
+          onClose={() => setExpandedRun(null)}
+        />
+      )}
+      {!isLoading && !expandedRun && (
+        <StyledExpandedApp>
+          <Back onClick={goBack} />
+          <Container row>
+            <Icon src={history} />
+            <Text size={21}>Run history for "{jobName}"</Text>
+          </Container>
+          <Spacer y={0.5} />
+          <Text color="#aaaabb66">
+            This job runs under the "{appName}" app.
+          </Text>
+          <Spacer y={1} />
+          {currentCluster?.id && currentProject?.id && (
+            <JobRuns
+              lastRunStatus="all"
+              namespace={`porter-stack-${appName}`}
+              sortType="Newest"
+              jobName={jobName}
+              setExpandedRun={(x: any) => setExpandedRun(x)}
+            />
+          )}
+        </StyledExpandedApp>
+      )}
+    </>
+  );
+};
+
+export default withRouter(ExpandedJob);
+
+const RefreshButton = styled.div`
+  color: #ffffff44;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  :hover {
+    color: #ffffff;
+    > img {
+      opacity: 1;
+    }
+  }
+
+  > img {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 11px;
+    margin-right: 10px;
+    opacity: 0.3;
+  }
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 12px;
+  margin-bottom: -2px;
+`;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-20px"};
+`;
+
+const TagWrapper = styled.div`
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 6px;
+`;
+
+const BranchTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #ffffff22;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const BranchSection = styled.div`
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+`;
+
+const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
+  height: ${(props) => props.height || "15px"};
+  opacity: ${(props) => props.opacity || 1};
+  margin-right: 10px;
+`;
+
+const BranchIcon = styled.img`
+  height: 14px;
+  opacity: 0.65;
+  margin-right: 5px;
+`;
+
+const Icon = styled.img`
+  height: 24px;
+  margin-right: 15px;
+`;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;
+
+const StyledExpandedApp = styled.div`
+  width: 100%;
+  height: 100%;
+
+  animation: fadeIn 0.5s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 8px;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+const Dot = styled.div`
+  margin-right: 16px;
+`;
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: 3px;
+  margin-top: 22px;
+`;

+ 559 - 0
dashboard/src/main/home/app-dashboard/expanded-app/expanded-job/ExpandedJobRun.tsx

@@ -0,0 +1,559 @@
+import React, { useContext, useEffect, useState } from "react";
+import { get, isEmpty } from "lodash";
+import styled from "styled-components";
+
+import job from "assets/job.png";
+import leftArrow from "assets/left-arrow.svg";
+import KeyValueArray from "components/form-components/KeyValueArray";
+import Loading from "components/Loading";
+import TabRegion, { TabOption } from "components/TabRegion";
+import TitleSection from "components/TitleSection";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import DeploymentType from "main/home/cluster-dashboard/expanded-chart/DeploymentType";
+import Logs from "../status/Logs";
+import { useRouting } from "shared/routing";
+import LogsSection, { InitLogData } from "main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection";
+import EventsTab from "main/home/cluster-dashboard/expanded-chart/events/EventsTab";
+import { getPodStatus } from "main/home/cluster-dashboard/expanded-chart/deploy-status-section/util";
+import { capitalize } from "shared/string_utils";
+import { usePods } from "shared/hooks/usePods";
+import Container from "components/porter/Container";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+
+const readableDate = (s: string) => {
+  let ts = new Date(s);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} on ${date}`;
+};
+
+const getLatestPod = (pods: any[]) => {
+  if (!Array.isArray(pods)) {
+    return undefined;
+  }
+
+  return [...pods]
+    .sort((a: any, b: any) => {
+      if (!a?.metadata?.creationTimestamp) {
+        return 1;
+      }
+
+      if (!b?.metadata?.creationTimestamp) {
+        return -1;
+      }
+
+      return (
+        new Date(b?.metadata?.creationTimestamp).getTime() -
+        new Date(a?.metadata?.creationTimestamp).getTime()
+      );
+    })
+    .shift();
+};
+
+export const isRunning = (deleting: boolean, job: any, pod: any) => {
+  if (deleting) {
+    return false;
+  }
+
+  if (job.status?.succeeded >= 1) {
+    return false;
+  }
+
+  if (job.status?.conditions) {
+    if (job.status?.conditions[0]?.reason == "DeadlineExceeded") {
+      return false;
+    }
+  }
+
+  if (job.status?.failed >= 1) {
+    return false;
+  }
+
+  if (job.status?.active >= 1) {
+    // determine the status from the pod
+    return pod ? pod.status.startTime : false;
+  }
+
+  return true;
+};
+
+export const renderStatus = (
+  deleting: boolean,
+  job: any,
+  pod: any,
+  time?: string
+) => {
+  if (deleting) {
+    return <Status color="#cc3d42">Deleting</Status>;
+  }
+
+  if (job.status?.succeeded >= 1) {
+    if (time) {
+      return <Status color="#38a88a">Succeeded at {time}</Status>;
+    }
+
+    return <Status color="#38a88a">Succeeded</Status>;
+  }
+
+  if (job.status?.conditions) {
+    if (job.status?.conditions[0]?.reason == "DeadlineExceeded") {
+      return <Status color="#cc3d42">Timed Out</Status>;
+    }
+  }
+
+  if (job.status?.failed >= 1) {
+    return <Status color="#cc3d42">Failed</Status>;
+  }
+
+  if (job.status?.active >= 1) {
+    // determine the status from the pod
+    return pod ? (
+      <Status color="#ffffff11">{capitalize(getPodStatus(pod?.status))}</Status>
+    ) : (
+      <Status color="#ffffff11">Running</Status>
+    );
+  }
+
+  return <Status color="#ffffff11">Running</Status>;
+};
+
+type ExpandedJobRunTabs = "events" | "logs" | "config" | string;
+
+const ExpandedJobRun = ({
+  currentChart,
+  jobRun,
+  onClose,
+}: {
+  currentChart: ChartType;
+  jobRun: any;
+  onClose: () => void;
+}) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [currentTab, setCurrentTab] = useState<ExpandedJobRunTabs>(
+    currentCluster.agent_integration_enabled ? "events" : "logs"
+  );
+  const { pushQueryParams } = useRouting();
+  const [useDeprecatedLogs, setUseDeprecatedLogs] = useState(false);
+
+  const [pods, isLoading] = usePods({
+    project_id: currentProject.id,
+    cluster_id: currentCluster.id,
+    namespace: jobRun.metadata?.namespace,
+    selectors: [`job-name=${jobRun.metadata?.name}`],
+    controller_kind: "job",
+    controller_name: jobRun.metadata?.name,
+    subscribed: true,
+  });
+
+  let chart = currentChart;
+  let run = jobRun;
+
+  useEffect(() => {
+    return () => {
+      pushQueryParams({}, ["job"]);
+    };
+  }, []);
+
+  const renderConfigSection = (job: any) => {
+    let commandString = job?.spec?.template?.spec?.containers[0]?.command?.join(
+      " "
+    );
+    let envArray = job?.spec?.template?.spec?.containers[0]?.env;
+    let envObject = {} as any;
+    envArray &&
+      envArray.forEach((env: any, i: number) => {
+        const secretName = get(env, "valueFrom.secretKeyRef.name");
+        envObject[env.name] = secretName
+          ? `PORTERSECRET_${secretName}`
+          : env.value;
+      });
+
+    // Handle no config to show
+    if (!commandString && isEmpty(envObject)) {
+      return <Placeholder>No config was found.</Placeholder>;
+    }
+
+    let tag = job.spec.template.spec.containers[0].image.split(":")[1];
+    return (
+      <ConfigSection>
+        {commandString ? (
+          <>
+            Command: <Command>{commandString}</Command>
+          </>
+        ) : (
+          <DarkMatter size="-18px" />
+        )}
+        <Row>
+          Image Tag: <Command>{tag}</Command>
+        </Row>
+        {!isEmpty(envObject) && (
+          <>
+            <KeyValueArray
+              envLoader={true}
+              values={envObject}
+              label="Environment variables:"
+              disabled={true}
+            />
+            <DarkMatter />
+          </>
+        )}
+      </ConfigSection>
+    );
+  };
+
+  const renderEventsSection = () => {
+    return (
+      <EventsTab
+        currentChart={currentChart}
+        overridingJobName={jobRun.metadata?.name}
+        setLogData={() => setCurrentTab("logs")}
+      />
+    );
+  };
+
+  const renderLogsSection = () => {
+    if (useDeprecatedLogs || !currentCluster.agent_integration_enabled) {
+      return (
+        <JobLogsWrapper>
+          <Logs
+            selectedPod={pods[0]}
+            podError={!pods[0] ? "Pod no longer exists." : ""}
+            rawText={true}
+          />
+        </JobLogsWrapper>
+      );
+    }
+
+    let initData: InitLogData = {};
+
+    if (run.status.completionTime) {
+      initData.timestamp = run.status.completionTime;
+    }
+
+    return (
+      <JobLogsWrapper>
+        <DeprecatedWarning>
+          Not seeing your logs? Switch back to{" "}
+          <DeprecatedSelect
+            onClick={() => {
+              setUseDeprecatedLogs(true);
+            }}
+          >
+            {" "}
+            deprecated logging.
+          </DeprecatedSelect>
+        </DeprecatedWarning>
+        <LogsSection
+          isFullscreen={false}
+          setIsFullscreen={() => {}}
+          overridingPodName={pods[0]?.metadata?.name || jobRun.metadata?.name}
+          currentChart={currentChart}
+          initData={initData}
+        />
+      </JobLogsWrapper>
+    );
+  };
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  let options: TabOption[] = [];
+
+  if (currentCluster.agent_integration_enabled) {
+    options.push({
+      label: "Events",
+      value: "events",
+    });
+  }
+
+  options.push(
+    {
+      label: "Logs",
+      value: "logs",
+    },
+    {
+      label: "Config",
+      value: "config",
+    }
+  );
+
+  return (
+    <StyledExpandedChart>
+      <BreadcrumbRow>
+        <Breadcrumb onClick={onClose}>
+          <ArrowIcon src={leftArrow} />
+          <Wrap>Back</Wrap>
+        </Breadcrumb>
+      </BreadcrumbRow>
+      <HeaderWrapper>
+        <Container row>
+          <Icon src={job} />
+          <Text size={21}>
+            {jobRun.metadata?.name.split('-').slice(1, -2).join('-')}
+          </Text>
+          <Spacer inline width="10px" />
+          <Text size={21} color="#aaaabb66">
+            at {run.status.completionTime ? readableDate(run.status.completionTime) : ""}
+          </Text>
+        </Container>
+        <Spacer y={0.5} />
+        <InfoWrapper>
+          <LastDeployed>
+            {renderStatus(
+              false,
+              run,
+              pods[0],
+              run.status.completionTime
+                ? readableDate(run.status.completionTime)
+                : ""
+            )}
+          </LastDeployed>
+        </InfoWrapper>
+      </HeaderWrapper>
+      <Spacer y={1} />
+      <BodyWrapper>
+        <TabRegion
+          currentTab={currentTab}
+          setCurrentTab={(newTab: string) => {
+            setCurrentTab(newTab);
+          }}
+          options={options}
+        >
+          {currentTab === "events" && renderEventsSection()}
+          {currentTab === "logs" && renderLogsSection()}
+          {currentTab === "config" && <>{renderConfigSection(run)}</>}
+        </TabRegion>
+      </BodyWrapper>
+    </StyledExpandedChart>
+  );
+};
+
+export default ExpandedJobRun;
+
+const Icon = styled.img`
+  height: 24px;
+  margin-right: 15px;
+`;
+
+const ArrowIcon = styled.img`
+  width: 15px;
+  margin-right: 8px;
+  opacity: 50%;
+`;
+
+const BreadcrumbRow = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: flex-start;
+`;
+
+const Breadcrumb = styled.div`
+  color: #aaaabb88;
+  font-size: 13px;
+  margin-bottom: 15px;
+  display: flex;
+  align-items: center;
+  margin-top: -10px;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const Wrap = styled.div`
+  z-index: 999;
+`;
+
+const Row = styled.div`
+  margin-top: 20px;
+`;
+
+const DarkMatter = styled.div<{ size?: string }>`
+  width: 100%;
+  margin-bottom: ${(props) => props.size || "-13px"};
+`;
+
+const Command = styled.span`
+  font-family: monospace;
+  color: #aaaabb;
+  margin-left: 7px;
+`;
+
+const ConfigSection = styled.div`
+  padding: 20px 30px 30px;
+  font-size: 13px;
+  font-weight: 500;
+  width: 100%;
+  border-radius: 8px;
+  background: #ffffff08;
+`;
+
+const JobLogsWrapper = styled.div`
+  min-height: 450px;
+  height: fit-content;
+  width: 100%;
+  border-radius: 8px;
+`;
+
+const Status = styled.div<{ color: string }>`
+  padding: 5px 10px;
+  background: ${(props) => props.color};
+  font-size: 13px;
+  border-radius: 3px;
+  height: 25px;
+  color: #ffffff;
+  margin-bottom: -3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Gray = styled.div`
+  color: #ffffff44;
+  margin-left: 15px;
+  font-weight: 400;
+  font-size: 18px;
+`;
+
+const BackButton = styled.div`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
+const Placeholder = styled.div`
+  min-height: 400px;
+  height: 50vh;
+  padding: 30px;
+  padding-bottom: 70px;
+  font-size: 13px;
+  color: #ffffff44;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const BodyWrapper = styled.div`
+  position: relative;
+  overflow: hidden;
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  height: 20px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 0;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  height: 25px;
+  font-size: 12px;
+  display: flex;
+  margin-left: 20px;
+  margin-bottom: -3px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+  background: #26282e;
+`;
+
+const NamespaceTag = styled.div`
+  height: 100%;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #43454a;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: 100%;
+  z-index: 0;
+  animation: fadeIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  display: flex;
+  overflow-y: auto;
+  padding-bottom: 120px;
+  flex-direction: column;
+  overflow: visible;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const DeprecatedWarning = styled.div`
+  font-size: 12px;
+  color: #ccc;
+  text-align: right;
+  width: 100%;
+  margin-bottom: 20px;
+`;
+
+const DeprecatedSelect = styled.span`
+  cursor: pointer;
+  color: #949effff;
+`;

+ 3 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx

@@ -21,6 +21,7 @@ interface ServiceProps {
   editService: (service: Service) => void;
   deleteService: () => void;
   defaultExpanded: boolean;
+  setExpandedJob: (x: string) => void;
 }
 
 const ServiceContainer: React.FC<ServiceProps> = ({
@@ -29,6 +30,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
   deleteService,
   editService,
   defaultExpanded,
+  setExpandedJob,
 }) => {
   const [showExpanded, setShowExpanded] = React.useState<boolean>(defaultExpanded);
   const [height, setHeight] = React.useState<Height>("auto");
@@ -136,6 +138,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
         getHasBuiltImage()
       ) && (
           <StatusFooter
+            setExpandedJob={setExpandedJob}
             chart={chart}
             service={service}
           />

+ 12 - 1
dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx

@@ -22,9 +22,19 @@ interface ServicesProps {
   chart?: any;
   limitOne?: boolean;
   customOnClick?: () => void;
+  setExpandedJob?: (x: string) => void;
 }
 
-const Services: React.FC<ServicesProps> = ({ services, setServices, addNewText, chart, defaultExpanded = false, limitOne = false, customOnClick }) => {
+const Services: React.FC<ServicesProps> = ({ 
+  services,
+  setServices,
+  addNewText,
+  chart,
+  defaultExpanded = false,
+  limitOne = false,
+  customOnClick,
+  setExpandedJob,
+}) => {
   const [showAddServiceModal, setShowAddServiceModal] = useState<boolean>(
     false
   );
@@ -72,6 +82,7 @@ const Services: React.FC<ServicesProps> = ({ services, setServices, addNewText,
             return (
               <ServiceContainer
                 key={service.name}
+                setExpandedJob={setExpandedJob}
                 service={service}
                 chart={chart}
                 editService={(newService: Service) =>

+ 24 - 11
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -462,17 +462,30 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   <Spinner src={loadingSrc} /> This application is currently
                   being deployed
                 </Header>
-                Navigate to the{" "}
-                <A
-                  href={
-                    props.currentChart.git_action_config &&
-                    `https://github.com/${props.currentChart.git_action_config?.git_repo}/actions`
-                  }
-                  target={"_blank"}
-                >
-                  Actions
-                </A>{" "}
-                tab of your GitHub repo to view live build logs.
+                {props.currentChart.git_action_config &&
+                props.currentChart.git_action_config.gitlab_integration_id ? (
+                  <>
+                    Navigate to the{" "}
+                    <A
+                      href={`https://gitlab.com/${props.currentChart.git_action_config?.git_repo}/-/jobs`}
+                      target={"_blank"}
+                    >
+                      Jobs
+                    </A>{" "}
+                    tab of your GitLab repo to view live build logs.
+                  </>
+                ) : (
+                  <>
+                    Navigate to the{" "}
+                    <A
+                      href={`https://github.com/${props.currentChart.git_action_config?.git_repo}/actions`}
+                      target={"_blank"}
+                    >
+                      Actions
+                    </A>{" "}
+                    tab of your GitHub repo to view live build logs.
+                  </>
+                )}
               </TextWrap>
             </Placeholder>
           );

+ 5 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx

@@ -95,14 +95,14 @@ const BuildpackConfigSection = forwardRef<
     if (actionConfig.kind === "gitlab") {
       return api.detectGitlabBuildpack<DetectBuildpackResponse>(
         "<token>",
-        { dir: actionConfig.folder_path || "." },
+        {
+          repo_path: actionConfig.git_repo,
+          branch: actionConfig.git_branch,
+          dir: actionConfig.folder_path || ".",
+        },
         {
           project_id: currentProject.id,
           integration_id: actionConfig.gitlab_integration_id,
-
-          repo_owner: actionConfig.git_repo.split("/")[0],
-          repo_name: actionConfig.git_repo.split("/")[1],
-          branch: actionConfig.git_branch,
         }
       );
     }

+ 84 - 5
dashboard/src/main/home/integrations/GitlabIntegrationList.tsx

@@ -8,26 +8,91 @@ import DynamicLink from "components/DynamicLink";
 
 interface Props {
   gitlabData: any[];
+  updateIntegrationList: () => void;
 }
 
+type StateType = {
+  isDelete: boolean;
+  deleteName: string;
+  deleteID: number;
+};
+
 const GitlabIntegrationList: React.FC<Props> = (props) => {
+  const [currentState, setCurrentState] = useState<StateType>({
+    isDelete: false,
+    deleteName: "",
+    deleteID: 0,
+  });
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const handleDeleteIntegration = () => {
+    api
+      .deleteGitlabIntegration(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          integration_id: currentState.deleteID,
+        }
+      )
+      .then(() => {
+        setCurrentState({
+          isDelete: false,
+          deleteName: "",
+          deleteID: 0,
+        });
+        props.updateIntegrationList();
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      });
+  };
+
   return (
     <>
+      <ConfirmOverlay
+        show={currentState.isDelete}
+        message={`Are you sure you want to delete the GitLab integration for instance ${currentState.deleteName}?`}
+        onYes={handleDeleteIntegration}
+        onNo={() =>
+          setCurrentState({
+            isDelete: false,
+            deleteName: "",
+            deleteID: 0,
+          })
+        }
+      />
       <StyledIntegrationList>
         {props.gitlabData?.length > 0 ? (
           props.gitlabData.map((inst, idx) => {
             return (
-              <Integration
-                onClick={() => {}}
-                disabled={false}
-                key={`${inst.team_id}-${inst.channel}`}
-              >
+              <Integration onClick={() => {}} disabled={false} key={inst.id}>
                 <MainRow disabled={false}>
                   <Flex>
                     <Icon src={integrationList.gitlab.icon} />
                     <Label>{inst.instance_url}</Label>
+                    {inst.username.includes("Unable") ? (
+                      <ErrorLabel>[{inst.username}]</ErrorLabel>
+                    ) : (
+                      <UsernameLabel>({inst.username})</UsernameLabel>
+                    )}
                   </Flex>
                   <MaterialIconTray disabled={false}>
+                    <i
+                      className="material-icons"
+                      onClick={() => {
+                        setCurrentState({
+                          isDelete: true,
+                          deleteName: inst.instance_url,
+                          deleteID: inst.id,
+                        });
+                      }}
+                    >
+                      delete
+                    </i>
                     <i
                       className="material-icons"
                       onClick={() => {
@@ -71,6 +136,20 @@ const Label = styled.div`
   font-weight: 500;
 `;
 
+const UsernameLabel = styled.div`
+  color: #ffffff66;
+  font-size: 14px;
+  font-weight: 500;
+  padding: 10px;
+`;
+
+const ErrorLabel = styled.div`
+  color: #f6685e;
+  font-size: 14px;
+  font-weight: 500;
+  padding: 10px;
+`;
+
 const StyledIntegrationList = styled.div`
   margin-top: 20px;
   margin-bottom: 80px;

+ 7 - 1
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -94,6 +94,7 @@ const IntegrationCategories: React.FC<Props> = (props) => {
             setGitlabData(res.data);
             setLoading(false);
           });
+        break;
       default:
         console.log("Unknown integration category.");
     }
@@ -155,7 +156,12 @@ const IntegrationCategories: React.FC<Props> = (props) => {
       {loading ? (
         <Loading />
       ) : props.category === "gitlab" ? (
-        <GitlabIntegrationList gitlabData={gitlabData} />
+        <GitlabIntegrationList
+          gitlabData={gitlabData}
+          updateIntegrationList={() =>
+            getIntegrationsForCategory(props.category)
+          }
+        />
       ) : props.category == "slack" ? (
         <SlackIntegrationList slackData={slackData} />
       ) : (

+ 1 - 1
dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx

@@ -124,7 +124,7 @@ const GitlabForm: React.FC<Props> = () => {
         <SaveButton
           onClick={submit}
           makeFlush={true}
-          text="Save Gitlab Settings"
+          text="Save GitLab Settings"
           status={buttonStatus || error?.message}
         />
       </StyledForm>

+ 76 - 40
dashboard/src/shared/api.tsx

@@ -480,6 +480,16 @@ const deleteRegistryIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}`;
 });
 
+const deleteGitlabIntegration = baseApi<
+  {},
+  {
+    project_id: number;
+    integration_id: number;
+  }
+>("DELETE", ({ project_id, integration_id }) => {
+  return `/api/projects/${project_id}/integrations/gitlab/${integration_id}`;
+});
+
 const deleteSlackIntegration = baseApi<
   {},
   {
@@ -633,24 +643,27 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const detectGitlabBuildpack = baseApi<
-  { dir: string },
+  {
+    repo_path: string;
+    branch: string;
+    dir: string;
+  },
   {
     project_id: number;
     integration_id: number;
-    repo_owner: string;
-    repo_name: string;
-    branch: string;
   }
 >(
   "GET",
-  ({ project_id, integration_id, repo_name, repo_owner, branch }) =>
-    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/${branch}/buildpack/detect`
+  ({ project_id, integration_id }) =>
+    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/buildpack/detect`
 );
 
 const getBranchContents = baseApi<
@@ -666,9 +679,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -684,9 +699,11 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getPorterYamlContents = baseApi<
@@ -702,28 +719,41 @@ const getPorterYamlContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
 });
 
-const getGitlabProcfileContents = baseApi<
+const getGitlabPorterYamlContents = baseApi<
   {
+    repo_path: string;
+    branch: string;
     path: string;
   },
   {
     project_id: number;
     integration_id: number;
-    owner: string;
-    name: string;
+  }
+>("GET", ({ project_id, integration_id }) => {
+  return `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/porteryaml`;
+});
+
+const getGitlabProcfileContents = baseApi<
+  {
+    repo_path: string;
     branch: string;
+    path: string;
+  },
+  {
+    project_id: number;
+    integration_id: number;
   }
 >(
   "GET",
-  ({ project_id, integration_id, owner, name, branch }) =>
-    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${owner}/${name}/${encodeURIComponent(
-      branch
-    )}/procfile`
+  ({ project_id, integration_id }) =>
+    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/procfile`
 );
 
 const getBranches = baseApi<
@@ -1558,9 +1588,11 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
-    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
-    }`;
+  return `/api/projects/${pathParams.id}/clusters/${
+    pathParams.cluster_id
+  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
+    pathParams.version ? "&version=" + pathParams.version : ""
+  }`;
 });
 
 const getConfigMap = baseApi<
@@ -2134,7 +2166,9 @@ const getGitProviders = baseApi<{}, { project_id: number }>(
 );
 
 const getGitlabRepos = baseApi<
-  {},
+  {
+    search_term: string;
+  },
   { project_id: number; integration_id: number }
 >(
   "GET",
@@ -2143,34 +2177,34 @@ const getGitlabRepos = baseApi<
 );
 
 const getGitlabBranches = baseApi<
-  {},
+  {
+    repo_path: string;
+    search_term: string;
+  },
   {
     project_id: number;
     integration_id: number;
-    repo_owner: string;
-    repo_name: string;
   }
 >(
   "GET",
-  ({ project_id, integration_id, repo_owner, repo_name }) =>
-    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/branches`
+  ({ project_id, integration_id }) =>
+    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/branches`
 );
 
 const getGitlabFolderContent = baseApi<
   {
+    repo_path: string;
+    branch: string;
     dir: string;
   },
   {
     project_id: number;
     integration_id: number;
-    repo_owner: string;
-    repo_name: string;
-    branch: string;
   }
 >(
   "GET",
-  ({ project_id, integration_id, repo_owner, repo_name, branch }) =>
-    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/${branch}/contents`
+  ({ project_id, integration_id }) =>
+    `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/contents`
 );
 
 const getLogPodValues = baseApi<
@@ -2487,7 +2521,7 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
-const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`);
+const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 const createSecretAndOpenGitHubPullRequest = baseApi<
   {
@@ -2554,6 +2588,7 @@ export default {
   deletePod,
   deleteProject,
   deleteRegistryIntegration,
+  deleteGitlabIntegration,
   deleteSlackIntegration,
   updateNotificationConfig,
   getNotificationConfig,
@@ -2707,6 +2742,7 @@ export default {
   getGitlabRepos,
   getGitlabBranches,
   getGitlabFolderContent,
+  getGitlabPorterYamlContents,
   getLogPodValues,
   getLogs,
   listPorterEvents,

+ 1 - 1
internal/integrations/buildpacks/go.go

@@ -155,7 +155,7 @@ func (runtime *goRuntime) DetectGithub(
 func (runtime *goRuntime) DetectGitlab(
 	client *gitlab.Client,
 	tree []*gitlab.TreeNode,
-	owner, name, path, ref string,
+	repoPath, path, ref string,
 	paketo, heroku *BuilderInfo,
 ) error {
 	results := make(chan struct {

+ 1 - 1
internal/integrations/buildpacks/nodejs.go

@@ -414,7 +414,7 @@ func (runtime *nodejsRuntime) DetectGithub(
 func (runtime *nodejsRuntime) DetectGitlab(
 	client *gitlab.Client,
 	tree []*gitlab.TreeNode,
-	owner, name, path, ref string,
+	repoPath, path, ref string,
 	paketo, heroku *BuilderInfo,
 ) error {
 	results := make(chan struct {

+ 1 - 1
internal/integrations/buildpacks/python.go

@@ -252,7 +252,7 @@ func (runtime *pythonRuntime) DetectGithub(
 func (runtime *pythonRuntime) DetectGitlab(
 	client *gitlab.Client,
 	tree []*gitlab.TreeNode,
-	owner, name, path, ref string,
+	repoPath, path, ref string,
 	paketo, heroku *BuilderInfo,
 ) error {
 	results := make(chan struct {

+ 6 - 6
internal/integrations/buildpacks/ruby.go

@@ -161,13 +161,13 @@ func (runtime *rubyRuntime) detectRackupGithub(
 }
 
 func (runtime *rubyRuntime) detectRackupGitlab(
-	client *gitlab.Client, owner, name, ref string, results chan struct {
+	client *gitlab.Client, repoPath, ref string, results chan struct {
 		string
 		bool
 	},
 ) {
 	fileContent, _, err := client.RepositoryFiles.GetRawFile(
-		fmt.Sprintf("%s/%s", owner, name), "Gemfile.lock", &gitlab.GetRawFileOptions{
+		repoPath, "Gemfile.lock", &gitlab.GetRawFileOptions{
 			Ref: gitlab.String(ref),
 		})
 	if err != nil {
@@ -325,7 +325,7 @@ func (runtime *rubyRuntime) DetectGithub(
 func (runtime *rubyRuntime) DetectGitlab(
 	client *gitlab.Client,
 	tree []*gitlab.TreeNode,
-	owner, name, path, ref string,
+	repoPath, path, ref string,
 	paketo, heroku *BuilderInfo,
 ) error {
 	gemfileFound := false
@@ -361,13 +361,13 @@ func (runtime *rubyRuntime) DetectGitlab(
 	}
 
 	fileContent, _, err := client.RepositoryFiles.GetRawFile(
-		fmt.Sprintf("%s/%s", owner, name), "Gemfile", &gitlab.GetRawFileOptions{
+		repoPath, "Gemfile", &gitlab.GetRawFileOptions{
 			Ref: gitlab.String(ref),
 		})
 	if err != nil {
 		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
 		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
-		return fmt.Errorf("error fetching contents of Gemfile for %s/%s: %v", owner, name, err)
+		return fmt.Errorf("error fetching contents of Gemfile for %s: %v", repoPath, err)
 	}
 	gemfileContent := string(fileContent)
 
@@ -405,7 +405,7 @@ func (runtime *rubyRuntime) DetectGitlab(
 	}
 	go runtime.detectPassenger(gemfileContent, results)
 	if !configRuFound && gemfileLockFound {
-		go runtime.detectRackupGitlab(client, owner, name, ref, results)
+		go runtime.detectRackupGitlab(client, repoPath, ref, results)
 	}
 	if rakefileFound {
 		go runtime.detectRake(gemfileContent, results)

+ 1 - 2
internal/integrations/buildpacks/shared.go

@@ -62,8 +62,7 @@ type Runtime interface {
 	DetectGitlab(
 		*gitlab.Client, // github client to pull contents of files
 		[]*gitlab.TreeNode, // the root folder structure of the git repo
-		string, // owner
-		string, // name
+		string, // repoPath
 		string, // path
 		string, // SHA, branch or tag
 		*BuilderInfo, // paketo

+ 47 - 29
internal/integrations/ci/gitlab/ci.go

@@ -15,10 +15,9 @@ import (
 )
 
 type GitlabCI struct {
-	ServerURL    string
-	GitRepoName  string
-	GitRepoOwner string
-	GitBranch    string
+	ServerURL   string
+	GitRepoPath string
+	GitBranch   string
 
 	Repo repository.Repository
 
@@ -44,18 +43,12 @@ func (g *GitlabCI) Setup() error {
 		return err
 	}
 
-	g.pID = fmt.Sprintf("%s/%s", g.GitRepoOwner, g.GitRepoName)
+	g.pID = g.GitRepoPath
 
-	branches, _, err := client.Branches.ListBranches(g.pID, &gitlab.ListBranchesOptions{})
-	if err != nil {
-		return fmt.Errorf("error fetching list of branches: %w", err)
-	}
+	err = g.setGitlabDefaultBranch(client)
 
-	for _, branch := range branches {
-		if branch.Default {
-			g.defaultGitBranch = branch.Name
-			break
-		}
+	if err != nil {
+		return err
 	}
 
 	err = g.createGitlabSecret(client)
@@ -67,7 +60,7 @@ func (g *GitlabCI) Setup() error {
 	jobName := getGitlabStageJobName(g.ReleaseName)
 
 	ciFile, resp, err := client.RepositoryFiles.GetRawFile(g.pID, ".gitlab-ci.yml", &gitlab.GetRawFileOptions{
-		Ref: gitlab.String(g.defaultGitBranch),
+		Ref: gitlab.String(g.GitBranch),
 	})
 
 	if resp.StatusCode == http.StatusNotFound {
@@ -81,7 +74,7 @@ func (g *GitlabCI) Setup() error {
 		contentsYAML, _ := yaml.Marshal(contentsMap)
 
 		_, _, err = client.RepositoryFiles.CreateFile(g.pID, ".gitlab-ci.yml", &gitlab.CreateFileOptions{
-			Branch:        gitlab.String(g.defaultGitBranch),
+			Branch:        gitlab.String(g.GitBranch),
 			AuthorName:    gitlab.String("Porter Bot"),
 			AuthorEmail:   gitlab.String("contact@getporter.dev"),
 			Content:       gitlab.String(string(contentsYAML)),
@@ -173,7 +166,7 @@ func (g *GitlabCI) Setup() error {
 		}
 
 		_, _, err = client.RepositoryFiles.UpdateFile(g.pID, ".gitlab-ci.yml", &gitlab.UpdateFileOptions{
-			Branch:        gitlab.String(g.defaultGitBranch),
+			Branch:        gitlab.String(g.GitBranch),
 			AuthorName:    gitlab.String("Porter Bot"),
 			AuthorEmail:   gitlab.String("contact@getporter.dev"),
 			Content:       gitlab.String(string(contentsYAML)),
@@ -194,18 +187,12 @@ func (g *GitlabCI) Cleanup() error {
 		return err
 	}
 
-	g.pID = fmt.Sprintf("%s/%s", g.GitRepoOwner, g.GitRepoName)
+	g.pID = g.GitRepoPath
 
-	branches, _, err := client.Branches.ListBranches(g.pID, &gitlab.ListBranchesOptions{})
-	if err != nil {
-		return fmt.Errorf("error fetching list of branches: %w", err)
-	}
+	err = g.setGitlabDefaultBranch(client)
 
-	for _, branch := range branches {
-		if branch.Default {
-			g.defaultGitBranch = branch.Name
-			break
-		}
+	if err != nil {
+		return err
 	}
 
 	err = g.deleteGitlabSecret(client)
@@ -217,7 +204,7 @@ func (g *GitlabCI) Cleanup() error {
 	jobName := getGitlabStageJobName(g.ReleaseName)
 
 	ciFile, resp, err := client.RepositoryFiles.GetRawFile(g.pID, ".gitlab-ci.yml", &gitlab.GetRawFileOptions{
-		Ref: gitlab.String(g.defaultGitBranch),
+		Ref: gitlab.String(g.GitBranch),
 	})
 
 	if resp.StatusCode == http.StatusNotFound {
@@ -293,7 +280,7 @@ func (g *GitlabCI) Cleanup() error {
 	}
 
 	_, _, err = client.RepositoryFiles.UpdateFile(g.pID, ".gitlab-ci.yml", &gitlab.UpdateFileOptions{
-		Branch:        gitlab.String(g.defaultGitBranch),
+		Branch:        gitlab.String(g.GitBranch),
 		AuthorName:    gitlab.String("Porter Bot"),
 		AuthorEmail:   gitlab.String("contact@getporter.dev"),
 		Content:       gitlab.String(string(contentsYAML)),
@@ -495,3 +482,34 @@ func (g *GitlabCI) getPorterTokenSecretName() string {
 func getGitlabStageJobName(releaseName string) string {
 	return fmt.Sprintf("porter-%s", strings.ToLower(strings.ReplaceAll(releaseName, "_", "-")))
 }
+
+func (g *GitlabCI) setGitlabDefaultBranch(client *gitlab.Client) error {
+	opt := &gitlab.ListBranchesOptions{
+		ListOptions: gitlab.ListOptions{
+			PerPage: 20,
+			Page:    1,
+		},
+	}
+
+	for {
+		branches, resp, err := client.Branches.ListBranches(g.pID, opt)
+		if err != nil {
+			return fmt.Errorf("error fetching list of branches: %w", err)
+		}
+
+		for _, branch := range branches {
+			if branch.Default {
+				g.defaultGitBranch = branch.Name
+				return nil
+			}
+		}
+		// Exit the loop when we've seen all pages.
+		if resp.NextPage == 0 {
+			break
+		}
+
+		// Update the page number to get the next page.
+		opt.Page = resp.NextPage
+	}
+	return nil
+}

+ 8 - 0
internal/repository/gorm/auth.go

@@ -1621,6 +1621,14 @@ func (repo *GitlabIntegrationRepository) ListGitlabIntegrationsByProjectID(proje
 	return gi, nil
 }
 
+func (repo *GitlabIntegrationRepository) DeleteGitlabIntegrationByID(projectID, id uint) error {
+	if err := repo.db.Where("project_id = ? AND id = ?", projectID, id).Delete(&ints.GitlabIntegration{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
 // EncryptGitlabIntegrationData will encrypt the gitlab integration data before
 // writing to the DB
 func (repo *GitlabIntegrationRepository) EncryptGitlabIntegrationData(

+ 1 - 0
internal/repository/integrations.go

@@ -93,6 +93,7 @@ type GitlabIntegrationRepository interface {
 	CreateGitlabIntegration(gi *ints.GitlabIntegration) (*ints.GitlabIntegration, error)
 	ReadGitlabIntegration(projectID, id uint) (*ints.GitlabIntegration, error)
 	ListGitlabIntegrationsByProjectID(projectID uint) ([]*ints.GitlabIntegration, error)
+	DeleteGitlabIntegrationByID(projectID, id uint) error
 }
 
 // GitlabAppOAuthIntegrationRepository represents the set of queries on the GitlabOAuthIntegration model

+ 15 - 0
internal/repository/test/auth.go

@@ -677,6 +677,21 @@ func (repo *GitlabIntegrationRepository) ListGitlabIntegrationsByProjectID(proje
 	return res, nil
 }
 
+func (repo *GitlabIntegrationRepository) DeleteGitlabIntegrationByID(projectID, id uint) error {
+	if !repo.canQuery {
+		return errors.New("Cannot write database")
+	}
+
+	if int(id-1) >= len(repo.gitlabIntegrations) || repo.gitlabIntegrations[id-1] == nil || repo.gitlabIntegrations[id-1].ProjectID != projectID {
+		return gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	repo.gitlabIntegrations[index] = nil
+
+	return nil
+}
+
 type GitlabAppOAuthIntegrationRepository struct {
 	canQuery                   bool
 	gitlabAppOAuthIntegrations []*ints.GitlabAppOAuthIntegration