diff --git a/internal/mirroring/get_test.go b/internal/mirroring/get_test.go index 5e5466f..4a5595c 100644 --- a/internal/mirroring/get_test.go +++ b/internal/mirroring/get_test.go @@ -259,12 +259,12 @@ func TestCheckLicense(t *testing.T) { }{ { name: "Ultimate tier license", - license: "ultimate", + license: ULTIMATE_PLAN, expectedError: false, }, { name: "Premium tier license", - license: "premium", + license: PREMIUM_PLAN, expectedError: false, }, { diff --git a/internal/mirroring/post.go b/internal/mirroring/post.go index 84b0ea8..1304c1e 100644 --- a/internal/mirroring/post.go +++ b/internal/mirroring/post.go @@ -24,7 +24,7 @@ func (destinationGitlab *GitlabInstance) createGroups(sourceGitlab *GitlabInstan // Reverse the mirror mapping to get the source group path for each destination group reversedMirrorMap, destinationGroupPaths := sourceGitlab.reverseGroupMirrorMap(mirrorMapping) - errorChan := make(chan error, len(destinationGroupPaths)) + errorChan := make(chan []error, len(destinationGroupPaths)) // Iterate over the groups in alphabetical order (little hack to ensure parent groups are created before children) for _, destinationGroupPath := range destinationGroupPaths { _, err := destinationGitlab.createGroup(destinationGroupPath, sourceGitlab, mirrorMapping, &reversedMirrorMap) @@ -39,20 +39,20 @@ func (destinationGitlab *GitlabInstance) createGroups(sourceGitlab *GitlabInstan // createGroup creates a GitLab group in the destination GitLab instance based on the source group and mirror mapping. // It checks if the group already exists in the destination instance and creates it if not. // The function also handles the copying of group avatars from the source to the destination instance. -func (destinationGitlab *GitlabInstance) createGroup(destinationGroupPath string, sourceGitlab *GitlabInstance, mirrorMapping *utils.MirrorMapping, reversedMirrorMap *map[string]string) (*gitlab.Group, error) { +func (destinationGitlab *GitlabInstance) createGroup(destinationGroupPath string, sourceGitlab *GitlabInstance, mirrorMapping *utils.MirrorMapping, reversedMirrorMap *map[string]string) (*gitlab.Group, []error) { // Retrieve the corresponding source group path sourceGroupPath := (*reversedMirrorMap)[destinationGroupPath] zap.L().Debug("Mirroring group", zap.String(ROLE_SOURCE, sourceGroupPath), zap.String(ROLE_DESTINATION, destinationGroupPath)) sourceGroup := sourceGitlab.Groups[sourceGroupPath] if sourceGroup == nil { - return nil, fmt.Errorf("group %s not found in destination GitLab instance (internal error, please review script)", sourceGroupPath) + return nil, []error{fmt.Errorf("group %s not found in destination GitLab instance (internal error, please review script)", sourceGroupPath)} } // Retrieve the corresponding group creation options from the mirror mapping groupCreationOptions, ok := mirrorMapping.GetGroup(sourceGroupPath) if !ok { - return nil, fmt.Errorf("source group %s not found in mirror mapping (internal error, please review script)", sourceGroupPath) + return nil, []error{fmt.Errorf("source group %s not found in mirror mapping (internal error, please review script)", sourceGroupPath)} } // Check if the group already exists in the destination GitLab instance @@ -62,12 +62,12 @@ func (destinationGitlab *GitlabInstance) createGroup(destinationGroupPath string zap.L().Debug("Group not found, creating new group in GitLab Instance", zap.String("group", destinationGroupPath), zap.String(ROLE, ROLE_DESTINATION)) destinationGroup, err = destinationGitlab.createGroupFromSource(sourceGroup, groupCreationOptions) if err != nil { - return nil, fmt.Errorf("failed to create group %s in destination GitLab instance: %s", destinationGroupPath, err) + return nil, []error{fmt.Errorf("failed to create group %s in destination GitLab instance: %s", destinationGroupPath, err)} } else { // Copy the group avatar from the source to the destination instance - err = sourceGitlab.copyGroupAvatar(destinationGitlab, destinationGroup, sourceGroup) - if err != nil { - return destinationGroup, fmt.Errorf("failed to copy group avatar for %s: %s", destinationGroupPath, err) + errArray := sourceGitlab.updateGroupFromSource(destinationGitlab, destinationGroup, sourceGroup, groupCreationOptions) + if errArray != nil { + return destinationGroup, errArray } } } @@ -226,7 +226,7 @@ func (g *GitlabInstance) createProjectFromSource(sourceProject *gitlab.Project, // The function handles the API calls concurrently using goroutines and a wait group. // It returns an error if any of the API calls fail. func (destinationGitlab *GitlabInstance) mirrorReleases(sourceGitlab *GitlabInstance, sourceProject *gitlab.Project, destinationProject *gitlab.Project) []error { - zap.L().Debug("Starting releases mirroring", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo)) + zap.L().Info("Starting releases mirroring", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo)) // Fetch existing releases from the destination project existingReleases, _, err := destinationGitlab.Gitlab.Releases.ListReleases(destinationProject.ID, &gitlab.ListReleasesOptions{}) if err != nil { diff --git a/internal/mirroring/put.go b/internal/mirroring/put.go index 570ac79..93dfd59 100644 --- a/internal/mirroring/put.go +++ b/internal/mirroring/put.go @@ -11,6 +11,118 @@ import ( "go.uber.org/zap" ) +// =========================================================================== +// PROJECTS PUT FUNCTIONS // +// =========================================================================== + +// updateProjectFromSource updates the destination project with settings from the source project. +// It enables the project mirror pull, copies the project avatar, and optionally adds the project to the CI/CD catalog. +// It also mirrors releases if the option is set. +// The function uses goroutines to perform these tasks concurrently and waits for all of them to finish. +func (destinationGitlabInstance *GitlabInstance) updateProjectFromSource(sourceGitlabInstance *GitlabInstance, sourceProject *gitlab.Project, destinationProject *gitlab.Project, copyOptions *utils.MirroringOptions) []error { + wg := sync.WaitGroup{} + maxErrors := 3 + if copyOptions.CI_CD_Catalog { + maxErrors++ + } + if copyOptions.MirrorReleases { + maxErrors++ + } + wg.Add(maxErrors) + errorChan := make(chan error, maxErrors) + + go func() { + defer wg.Done() + errorChan <- destinationGitlabInstance.syncProjectAttributes(sourceProject, destinationProject, copyOptions) + }() + + go func() { + defer wg.Done() + errorChan <- destinationGitlabInstance.enableProjectMirrorPull(sourceProject, destinationProject, copyOptions) + }() + + go func() { + defer wg.Done() + errorChan <- sourceGitlabInstance.copyProjectAvatar(destinationGitlabInstance, destinationProject, sourceProject) + }() + + if copyOptions.CI_CD_Catalog { + go func() { + defer wg.Done() + errorChan <- destinationGitlabInstance.addProjectToCICDCatalog(destinationProject) + }() + } + + allErrors := []error{} + if copyOptions.MirrorReleases { + go func() { + defer wg.Done() + allErrors = destinationGitlabInstance.mirrorReleases(sourceGitlabInstance, sourceProject, destinationProject) + }() + } + + wg.Wait() + close(errorChan) + for err := range errorChan { + if err != nil { + allErrors = append(allErrors, err) + } + } + return allErrors +} + +// syncProjectAttributes updates the destination project with settings from the source project. +// It checks if any diverged project data exists and if so, it overwrites it. +func (destinationGitlabInstance *GitlabInstance) syncProjectAttributes(sourceProject *gitlab.Project, destinationProject *gitlab.Project, copyOptions *utils.MirroringOptions) error { + zap.L().Debug("Checking if project requires attributes resync", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo)) + gitlabEditOptions := &gitlab.EditProjectOptions{} + missmatched := false + if sourceProject.Name != destinationProject.Name { + gitlabEditOptions.Name = &sourceProject.Name + missmatched = true + } + if sourceProject.Description != destinationProject.Description { + gitlabEditOptions.Description = &sourceProject.Description + missmatched = true + } + if sourceProject.DefaultBranch != destinationProject.DefaultBranch { + gitlabEditOptions.DefaultBranch = &sourceProject.DefaultBranch + missmatched = true + } + if !utils.StringArraysMatchValues(sourceProject.Topics, destinationProject.Topics) { + gitlabEditOptions.Topics = &sourceProject.Topics + missmatched = true + } + if copyOptions.MirrorTriggerBuilds != destinationProject.MirrorTriggerBuilds { + gitlabEditOptions.MirrorTriggerBuilds = ©Options.MirrorTriggerBuilds + missmatched = true + } + if !destinationProject.MirrorOverwritesDivergedBranches { + gitlabEditOptions.MirrorOverwritesDivergedBranches = gitlab.Ptr(true) + missmatched = true + } + if !destinationProject.Mirror { + gitlabEditOptions.Mirror = gitlab.Ptr(true) + missmatched = true + } + if copyOptions.Visibility != string(destinationProject.Visibility) { + visibilityValue := utils.ConvertVisibility(copyOptions.Visibility) + gitlabEditOptions.Visibility = &visibilityValue + missmatched = true + } + + if missmatched { + destinationProject, _, err := destinationGitlabInstance.Gitlab.Projects.EditProject(destinationProject.ID, gitlabEditOptions) + if err != nil { + return fmt.Errorf("failed to edit project %s: %s", destinationProject.HTTPURLToRepo, err) + } + zap.L().Debug("Project attributes resync completed", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo)) + } else { + zap.L().Debug("Project attributes are already in sync, skipping", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo)) + } + return nil +} + // enableProjectMirrorPull enables the pull mirror for a project in the destination GitLab instance. // It sets the source project URL, enables mirroring, and configures other options like triggering builds and overwriting diverged branches. func (g *GitlabInstance) enableProjectMirrorPull(sourceProject *gitlab.Project, destinationProject *gitlab.Project, mirrorOptions *utils.MirroringOptions) error { @@ -56,6 +168,34 @@ func (sourceGitlabInstance *GitlabInstance) copyProjectAvatar(destinationGitlabI return nil } +// =========================================================================== +// GROUPS PUT FUNCTIONS // +// =========================================================================== + +// updateGroupFromSource updates the destination group with settings from the source group. +// It copies the group avatar and updates the group attributes. +func (destinationGitlabInstance *GitlabInstance) updateGroupFromSource(sourceGitlabInstance *GitlabInstance, sourceGroup *gitlab.Group, destinationGroup *gitlab.Group, copyOptions *utils.MirroringOptions) []error { + wg := sync.WaitGroup{} + maxErrors := 2 + wg.Add(maxErrors) + errorChan := make(chan error, maxErrors) + + go func() { + defer wg.Done() + errorChan <- destinationGitlabInstance.syncGroupAttributes(sourceGroup, destinationGroup, copyOptions) + }() + + go func() { + defer wg.Done() + errorChan <- sourceGitlabInstance.copyGroupAvatar(destinationGitlabInstance, destinationGroup, sourceGroup) + }() + + wg.Wait() + close(errorChan) + + return utils.MergeErrors(errorChan) +} + // copyGroupAvatar copies the avatar from the source group to the destination group. // It first checks if the destination group already has an avatar set. If not, it downloads the avatar from the source group // and uploads it to the destination group. @@ -88,62 +228,34 @@ func (sourceGitlabInstance *GitlabInstance) copyGroupAvatar(destinationGitlabIns return nil } -// updateProjectFromSource updates the destination project with settings from the source project. -// It enables the project mirror pull, copies the project avatar, and optionally adds the project to the CI/CD catalog. -// It also mirrors releases if the option is set. -// The function uses goroutines to perform these tasks concurrently and waits for all of them to finish. -func (destinationGitlabInstance *GitlabInstance) updateProjectFromSource(sourceGitlabInstance *GitlabInstance, sourceProject *gitlab.Project, destinationProject *gitlab.Project, copyOptions *utils.MirroringOptions) []error { - wg := sync.WaitGroup{} - maxErrors := 2 - if copyOptions.CI_CD_Catalog { - maxErrors++ +// syncGroupAttributes updates the destination group with settings from the source group. +// It checks if any diverged group data exists and if so, it overwrites it. +func (destinationGitlabInstance *GitlabInstance) syncGroupAttributes(sourceGroup *gitlab.Group, destinationGroup *gitlab.Group, copyOptions *utils.MirroringOptions) error { + zap.L().Debug("Checking if group requires attributes resync", zap.String(ROLE_SOURCE, sourceGroup.FullPath), zap.String(ROLE_DESTINATION, destinationGroup.FullPath)) + gitlabEditOptions := &gitlab.UpdateGroupOptions{} + missmatched := false + if sourceGroup.Name != destinationGroup.Name { + gitlabEditOptions.Name = &sourceGroup.Name + missmatched = true } - if copyOptions.MirrorReleases { - maxErrors++ + if sourceGroup.Description != destinationGroup.Description { + gitlabEditOptions.Description = &sourceGroup.Description + missmatched = true + } + if copyOptions.Visibility != string(destinationGroup.Visibility) { + visibilityValue := utils.ConvertVisibility(copyOptions.Visibility) + gitlabEditOptions.Visibility = &visibilityValue + missmatched = true } - wg.Add(maxErrors) - errorChan := make(chan error, maxErrors) - - go func() { - defer wg.Done() - - zap.L().Debug("Enabling project mirror pull", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo)) - err := destinationGitlabInstance.enableProjectMirrorPull(sourceProject, destinationProject, copyOptions) - if err != nil { - errorChan <- fmt.Errorf("failed to enable project mirror pull for %s: %s", destinationProject.HTTPURLToRepo, err) - } - }() - go func() { - defer wg.Done() - zap.L().Debug("Copying project avatar", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo)) - err := sourceGitlabInstance.copyProjectAvatar(destinationGitlabInstance, destinationProject, sourceProject) + if missmatched { + destinationGroup, _, err := destinationGitlabInstance.Gitlab.Groups.UpdateGroup(destinationGroup.ID, gitlabEditOptions) if err != nil { - errorChan <- fmt.Errorf("failed to copy project avatar for %s: %s", destinationProject.HTTPURLToRepo, err) + return fmt.Errorf("failed to edit group %s: %s", destinationGroup.FullPath, err) } - }() - - if copyOptions.CI_CD_Catalog { - go func() { - defer wg.Done() - err := destinationGitlabInstance.addProjectToCICDCatalog(destinationProject) - if err != nil { - errorChan <- fmt.Errorf("failed to add project %s to CI/CD catalog: %s", destinationProject.HTTPURLToRepo, err) - } - }() + zap.L().Debug("Group attributes resync completed", zap.String(ROLE_SOURCE, sourceGroup.FullPath), zap.String(ROLE_DESTINATION, destinationGroup.FullPath)) + } else { + zap.L().Debug("Group attributes are already in sync, skipping", zap.String(ROLE_SOURCE, sourceGroup.FullPath), zap.String(ROLE_DESTINATION, destinationGroup.FullPath)) } - - if copyOptions.MirrorReleases { - go func() { - defer wg.Done() - err := destinationGitlabInstance.mirrorReleases(sourceGitlabInstance, sourceProject, destinationProject) - if err != nil { - errorChan <- fmt.Errorf("failed to copy project %s releases: %s", destinationProject.HTTPURLToRepo, err) - } - }() - } - - wg.Wait() - close(errorChan) - return utils.MergeErrors(errorChan) + return nil } diff --git a/internal/utils/types.go b/internal/utils/types.go index a835548..19a193b 100644 --- a/internal/utils/types.go +++ b/internal/utils/types.go @@ -160,7 +160,7 @@ func (m *MirrorMapping) checkProjects(errChan chan error) { // Check the visibility visibilityString := strings.TrimSpace(string(options.Visibility)) if visibilityString != "" && !checkVisibility(visibilityString) { - errChan <- fmt.Errorf("invalid project visibility: %s", string(options.Visibility)) + errChan <- fmt.Errorf("invalid project visibility: %s", options.Visibility) options.Visibility = string(gitlab.PublicVisibility) } } @@ -225,3 +225,32 @@ func checkVisibility(visibility string) bool { } return valid } + +func ConvertVisibility(visibility string) gitlab.VisibilityValue { + switch visibility { + case string(gitlab.PublicVisibility): + return gitlab.PublicVisibility + case string(gitlab.InternalVisibility): + return gitlab.InternalVisibility + case string(gitlab.PrivateVisibility): + return gitlab.PrivateVisibility + default: + return gitlab.PublicVisibility + } +} + +func StringArraysMatchValues(array1 []string, array2 []string) bool { + if len(array1) != len(array2) { + return false + } + matchMap := make(map[string]struct{}, len(array1)) + for _, value := range array1 { + matchMap[value] = struct{}{} + } + for _, value := range array2 { + if _, ok := matchMap[value]; !ok { + return false + } + } + return true +} diff --git a/internal/utils/types_test.go b/internal/utils/types_test.go index 2bfa207..140bedd 100644 --- a/internal/utils/types_test.go +++ b/internal/utils/types_test.go @@ -5,6 +5,8 @@ import ( "os" "reflect" "testing" + + gitlab "gitlab.com/gitlab-org/api/client-go" ) const ( @@ -250,3 +252,279 @@ func TestCheck(t *testing.T) { }) } } + +func TestStringArraysMatchValues(t *testing.T) { + tests := []struct { + name string + a, b []string + want bool + }{ + { + name: "both empty", + a: []string{}, + b: []string{}, + want: true, + }, + { + name: "same order", + a: []string{"foo", "bar", "baz"}, + b: []string{"foo", "bar", "baz"}, + want: true, + }, + { + name: "different order", + a: []string{"foo", "bar", "baz"}, + b: []string{"baz", "foo", "bar"}, + want: true, + }, + { + name: "duplicate values", + a: []string{"x", "x", "y"}, + b: []string{"y", "x", "x"}, + want: true, + }, + { + name: "different lengths", + a: []string{"one", "two"}, + b: []string{"one"}, + want: false, + }, + { + name: "mismatched values", + a: []string{"a", "b", "c"}, + b: []string{"a", "b", "d"}, + want: false, + }, + } + + for _, tc := range tests { + tc := tc // capture range variable + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := StringArraysMatchValues(tc.a, tc.b) + if got != tc.want { + t.Errorf("StringArraysMatchValues(%v, %v) = %v; want %v", + tc.a, tc.b, got, tc.want) + } + }) + } +} + +func TestConvertVisibility(t *testing.T) { + tests := []struct { + name string + input string + want gitlab.VisibilityValue + }{ + { + name: "public visibility", + input: string(gitlab.PublicVisibility), + want: gitlab.PublicVisibility, + }, + { + name: "internal visibility", + input: string(gitlab.InternalVisibility), + want: gitlab.InternalVisibility, + }, + { + name: "private visibility", + input: string(gitlab.PrivateVisibility), + want: gitlab.PrivateVisibility, + }, + { + name: "unknown defaults to public", + input: "something-else", + want: gitlab.PublicVisibility, + }, + } + + for _, tc := range tests { + tc := tc // capture range variable + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := ConvertVisibility(tc.input) + if got != tc.want { + t.Errorf("ConvertVisibility(%q) = %v; want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestCheckVisibility(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + { + name: "public visibility is valid", + input: string(gitlab.PublicVisibility), + want: true, + }, + { + name: "internal visibility is valid", + input: string(gitlab.InternalVisibility), + want: true, + }, + { + name: "private visibility is valid", + input: string(gitlab.PrivateVisibility), + want: true, + }, + { + name: "unknown visibility is invalid", + input: "some-other", + want: false, + }, + { + name: "empty string is invalid", + input: "", + want: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := checkVisibility(tc.input) + if got != tc.want { + t.Errorf("checkVisibility(%q) = %v; want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestMirrorMapping_GetProject(t *testing.T) { + // Prepare a mirror mapping with some project entries + opts1 := &MirroringOptions{ + DestinationPath: "dest1", + CI_CD_Catalog: true, + Issues: false, + MirrorTriggerBuilds: true, + Visibility: "public", + MirrorReleases: false, + } + opts2 := &MirroringOptions{ + DestinationPath: "dest2", + CI_CD_Catalog: false, + Issues: true, + MirrorTriggerBuilds: false, + Visibility: "private", + MirrorReleases: true, + } + mm := &MirrorMapping{ + Projects: map[string]*MirroringOptions{ + "project-one": opts1, + "project-two": opts2, + }, + Groups: map[string]*MirroringOptions{}, + } + + tests := []struct { + name string + key string + want *MirroringOptions + wantOk bool + }{ + { + name: "existing project-one", + key: "project-one", + want: opts1, + wantOk: true, + }, + { + name: "existing project-two", + key: "project-two", + want: opts2, + wantOk: true, + }, + { + name: "nonexistent project", + key: "no-such-project", + want: nil, + wantOk: false, + }, + } + + for _, tc := range tests { + tc := tc // capture range variable + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := mm.GetProject(tc.key) + if ok != tc.wantOk { + t.Errorf("GetProject(%q) returned ok=%v, want %v", tc.key, ok, tc.wantOk) + } + if got != tc.want { + t.Errorf("GetProject(%q) returned %+v, want %+v", tc.key, got, tc.want) + } + }) + } +} + +func TestMirrorMapping_GetGroup(t *testing.T) { + // Prepare a mirror mapping with some group entries + optsA := &MirroringOptions{ + DestinationPath: "groupDestA", + CI_CD_Catalog: true, + Issues: true, + MirrorTriggerBuilds: false, + Visibility: "internal", + MirrorReleases: true, + } + optsB := &MirroringOptions{ + DestinationPath: "groupDestB", + CI_CD_Catalog: false, + Issues: false, + MirrorTriggerBuilds: true, + Visibility: "private", + MirrorReleases: false, + } + mm := &MirrorMapping{ + Projects: map[string]*MirroringOptions{}, + Groups: map[string]*MirroringOptions{ + "group-A": optsA, + "group-B": optsB, + }, + } + + tests := []struct { + name string + key string + want *MirroringOptions + wantOk bool + }{ + { + name: "existing group-A", + key: "group-A", + want: optsA, + wantOk: true, + }, + { + name: "existing group-B", + key: "group-B", + want: optsB, + wantOk: true, + }, + { + name: "nonexistent group", + key: "no-such-group", + want: nil, + wantOk: false, + }, + } + + for _, tc := range tests { + tc := tc // capture range variable + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := mm.GetGroup(tc.key) + if ok != tc.wantOk { + t.Errorf("GetGroup(%q) returned ok=%v, want %v", tc.key, ok, tc.wantOk) + } + if got != tc.want { + t.Errorf("GetGroup(%q) returned %+v, want %+v", tc.key, got, tc.want) + } + }) + } +}