From e7e68bd9ef3107e6562239d8016e98392ab333a9 Mon Sep 17 00:00:00 2001 From: BoxBoxJason Date: Fri, 16 May 2025 21:20:38 +0200 Subject: [PATCH 1/3] test: use variables in licenses unit tests --- internal/mirroring/get_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, }, { From 6d300f1463c84bec6bb779d55edc10439de37790 Mon Sep 17 00:00:00 2001 From: BoxBoxJason Date: Fri, 16 May 2025 23:09:18 +0200 Subject: [PATCH 2/3] feat: add full project resync added a function to check if a project data does not match and update it if needed created unit tests for all new functions added --- internal/mirroring/post.go | 2 +- internal/mirroring/put.go | 88 ++++++++--- internal/utils/types.go | 31 +++- internal/utils/types_test.go | 278 +++++++++++++++++++++++++++++++++++ 4 files changed, 377 insertions(+), 22 deletions(-) diff --git a/internal/mirroring/post.go b/internal/mirroring/post.go index 84b0ea8..3183080 100644 --- a/internal/mirroring/post.go +++ b/internal/mirroring/post.go @@ -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..26127b0 100644 --- a/internal/mirroring/put.go +++ b/internal/mirroring/put.go @@ -94,7 +94,7 @@ func (sourceGitlabInstance *GitlabInstance) copyGroupAvatar(destinationGitlabIns // 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 + maxErrors := 3 if copyOptions.CI_CD_Catalog { maxErrors++ } @@ -106,44 +106,92 @@ func (destinationGitlabInstance *GitlabInstance) updateProjectFromSource(sourceG go func() { defer wg.Done() + errorChan <- destinationGitlabInstance.syncProjectAttributes(sourceProject, destinationProject, copyOptions) + }() - 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() + errorChan <- destinationGitlabInstance.enableProjectMirrorPull(sourceProject, destinationProject, copyOptions) }() 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 err != nil { - errorChan <- fmt.Errorf("failed to copy project avatar for %s: %s", destinationProject.HTTPURLToRepo, err) - } + errorChan <- sourceGitlabInstance.copyProjectAvatar(destinationGitlabInstance, destinationProject, sourceProject) }() 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) - } + errorChan <- destinationGitlabInstance.addProjectToCICDCatalog(destinationProject) }() } + allErrors := []error{} 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) - } + allErrors = destinationGitlabInstance.mirrorReleases(sourceGitlabInstance, sourceProject, destinationProject) }() } wg.Wait() close(errorChan) - return utils.MergeErrors(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 } 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) + } + }) + } +} From 3b99d6e0910a742fb2dd5a0a3ffd668d66eafaf7 Mon Sep 17 00:00:00 2001 From: BoxBoxJason Date: Fri, 16 May 2025 23:10:49 +0200 Subject: [PATCH 3/3] feat: add full group resync added a function to check if a group data does not match and update it if needed also moved some functions for better maintainability --- internal/mirroring/post.go | 16 +-- internal/mirroring/put.go | 216 ++++++++++++++++++++++++------------- 2 files changed, 148 insertions(+), 84 deletions(-) diff --git a/internal/mirroring/post.go b/internal/mirroring/post.go index 3183080..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 } } } diff --git a/internal/mirroring/put.go b/internal/mirroring/put.go index 26127b0..93dfd59 100644 --- a/internal/mirroring/put.go +++ b/internal/mirroring/put.go @@ -11,82 +11,9 @@ import ( "go.uber.org/zap" ) -// 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 { - zap.L().Debug("Enabling project mirror pull", zap.String("sourceProject", sourceProject.HTTPURLToRepo), zap.String("destinationProject", destinationProject.HTTPURLToRepo)) - _, _, err := g.Gitlab.Projects.ConfigureProjectPullMirror(destinationProject.ID, &gitlab.ConfigureProjectPullMirrorOptions{ - URL: &sourceProject.HTTPURLToRepo, - OnlyMirrorProtectedBranches: gitlab.Ptr(true), - Enabled: gitlab.Ptr(true), - MirrorOverwritesDivergedBranches: gitlab.Ptr(true), - MirrorTriggerBuilds: gitlab.Ptr(mirrorOptions.MirrorTriggerBuilds), - }) - return err -} - -// copyProjectAvatar copies the avatar from the source project to the destination project. -// It first checks if the destination project already has an avatar set. If not, it downloads the avatar from the source project -// and uploads it to the destination project. -// The avatar is saved with a unique filename based on the current timestamp. -// The function returns an error if any step fails, including downloading or uploading the avatar. -func (sourceGitlabInstance *GitlabInstance) copyProjectAvatar(destinationGitlabInstance *GitlabInstance, destinationProject *gitlab.Project, sourceProject *gitlab.Project) error { - zap.L().Debug("Checking if project avatar is already set", zap.String("project", destinationProject.HTTPURLToRepo)) - - // Check if the destination project already has an avatar - if destinationProject.AvatarURL != "" { - zap.L().Debug("Project already has an avatar set, skipping.", zap.String("project", destinationProject.HTTPURLToRepo), zap.String("path", destinationProject.AvatarURL)) - return nil - } - - zap.L().Debug("Copying project avatar", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo)) - - // Download the source project avatar - sourceProjectAvatar, _, err := sourceGitlabInstance.Gitlab.Projects.DownloadAvatar(sourceProject.ID) - if err != nil { - return fmt.Errorf("failed to download avatar for project %s: %s", sourceProject.HTTPURLToRepo, err) - } - - // Upload the avatar to the destination project - _, _, err = destinationGitlabInstance.Gitlab.Projects.UploadAvatar(destinationProject.ID, sourceProjectAvatar, fmt.Sprintf("avatar-%d.png", time.Now().Unix())) - if err != nil { - return fmt.Errorf("failed to upload avatar for project %s: %s", destinationProject.HTTPURLToRepo, err) - } - - return nil -} - -// 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. -// The avatar is saved with a unique filename based on the current timestamp. -// The function returns an error if any step fails, including downloading or uploading the avatar. -func (sourceGitlabInstance *GitlabInstance) copyGroupAvatar(destinationGitlabInstance *GitlabInstance, destinationGroup *gitlab.Group, sourceGroup *gitlab.Group) error { - zap.L().Debug("Checking if group avatar is already set", zap.String("group", destinationGroup.WebURL)) - - // Check if the destination group already has an avatar - if destinationGroup.AvatarURL != "" { - zap.L().Debug("Group avatar already set", zap.String("group", destinationGroup.WebURL), zap.String("path", destinationGroup.AvatarURL)) - return nil - } - - zap.L().Debug("Copying group avatar", zap.String(ROLE_SOURCE, sourceGroup.WebURL), zap.String(ROLE_DESTINATION, destinationGroup.WebURL)) - - // Download the source group avatar - sourceGroupAvatar, _, err := sourceGitlabInstance.Gitlab.Groups.DownloadAvatar(sourceGroup.ID) - if err != nil { - return fmt.Errorf("failed to download avatar for group %s: %s", sourceGroup.WebURL, err) - } - - // Upload the avatar to the destination group - filename := fmt.Sprintf("avatar-%d.png", time.Now().Unix()) - _, _, err = destinationGitlabInstance.Gitlab.Groups.UploadAvatar(destinationGroup.ID, sourceGroupAvatar, filename) - if err != nil { - return fmt.Errorf("failed to upload avatar for group %s: %s", destinationGroup.WebURL, err) - } - - return nil -} +// =========================================================================== +// 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. @@ -195,3 +122,140 @@ func (destinationGitlabInstance *GitlabInstance) syncProjectAttributes(sourcePro } 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 { + zap.L().Debug("Enabling project mirror pull", zap.String("sourceProject", sourceProject.HTTPURLToRepo), zap.String("destinationProject", destinationProject.HTTPURLToRepo)) + _, _, err := g.Gitlab.Projects.ConfigureProjectPullMirror(destinationProject.ID, &gitlab.ConfigureProjectPullMirrorOptions{ + URL: &sourceProject.HTTPURLToRepo, + OnlyMirrorProtectedBranches: gitlab.Ptr(true), + Enabled: gitlab.Ptr(true), + MirrorOverwritesDivergedBranches: gitlab.Ptr(true), + MirrorTriggerBuilds: gitlab.Ptr(mirrorOptions.MirrorTriggerBuilds), + }) + return err +} + +// copyProjectAvatar copies the avatar from the source project to the destination project. +// It first checks if the destination project already has an avatar set. If not, it downloads the avatar from the source project +// and uploads it to the destination project. +// The avatar is saved with a unique filename based on the current timestamp. +// The function returns an error if any step fails, including downloading or uploading the avatar. +func (sourceGitlabInstance *GitlabInstance) copyProjectAvatar(destinationGitlabInstance *GitlabInstance, destinationProject *gitlab.Project, sourceProject *gitlab.Project) error { + zap.L().Debug("Checking if project avatar is already set", zap.String("project", destinationProject.HTTPURLToRepo)) + + // Check if the destination project already has an avatar + if destinationProject.AvatarURL != "" { + zap.L().Debug("Project already has an avatar set, skipping.", zap.String("project", destinationProject.HTTPURLToRepo), zap.String("path", destinationProject.AvatarURL)) + return nil + } + + zap.L().Debug("Copying project avatar", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo)) + + // Download the source project avatar + sourceProjectAvatar, _, err := sourceGitlabInstance.Gitlab.Projects.DownloadAvatar(sourceProject.ID) + if err != nil { + return fmt.Errorf("failed to download avatar for project %s: %s", sourceProject.HTTPURLToRepo, err) + } + + // Upload the avatar to the destination project + _, _, err = destinationGitlabInstance.Gitlab.Projects.UploadAvatar(destinationProject.ID, sourceProjectAvatar, fmt.Sprintf("avatar-%d.png", time.Now().Unix())) + if err != nil { + return fmt.Errorf("failed to upload avatar for project %s: %s", destinationProject.HTTPURLToRepo, err) + } + + 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. +// The avatar is saved with a unique filename based on the current timestamp. +// The function returns an error if any step fails, including downloading or uploading the avatar. +func (sourceGitlabInstance *GitlabInstance) copyGroupAvatar(destinationGitlabInstance *GitlabInstance, destinationGroup *gitlab.Group, sourceGroup *gitlab.Group) error { + zap.L().Debug("Checking if group avatar is already set", zap.String("group", destinationGroup.WebURL)) + + // Check if the destination group already has an avatar + if destinationGroup.AvatarURL != "" { + zap.L().Debug("Group avatar already set", zap.String("group", destinationGroup.WebURL), zap.String("path", destinationGroup.AvatarURL)) + return nil + } + + zap.L().Debug("Copying group avatar", zap.String(ROLE_SOURCE, sourceGroup.WebURL), zap.String(ROLE_DESTINATION, destinationGroup.WebURL)) + + // Download the source group avatar + sourceGroupAvatar, _, err := sourceGitlabInstance.Gitlab.Groups.DownloadAvatar(sourceGroup.ID) + if err != nil { + return fmt.Errorf("failed to download avatar for group %s: %s", sourceGroup.WebURL, err) + } + + // Upload the avatar to the destination group + filename := fmt.Sprintf("avatar-%d.png", time.Now().Unix()) + _, _, err = destinationGitlabInstance.Gitlab.Groups.UploadAvatar(destinationGroup.ID, sourceGroupAvatar, filename) + if err != nil { + return fmt.Errorf("failed to upload avatar for group %s: %s", destinationGroup.WebURL, err) + } + + return nil +} + +// 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 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 + } + + if missmatched { + destinationGroup, _, err := destinationGitlabInstance.Gitlab.Groups.UpdateGroup(destinationGroup.ID, gitlabEditOptions) + if err != nil { + return fmt.Errorf("failed to edit group %s: %s", destinationGroup.FullPath, 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)) + } + return nil +}