Provisioning: Fix issue with double prefix for github repository (#103781)

This commit is contained in:
Roberto Jiménez Sánchez
2025-04-10 19:57:42 +02:00
committed by GitHub
parent 45a55234e1
commit 2eaeff8ea7
11 changed files with 2038 additions and 14 deletions
@@ -92,7 +92,7 @@ func IncrementalSync(ctx context.Context, repo repository.Versioned, previousRef
result.Resource = gvk.Kind
result.Group = gvk.Group
case repository.FileActionRenamed:
name, gvk, err := repositoryResources.RenameResourceFile(ctx, change.Path, change.PreviousRef, change.Path, change.Ref)
name, gvk, err := repositoryResources.RenameResourceFile(ctx, change.PreviousPath, change.PreviousRef, change.Path, change.Ref)
if err != nil {
result.Error = err
}
@@ -220,7 +220,7 @@ func TestIncrementalSync(t *testing.T) {
progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return()
// Mock resource rename
repoResources.On("RenameResourceFile", mock.Anything, "dashboards/new.json", "old-ref", "dashboards/new.json", "new-ref").
repoResources.On("RenameResourceFile", mock.Anything, "dashboards/old.json", "old-ref", "dashboards/new.json", "new-ref").
Return("renamed-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
// Mock progress recording
@@ -262,8 +262,15 @@ func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]FileTree
entries := make([]FileTreeEntry, 0, len(tree))
for _, entry := range tree {
isBlob := !entry.IsDirectory()
// FIXME: this we could potentially do somewhere else on in a different way
filePath := entry.GetPath()
if !isBlob && !safepath.IsDir(filePath) {
filePath = filePath + "/"
}
converted := FileTreeEntry{
Path: entry.GetPath(),
Path: filePath,
Size: entry.GetSize(),
Hash: entry.GetSHA(),
Blob: !entry.IsDirectory(),
@@ -672,30 +679,75 @@ func (r *githubRepository) CompareFiles(ctx context.Context, base, ref string) (
// reference: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit
switch f.GetStatus() {
case "added", "copied":
currentPath, err := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
if err != nil {
// do nothing as it's outside of configured path
continue
}
changes = append(changes, VersionedFileChange{
Path: f.GetFilename(),
Path: currentPath,
Ref: ref,
Action: FileActionCreated,
})
case "modified", "changed":
currentPath, err := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
if err != nil {
// do nothing as it's outside of configured path
continue
}
changes = append(changes, VersionedFileChange{
Path: f.GetFilename(),
Path: currentPath,
Ref: ref,
Action: FileActionUpdated,
})
case "renamed":
previousPath, previousErr := safepath.RelativeTo(f.GetPreviousFilename(), r.config.Spec.GitHub.Path)
currentPath, currentErr := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
// Handle all possible combinations of path validation results:
// 1. Both paths outside configured path, do nothing
// 2. Both paths inside configured path, rename
// 3. Moving out of configured path, delete previous file
// 4. Moving into configured path, create new file
switch {
case previousErr != nil && currentErr != nil:
// do nothing as it's outside of configured path
case previousErr == nil && currentErr == nil:
changes = append(changes, VersionedFileChange{
Path: currentPath,
PreviousPath: previousPath,
Ref: ref,
PreviousRef: base,
Action: FileActionRenamed,
})
case previousErr == nil && currentErr != nil:
changes = append(changes, VersionedFileChange{
Path: currentPath,
Ref: ref,
Action: FileActionDeleted,
})
case previousErr != nil && currentErr == nil:
changes = append(changes, VersionedFileChange{
Path: currentPath,
Ref: ref,
Action: FileActionCreated,
})
}
case "removed":
currentPath, err := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
if err != nil {
// do nothing as it's outside of configured path
continue
}
changes = append(changes, VersionedFileChange{
Path: f.GetFilename(),
PreviousPath: f.GetPreviousFilename(),
Ref: ref,
PreviousRef: base,
Action: FileActionRenamed,
})
case "removed":
changes = append(changes, VersionedFileChange{
Ref: base,
Path: f.GetFilename(),
Action: FileActionDeleted,
Path: currentPath,
PreviousPath: currentPath,
Action: FileActionDeleted,
})
case "unchanged":
// do nothing
@@ -29,6 +29,7 @@ const MaxFileSize = 10 * 1024 * 1024 // 10MB in bytes
type ErrRateLimited = github.RateLimitError
//go:generate mockery --name Client --structname MockClient --inpackage --filename mock_client.go --with-expecter
type Client interface {
// IsAuthenticated checks if the client is authenticated.
IsAuthenticated(ctx context.Context) error
@@ -103,6 +104,7 @@ type Client interface {
ClearAllPullRequestFileComments(ctx context.Context, owner, repository string, number int) error
}
//go:generate mockery --name RepositoryContent --structname MockRepositoryContent --inpackage --filename mock_repository_content.go --with-expecter
type RepositoryContent interface {
// Returns true if this is a directory, false if it is a file.
IsDirectory() bool
@@ -142,6 +144,7 @@ type Commit struct {
CreatedAt time.Time
}
//go:generate mockery --name CommitFile --structname MockCommitFile --inpackage --filename mock_commit_file.go --with-expecter
type CommitFile interface {
GetSHA() string
GetFilename() string
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package github
import mock "github.com/stretchr/testify/mock"
// MockCommitFile is an autogenerated mock type for the CommitFile type
type MockCommitFile struct {
mock.Mock
}
type MockCommitFile_Expecter struct {
mock *mock.Mock
}
func (_m *MockCommitFile) EXPECT() *MockCommitFile_Expecter {
return &MockCommitFile_Expecter{mock: &_m.Mock}
}
// GetFilename provides a mock function with no fields
func (_m *MockCommitFile) GetFilename() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetFilename")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockCommitFile_GetFilename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFilename'
type MockCommitFile_GetFilename_Call struct {
*mock.Call
}
// GetFilename is a helper method to define mock.On call
func (_e *MockCommitFile_Expecter) GetFilename() *MockCommitFile_GetFilename_Call {
return &MockCommitFile_GetFilename_Call{Call: _e.mock.On("GetFilename")}
}
func (_c *MockCommitFile_GetFilename_Call) Run(run func()) *MockCommitFile_GetFilename_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockCommitFile_GetFilename_Call) Return(_a0 string) *MockCommitFile_GetFilename_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCommitFile_GetFilename_Call) RunAndReturn(run func() string) *MockCommitFile_GetFilename_Call {
_c.Call.Return(run)
return _c
}
// GetPreviousFilename provides a mock function with no fields
func (_m *MockCommitFile) GetPreviousFilename() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPreviousFilename")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockCommitFile_GetPreviousFilename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPreviousFilename'
type MockCommitFile_GetPreviousFilename_Call struct {
*mock.Call
}
// GetPreviousFilename is a helper method to define mock.On call
func (_e *MockCommitFile_Expecter) GetPreviousFilename() *MockCommitFile_GetPreviousFilename_Call {
return &MockCommitFile_GetPreviousFilename_Call{Call: _e.mock.On("GetPreviousFilename")}
}
func (_c *MockCommitFile_GetPreviousFilename_Call) Run(run func()) *MockCommitFile_GetPreviousFilename_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockCommitFile_GetPreviousFilename_Call) Return(_a0 string) *MockCommitFile_GetPreviousFilename_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCommitFile_GetPreviousFilename_Call) RunAndReturn(run func() string) *MockCommitFile_GetPreviousFilename_Call {
_c.Call.Return(run)
return _c
}
// GetSHA provides a mock function with no fields
func (_m *MockCommitFile) GetSHA() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetSHA")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockCommitFile_GetSHA_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSHA'
type MockCommitFile_GetSHA_Call struct {
*mock.Call
}
// GetSHA is a helper method to define mock.On call
func (_e *MockCommitFile_Expecter) GetSHA() *MockCommitFile_GetSHA_Call {
return &MockCommitFile_GetSHA_Call{Call: _e.mock.On("GetSHA")}
}
func (_c *MockCommitFile_GetSHA_Call) Run(run func()) *MockCommitFile_GetSHA_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockCommitFile_GetSHA_Call) Return(_a0 string) *MockCommitFile_GetSHA_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCommitFile_GetSHA_Call) RunAndReturn(run func() string) *MockCommitFile_GetSHA_Call {
_c.Call.Return(run)
return _c
}
// GetStatus provides a mock function with no fields
func (_m *MockCommitFile) GetStatus() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetStatus")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockCommitFile_GetStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStatus'
type MockCommitFile_GetStatus_Call struct {
*mock.Call
}
// GetStatus is a helper method to define mock.On call
func (_e *MockCommitFile_Expecter) GetStatus() *MockCommitFile_GetStatus_Call {
return &MockCommitFile_GetStatus_Call{Call: _e.mock.On("GetStatus")}
}
func (_c *MockCommitFile_GetStatus_Call) Run(run func()) *MockCommitFile_GetStatus_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockCommitFile_GetStatus_Call) Return(_a0 string) *MockCommitFile_GetStatus_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCommitFile_GetStatus_Call) RunAndReturn(run func() string) *MockCommitFile_GetStatus_Call {
_c.Call.Return(run)
return _c
}
// NewMockCommitFile creates a new instance of MockCommitFile. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockCommitFile(t interface {
mock.TestingT
Cleanup(func())
}) *MockCommitFile {
mock := &MockCommitFile{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -0,0 +1,312 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package github
import mock "github.com/stretchr/testify/mock"
// MockRepositoryContent is an autogenerated mock type for the RepositoryContent type
type MockRepositoryContent struct {
mock.Mock
}
type MockRepositoryContent_Expecter struct {
mock *mock.Mock
}
func (_m *MockRepositoryContent) EXPECT() *MockRepositoryContent_Expecter {
return &MockRepositoryContent_Expecter{mock: &_m.Mock}
}
// GetFileContent provides a mock function with no fields
func (_m *MockRepositoryContent) GetFileContent() (string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetFileContent")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockRepositoryContent_GetFileContent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFileContent'
type MockRepositoryContent_GetFileContent_Call struct {
*mock.Call
}
// GetFileContent is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) GetFileContent() *MockRepositoryContent_GetFileContent_Call {
return &MockRepositoryContent_GetFileContent_Call{Call: _e.mock.On("GetFileContent")}
}
func (_c *MockRepositoryContent_GetFileContent_Call) Run(run func()) *MockRepositoryContent_GetFileContent_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_GetFileContent_Call) Return(_a0 string, _a1 error) *MockRepositoryContent_GetFileContent_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockRepositoryContent_GetFileContent_Call) RunAndReturn(run func() (string, error)) *MockRepositoryContent_GetFileContent_Call {
_c.Call.Return(run)
return _c
}
// GetPath provides a mock function with no fields
func (_m *MockRepositoryContent) GetPath() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPath")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockRepositoryContent_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath'
type MockRepositoryContent_GetPath_Call struct {
*mock.Call
}
// GetPath is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) GetPath() *MockRepositoryContent_GetPath_Call {
return &MockRepositoryContent_GetPath_Call{Call: _e.mock.On("GetPath")}
}
func (_c *MockRepositoryContent_GetPath_Call) Run(run func()) *MockRepositoryContent_GetPath_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_GetPath_Call) Return(_a0 string) *MockRepositoryContent_GetPath_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositoryContent_GetPath_Call) RunAndReturn(run func() string) *MockRepositoryContent_GetPath_Call {
_c.Call.Return(run)
return _c
}
// GetSHA provides a mock function with no fields
func (_m *MockRepositoryContent) GetSHA() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetSHA")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockRepositoryContent_GetSHA_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSHA'
type MockRepositoryContent_GetSHA_Call struct {
*mock.Call
}
// GetSHA is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) GetSHA() *MockRepositoryContent_GetSHA_Call {
return &MockRepositoryContent_GetSHA_Call{Call: _e.mock.On("GetSHA")}
}
func (_c *MockRepositoryContent_GetSHA_Call) Run(run func()) *MockRepositoryContent_GetSHA_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_GetSHA_Call) Return(_a0 string) *MockRepositoryContent_GetSHA_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositoryContent_GetSHA_Call) RunAndReturn(run func() string) *MockRepositoryContent_GetSHA_Call {
_c.Call.Return(run)
return _c
}
// GetSize provides a mock function with no fields
func (_m *MockRepositoryContent) GetSize() int64 {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetSize")
}
var r0 int64
if rf, ok := ret.Get(0).(func() int64); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(int64)
}
return r0
}
// MockRepositoryContent_GetSize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSize'
type MockRepositoryContent_GetSize_Call struct {
*mock.Call
}
// GetSize is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) GetSize() *MockRepositoryContent_GetSize_Call {
return &MockRepositoryContent_GetSize_Call{Call: _e.mock.On("GetSize")}
}
func (_c *MockRepositoryContent_GetSize_Call) Run(run func()) *MockRepositoryContent_GetSize_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_GetSize_Call) Return(_a0 int64) *MockRepositoryContent_GetSize_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositoryContent_GetSize_Call) RunAndReturn(run func() int64) *MockRepositoryContent_GetSize_Call {
_c.Call.Return(run)
return _c
}
// IsDirectory provides a mock function with no fields
func (_m *MockRepositoryContent) IsDirectory() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsDirectory")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockRepositoryContent_IsDirectory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsDirectory'
type MockRepositoryContent_IsDirectory_Call struct {
*mock.Call
}
// IsDirectory is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) IsDirectory() *MockRepositoryContent_IsDirectory_Call {
return &MockRepositoryContent_IsDirectory_Call{Call: _e.mock.On("IsDirectory")}
}
func (_c *MockRepositoryContent_IsDirectory_Call) Run(run func()) *MockRepositoryContent_IsDirectory_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_IsDirectory_Call) Return(_a0 bool) *MockRepositoryContent_IsDirectory_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositoryContent_IsDirectory_Call) RunAndReturn(run func() bool) *MockRepositoryContent_IsDirectory_Call {
_c.Call.Return(run)
return _c
}
// IsSymlink provides a mock function with no fields
func (_m *MockRepositoryContent) IsSymlink() bool {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for IsSymlink")
}
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockRepositoryContent_IsSymlink_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsSymlink'
type MockRepositoryContent_IsSymlink_Call struct {
*mock.Call
}
// IsSymlink is a helper method to define mock.On call
func (_e *MockRepositoryContent_Expecter) IsSymlink() *MockRepositoryContent_IsSymlink_Call {
return &MockRepositoryContent_IsSymlink_Call{Call: _e.mock.On("IsSymlink")}
}
func (_c *MockRepositoryContent_IsSymlink_Call) Run(run func()) *MockRepositoryContent_IsSymlink_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockRepositoryContent_IsSymlink_Call) Return(_a0 bool) *MockRepositoryContent_IsSymlink_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositoryContent_IsSymlink_Call) RunAndReturn(run func() bool) *MockRepositoryContent_IsSymlink_Call {
_c.Call.Return(run)
return _c
}
// NewMockRepositoryContent creates a new instance of MockRepositoryContent. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockRepositoryContent(t interface {
mock.TestingT
Cleanup(func())
}) *MockRepositoryContent {
mock := &MockRepositoryContent{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,6 +1,7 @@
package repository
import (
context "context"
"fmt"
"net/http"
"os"
@@ -8,10 +9,12 @@ import (
"testing"
"github.com/stretchr/testify/assert"
mock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
)
func TestIsValidGitBranchName(t *testing.T) {
@@ -147,3 +150,101 @@ func TestParseWebhooks(t *testing.T) {
})
}
}
func TestReadTree(t *testing.T) {
tests := []struct {
name string
path string
tree []pgh.RepositoryContent
expected []FileTreeEntry
}{
{name: "empty tree", tree: []pgh.RepositoryContent{}, expected: []FileTreeEntry{}},
{name: "single file", tree: func() []pgh.RepositoryContent {
content := pgh.NewMockRepositoryContent(t)
content.EXPECT().GetPath().Return("file.txt")
content.EXPECT().GetSize().Return(int64(100))
content.EXPECT().GetSHA().Return("abc123")
content.EXPECT().IsDirectory().Return(false)
return []pgh.RepositoryContent{content}
}(), expected: []FileTreeEntry{
{Path: "file.txt", Size: 100, Hash: "abc123", Blob: true},
}},
{name: "single directory", tree: func() []pgh.RepositoryContent {
content := pgh.NewMockRepositoryContent(t)
content.EXPECT().GetPath().Return("dir")
content.EXPECT().IsDirectory().Return(true)
content.EXPECT().GetSize().Return(int64(0))
content.EXPECT().GetSHA().Return("")
return []pgh.RepositoryContent{content}
}(), expected: []FileTreeEntry{
{Path: "dir/", Blob: false},
}},
{name: "mixed content", tree: func() []pgh.RepositoryContent {
file1 := pgh.NewMockRepositoryContent(t)
file1.EXPECT().GetPath().Return("file1.txt")
file1.EXPECT().GetSize().Return(int64(100))
file1.EXPECT().GetSHA().Return("abc123")
file1.EXPECT().IsDirectory().Return(false)
dir := pgh.NewMockRepositoryContent(t)
dir.EXPECT().GetPath().Return("dir")
dir.EXPECT().IsDirectory().Return(true)
dir.EXPECT().GetSize().Return(int64(0))
dir.EXPECT().GetSHA().Return("")
file2 := pgh.NewMockRepositoryContent(t)
file2.EXPECT().GetPath().Return("file2.txt")
file2.EXPECT().GetSize().Return(int64(200))
file2.EXPECT().GetSHA().Return("def456")
file2.EXPECT().IsDirectory().Return(false)
return []pgh.RepositoryContent{file1, dir, file2}
}(), expected: []FileTreeEntry{
{Path: "file1.txt", Size: 100, Hash: "abc123", Blob: true},
{Path: "dir/", Blob: false},
{Path: "file2.txt", Size: 200, Hash: "def456", Blob: true},
}},
{name: "with path prefix", path: "prefix", tree: func() []pgh.RepositoryContent {
file := pgh.NewMockRepositoryContent(t)
file.EXPECT().GetPath().Return("file.txt")
file.EXPECT().GetSize().Return(int64(100))
file.EXPECT().GetSHA().Return("abc123")
file.EXPECT().IsDirectory().Return(false)
dir := pgh.NewMockRepositoryContent(t)
dir.EXPECT().GetPath().Return("dir")
dir.EXPECT().GetSize().Return(int64(0))
dir.EXPECT().GetSHA().Return("")
dir.EXPECT().IsDirectory().Return(true)
return []pgh.RepositoryContent{file, dir}
}(), expected: []FileTreeEntry{
{Path: "file.txt", Size: 100, Hash: "abc123", Blob: true},
{Path: "dir/", Blob: false},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ghMock := pgh.NewMockClient(t)
gh := &githubRepository{
owner: "owner",
repo: "repo",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Path: tt.path,
},
},
},
gh: ghMock,
}
ghMock.On("GetTree", mock.Anything, "owner", "repo", tt.path, "some-ref", true).Return(tt.tree, false, nil)
tree, err := gh.ReadTree(context.Background(), "some-ref")
require.NoError(t, err)
require.Equal(t, tt.expected, tree)
})
}
}
@@ -46,6 +46,9 @@ func TestLocalResolver(t *testing.T) {
"client.go",
"factory.go",
"impl.go",
"mock_client.go",
"mock_commit_file.go",
"mock_repository_content.go",
"testdata",
"testdata/webhook-issue_comment-created.json",
"testdata/webhook-ping-check.json",
@@ -1,6 +1,7 @@
package safepath
import (
"fmt"
"path"
"strings"
)
@@ -36,3 +37,29 @@ func Dir(filePath string) string {
func InDir(filePath, dir string) bool {
return strings.HasPrefix(filePath, dir)
}
// RelativeTo returns the relative path of the filePath to the given directory.
// It handles cases where either filePath or dir have leading or trailing slashes.
func RelativeTo(filePath, dir string) (string, error) {
if dir == "/" || dir == "" {
return filePath, nil
}
// Normalize paths by trimming leading and trailing slashes
normalizedDir := strings.Trim(dir, "/")
if normalizedDir != "" {
normalizedDir += "/"
}
normalizedPath := strings.TrimPrefix(filePath, "/")
// Check if the normalized path is in the normalized directory
if !strings.HasPrefix(normalizedPath, normalizedDir) {
return "", fmt.Errorf("filePath is not a subdirectory of dir")
}
// Get the relative path by trimming the directory prefix
relativePath := strings.TrimPrefix(normalizedPath, normalizedDir)
return relativePath, nil
}
@@ -160,3 +160,101 @@ func TestInDir(t *testing.T) {
})
}
}
func TestRelativeTo(t *testing.T) {
tests := []struct {
name string
filePath string
dir string
want string
expectError bool
}{
{
name: "simple relative path",
filePath: "folder/subfolder/file.txt",
dir: "folder",
want: "subfolder/file.txt",
},
{
name: "relative path with leading slash",
filePath: "/prefix/folder/subfolder/file.txt",
dir: "/prefix/folder",
want: "subfolder/file.txt",
},
{
name: "relative path with leading slash in dir but in filePath",
filePath: "prefix/folder/subfolder/file.txt",
dir: "/prefix/folder",
want: "subfolder/file.txt",
},
{
name: "with trailing slash in dir",
filePath: "folder/subfolder/file.txt",
dir: "folder/",
want: "subfolder/file.txt",
},
{
name: "with trailing slash in both",
filePath: "folder/subfolder/",
dir: "folder/",
want: "subfolder/",
},
{
name: "empty directory",
filePath: "file.txt",
dir: "",
want: "file.txt",
},
{
name: "directory is root",
filePath: "folder/file.txt",
dir: "/",
want: "folder/file.txt",
},
{
name: "nested directories",
filePath: "a/b/c/d/file.txt",
dir: "a/b",
want: "c/d/file.txt",
},
{
name: "file not in directory",
filePath: "other/file.txt",
dir: "folder",
want: "",
expectError: true,
},
{
name: "file path shorter than directory",
filePath: "file.txt",
dir: "folder/subfolder",
want: "",
expectError: true,
},
{
name: "same directory",
filePath: "folder/file.txt",
dir: "folder",
want: "file.txt",
},
{
name: "directory with similar prefix",
filePath: "folder2/file.txt",
dir: "folder",
want: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := RelativeTo(tt.filePath, tt.dir)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.want, got)
}
})
}
}