merge changes from main

eb/code-1016-2
enver 2024-03-25 10:23:02 +01:00
commit 45b70c3ef2
49 changed files with 1571 additions and 363 deletions

View File

@ -97,7 +97,7 @@ func (c *Controller) Create(ctx context.Context, session *auth.Session, in *Crea
}
err = c.repoStore.Create(ctx, repo)
if err != nil {
if dErr := c.deleteGitRepository(ctx, session, repo); dErr != nil {
if dErr := c.DeleteGitRepository(ctx, session, repo); dErr != nil {
log.Ctx(ctx).Warn().Err(dErr).Msg("failed to delete repo for cleanup")
}
return fmt.Errorf("failed to create repository in storage: %w", err)

View File

@ -68,8 +68,8 @@ func (c *Controller) PurgeNoAuth(
return fmt.Errorf("failed to delete repo from db: %w", err)
}
if err := c.deleteGitRepository(ctx, session, repo); err != nil {
return fmt.Errorf("failed to delete git repository: %w", err)
if err := c.DeleteGitRepository(ctx, session, repo); err != nil {
log.Ctx(ctx).Err(err).Msg("failed to remove git repository")
}
c.eventReporter.Deleted(
@ -81,7 +81,7 @@ func (c *Controller) PurgeNoAuth(
return nil
}
func (c *Controller) deleteGitRepository(
func (c *Controller) DeleteGitRepository(
ctx context.Context,
session *auth.Session,
repo *types.Repository,

View File

@ -21,12 +21,15 @@ import (
apiauth "github.com/harness/gitness/app/api/auth"
"github.com/harness/gitness/app/api/usererror"
"github.com/harness/gitness/app/auth"
"github.com/harness/gitness/errors"
"github.com/harness/gitness/store"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
)
type RestoreInput struct {
NewIdentifier string `json:"new_identifier,omitempty"`
NewIdentifier *string `json:"new_identifier,omitempty"`
NewParentRef *string `json:"new_parent_ref,omitempty"`
}
func (c *Controller) Restore(
@ -49,10 +52,31 @@ func (c *Controller) Restore(
return nil, usererror.BadRequest("cannot restore a repo that hasn't been deleted")
}
repo, err = c.repoStore.Restore(ctx, repo, in.NewIdentifier)
parentID := repo.ParentID
if in.NewParentRef != nil {
space, err := c.spaceStore.FindByRef(ctx, *in.NewParentRef)
if errors.Is(err, store.ErrResourceNotFound) {
return nil, usererror.BadRequest("The provided new parent ref wasn't found.")
}
if err != nil {
return nil, fmt.Errorf("failed to find the parent ref '%s': %w", *in.NewParentRef, err)
}
parentID = space.ID
}
return c.RestoreNoAuth(ctx, repo, in.NewIdentifier, &parentID)
}
func (c *Controller) RestoreNoAuth(
ctx context.Context,
repo *types.Repository,
newIdentifier *string,
newParentID *int64,
) (*types.Repository, error) {
repo, err := c.repoStore.Restore(ctx, repo, newIdentifier, newParentID)
if err != nil {
return nil, fmt.Errorf("failed to restore the repo: %w", err)
}
return repo, nil
}

View File

@ -87,7 +87,7 @@ func (c *Controller) createSpaceInnerInTX(
if err != nil {
return nil, fmt.Errorf("failed to find primary path for parent '%d': %w", parentID, err)
}
spacePath = paths.Concatinate(parentPath.Value, in.Identifier)
spacePath = paths.Concatenate(parentPath.Value, in.Identifier)
// ensure path is within accepted depth!
err = check.PathDepth(spacePath, true)

View File

@ -1,118 +0,0 @@
// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package space
import (
"context"
"fmt"
"math"
"time"
apiauth "github.com/harness/gitness/app/api/auth"
"github.com/harness/gitness/app/auth"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
)
// Delete deletes a space.
func (c *Controller) Delete(
ctx context.Context,
session *auth.Session,
spaceRef string,
) error {
space, err := c.spaceStore.FindByRef(ctx, spaceRef)
if err != nil {
return err
}
if err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionSpaceDelete, false); err != nil {
return err
}
return c.DeleteNoAuth(ctx, session, space.ID)
}
// DeleteNoAuth deletes the space - no authorization is verified.
// WARNING this is meant for internal calls only.
func (c *Controller) DeleteNoAuth(
ctx context.Context,
session *auth.Session,
spaceID int64,
) error {
filter := &types.SpaceFilter{
Page: 1,
Size: math.MaxInt,
Query: "",
Order: enum.OrderAsc,
Sort: enum.SpaceAttrNone,
}
subSpaces, _, err := c.ListSpacesNoAuth(ctx, spaceID, filter)
if err != nil {
return fmt.Errorf("failed to list space %d sub spaces: %w", spaceID, err)
}
for _, space := range subSpaces {
err = c.DeleteNoAuth(ctx, session, space.ID)
if err != nil {
return fmt.Errorf("failed to delete space %d: %w", space.ID, err)
}
}
err = c.deleteRepositoriesNoAuth(ctx, session, spaceID)
if err != nil {
return fmt.Errorf("failed to delete repositories of space %d: %w", spaceID, err)
}
err = c.spaceStore.Delete(ctx, spaceID)
if err != nil {
return fmt.Errorf("spaceStore failed to delete space %d: %w", spaceID, err)
}
return nil
}
// deleteRepositoriesNoAuth deletes all repositories in a space - no authorization is verified.
// WARNING this is meant for internal calls only.
func (c *Controller) deleteRepositoriesNoAuth(
ctx context.Context,
session *auth.Session,
spaceID int64,
) error {
filter := &types.RepoFilter{
Page: 1,
Size: int(math.MaxInt),
Query: "",
Order: enum.OrderAsc,
Sort: enum.RepoAttrNone,
DeletedBeforeOrAt: nil,
}
repos, _, err := c.ListRepositoriesNoAuth(ctx, spaceID, filter)
if err != nil {
return fmt.Errorf("failed to list space repositories: %w", err)
}
// TEMPORARY until we support space delete/restore CODE-1413
recent := time.Now().Add(+time.Hour * 24).UnixMilli()
filter.DeletedBeforeOrAt = &recent
alreadyDeletedRepos, _, err := c.ListRepositoriesNoAuth(ctx, spaceID, filter)
if err != nil {
return fmt.Errorf("failed to list delete repositories for space %d: %w", spaceID, err)
}
repos = append(repos, alreadyDeletedRepos...)
for _, repo := range repos {
err = c.repoCtrl.PurgeNoAuth(ctx, session, repo)
if err != nil {
return fmt.Errorf("failed to delete repository %d: %w", repo.ID, err)
}
}
return nil
}

View File

@ -0,0 +1,123 @@
// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package space
import (
"context"
"fmt"
"math"
"time"
apiauth "github.com/harness/gitness/app/api/auth"
"github.com/harness/gitness/app/auth"
"github.com/harness/gitness/contextutil"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/rs/zerolog/log"
)
// Purge deletes the space and all its subspaces and repositories permanently.
func (c *Controller) Purge(
ctx context.Context,
session *auth.Session,
spaceRef string,
deletedAt int64,
) error {
space, err := c.spaceStore.FindByRefAndDeletedAt(ctx, spaceRef, deletedAt)
if err != nil {
return err
}
// authz will check the permission within the first existing parent since space was deleted.
// purge top level space is limited to admin only.
err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionSpaceDelete, false)
if err != nil {
return fmt.Errorf("failed to authorize on space purge: %w", err)
}
return c.PurgeNoAuth(ctx, session, space.ID, deletedAt)
}
// PurgeNoAuth purges the space - no authorization is verified.
// WARNING For internal calls only.
func (c *Controller) PurgeNoAuth(
ctx context.Context,
session *auth.Session,
spaceID int64,
deletedAt int64,
) error {
// the max time we give a purge space to succeed
const timeout = 15 * time.Minute
// create new, time-restricted context to guarantee space purge completion, even if request is canceled.
ctx, cancel := context.WithTimeout(
contextutil.WithNewValues(context.Background(), ctx),
timeout,
)
defer cancel()
var toBeDeletedRepos []*types.Repository
var err error
err = c.tx.WithTx(ctx, func(ctx context.Context) error {
toBeDeletedRepos, err = c.purgeSpaceInnerInTx(ctx, spaceID, deletedAt)
return err
})
if err != nil {
return fmt.Errorf("failed to purge space %d in a tnx: %w", spaceID, err)
}
// permanently purge all repositories in the space and its subspaces after successful space purge tnx.
// cleanup will handle failed repository deletions.
for _, repo := range toBeDeletedRepos {
err := c.repoCtrl.DeleteGitRepository(ctx, session, repo)
if err != nil {
log.Ctx(ctx).Warn().Err(err).
Str("repo_identifier", repo.Identifier).
Int64("repo_id", repo.ID).
Int64("repo_parent_id", repo.ParentID).
Msg("failed to delete repository")
}
}
return nil
}
func (c *Controller) purgeSpaceInnerInTx(
ctx context.Context,
spaceID int64,
deletedAt int64,
) ([]*types.Repository, error) {
filter := &types.RepoFilter{
Page: 1,
Size: int(math.MaxInt),
Query: "",
Order: enum.OrderAsc,
Sort: enum.RepoAttrDeleted,
DeletedBeforeOrAt: &deletedAt,
Recursive: true,
}
repos, err := c.repoStore.List(ctx, spaceID, filter)
if err != nil {
return nil, fmt.Errorf("failed to list space repositories: %w", err)
}
// purge cascade deletes all the child spaces from DB.
err = c.spaceStore.Purge(ctx, spaceID, &deletedAt)
if err != nil {
return nil, fmt.Errorf("spaceStore failed to delete space %d: %w", spaceID, err)
}
return repos, nil
}

View File

@ -0,0 +1,260 @@
// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package space
import (
"context"
"errors"
"fmt"
"math"
"time"
apiauth "github.com/harness/gitness/app/api/auth"
"github.com/harness/gitness/app/api/usererror"
"github.com/harness/gitness/app/auth"
"github.com/harness/gitness/app/paths"
"github.com/harness/gitness/store"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/check"
"github.com/harness/gitness/types/enum"
)
type RestoreInput struct {
NewIdentifier *string `json:"new_identifier,omitempty"`
NewParentRef *string `json:"new_parent_ref,omitempty"` // Reference of the new parent space
}
var errSpacePathInvalid = usererror.BadRequest("Space ref or identifier is invalid.")
func (c *Controller) Restore(
ctx context.Context,
session *auth.Session,
spaceRef string,
deletedAt int64,
in *RestoreInput,
) (*types.Space, error) {
if err := c.sanitizeRestoreInput(in); err != nil {
return nil, fmt.Errorf("failed to sanitize restore input: %w", err)
}
space, err := c.spaceStore.FindByRefAndDeletedAt(ctx, spaceRef, deletedAt)
if err != nil {
return nil, fmt.Errorf("failed to find the space: %w", err)
}
// check view permission on the original ref.
err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionSpaceView, false)
if err != nil {
return nil, fmt.Errorf("failed to authorize on space restore: %w", err)
}
parentSpace, err := c.getParentSpace(ctx, space, in.NewParentRef)
if err != nil {
return nil, fmt.Errorf("failed to get space parent: %w", err)
}
// check create permissions within the parent space scope.
if err = apiauth.CheckSpaceScope(
ctx,
c.authorizer,
session,
parentSpace,
enum.ResourceTypeSpace,
enum.PermissionSpaceEdit,
false,
); err != nil {
return nil, fmt.Errorf("authorization failed on space restore: %w", err)
}
spacePath := paths.Concatenate(parentSpace.Path, space.Identifier)
if in.NewIdentifier != nil {
spacePath = paths.Concatenate(parentSpace.Path, *in.NewIdentifier)
}
err = c.tx.WithTx(ctx, func(ctx context.Context) error {
space, err = c.restoreSpaceInnerInTx(
ctx,
space,
deletedAt,
in.NewIdentifier,
&parentSpace.ID,
spacePath)
return err
})
if err != nil {
return nil, fmt.Errorf("failed to restore space in a tnx: %w", err)
}
return space, nil
}
func (c *Controller) restoreSpaceInnerInTx(
ctx context.Context,
space *types.Space,
deletedAt int64,
newIdentifier *string,
newParentID *int64,
spacePath string,
) (*types.Space, error) {
filter := &types.SpaceFilter{
Page: 1,
Size: math.MaxInt,
Query: "",
Order: enum.OrderDesc,
Sort: enum.SpaceAttrCreated,
DeletedBeforeOrAt: &deletedAt,
Recursive: true,
}
subSpaces, err := c.spaceStore.List(ctx, space.ID, filter)
if err != nil {
return nil, fmt.Errorf("failed to list space %d sub spaces recursively: %w", space.ID, err)
}
var subspacePath string
for _, subspace := range subSpaces {
// check the path depth before restore nested subspaces.
subspacePath = subspace.Path[len(space.Path):]
if err = check.PathDepth(paths.Concatenate(spacePath, subspacePath), true); err != nil {
return nil, fmt.Errorf("path is invalid: %w", err)
}
// identifier and parent ID of sub spaces shouldn't change.
_, err = c.restoreNoAuth(ctx, subspace, nil, nil)
if err != nil {
return nil, fmt.Errorf("failed to restore subspace: %w", err)
}
}
if err := c.restoreRepositoriesNoAuth(ctx, space.ID, deletedAt); err != nil {
return nil, fmt.Errorf("failed to restore space %d repositories: %w", space.ID, err)
}
// restore the target space
restoredSpace, err := c.restoreNoAuth(ctx, space, newIdentifier, newParentID)
if err != nil {
return nil, fmt.Errorf("failed to restore space: %w", err)
}
if err = check.PathDepth(restoredSpace.Path, true); err != nil {
return nil, fmt.Errorf("path is invalid: %w", err)
}
return restoredSpace, nil
}
func (c *Controller) restoreNoAuth(
ctx context.Context,
space *types.Space,
newIdentifier *string,
newParentID *int64,
) (*types.Space, error) {
space, err := c.spaceStore.Restore(ctx, space, newIdentifier, newParentID)
if err != nil {
return nil, fmt.Errorf("failed to restore the space: %w", err)
}
now := time.Now().UnixMilli()
pathSegment := &types.SpacePathSegment{
Identifier: space.Identifier,
IsPrimary: true,
SpaceID: space.ID,
ParentID: space.ParentID,
CreatedBy: space.CreatedBy,
Created: now,
Updated: now,
}
err = c.spacePathStore.InsertSegment(ctx, pathSegment)
if errors.Is(err, store.ErrDuplicate) {
return nil, usererror.BadRequest(fmt.Sprintf("A primary path already exists for %s.",
space.Identifier))
}
if err != nil {
return nil, fmt.Errorf("failed to insert space path on restore: %w", err)
}
return space, nil
}
func (c *Controller) restoreRepositoriesNoAuth(
ctx context.Context,
spaceID int64,
deletedAt int64,
) error {
filter := &types.RepoFilter{
Page: 1,
Size: int(math.MaxInt),
Query: "",
Order: enum.OrderAsc,
Sort: enum.RepoAttrNone,
DeletedBeforeOrAt: &deletedAt,
Recursive: true,
}
repos, err := c.repoStore.List(ctx, spaceID, filter)
if err != nil {
return fmt.Errorf("failed to list space repositories: %w", err)
}
for _, repo := range repos {
_, err = c.repoCtrl.RestoreNoAuth(ctx, repo, nil, nil)
if err != nil {
return fmt.Errorf("failed to restore repository: %w", err)
}
}
return nil
}
func (c *Controller) getParentSpace(
ctx context.Context,
space *types.Space,
newParentRef *string,
) (*types.Space, error) {
var parentSpace *types.Space
var err error
if newParentRef == nil {
if space.ParentID == 0 {
return &types.Space{}, nil
}
parentSpace, err = c.spaceStore.Find(ctx, space.ParentID)
if err != nil {
return nil, fmt.Errorf("failed to find the space parent %d", space.ParentID)
}
return parentSpace, nil
}
// the provided new reference for space parent must exist.
parentSpace, err = c.spaceStore.FindByRef(ctx, *newParentRef)
if err != nil {
return nil, fmt.Errorf("failed to find the parent space by ref '%s': %w - returning usererror %w",
*newParentRef, err, errSpacePathInvalid)
}
return parentSpace, nil
}
func (c *Controller) sanitizeRestoreInput(in *RestoreInput) error {
if in.NewParentRef == nil {
return nil
}
if len(*in.NewParentRef) > 0 && !c.nestedSpacesEnabled {
return errNestedSpacesNotSupported
}
return nil
}

View File

@ -0,0 +1,149 @@
// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package space
import (
"context"
"fmt"
"math"
"time"
apiauth "github.com/harness/gitness/app/api/auth"
"github.com/harness/gitness/app/auth"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
)
type SoftDeleteResponse struct {
DeletedAt int64 `json:"deleted_at"`
}
// SoftDelete marks deleted timestamp for the space and all its subspaces and repositories inside.
func (c *Controller) SoftDelete(
ctx context.Context,
session *auth.Session,
spaceRef string,
) (*SoftDeleteResponse, error) {
space, err := c.spaceStore.FindByRef(ctx, spaceRef)
if err != nil {
return nil, fmt.Errorf("failed to find space for soft delete: %w", err)
}
if err = apiauth.CheckSpace(
ctx,
c.authorizer,
session,
space,
enum.PermissionSpaceDelete,
false,
); err != nil {
return nil, fmt.Errorf("failed to check access: %w", err)
}
return c.SoftDeleteNoAuth(ctx, space)
}
// SoftDeleteNoAuth soft deletes the space - no authorization is verified.
// WARNING For internal calls only.
func (c *Controller) SoftDeleteNoAuth(
ctx context.Context,
space *types.Space,
) (*SoftDeleteResponse, error) {
var softDelRes *SoftDeleteResponse
var err error
err = c.tx.WithTx(ctx, func(ctx context.Context) error {
softDelRes, err = c.softDeleteInnerInTx(ctx, space)
return err
})
if err != nil {
return nil, fmt.Errorf("failed to soft delete the space: %w", err)
}
return softDelRes, nil
}
func (c *Controller) softDeleteInnerInTx(
ctx context.Context,
space *types.Space,
) (*SoftDeleteResponse, error) {
filter := &types.SpaceFilter{
Page: 1,
Size: math.MaxInt,
Query: "",
Order: enum.OrderAsc,
Sort: enum.SpaceAttrCreated,
DeletedBeforeOrAt: nil, // only filter active subspaces
Recursive: true,
}
subSpaces, err := c.spaceStore.List(ctx, space.ID, filter)
if err != nil {
return nil, fmt.Errorf("failed to list space %d sub spaces recursively: %w", space.ID, err)
}
now := time.Now().UnixMilli()
for _, space := range subSpaces {
if err := c.spaceStore.SoftDelete(ctx, space, now); err != nil {
return nil, fmt.Errorf("failed to soft delete subspace: %w", err)
}
}
err = c.softDeleteRepositoriesNoAuth(ctx, space.ID, now)
if err != nil {
return nil, fmt.Errorf("failed to soft delete repositories of space %d: %w", space.ID, err)
}
if err = c.spaceStore.SoftDelete(ctx, space, now); err != nil {
return nil, fmt.Errorf("spaceStore failed to soft delete space: %w", err)
}
err = c.spacePathStore.DeletePathsAndDescendandPaths(ctx, space.ID)
if err != nil {
return nil, fmt.Errorf("spacePathStore failed to delete descendant paths of %d: %w", space.ID, err)
}
return &SoftDeleteResponse{DeletedAt: now}, nil
}
// softDeleteRepositoriesNoAuth soft deletes all repositories in a space - no authorization is verified.
// WARNING For internal calls only.
func (c *Controller) softDeleteRepositoriesNoAuth(
ctx context.Context,
spaceID int64,
deletedAt int64,
) error {
filter := &types.RepoFilter{
Page: 1,
Size: int(math.MaxInt),
Query: "",
Order: enum.OrderAsc,
Sort: enum.RepoAttrNone,
DeletedBeforeOrAt: nil, // only filter active repos
Recursive: true,
}
repos, err := c.repoStore.List(ctx, spaceID, filter)
if err != nil {
return fmt.Errorf("failed to list space repositories: %w", err)
}
for _, repo := range repos {
err = c.repoCtrl.SoftDeleteNoAuth(ctx, repo, deletedAt)
if err != nil {
return fmt.Errorf("failed to soft delete repository: %w", err)
}
}
return nil
}

View File

@ -25,7 +25,6 @@ import (
func HandlePurge(repoCtrl *repo.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
repoRef, err := request.GetRepoRefFromPath(r)
@ -34,7 +33,7 @@ func HandlePurge(repoCtrl *repo.Controller) http.HandlerFunc {
return
}
deletedAt, err := request.GetDeletedAtFromQuery(r)
deletedAt, err := request.GetDeletedAtFromQueryOrError(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return

View File

@ -35,7 +35,7 @@ func HandleRestore(repoCtrl *repo.Controller) http.HandlerFunc {
return
}
deletedAt, err := request.GetDeletedAtFromQuery(r)
deletedAt, err := request.GetDeletedAtFromQueryOrError(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return

View File

@ -34,7 +34,12 @@ func HandleListSpaces(spaceCtrl *space.Controller) http.HandlerFunc {
return
}
spaceFilter := request.ParseSpaceFilter(r)
spaceFilter, err := request.ParseSpaceFilter(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
if spaceFilter.Order == enum.OrderDefault {
spaceFilter.Order = enum.OrderAsc
}

View File

@ -34,17 +34,16 @@ func HandleListRepos(spaceCtrl *space.Controller) http.HandlerFunc {
return
}
filter := request.ParseRepoFilter(r)
if filter.Order == enum.OrderDefault {
filter.Order = enum.OrderAsc
}
filter.Recursive, err = request.ParseRecursiveFromQuery(r)
filter, err := request.ParseRepoFilter(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
if filter.Order == enum.OrderDefault {
filter.Order = enum.OrderAsc
}
repos, count, err := spaceCtrl.ListRepositories(
ctx, session, spaceRef, filter)
if err != nil {

View File

@ -0,0 +1,51 @@
// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package space
import (
"net/http"
"github.com/harness/gitness/app/api/controller/space"
"github.com/harness/gitness/app/api/render"
"github.com/harness/gitness/app/api/request"
)
// HandlePurge handles the purge delete space HTTP API.
func HandlePurge(spaceCtrl *space.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
spaceRef, err := request.GetSpaceRefFromPath(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
deletedAt, err := request.GetDeletedAtFromQueryOrError(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
err = spaceCtrl.Purge(ctx, session, spaceRef, deletedAt)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
render.DeleteSuccessful(w)
}
}

View File

@ -0,0 +1,59 @@
// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package space
import (
"encoding/json"
"net/http"
"github.com/harness/gitness/app/api/controller/space"
"github.com/harness/gitness/app/api/render"
"github.com/harness/gitness/app/api/request"
)
// HandleRestore handles the restore of soft deleted space HTTP API.
func HandleRestore(spaceCtrl *space.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
spaceRef, err := request.GetSpaceRefFromPath(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
deletedAt, err := request.GetDeletedAtFromQueryOrError(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
in := new(space.RestoreInput)
err = json.NewDecoder(r.Body).Decode(in)
if err != nil {
render.BadRequestf(ctx, w, "Invalid request body: %s.", err)
return
}
space, err := spaceCtrl.Restore(ctx, session, spaceRef, deletedAt, in)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
render.JSON(w, http.StatusOK, space)
}
}

View File

@ -22,23 +22,25 @@ import (
"github.com/harness/gitness/app/api/request"
)
// HandleDelete handles the delete space HTTP API.
func HandleDelete(spaceCtrl *space.Controller) http.HandlerFunc {
// HandleSoftDelete handles the soft delete space HTTP API.
func HandleSoftDelete(spaceCtrl *space.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
spaceRef, err := request.GetSpaceRefFromPath(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
err = spaceCtrl.Delete(ctx, session, spaceRef)
res, err := spaceCtrl.SoftDelete(ctx, session, spaceRef)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
render.DeleteSuccessful(w)
render.JSON(w, http.StatusOK, res)
}
}

View File

@ -50,6 +50,11 @@ type exportSpaceRequest struct {
space.ExportInput
}
type restoreSpaceRequest struct {
spaceRequest
space.RestoreInput
}
var queryParameterSortRepo = openapi3.ParameterOrRef{
Parameter: &openapi3.Parameter{
Name: request.QueryParamSort,
@ -246,13 +251,38 @@ func spaceOperations(reflector *openapi3.Reflector) {
opDelete.WithTags("space")
opDelete.WithMapOfAnything(map[string]interface{}{"operationId": "deleteSpace"})
_ = reflector.SetRequest(&opDelete, new(spaceRequest), http.MethodDelete)
_ = reflector.SetJSONResponse(&opDelete, nil, http.StatusNoContent)
_ = reflector.SetJSONResponse(&opDelete, new(space.SoftDeleteResponse), http.StatusOK)
_ = reflector.SetJSONResponse(&opDelete, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&opDelete, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&opDelete, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&opDelete, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodDelete, "/spaces/{space_ref}", opDelete)
opPurge := openapi3.Operation{}
opPurge.WithTags("space")
opPurge.WithMapOfAnything(map[string]interface{}{"operationId": "purgeSpace"})
opPurge.WithParameters(queryParameterDeletedAt)
_ = reflector.SetRequest(&opPurge, new(spaceRequest), http.MethodPost)
_ = reflector.SetJSONResponse(&opPurge, nil, http.StatusNoContent)
_ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&opPurge, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodPost, "/spaces/{space_ref}/purge", opPurge)
opRestore := openapi3.Operation{}
opRestore.WithTags("space")
opRestore.WithMapOfAnything(map[string]interface{}{"operationId": "restoreSpace"})
opRestore.WithParameters(queryParameterDeletedAt)
_ = reflector.SetRequest(&opRestore, new(restoreSpaceRequest), http.MethodPost)
_ = reflector.SetJSONResponse(&opRestore, new(types.Space), http.StatusOK)
_ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusBadRequest)
_ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&opRestore, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodPost, "/spaces/{space_ref}/restore", opRestore)
opMove := openapi3.Operation{}
opMove.WithTags("space")
opMove.WithMapOfAnything(map[string]interface{}{"operationId": "moveSpace"})

View File

@ -29,6 +29,7 @@ const (
QueryParamSort = "sort"
QueryParamOrder = "order"
QueryParamQuery = "query"
QueryParamRecursive = "recursive"
QueryParamState = "state"
QueryParamKind = "kind"
@ -37,7 +38,8 @@ const (
QueryParamAfter = "after"
QueryParamBefore = "before"
QueryParamDeletedAt = "deleted_at"
QueryParamDeletedBeforeOrAt = "deleted_before_or_at"
QueryParamDeletedAt = "deleted_at"
QueryParamPage = "page"
QueryParamLimit = "limit"
@ -121,7 +123,25 @@ func GetContentEncodingFromHeadersOrDefault(r *http.Request, dflt string) string
return GetHeaderOrDefault(r, HeaderContentEncoding, dflt)
}
// GetDeletedAtFromQuery extracts the resource deleted timestamp from the query.
func GetDeletedAtFromQuery(r *http.Request) (int64, error) {
// ParseRecursiveFromQuery extracts the recursive option from the URL query.
func ParseRecursiveFromQuery(r *http.Request) (bool, error) {
return QueryParamAsBoolOrDefault(r, QueryParamRecursive, false)
}
// GetDeletedAtFromQueryOrError gets the exact resource deletion timestamp from the query.
func GetDeletedAtFromQueryOrError(r *http.Request) (int64, error) {
return QueryParamAsPositiveInt64(r, QueryParamDeletedAt)
}
// GetDeletedBeforeOrAtFromQuery gets the resource deletion timestamp from the query as an optional parameter.
func GetDeletedBeforeOrAtFromQuery(r *http.Request) (int64, bool, error) {
value, err := QueryParamAsPositiveInt64OrDefault(r, QueryParamDeletedBeforeOrAt, 0)
if err != nil {
return 0, false, err
}
if value == 0 {
return value, false, nil
}
return value, true, nil
}

View File

@ -23,9 +23,8 @@ import (
)
const (
PathParamRepoRef = "repo_ref"
QueryParamRepoID = "repo_id"
QueryParamRecursive = "recursive"
PathParamRepoRef = "repo_ref"
QueryParamRepoID = "repo_id"
)
func GetRepoRefFromPath(r *http.Request) (string, error) {
@ -46,17 +45,30 @@ func ParseSortRepo(r *http.Request) enum.RepoAttr {
}
// ParseRepoFilter extracts the repository filter from the url.
func ParseRepoFilter(r *http.Request) *types.RepoFilter {
return &types.RepoFilter{
Query: ParseQuery(r),
Order: ParseOrder(r),
Page: ParsePage(r),
Sort: ParseSortRepo(r),
Size: ParseLimit(r),
func ParseRepoFilter(r *http.Request) (*types.RepoFilter, error) {
// recursive is optional to get all repos in a sapce and its subsapces recursively.
recursive, err := ParseRecursiveFromQuery(r)
if err != nil {
return nil, err
}
}
// ParseRecursiveFromQuery extracts the recursive option from the URL query.
func ParseRecursiveFromQuery(r *http.Request) (bool, error) {
return QueryParamAsBoolOrDefault(r, QueryParamRecursive, false)
// deletedBeforeOrAt is optional to retrieve repos deleted before or at the specified timestamp.
var deletionTime *int64
value, ok, err := GetDeletedBeforeOrAtFromQuery(r)
if err != nil {
return nil, err
}
if ok {
deletionTime = &value
}
return &types.RepoFilter{
Query: ParseQuery(r),
Order: ParseOrder(r),
Page: ParsePage(r),
Sort: ParseSortRepo(r),
Size: ParseLimit(r),
Recursive: recursive,
DeletedBeforeOrAt: deletionTime,
}, nil
}

View File

@ -44,12 +44,30 @@ func ParseSortSpace(r *http.Request) enum.SpaceAttr {
}
// ParseSpaceFilter extracts the space filter from the url.
func ParseSpaceFilter(r *http.Request) *types.SpaceFilter {
return &types.SpaceFilter{
Query: ParseQuery(r),
Order: ParseOrder(r),
Page: ParsePage(r),
Sort: ParseSortSpace(r),
Size: ParseLimit(r),
func ParseSpaceFilter(r *http.Request) (*types.SpaceFilter, error) {
// recursive is optional to get sapce and its subsapces recursively.
recursive, err := ParseRecursiveFromQuery(r)
if err != nil {
return nil, err
}
// deletedBeforeOrAt is optional to retrieve spaces deleted before or at the specified timestamp.
var deletionTime *int64
value, ok, err := GetDeletedBeforeOrAtFromQuery(r)
if err != nil {
return nil, err
}
if ok {
deletionTime = &value
}
return &types.SpaceFilter{
Query: ParseQuery(r),
Order: ParseOrder(r),
Page: ParsePage(r),
Sort: ParseSortSpace(r),
Size: ParseLimit(r),
Recursive: recursive,
DeletedBeforeOrAt: deletionTime,
}, nil
}

View File

@ -81,7 +81,7 @@ func (a *MembershipAuthorizer) Check(
//nolint:exhaustive // we want to fail on anything else
switch resource.Type {
case enum.ResourceTypeSpace:
spacePath = paths.Concatinate(scope.SpacePath, resource.Identifier)
spacePath = paths.Concatenate(scope.SpacePath, resource.Identifier)
case enum.ResourceTypeRepo:
spacePath = scope.SpacePath

View File

@ -57,10 +57,14 @@ func (g permissionCacheGetter) Find(ctx context.Context, key PermissionCacheKey)
spaceRef := key.SpaceRef
principalID := key.PrincipalID
// Find the starting space.
space, err := g.spaceStore.FindByRef(ctx, spaceRef)
// Find the first existing space.
space, err := g.findFirstExistingSpace(ctx, spaceRef)
// authz fails if no active space is found on the path; admins can still operate on deleted top-level spaces.
if errors.Is(err, gitness_store.ErrResourceNotFound) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to find space '%s': %w", spaceRef, err)
return false, fmt.Errorf("failed to find an existing space on path '%s': %w", spaceRef, err)
}
// limit the depth to be safe (e.g. root/space1/space2 => maxDepth of 3)
@ -102,3 +106,27 @@ func roleHasPermission(role enum.MembershipRole, permission enum.Permission) boo
_, hasRole := slices.BinarySearch(role.Permissions(), permission)
return hasRole
}
// findFirstExistingSpace returns the initial or first existing ancestor space (permissions are inherited).
func (g permissionCacheGetter) findFirstExistingSpace(ctx context.Context, spaceRef string) (*types.Space, error) {
for {
space, err := g.spaceStore.FindByRef(ctx, spaceRef)
if err == nil {
return space, nil
}
if !errors.Is(err, gitness_store.ErrResourceNotFound) {
return nil, fmt.Errorf("failed to find space '%s': %w", spaceRef, err)
}
// check whether parent space exists as permissions are inherited.
spaceRef, _, err = paths.DisectLeaf(spaceRef)
if err != nil {
return nil, fmt.Errorf("failed to disect path '%s': %w", spaceRef, err)
}
if spaceRef == "" {
return nil, gitness_store.ErrResourceNotFound
}
}
}

View File

@ -60,12 +60,12 @@ func DisectRoot(path string) (string, string, error) {
}
/*
* Concatinate two paths together (takes care of leading / trailing '/')
* Concatenate two paths together (takes care of leading / trailing '/')
* e.g. (space1/, /space2/) -> space1/space2
*
* NOTE: "//" is not a valid path, so all '/' will be trimmed.
*/
func Concatinate(path1 string, path2 string) string {
func Concatenate(path1 string, path2 string) string {
path1 = strings.Trim(path1, types.PathSeparator)
path2 = strings.Trim(path2, types.PathSeparator)

View File

@ -214,7 +214,9 @@ func setupSpaces(r chi.Router, appCtx context.Context, spaceCtrl *space.Controll
// space operations
r.Get("/", handlerspace.HandleFind(spaceCtrl))
r.Patch("/", handlerspace.HandleUpdate(spaceCtrl))
r.Delete("/", handlerspace.HandleDelete(spaceCtrl))
r.Delete("/", handlerspace.HandleSoftDelete(spaceCtrl))
r.Post("/restore", handlerspace.HandleRestore(spaceCtrl))
r.Post("/purge", handlerspace.HandlePurge(spaceCtrl))
r.Get("/events", handlerspace.HandleEvents(appCtx, spaceCtrl))

View File

@ -64,7 +64,7 @@ func (c *pathCache) Get(ctx context.Context, key string) (*types.SpacePath, erro
segments := paths.Segments(key)
uniqueKey := ""
for i, segment := range segments {
uniqueKey = paths.Concatinate(uniqueKey, c.spacePathTransformation(segment, i == 0))
uniqueKey = paths.Concatenate(uniqueKey, c.spacePathTransformation(segment, i == 0))
}
return c.inner.Get(ctx, uniqueKey)

View File

@ -146,6 +146,9 @@ type (
// DeletePrimarySegment deletes the primary segment of a space.
DeletePrimarySegment(ctx context.Context, spaceID int64) error
// DeletePathsAndDescendandPaths deletes all space paths reachable from spaceID including itself.
DeletePathsAndDescendandPaths(ctx context.Context, spaceID int64) error
}
// SpaceStore defines the space data storage.
@ -156,6 +159,9 @@ type (
// FindByRef finds the space using the spaceRef as either the id or the space path.
FindByRef(ctx context.Context, spaceRef string) (*types.Space, error)
// FindByRefAndDeletedAt finds the space using the spaceRef and deleted timestamp.
FindByRefAndDeletedAt(ctx context.Context, spaceRef string, deletedAt int64) (*types.Space, error)
// GetRootSpace returns a space where space_parent_id is NULL.
GetRootSpace(ctx context.Context, spaceID int64) (*types.Space, error)
@ -169,8 +175,15 @@ type (
UpdateOptLock(ctx context.Context, space *types.Space,
mutateFn func(space *types.Space) error) (*types.Space, error)
// Delete deletes the space.
Delete(ctx context.Context, id int64) error
// SoftDelete deletes the space.
SoftDelete(ctx context.Context, space *types.Space, deletedAt int64) error
// Purge deletes a space permanently.
Purge(ctx context.Context, id int64, deletedAt *int64) error
// Restore restores a soft deleted space.
Restore(ctx context.Context, space *types.Space,
newIdentifier *string, newParentID *int64) (*types.Space, error)
// Count the child spaces of a space.
Count(ctx context.Context, id int64, opts *types.SpaceFilter) (int64, error)
@ -214,7 +227,7 @@ type (
// Restore a deleted repo using the optimistic locking mechanism.
Restore(ctx context.Context, repo *types.Repository,
newIdentifier string) (*types.Repository, error)
newIdentifier *string, newParentID *int64) (*types.Repository, error)
// Count of active repos in a space. With "DeletedBeforeOrAt" filter, counts deleted repos.
Count(ctx context.Context, parentID int64, opts *types.RepoFilter) (int64, error)

View File

@ -37,11 +37,13 @@ func NewMembershipStore(
db *sqlx.DB,
pCache store.PrincipalInfoCache,
spacePathStore store.SpacePathStore,
spaceStore store.SpaceStore,
) *MembershipStore {
return &MembershipStore{
db: db,
pCache: pCache,
spacePathStore: spacePathStore,
spaceStore: spaceStore,
}
}
@ -50,6 +52,7 @@ type MembershipStore struct {
db *sqlx.DB
pCache store.PrincipalInfoCache
spacePathStore store.SpacePathStore
spaceStore store.SpaceStore
}
type membership struct {
@ -472,7 +475,7 @@ func (s *MembershipStore) mapToMembershipSpaces(ctx context.Context,
for i := range ms {
m := ms[i]
res[i].Membership = mapToMembership(&m.membership)
space, err := mapToSpace(ctx, s.spacePathStore, &m.space)
space, err := mapToSpace(ctx, s.db, s.spacePathStore, &m.space)
if err != nil {
return nil, fmt.Errorf("faild to map space %d: %w", m.space.ID, err)
}

View File

@ -105,7 +105,7 @@ func migrateAfter_0039_alter_table_webhooks_uid(ctx context.Context, dbtx *sql.T
for i := 0; i < n; i++ {
wh := buffer[i]
// concatinate repoID + spaceID to get unique parent id (only used to identify same parents)
// concatenate repoID + spaceID to get unique parent id (only used to identify same parents)
newParentID := fmt.Sprintf("%d_%d", wh.repoID.ValueOrZero(), wh.spaceID.ValueOrZero())
if newParentID != parentID {
// new parent? reset child identifiers

View File

@ -0,0 +1,7 @@
ALTER TABLE spaces DROP COLUMN space_deleted;
DROP INDEX spaces_parent_id;
DROP INDEX spaces_deleted;
CREATE INDEX spaces_parent_id
ON spaces(space_parent_id);

View File

@ -0,0 +1,11 @@
ALTER TABLE spaces ADD COLUMN space_deleted BIGINT DEFAULT NULL;
DROP INDEX spaces_parent_id;
CREATE INDEX spaces_parent_id
ON spaces(space_parent_id)
WHERE space_deleted IS NULL;
CREATE INDEX spaces_deleted_parent_id
ON spaces(space_deleted, space_parent_id)
WHERE space_deleted IS NOT NULL;

View File

@ -0,0 +1,7 @@
ALTER TABLE spaces DROP COLUMN space_deleted;
DROP INDEX spaces_parent_id;
DROP INDEX spaces_deleted;
CREATE INDEX spaces_parent_id
ON spaces(space_parent_id);

View File

@ -0,0 +1,11 @@
ALTER TABLE spaces ADD COLUMN space_deleted BIGINT DEFAULT NULL;
DROP INDEX spaces_parent_id;
CREATE INDEX spaces_parent_id
ON spaces(space_parent_id)
WHERE space_deleted IS NULL;
CREATE INDEX spaces_deleted_parent_id
ON spaces(space_deleted, space_parent_id)
WHERE space_deleted IS NOT NULL;

View File

@ -42,11 +42,13 @@ func NewRepoStore(
db *sqlx.DB,
spacePathCache store.SpacePathCache,
spacePathStore store.SpacePathStore,
spaceStore store.SpaceStore,
) *RepoStore {
return &RepoStore{
db: db,
spacePathCache: spacePathCache,
spacePathStore: spacePathStore,
spaceStore: spaceStore,
}
}
@ -55,6 +57,7 @@ type RepoStore struct {
db *sqlx.DB
spacePathCache store.SpacePathCache
spacePathStore store.SpacePathStore
spaceStore store.SpaceStore
}
type repository struct {
@ -492,12 +495,16 @@ func (s *RepoStore) Purge(ctx context.Context, id int64, deletedAt *int64) error
func (s *RepoStore) Restore(
ctx context.Context,
repo *types.Repository,
newIdentifier string,
newIdentifier *string,
newParentID *int64,
) (*types.Repository, error) {
repo, err := s.updateDeletedOptLock(ctx, repo, func(r *types.Repository) error {
r.Deleted = nil
if newIdentifier != "" {
r.Identifier = newIdentifier
if newIdentifier != nil {
r.Identifier = *newIdentifier
}
if newParentID != nil {
r.ParentID = *newParentID
}
return nil
})
@ -752,10 +759,14 @@ func (s *RepoStore) mapToRepo(
func (s *RepoStore) getRepoPath(ctx context.Context, parentID int64, repoIdentifier string) (string, error) {
spacePath, err := s.spacePathStore.FindPrimaryBySpaceID(ctx, parentID)
// try to re-create the space path if was soft deleted.
if errors.Is(err, gitness_store.ErrResourceNotFound) {
return getPathForDeletedSpace(ctx, s.db, parentID)
}
if err != nil {
return "", fmt.Errorf("failed to get primary path for space %d: %w", parentID, err)
}
return paths.Concatinate(spacePath.Value, repoIdentifier), nil
return paths.Concatenate(spacePath.Value, repoIdentifier), nil
}
func (s *RepoStore) mapToRepos(

View File

@ -78,7 +78,7 @@ func setupStores(t *testing.T, db *sqlx.DB) (
spacePathCache := cache.New(spacePathStore, spacePathTransformation)
spaceStore := database.NewSpaceStore(db, spacePathCache, spacePathStore)
repoStore := database.NewRepoStore(db, spacePathCache, spacePathStore)
repoStore := database.NewRepoStore(db, spacePathCache, spacePathStore, spaceStore)
return principalStore, spaceStore, spacePathStore, repoStore
}

View File

@ -21,6 +21,7 @@ import (
"strings"
"time"
"github.com/harness/gitness/app/paths"
"github.com/harness/gitness/app/store"
gitness_store "github.com/harness/gitness/store"
"github.com/harness/gitness/store/database"
@ -28,6 +29,7 @@ import (
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/Masterminds/squirrel"
"github.com/guregu/null"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
@ -67,6 +69,7 @@ type space struct {
CreatedBy int64 `db:"space_created_by"`
Created int64 `db:"space_created"`
Updated int64 `db:"space_updated"`
Deleted null.Int `db:"space_deleted"`
}
const (
@ -79,7 +82,8 @@ const (
,space_is_public
,space_created_by
,space_created
,space_updated`
,space_updated
,space_deleted`
spaceSelectBase = `
SELECT` + spaceColumns + `
@ -88,21 +92,57 @@ const (
// Find the space by id.
func (s *SpaceStore) Find(ctx context.Context, id int64) (*types.Space, error) {
const sqlQuery = spaceSelectBase + `
WHERE space_id = $1`
return s.find(ctx, id, nil)
}
func (s *SpaceStore) find(ctx context.Context, id int64, deletedAt *int64) (*types.Space, error) {
stmt := database.Builder.
Select(spaceColumns).
From("spaces").
Where("space_id = ?", id)
if deletedAt != nil {
stmt = stmt.Where("space_deleted = ?", *deletedAt)
} else {
stmt = stmt.Where("space_deleted IS NULL")
}
db := dbtx.GetAccessor(ctx, s.db)
dst := new(space)
if err := db.GetContext(ctx, dst, sqlQuery, id); err != nil {
sql, args, err := stmt.ToSql()
if err != nil {
return nil, errors.Wrap(err, "Failed to convert query to sql")
}
if err = db.GetContext(ctx, dst, sql, args...); err != nil {
return nil, database.ProcessSQLErrorf(ctx, err, "Failed to find space")
}
return mapToSpace(ctx, s.spacePathStore, dst)
return mapToSpace(ctx, s.db, s.spacePathStore, dst)
}
// FindByRef finds the space using the spaceRef as either the id or the space path.
func (s *SpaceStore) FindByRef(ctx context.Context, spaceRef string) (*types.Space, error) {
return s.findByRef(ctx, spaceRef, nil)
}
// FindByRefAndDeletedAt finds the space using the spaceRef as either the id or the space path and deleted timestamp.
func (s *SpaceStore) FindByRefAndDeletedAt(
ctx context.Context,
spaceRef string,
deletedAt int64,
) (*types.Space, error) {
// ASSUMPTION: digits only is not a valid space path
id, err := strconv.ParseInt(spaceRef, 10, 64)
if err != nil {
return s.findByPathAndDeletedAt(ctx, spaceRef, deletedAt)
}
return s.find(ctx, id, &deletedAt)
}
func (s *SpaceStore) findByRef(ctx context.Context, spaceRef string, deletedAt *int64) (*types.Space, error) {
// ASSUMPTION: digits only is not a valid space path
id, err := strconv.ParseInt(spaceRef, 10, 64)
if err != nil {
@ -114,8 +154,44 @@ func (s *SpaceStore) FindByRef(ctx context.Context, spaceRef string) (*types.Spa
id = path.SpaceID
}
return s.find(ctx, id, deletedAt)
}
return s.Find(ctx, id)
func (s *SpaceStore) findByPathAndDeletedAt(
ctx context.Context,
spaceRef string,
deletedAt int64,
) (*types.Space, error) {
segments := paths.Segments(spaceRef)
if len(segments) < 1 {
return nil, fmt.Errorf("invalid space reference provided")
}
var stmt squirrel.SelectBuilder
switch {
case len(segments) == 1:
stmt = database.Builder.
Select("space_id").
From("spaces").
Where("space_uid = ? AND space_deleted = ? AND space_parent_id IS NULL", segments[0], deletedAt)
case len(segments) > 1:
stmt = buildRecursiveSelectQueryUsingPath(segments, deletedAt)
}
sql, args, err := stmt.ToSql()
if err != nil {
return nil, errors.Wrap(err, "Failed to create sql query")
}
db := dbtx.GetAccessor(ctx, s.db)
var spaceID int64
if err = db.GetContext(ctx, &spaceID, sql, args...); err != nil {
return nil, database.ProcessSQLErrorf(ctx, err, "Failed executing custom select query")
}
return s.find(ctx, spaceID, &deletedAt)
}
// GetRootSpace returns a space where space_parent_id is NULL.
@ -161,6 +237,7 @@ func (s *SpaceStore) Create(ctx context.Context, space *types.Space) error {
,space_created_by
,space_created
,space_updated
,space_deleted
) values (
:space_version
,:space_parent_id
@ -170,6 +247,7 @@ func (s *SpaceStore) Create(ctx context.Context, space *types.Space) error {
,:space_created_by
,:space_created
,:space_updated
,:space_deleted
) RETURNING space_id`
db := dbtx.GetAccessor(ctx, s.db)
@ -201,6 +279,7 @@ func (s *SpaceStore) Update(ctx context.Context, space *types.Space) error {
,space_uid = :space_uid
,space_description = :space_description
,space_is_public = :space_is_public
,space_deleted = :space_deleted
WHERE space_id = :space_id AND space_version = :space_version - 1`
dbSpace := mapToInternalSpace(space)
@ -234,7 +313,7 @@ func (s *SpaceStore) Update(ctx context.Context, space *types.Space) error {
space.Updated = dbSpace.Updated
// update path in case parent/identifier changed
space.Path, err = getSpacePath(ctx, s.spacePathStore, space.ID)
space.Path, err = getSpacePath(ctx, s.db, s.spacePathStore, space.ID)
if err != nil {
return err
}
@ -242,8 +321,9 @@ func (s *SpaceStore) Update(ctx context.Context, space *types.Space) error {
return nil
}
// UpdateOptLock updates the space using the optimistic locking mechanism.
func (s *SpaceStore) UpdateOptLock(ctx context.Context,
// updateOptLock updates the space using the optimistic locking mechanism.
func (s *SpaceStore) updateOptLock(
ctx context.Context,
space *types.Space,
mutateFn func(space *types.Space) error,
) (*types.Space, error) {
@ -263,30 +343,129 @@ func (s *SpaceStore) UpdateOptLock(ctx context.Context,
return nil, err
}
space, err = s.Find(ctx, space.ID)
space, err = s.find(ctx, space.ID, space.Deleted)
if err != nil {
return nil, err
}
}
}
// Delete deletes a space.
func (s *SpaceStore) Delete(ctx context.Context, id int64) error {
const sqlQuery = `
DELETE FROM spaces
WHERE space_id = $1`
// UpdateOptLock updates the space using the optimistic locking mechanism.
func (s *SpaceStore) UpdateOptLock(
ctx context.Context,
space *types.Space,
mutateFn func(space *types.Space) error,
) (*types.Space, error) {
return s.updateOptLock(
ctx,
space,
func(r *types.Space) error {
if space.Deleted != nil {
return gitness_store.ErrResourceNotFound
}
return mutateFn(r)
},
)
}
// UpdateDeletedOptLock updates a soft deleted space using the optimistic locking mechanism.
func (s *SpaceStore) updateDeletedOptLock(
ctx context.Context,
space *types.Space,
mutateFn func(space *types.Space) error,
) (*types.Space, error) {
return s.updateOptLock(
ctx,
space,
func(r *types.Space) error {
if space.Deleted == nil {
return gitness_store.ErrResourceNotFound
}
return mutateFn(r)
},
)
}
// SoftDelete deletes a space softly.
func (s *SpaceStore) SoftDelete(
ctx context.Context,
space *types.Space,
deletedAt int64,
) error {
_, err := s.UpdateOptLock(ctx, space, func(s *types.Space) error {
s.Deleted = &deletedAt
return nil
})
if err != nil {
return err
}
return nil
}
// Purge deletes a space permanently.
func (s *SpaceStore) Purge(ctx context.Context, id int64, deletedAt *int64) error {
stmt := database.Builder.
Delete("spaces").
Where("space_id = ?", id)
if deletedAt != nil {
stmt = stmt.Where("space_deleted = ?", *deletedAt)
} else {
stmt = stmt.Where("space_deleted IS NULL")
}
sql, args, err := stmt.ToSql()
if err != nil {
return fmt.Errorf("failed to convert purge space query to sql: %w", err)
}
db := dbtx.GetAccessor(ctx, s.db)
if _, err := db.ExecContext(ctx, sqlQuery, id); err != nil {
return database.ProcessSQLErrorf(ctx, err, "The delete query failed")
_, err = db.ExecContext(ctx, sql, args...)
if err != nil {
return database.ProcessSQLErrorf(ctx, err, "the delete query failed")
}
return nil
}
// Restore restores a soft deleted space.
func (s *SpaceStore) Restore(
ctx context.Context,
space *types.Space,
newIdentifier *string,
newParentID *int64,
) (*types.Space, error) {
space, err := s.updateDeletedOptLock(ctx, space, func(s *types.Space) error {
s.Deleted = nil
if newParentID != nil {
s.ParentID = *newParentID
}
if newIdentifier != nil {
s.Identifier = *newIdentifier
}
return nil
})
if err != nil {
return nil, err
}
return space, nil
}
// Count the child spaces of a space.
func (s *SpaceStore) Count(ctx context.Context, id int64, opts *types.SpaceFilter) (int64, error) {
if opts.Recursive {
return s.countAll(ctx, id, opts)
}
return s.count(ctx, id, opts)
}
func (s *SpaceStore) count(
ctx context.Context,
id int64,
opts *types.SpaceFilter,
) (int64, error) {
stmt := database.Builder.
Select("count(*)").
From("spaces").
@ -296,6 +475,8 @@ func (s *SpaceStore) Count(ctx context.Context, id int64, opts *types.SpaceFilte
stmt = stmt.Where("LOWER(space_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query)))
}
stmt = s.applyQueryFilter(stmt, opts)
sql, args, err := stmt.ToSql()
if err != nil {
return 0, errors.Wrap(err, "Failed to convert query to sql")
@ -312,33 +493,70 @@ func (s *SpaceStore) Count(ctx context.Context, id int64, opts *types.SpaceFilte
return count, nil
}
func (s *SpaceStore) countAll(
ctx context.Context,
id int64,
opts *types.SpaceFilter,
) (int64, error) {
ctePrefix := `WITH RECURSIVE SpaceHierarchy AS (
SELECT space_id, space_parent_id, space_deleted, space_uid
FROM spaces
WHERE space_id = ?
UNION
SELECT s.space_id, s.space_parent_id, s.space_deleted, s.space_uid
FROM spaces s
JOIN SpaceHierarchy h ON s.space_parent_id = h.space_id
)`
db := dbtx.GetAccessor(ctx, s.db)
stmt := database.Builder.
Select("COUNT(*)").
Prefix(ctePrefix, id).
From("SpaceHierarchy h1").
Where("h1.space_id <> ?", id)
stmt = s.applyQueryFilter(stmt, opts)
sql, args, err := stmt.ToSql()
if err != nil {
return 0, errors.Wrap(err, "Failed to convert query to sql")
}
var count int64
if err = db.GetContext(ctx, &count, sql, args...); err != nil {
return 0, database.ProcessSQLErrorf(ctx, err, "failed to count sub spaces")
}
return count, nil
}
// List returns a list of spaces under the parent space.
func (s *SpaceStore) List(ctx context.Context, id int64, opts *types.SpaceFilter) ([]*types.Space, error) {
func (s *SpaceStore) List(
ctx context.Context,
id int64,
opts *types.SpaceFilter,
) ([]*types.Space, error) {
if opts.Recursive {
return s.listAll(ctx, id, opts)
}
return s.list(ctx, id, opts)
}
func (s *SpaceStore) list(
ctx context.Context,
id int64,
opts *types.SpaceFilter,
) ([]*types.Space, error) {
stmt := database.Builder.
Select(spaceColumns).
From("spaces").
Where("space_parent_id = ?", fmt.Sprint(id))
stmt = stmt.Limit(database.Limit(opts.Size))
stmt = stmt.Offset(database.Offset(opts.Page, opts.Size))
if opts.Query != "" {
stmt = stmt.Where("LOWER(space_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query)))
}
switch opts.Sort {
case enum.SpaceAttrUID, enum.SpaceAttrIdentifier, enum.SpaceAttrNone:
// NOTE: string concatenation is safe because the
// order attribute is an enum and is not user-defined,
// and is therefore not subject to injection attacks.
stmt = stmt.OrderBy("space_uid " + opts.Order.String())
//TODO: Postgres does not support COLLATE NOCASE for UTF8
// stmt = stmt.OrderBy("space_uid COLLATE NOCASE " + opts.Order.String())
case enum.SpaceAttrCreated:
stmt = stmt.OrderBy("space_created " + opts.Order.String())
case enum.SpaceAttrUpdated:
stmt = stmt.OrderBy("space_updated " + opts.Order.String())
}
stmt = s.applyQueryFilter(stmt, opts)
stmt = s.applySortFilter(stmt, opts)
sql, args, err := stmt.ToSql()
if err != nil {
@ -352,11 +570,121 @@ func (s *SpaceStore) List(ctx context.Context, id int64, opts *types.SpaceFilter
return nil, database.ProcessSQLErrorf(ctx, err, "Failed executing custom list query")
}
return s.mapToSpaces(ctx, dst)
return s.mapToSpaces(ctx, s.db, dst)
}
func (s *SpaceStore) listAll(ctx context.Context,
id int64,
opts *types.SpaceFilter,
) ([]*types.Space, error) {
ctePrefix := `WITH RECURSIVE SpaceHierarchy AS (
SELECT *
FROM spaces
WHERE space_id = ?
UNION
SELECT s.*
FROM spaces s
JOIN SpaceHierarchy h ON s.space_parent_id = h.space_id
)`
db := dbtx.GetAccessor(ctx, s.db)
stmt := database.Builder.
Select(spaceColumns).
Prefix(ctePrefix, id).
From("SpaceHierarchy h1").
Where("h1.space_id <> ?", id)
stmt = s.applyQueryFilter(stmt, opts)
stmt = s.applySortFilter(stmt, opts)
sql, args, err := stmt.ToSql()
if err != nil {
return nil, errors.Wrap(err, "Failed to convert query to sql")
}
var dst []*space
if err = db.SelectContext(ctx, &dst, sql, args...); err != nil {
return nil, database.ProcessSQLErrorf(ctx, err, "Failed executing custom list query")
}
return s.mapToSpaces(ctx, s.db, dst)
}
func (s *SpaceStore) applyQueryFilter(
stmt squirrel.SelectBuilder,
opts *types.SpaceFilter,
) squirrel.SelectBuilder {
if opts.Query != "" {
stmt = stmt.Where("LOWER(space_uid) LIKE ?", fmt.Sprintf("%%%s%%", strings.ToLower(opts.Query)))
}
if opts.DeletedBeforeOrAt != nil {
stmt = stmt.Where("space_deleted <= ?", opts.DeletedBeforeOrAt)
} else {
stmt = stmt.Where("space_deleted IS NULL")
}
return stmt
}
func getPathForDeletedSpace(
ctx context.Context,
sqlxdb *sqlx.DB,
id int64,
) (string, error) {
sqlQuery := spaceSelectBase + `
where space_id = $1`
path := ""
nextSpaceID := null.IntFrom(id)
db := dbtx.GetAccessor(ctx, sqlxdb)
dst := new(space)
for nextSpaceID.Valid {
err := db.GetContext(ctx, dst, sqlQuery, nextSpaceID.Int64)
if err != nil {
return "", fmt.Errorf("failed to find the space %d: %w", id, err)
}
path = paths.Concatenate(dst.Identifier, path)
nextSpaceID = dst.ParentID
}
return path, nil
}
func (s *SpaceStore) applySortFilter(
stmt squirrel.SelectBuilder,
opts *types.SpaceFilter,
) squirrel.SelectBuilder {
stmt = stmt.Limit(database.Limit(opts.Size))
stmt = stmt.Offset(database.Offset(opts.Page, opts.Size))
switch opts.Sort {
case enum.SpaceAttrUID, enum.SpaceAttrIdentifier, enum.SpaceAttrNone:
// NOTE: string concatenation is safe because the
// order attribute is an enum and is not user-defined,
// and is therefore not subject to injection attacks.
stmt = stmt.OrderBy("space_uid " + opts.Order.String())
//TODO: Postgres does not support COLLATE NOCASE for UTF8
// stmt = stmt.OrderBy("space_uid COLLATE NOCASE " + opts.Order.String())
case enum.SpaceAttrCreated:
stmt = stmt.OrderBy("space_created " + opts.Order.String())
case enum.SpaceAttrUpdated:
stmt = stmt.OrderBy("space_updated " + opts.Order.String())
case enum.SpaceAttrDeleted:
stmt = stmt.OrderBy("space_deleted " + opts.Order.String())
}
return stmt
}
func mapToSpace(
ctx context.Context,
sqlxdb *sqlx.DB,
spacePathStore store.SpacePathStore,
in *space,
) (*types.Space, error) {
@ -370,6 +698,7 @@ func mapToSpace(
Created: in.Created,
CreatedBy: in.CreatedBy,
Updated: in.Updated,
Deleted: in.Deleted.Ptr(),
}
// Only overwrite ParentID if it's not a root space
@ -378,7 +707,7 @@ func mapToSpace(
}
// backfill path
res.Path, err = getSpacePath(ctx, spacePathStore, in.ID)
res.Path, err = getSpacePath(ctx, sqlxdb, spacePathStore, in.ID)
if err != nil {
return nil, fmt.Errorf("failed to get primary path for space %d: %w", in.ID, err)
}
@ -388,10 +717,15 @@ func mapToSpace(
func getSpacePath(
ctx context.Context,
sqlxdb *sqlx.DB,
spacePathStore store.SpacePathStore,
spaceID int64,
) (string, error) {
spacePath, err := spacePathStore.FindPrimaryBySpaceID(ctx, spaceID)
// delete space will delete paths; generate the path if space is soft deleted.
if errors.Is(err, gitness_store.ErrResourceNotFound) {
return getPathForDeletedSpace(ctx, sqlxdb, spaceID)
}
if err != nil {
return "", fmt.Errorf("failed to get primary path for space %d: %w", spaceID, err)
}
@ -401,12 +735,13 @@ func getSpacePath(
func (s *SpaceStore) mapToSpaces(
ctx context.Context,
sqlxdb *sqlx.DB,
spaces []*space,
) ([]*types.Space, error) {
var err error
res := make([]*types.Space, len(spaces))
for i := range spaces {
res[i], err = mapToSpace(ctx, s.spacePathStore, spaces[i])
res[i], err = mapToSpace(ctx, sqlxdb, s.spacePathStore, spaces[i])
if err != nil {
return nil, err
}
@ -424,6 +759,7 @@ func mapToInternalSpace(s *types.Space) *space {
Created: s.Created,
CreatedBy: s.CreatedBy,
Updated: s.Updated,
Deleted: null.IntFromPtr(s.Deleted),
}
// Only overwrite ParentID if it's not a root space
@ -434,3 +770,27 @@ func mapToInternalSpace(s *types.Space) *space {
return res
}
// buildRecursiveSelectQueryUsingPath builds the recursive select query using path among active or soft deleted spaces.
func buildRecursiveSelectQueryUsingPath(segments []string, deletedAt int64) squirrel.SelectBuilder {
leaf := "s" + fmt.Sprint(len(segments)-1)
// add the current space (leaf)
stmt := database.Builder.
Select(leaf+".space_id").
From("spaces "+leaf).
Where(leaf+".space_uid = ? AND "+leaf+".space_deleted = ?", segments[len(segments)-1], deletedAt)
for i := len(segments) - 2; i >= 0; i-- {
parentAlias := "s" + fmt.Sprint(i)
alias := "s" + fmt.Sprint(i+1)
stmt = stmt.InnerJoin(fmt.Sprintf("spaces %s ON %s.space_id = %s.space_parent_id", parentAlias, parentAlias, alias)).
Where(parentAlias+".space_uid = ?", segments[i])
}
// add parent check for root
stmt = stmt.Where("s0.space_parent_id IS NULL")
return stmt
}

View File

@ -31,7 +31,10 @@ import (
var _ store.SpacePathStore = (*SpacePathStore)(nil)
// NewSpacePathStore returns a new SpacePathStore.
func NewSpacePathStore(db *sqlx.DB, pathTransformation store.SpacePathTransformation) *SpacePathStore {
func NewSpacePathStore(
db *sqlx.DB,
pathTransformation store.SpacePathTransformation,
) *SpacePathStore {
return &SpacePathStore{
db: db,
spacePathTransformation: pathTransformation,
@ -132,7 +135,7 @@ func (s *SpacePathStore) FindPrimaryBySpaceID(ctx context.Context, spaceID int64
return nil, database.ProcessSQLErrorf(ctx, err, "Failed to find primary segment for %d", nextSpaceID.Int64)
}
path = paths.Concatinate(dst.Identifier, path)
path = paths.Concatenate(dst.Identifier, path)
nextSpaceID = dst.ParentID
}
@ -176,7 +179,7 @@ func (s *SpacePathStore) FindByPath(ctx context.Context, path string) (*types.Sp
)
}
originalPath = paths.Concatinate(originalPath, segment.Identifier)
originalPath = paths.Concatenate(originalPath, segment.Identifier)
parentID = segment.SpaceID
isPrimary = isPrimary && segment.IsPrimary.ValueOrZero()
}
@ -203,6 +206,31 @@ func (s *SpacePathStore) DeletePrimarySegment(ctx context.Context, spaceID int64
return nil
}
// DeletePathsAndDescendandPaths deletes all space paths reachable from spaceID including itself.
func (s *SpacePathStore) DeletePathsAndDescendandPaths(ctx context.Context, spaceID int64) error {
const sqlQuery = `WITH RECURSIVE DescendantPaths AS (
SELECT space_path_id, space_path_space_id, space_path_parent_id
FROM space_paths
WHERE space_path_space_id = $1
UNION
SELECT sp.space_path_id, sp.space_path_space_id, sp.space_path_parent_id
FROM space_paths sp
JOIN DescendantPaths dp ON sp.space_path_parent_id = dp.space_path_space_id
)
DELETE FROM space_paths
WHERE space_path_id IN (SELECT space_path_id FROM DescendantPaths);`
db := dbtx.GetAccessor(ctx, s.db)
if _, err := db.ExecContext(ctx, sqlQuery, spaceID); err != nil {
return database.ProcessSQLErrorf(ctx, err, "the delete query failed")
}
return nil
}
func (s *SpacePathStore) mapToInternalSpacePathSegment(p *types.SpacePathSegment) *spacePathSegment {
res := &spacePathSegment{
ID: p.ID,

View File

@ -107,8 +107,9 @@ func ProvideRepoStore(
db *sqlx.DB,
spacePathCache store.SpacePathCache,
spacePathStore store.SpacePathStore,
spaceStore store.SpaceStore,
) store.RepoStore {
return NewRepoStore(db, spacePathCache, spacePathStore)
return NewRepoStore(db, spacePathCache, spacePathStore, spaceStore)
}
// ProvideRuleStore provides a rule store.
@ -178,8 +179,9 @@ func ProvideMembershipStore(
db *sqlx.DB,
principalInfoCache store.PrincipalInfoCache,
spacePathStore store.SpacePathStore,
spaceStore store.SpaceStore,
) store.MembershipStore {
return NewMembershipStore(db, principalInfoCache, spacePathStore)
return NewMembershipStore(db, principalInfoCache, spacePathStore, spaceStore)
}
// ProvideTokenStore provides a token store.

View File

@ -108,7 +108,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
spaceStore := database.ProvideSpaceStore(db, spacePathCache, spacePathStore)
principalInfoView := database.ProvidePrincipalInfoView(db)
principalInfoCache := cache.ProvidePrincipalInfoCache(principalInfoView)
membershipStore := database.ProvideMembershipStore(db, principalInfoCache, spacePathStore)
membershipStore := database.ProvideMembershipStore(db, principalInfoCache, spacePathStore, spaceStore)
permissionCache := authz.ProvidePermissionCache(spaceStore, membershipStore)
authorizer := authz.ProvideAuthorizer(permissionCache, spaceStore)
principalUIDTransformation := store.ProvidePrincipalUIDTransformation()
@ -122,7 +122,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
if err != nil {
return nil, err
}
repoStore := database.ProvideRepoStore(db, spacePathCache, spacePathStore)
repoStore := database.ProvideRepoStore(db, spacePathCache, spacePathStore, spaceStore)
pipelineStore := database.ProvidePipelineStore(db)
ruleStore := database.ProvideRuleStore(db, principalInfoCache)
protectionManager, err := protection.ProvideManager(ruleStore)

View File

@ -426,12 +426,11 @@ func getChangeInfoTypes(
return changeInfoTypes, nil
}
// Will match "31\t0\t.harness/apidiff.yaml".
// Will extract 31, 0 and .harness/apidiff.yaml.
var insertionsDeletionsRegex = regexp.MustCompile(`(\d+)\t(\d+)\t(.+)`)
// Will match "31\t0\t.harness/apidiff.yaml" and extract 31, 0 and .harness/apidiff.yaml.
// Will match "-\t-\ttools/code-api/chart/charts/harness-common-1.0.27.tgz" and extract -, -, and a filename.
var insertionsDeletionsRegex = regexp.MustCompile(`(\d+|-)\t(\d+|-)\t(.+)`)
// Will match "0\t0\tREADME.md => README_new.md".
// Will extract README_new.md.
// Will match "0\t0\tREADME.md => README_new.md" and extract README_new.md.
var renameRegexWithArrow = regexp.MustCompile(`\d+\t\d+\t.+\s=>\s(.+)`)
func getChangeInfoChanges(
@ -457,6 +456,11 @@ func getChangeInfoChanges(
path = renMatches[1]
}
if matches[1] == "-" || matches[2] == "-" {
changeInfos[path] = changeInfoChange{}
continue
}
insertions, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil {
return map[string]changeInfoChange{},

1
go.mod
View File

@ -73,7 +73,6 @@ require (
cloud.google.com/go/iam v1.1.0 // indirect
dario.cat/mergo v1.0.0 // indirect
github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/antonmedv/expr v1.15.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar v1.3.4 // indirect

27
go.sum
View File

@ -40,11 +40,7 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/antonmedv/expr v1.15.2 h1:afFXpDWIC2n3bF+kTZE1JvFo+c34uaM3sTqh8z0xfdU=
github.com/antonmedv/expr v1.15.2/go.mod h1:0E/6TxnOlRNp81GMzX9QfDPAmHo2Phg00y4JUv1ihsE=
@ -69,9 +65,6 @@ github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQ
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/bmatcuk/doublestar/v4 v4.6.0 h1:HTuxyug8GyFbRkrffIpzNCSK4luc0TY3wzXvzIZhEXc=
github.com/bmatcuk/doublestar/v4 v4.6.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bool64/dev v0.1.41/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU=
github.com/bool64/dev v0.1.42/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU=
github.com/bool64/dev v0.2.22 h1:YJFKBRKplkt+0Emq/5Xk1Z5QRmMNzc1UOJkR3rxJksA=
github.com/bool64/dev v0.2.32 h1:DRZtloaoH1Igky3zphaUHV9+SLIV2H3lsf78JsJHFg0=
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
github.com/buildkite/yaml v2.1.0+incompatible h1:xirI+ql5GzfikVNDmt+yeiXpf/v1Gt03qXTtT5WXdr8=
@ -194,7 +187,6 @@ github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgO
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs=
@ -236,7 +228,6 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -415,15 +406,12 @@ github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXL
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
@ -499,10 +487,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/natessilva/dag v0.0.0-20180124060714-7194b8dcc5c4 h1:dnMxwus89s86tI8rcGVp2HwZzlz7c5o92VOy7dSckBQ=
github.com/natessilva/dag v0.0.0-20180124060714-7194b8dcc5c4/go.mod h1:cojhOHk1gbMeklOyDP2oKKLftefXoJreOQGOrXk+Z38=
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
@ -619,10 +605,7 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/httpgzip v0.0.0-20190720172056-320755c1c1b0/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
@ -663,8 +646,6 @@ github.com/swaggest/openapi-go v0.2.23 h1:DYUezSTyw180z1bL51wUnalYYbTMwHBjp1Itvj
github.com/swaggest/openapi-go v0.2.23/go.mod h1:T1Koc6EAFAvnCI1MUqOOPDniqGzZy6dOiHtA/j54k14=
github.com/swaggest/refl v1.1.0 h1:a+9a75Kv6ciMozPjVbOfcVTEQe81t2R3emvaD9oGQGc=
github.com/swaggest/refl v1.1.0/go.mod h1:g3Qa6ki0A/L2yxiuUpT+cuBURuRaltF5SDQpg1kMZSY=
github.com/swaggest/swgui v1.4.2 h1:6AT8ICO0+t6WpbIFsACf5vBmviVX0sqspNbZLoe6vgw=
github.com/swaggest/swgui v1.4.2/go.mod h1:xWDsT2h8obEoGHzX/a6FRClUOS8NvkICyInhi7s3fN8=
github.com/swaggest/swgui v1.8.0 h1:dPu8TsYIOraaObAkyNdoiLI8mu7nOqQ6SU7HOv254rM=
github.com/swaggest/swgui v1.8.0/go.mod h1:YBaAVAwS3ndfvdtW8A4yWDJpge+W57y+8kW+f/DqZtU=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@ -674,8 +655,6 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc=
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
github.com/vearutop/statigz v1.1.5 h1:qWvRgXFsseWVTFCkIvwHQPpaLNf9WI0+dDJE7I9432o=
github.com/vearutop/statigz v1.1.5/go.mod h1:czAv7iXgPv/s+xsgXpVEhhD0NSOQ4wZPgmM/n7LANDI=
github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU=
github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE=
github.com/vinzenz/yaml v0.0.0-20170920082545-91409cdd725d/go.mod h1:mb5taDqMnJiZNRQ3+02W2IFG+oEz1+dTuCXkp4jpkfo=
@ -684,7 +663,6 @@ github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCO
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@ -748,7 +726,6 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
@ -778,9 +755,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211105192438-b53810dc28af/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
@ -839,7 +814,6 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -894,7 +868,6 @@ golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=

View File

@ -27,6 +27,7 @@ const (
SpaceAttrIdentifier
SpaceAttrCreated
SpaceAttrUpdated
SpaceAttrDeleted
)
// ParseSpaceAttr parses the space attribute string
@ -42,6 +43,8 @@ func ParseSpaceAttr(s string) SpaceAttr {
return SpaceAttrCreated
case updated, updatedAt:
return SpaceAttrUpdated
case deleted, deletedAt:
return SpaceAttrDeleted
default:
return SpaceAttrNone
}
@ -59,6 +62,8 @@ func (a SpaceAttr) String() string {
return created
case SpaceAttrUpdated:
return updated
case SpaceAttrDeleted:
return deleted
case SpaceAttrNone:
return ""
default:

View File

@ -43,6 +43,7 @@ type Space struct {
CreatedBy int64 `json:"created_by"`
Created int64 `json:"created"`
Updated int64 `json:"updated"`
Deleted *int64 `json:"deleted,omitempty"`
}
// TODO [CODE-1363]: remove after identifier migration.
@ -60,9 +61,11 @@ func (s Space) MarshalJSON() ([]byte, error) {
// Stores spaces query parameters.
type SpaceFilter struct {
Page int `json:"page"`
Size int `json:"size"`
Query string `json:"query"`
Sort enum.SpaceAttr `json:"sort"`
Order enum.Order `json:"order"`
Page int `json:"page"`
Size int `json:"size"`
Query string `json:"query"`
Sort enum.SpaceAttr `json:"sort"`
Order enum.Order `json:"order"`
DeletedBeforeOrAt *int64 `json:"deleted_before_or_at,omitempty"`
Recursive bool `json:"recursive"`
}

View File

@ -150,6 +150,14 @@ export function MarkdownEditorWithPreview({
verb: 'POST',
path: `/api/v1/repos/${repoMetadata?.path}/+/genai/change-summary`
})
const isDirty = useRef(dirty)
useEffect(
function setDirtyRef() {
isDirty.current = dirty
},
[dirty]
)
const myKeymap = keymap.of([
{
@ -157,7 +165,15 @@ export function MarkdownEditorWithPreview({
run: undo,
preventDefault: true
},
{ key: 'Mod-Shift-z', run: redo, preventDefault: true }
{ key: 'Mod-Shift-z', run: redo, preventDefault: true },
{
key: 'Mod-Enter',
run: () => {
if (isDirty.current) onSaveHandler()
return true
},
preventDefault: true
}
])
const dispatchContent = (content: string, userEvent: boolean) => {
@ -401,6 +417,7 @@ export function MarkdownEditorWithPreview({
const handleFileChange = (event: any) => {
setFile(event?.target?.files[0])
}
const onSaveHandler = useCallback(() => onSave?.(viewRef.current?.state.doc.toString() || ''), [onSave])
return (
<Container ref={containerRef} className={cx(css.container, { [css.noBorder]: noBorder }, className)}>
@ -461,8 +478,12 @@ export function MarkdownEditorWithPreview({
variation={ButtonVariation.PRIMARY}
disabled={false}
onClick={() => {
handleUpload(file as File, setMarkdownContent, repoMetadata, showError, standalone, routingId)
setOpen(false)
if (file !== undefined) {
handleUpload(file as File, setMarkdownContent, repoMetadata, showError, standalone, routingId)
setOpen(false)
} else {
showError(getString('uploadAFileError'))
}
}}
/>
<Button
@ -527,8 +548,8 @@ export function MarkdownEditorWithPreview({
setDirty={setDirty}
maxHeight={editorHeight}
className={selectedTab === MarkdownEditorTab.PREVIEW ? css.hidden : undefined}
onChange={(doc, _viewUpdate, isDirty) => {
if (isDirty) {
onChange={(doc, _viewUpdate, _isDirty) => {
if (_isDirty) {
onChange?.(doc.toString())
}
}}
@ -540,17 +561,9 @@ export function MarkdownEditorWithPreview({
{!hideButtons && (
<Container className={css.buttonsBar}>
<Layout.Horizontal spacing="small">
<Button
disabled={!dirty}
variation={ButtonVariation.PRIMARY}
onClick={() => onSave?.(viewRef.current?.state.doc.toString() || '')}
text={i18n.save}
/>
<Button disabled={!dirty} variation={ButtonVariation.PRIMARY} onClick={onSaveHandler} text={i18n.save} />
{SecondarySaveButton && (
<SecondarySaveButton
disabled={!dirty}
onClick={async () => await onSave?.(viewRef.current?.state.doc.toString() || '')}
/>
<SecondarySaveButton disabled={!dirty} onClick={async () => await onSaveHandler()} />
)}
{!hideCancel && <Button variation={ButtonVariation.TERTIARY} onClick={onCancel} text={i18n.cancel} />}
</Layout.Horizontal>

View File

@ -36,9 +36,12 @@
}
}
p {
white-space: break-spaces;
}
// TODO: Disable white-space on global markdown p tags since it conflicts with simple
// html tags from Markdown in Github
// Remove later if things don't break....
// p {
// white-space: break-spaces;
// }
tt,
code {

View File

@ -15,7 +15,7 @@
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { debounce, has, omit } from 'lodash-es'
import { debounce, get, has, omit } from 'lodash-es'
import { FormikContextType, connect } from 'formik'
import { Layout, Text, FormInput, Button, ButtonVariation, ButtonSize, Container } from '@harnessio/uicore'
import { FontVariation } from '@harnessio/design-system'
@ -29,6 +29,9 @@ interface MultiListConnectedProps extends MultiListProps {
}
interface MultiListProps {
/** unique field identifier */
identifier: string
/** fully qualified field name */
name: string
label: string
readOnly?: boolean
@ -40,7 +43,7 @@ interface MultiListProps {
- <field-value-2>,
...
*/
export const MultiList = ({ name, label, readOnly, formik }: MultiListConnectedProps): JSX.Element => {
export const MultiList = ({ identifier, name, label, readOnly, formik }: MultiListConnectedProps): JSX.Element => {
const { getString } = useStrings()
const [valueMap, setValueMap] = useState<Map<string, string>>(new Map<string, string>([]))
/*
@ -50,32 +53,41 @@ export const MultiList = ({ name, label, readOnly, formik }: MultiListConnectedP
*/
const counter = useRef<number>(0)
/* When list already has items in it */
useEffect((): void => {
const existingValues: string[] = get(formik?.initialValues, name, [])
const existingItemCount = existingValues.length
if (existingItemCount > 0) {
setValueMap((existingValueMap: Map<string, string>) => {
const existingValueMapClone = new Map(existingValueMap)
existingValues.map((item: string, index: number) => {
const rowKey = getRowKey(identifier, index)
existingValueMapClone.set(rowKey, item)
formik?.setFieldValue(rowKey, item)
})
return existingValueMapClone
})
counter.current += existingItemCount
}
}, [get(formik?.initialValues, name)])
useEffect(() => {
const values = Array.from(valueMap.values() || []).filter((value: string) => !!value)
if (values.length > 0) {
formik?.setFieldValue(name, values)
} else {
cleanupField()
}
}, [valueMap])
const cleanupField = useCallback((): void => {
formik?.setValues(omit({ ...formik?.values }, name))
}, [formik?.values])
const getFieldName = useCallback(
(index: number): string => {
return `${name}-${index}`
},
[name]
)
const getRowKey = useCallback((prefix: string, index: number): string => {
return `${prefix}-${index}`
}, [])
const handleAddRowToList = useCallback((): void => {
setValueMap((existingValueMap: Map<string, string>) => {
const rowKeyToAdd = getFieldName(counter.current)
if (!existingValueMap.has(rowKeyToAdd)) {
const rowKey = getRowKey(identifier, counter.current)
if (!existingValueMap.has(rowKey)) {
const existingValueMapClone = new Map(existingValueMap)
existingValueMapClone.set(rowKeyToAdd, '') /* Add key <field-name-1>, <field-name-2>, ... */
existingValueMapClone.set(rowKey, '') /* Add key <field-name-1>, <field-name-2>, ... */
counter.current++ /* this counter always increases, even if a row is removed. This ensures no key collision in the existing value map. */
return existingValueMapClone
}
@ -113,7 +125,10 @@ export const MultiList = ({ name, label, readOnly, formik }: MultiListConnectedP
const renderRow = useCallback((rowKey: string): React.ReactElement => {
return (
<Layout.Horizontal margin={{ bottom: 'none' }} flex={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Layout.Horizontal
margin={{ bottom: 'none' }}
flex={{ justifyContent: 'space-between', alignItems: 'center' }}
key={rowKey}>
<Container width="90%">
<FormInput.Text
name={rowKey}

View File

@ -18,7 +18,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Formik, FormikContextType } from 'formik'
import { parse } from 'yaml'
import cx from 'classnames'
import { capitalize, get, has, isEmpty, isUndefined, set } from 'lodash-es'
import { capitalize, get, has, isEmpty, isUndefined, pick, set } from 'lodash-es'
import type { IRange } from 'monaco-editor'
import { Classes, PopoverInteractionKind, PopoverPosition } from '@blueprintjs/core'
import { Color, FontVariation } from '@harnessio/design-system'
@ -381,7 +381,17 @@ export const PluginsPanel = (props: PluginsPanelInterface): JSX.Element => {
)
const renderPluginFormField = useCallback(
({ label, name, properties }: { label: string; name: string; properties: PluginInput }): JSX.Element => {
({
label,
identifier,
name,
properties
}: {
label: string
identifier: string
name: string
properties: PluginInput
}): JSX.Element => {
const { type, options } = properties
switch (type) {
@ -391,9 +401,9 @@ export const PluginsPanel = (props: PluginsPanelInterface): JSX.Element => {
return (
<WrapperComponent
name={name}
key={name}
label={generateLabelForPluginField({ label, properties })}
style={{ width: '100%' }}
key={name}
/>
)
}
@ -402,9 +412,9 @@ export const PluginsPanel = (props: PluginsPanelInterface): JSX.Element => {
<Container className={css.toggle}>
<FormInput.Toggle
name={name}
key={name}
label={generateLabelForPluginField({ label, properties }) as string}
style={{ width: '100%' }}
key={name}
/>
</Container>
)
@ -412,7 +422,9 @@ export const PluginsPanel = (props: PluginsPanelInterface): JSX.Element => {
return (
<Container margin={{ bottom: 'large' }}>
<MultiList
identifier={identifier}
name={name}
key={name}
label={generateLabelForPluginField({ label, properties }) as string}
formik={formikRef.current}
/>
@ -423,6 +435,7 @@ export const PluginsPanel = (props: PluginsPanelInterface): JSX.Element => {
<Container margin={{ bottom: 'large' }}>
<MultiMap
name={name}
key={name}
label={generateLabelForPluginField({ label, properties }) as string}
formik={formikRef.current}
/>
@ -570,6 +583,14 @@ export const PluginsPanel = (props: PluginsPanelInterface): JSX.Element => {
[]
)
const sanitizePluginYAMLPayload = useCallback(
(existingPayload: Record<string, any>, validKeys: string[]): Record<string, any> => {
/* Ensure only keys in a plugin's input are added to the actual YAML, everything else should get removed */
return pick(get(existingPayload, PluginSpecInputPath), validKeys)
},
[]
)
const renderPluginConfigForm = useCallback((): JSX.Element => {
const pluginInputs = getPluginInputsFromSpec(get(plugin, PluginSpecPath, '') as string) as PluginInputs
const allPluginInputs = insertNameFieldToPluginInputs(pluginInputs)
@ -608,22 +629,20 @@ export const PluginsPanel = (props: PluginsPanelInterface): JSX.Element => {
<Formik<PluginFormDataInterface>
initialValues={formInitialValues || {}}
onSubmit={(values: PluginFormDataInterface) => {
const payloadForYAMLUpdate = get(values, pathToField, {})
let payloadForYAMLUpdate = get(values, pathToField, {})
if (isEmpty(payloadForYAMLUpdate)) {
return
}
if (pluginCategory === PluginCategory.Drone) {
payloadForYAMLUpdate = sanitizePluginYAMLPayload(payloadForYAMLUpdate, Object.keys(allPluginInputs))
}
const updatedYAMLPayload = set({}, PluginSpecInputPath, payloadForYAMLUpdate)
set(updatedYAMLPayload, 'type', pluginCategory)
set(updatedYAMLPayload, `${PluginSpecPath}.name`, plugin?.uid)
onPluginAddUpdate({
pathToField,
isUpdate,
formData: set(
{},
pathToField,
pluginCategory === PluginCategory.Drone
? set(payloadForYAMLUpdate, `${PluginSpecPath}.name`, plugin?.uid)
: has(payloadForYAMLUpdate, 'type')
? payloadForYAMLUpdate
: set(payloadForYAMLUpdate, 'type', pluginCategory)
)
formData: set({}, pathToField, updatedYAMLPayload)
})
}}
enableReinitialize>
@ -644,6 +663,7 @@ export const PluginsPanel = (props: PluginsPanelInterface): JSX.Element => {
{Object.keys(allPluginInputs).map((field: string) => {
return renderPluginFormField({
label: field,
identifier: field,
/* "name" gets rendered at outside step's spec */
name: getFormikFieldName({
fieldName: field,

View File

@ -359,6 +359,7 @@ export interface StringsMap {
'imageUpload.text': string
'imageUpload.title': string
'imageUpload.upload': string
importFailed: string
importGitRepo: string
importGitRepos: string
importProgress: string
@ -851,6 +852,7 @@ export interface StringsMap {
updateUser: string
updateWebhook: string
updated: string
uploadAFileError: string
user: string
userCreated: string
userId: string

View File

@ -1001,3 +1001,5 @@ reqChanges: 'Request changes'
summary: Summary
prGenSummary: Have Harness AIDA summarize the code changes in this pull request
aidaGenSummary: '[AIDA is generating a summary...]'
importFailed: Import Failed
uploadAFileError: There is no image or video uploaded. Please upload an image or video.

View File

@ -53,12 +53,22 @@ import { RepoPublicLabel } from 'components/RepoPublicLabel/RepoPublicLabel'
import KeywordSearch from 'components/CodeSearch/KeywordSearch'
import { OptionsMenuButton } from 'components/OptionsMenuButton/OptionsMenuButton'
import { useConfirmAct } from 'hooks/useConfirmAction'
import { getUsingFetch, getConfig } from 'services/config'
import noRepoImage from './no-repo.svg?url'
import FeatureMap from './FeatureMap/FeatureMap'
import css from './RepositoriesListing.module.scss'
interface TypesRepoExtended extends TypesRepository {
importing?: boolean
importProgress?: string
}
enum ImportStatus {
FAILED = 'failed'
}
interface progessState {
state: string
}
export default function RepositoriesListing() {
@ -68,11 +78,12 @@ export default function RepositoriesListing() {
const [nameTextWidth, setNameTextWidth] = useState(600)
const space = useGetSpaceParam()
const [searchTerm, setSearchTerm] = useState<string | undefined>()
const { routes, standalone } = useAppContext()
const { routes, standalone, hooks, routingId } = useAppContext()
const { updateQueryParams } = useUpdateQueryParams()
const pageBrowser = useQueryParams<PageBrowserProps>()
const pageInit = pageBrowser.page ? parseInt(pageBrowser.page) : 1
const [page, setPage] = usePageIndex(pageInit)
const [updatedRepositories, setUpdatedRepositories] = useState<TypesRepository[]>()
const {
data: repositories,
@ -113,6 +124,42 @@ export default function RepositoriesListing() {
}
}, [space, setPage]) // eslint-disable-line react-hooks/exhaustive-deps
const bearerToken = hooks?.useGetToken?.() || ''
const addImportProgressToData = async (repos: TypesRepository[]) => {
const updatedData = await Promise.all(
repos.map(async repo => {
if (repo.importing) {
const importProgress = await getUsingFetch(
getConfig('code/api/v1'),
`/repos/${repo.path}/+/import-progress`,
bearerToken,
{
queryParams: {
accountIdentifier: routingId
}
}
)
return { ...repo, importProgress: (importProgress as progessState).state }
}
return repo
})
)
return updatedData
}
useEffect(() => {
const fetchRepo = async () => {
if (repositories) {
const updatedRepos = await addImportProgressToData(repositories)
setUpdatedRepositories(updatedRepos)
}
}
fetchRepo()
}, [repositories]) // eslint-disable-line react-hooks/exhaustive-deps
const columns: Column<TypesRepoExtended>[] = useMemo(
() => [
{
@ -130,7 +177,11 @@ export default function RepositoriesListing() {
<RepoPublicLabel isPublic={row.original.is_public} margin={{ left: 'small' }} />
</Text>
{record.importing ? (
{record?.importProgress === ImportStatus.FAILED ? (
<Text className={css.desc} width={nameTextWidth} lineClamp={1}>
{getString('importFailed')}
</Text>
) : record.importing ? (
<Text className={css.desc} width={nameTextWidth} lineClamp={1}>
{getString('importProgress')}
</Text>
@ -151,7 +202,7 @@ export default function RepositoriesListing() {
Header: getString('repos.updated'),
width: '180px',
Cell: ({ row }: CellProps<TypesRepoExtended>) => {
return row.original.importing ? (
return row?.original?.importProgress === ImportStatus.FAILED ? null : row.original.importing ? (
<Layout.Horizontal style={{ alignItems: 'center' }} padding={{ right: 'large' }}>
<ProgressBar intent={Intent.PRIMARY} className={css.progressBar} />
</Layout.Horizontal>
@ -174,51 +225,53 @@ export default function RepositoriesListing() {
const confirmCancelImport = useConfirmAct()
return (
<Container onClick={Utils.stopEvent}>
{row.original.importing && (
<OptionsMenuButton
isDark
width="100px"
items={[
{
text: getString('cancelImport'),
onClick: () =>
confirmCancelImport({
title: getString('cancelImport'),
confirmText: getString('cancelImport'),
intent: Intent.DANGER,
message: (
<Text
style={{ wordBreak: 'break-word' }}
font={{ variation: FontVariation.BODY2_SEMI, size: 'small' }}>
<String
useRichText
stringID="cancelImportConfirm"
vars={{ name: row.original?.uid }}
tagName="div"
/>
</Text>
),
action: async () => {
deleteRepo(`${row.original?.path as string}/+/`)
.then(() => {
showSuccess(getString('cancelledImport'), 2000)
refetch()
})
.catch(err => {
showError(getErrorMessage(err), 0, getString('failedToCancelImport'))
})
}
})
}
]}
/>
)}
{row?.original?.importProgress === ImportStatus.FAILED
? null
: row.original.importing && (
<OptionsMenuButton
isDark
width="100px"
items={[
{
text: getString('cancelImport'),
onClick: () =>
confirmCancelImport({
title: getString('cancelImport'),
confirmText: getString('cancelImport'),
intent: Intent.DANGER,
message: (
<Text
style={{ wordBreak: 'break-word' }}
font={{ variation: FontVariation.BODY2_SEMI, size: 'small' }}>
<String
useRichText
stringID="cancelImportConfirm"
vars={{ name: row.original?.uid }}
tagName="div"
/>
</Text>
),
action: async () => {
deleteRepo(`${row.original?.path as string}/+/`)
.then(() => {
showSuccess(getString('cancelledImport'), 2000)
refetch()
})
.catch(err => {
showError(getErrorMessage(err), 0, getString('failedToCancelImport'))
})
}
})
}
]}
/>
)}
</Container>
)
}
}
],
[nameTextWidth, getString, searchTerm]
[nameTextWidth, getString, searchTerm] // eslint-disable-line react-hooks/exhaustive-deps
)
const onResize = useCallback(() => {
@ -284,7 +337,7 @@ export default function RepositoriesListing() {
<Table<TypesRepoExtended>
className={css.table}
columns={columns}
data={repositories || []}
data={updatedRepositories || []}
onRowClick={repoInfo => {
return repoInfo.importing
? undefined