Skip to content

Commit 8d64585

Browse files
BoxBoxJasonBoxBoxJason
authored andcommitted
build: Add first project draft
0 parents  commit 8d64585

File tree

10 files changed

+574
-0
lines changed

10 files changed

+574
-0
lines changed

.gitignore

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# IDE
2+
.vscode
3+
.idea
4+
.idea/
5+
*.iml
6+
.idea_modules
7+
*.ipr
8+
*.iws
9+
*.bak
10+
*.swp
11+
*.swo
12+
*.swn
13+
*.suo
14+
*.workspace
15+
16+
# Compile output
17+
dist/
18+
build/
19+
20+
# Node.js
21+
node_modules/
22+
npm-debug.log
23+
yarn-error.log
24+
yarn-debug.log*
25+
yarn.lock
26+
27+
# Python
28+
__pycache__/
29+
*.pyc
30+
.env/
31+
.venv/
32+
venv/
33+
env/
34+
35+
# Mac
36+
.DS_Store
37+
.AppleDouble
38+
.LSOverride
39+
40+
# Windows
41+
Thumbs.db
42+
ehthumbs.db
43+
Desktop.ini
44+
45+
# Keys
46+
*.key
47+
*.pem
48+
*.p12
49+
*.pfx
50+
*.crt
51+
*.csr
52+
*.cer
53+
*.jks
54+
*.pub

cmd/main.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"os"
7+
"path"
8+
"strings"
9+
10+
"github.com/boxboxjason/gitlab-sync/utils"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
type ParserArgs struct {
15+
SourceGitlabURL string
16+
SourceGitlabToken string
17+
DestinationGitlabURL string
18+
DestinationGitlabToken string
19+
MirrorMapping utils.MirrorMapping
20+
Verbose bool
21+
NoPrompt bool
22+
}
23+
24+
func main() {
25+
var args ParserArgs
26+
var mirrorMappingPath string
27+
28+
var rootCmd = &cobra.Command{
29+
Use: "gitlab-sync",
30+
Short: "Copy and enable mirroring of gitlab projects and groups",
31+
Long: "Fully customizable gitlab repositories and groups mirroring between two (or one) gitlab instances.",
32+
Run: func(cmd *cobra.Command, cmdArgs []string) {
33+
34+
utils.SetVerbose(args.Verbose)
35+
utils.LogVerbose("Verbose mode enabled")
36+
utils.LogVerbose("Parsing command line arguments")
37+
38+
// Check if the source GitLab URL is provided
39+
args.SourceGitlabURL = promptForMandatoryInput(args.SourceGitlabURL, "Input Source GitLab URL (MANDATORY)", "Source GitLab URL is mandatory", "Source GitLab URL", args.NoPrompt, false)
40+
41+
// Check if the destination GitLab URL is provided
42+
args.DestinationGitlabURL = promptForMandatoryInput(args.DestinationGitlabURL, "Input Destination GitLab URL (MANDATORY)", "Destination GitLab URL is mandatory", "Destination GitLab URL", args.NoPrompt, false)
43+
44+
// Check if the Destination GitLab Token is provided
45+
args.DestinationGitlabToken = promptForMandatoryInput(args.DestinationGitlabToken, "Input Destination GitLab Token with api permissions (MANDATORY)", "Destination GitLab Token is mandatory", "Destination GitLab Token set", args.NoPrompt, true)
46+
47+
// Check if the Mirror Mapping file path is provided
48+
mirrorMappingPath = promptForMandatoryInput(mirrorMappingPath, "Input Mirror Mapping file path (MANDATORY)", "Mirror Mapping file path is mandatory", "Mirror Mapping file path set", args.NoPrompt, false)
49+
utils.LogVerbose("Mirror Mapping file resolved path: " + path.Clean(mirrorMappingPath))
50+
51+
utils.LogVerbose("Parsing mirror mapping file")
52+
mapping, err := utils.OpenMirrorMapping(mirrorMappingPath)
53+
if err != nil {
54+
log.Fatalf("Error opening mirror mapping file: %v", err)
55+
}
56+
args.MirrorMapping = *mapping
57+
utils.LogVerbose("Mirror mapping file parsed successfully")
58+
},
59+
}
60+
61+
rootCmd.Flags().StringVar(&args.SourceGitlabURL, "source-url", os.Getenv("SOURCE_GITLAB_URL"), "Source GitLab URL")
62+
rootCmd.Flags().StringVar(&args.SourceGitlabToken, "source-token", os.Getenv("SOURCE_GITLAB_TOKEN"), "Source GitLab Token")
63+
rootCmd.Flags().StringVar(&args.DestinationGitlabURL, "destination-url", os.Getenv("DESTINATION_GITLAB_URL"), "Destination GitLab URL")
64+
rootCmd.Flags().StringVar(&args.DestinationGitlabToken, "destination-token", os.Getenv("DESTINATION_GITLAB_TOKEN"), "Destination GitLab Token")
65+
rootCmd.Flags().BoolVar(&args.Verbose, "verbose", false, "Enable verbose output")
66+
rootCmd.Flags().BoolVar(&args.NoPrompt, "no-prompt", false, "Disable prompting for missing values")
67+
rootCmd.Flags().StringVar(&mirrorMappingPath, "mirror-mapping", os.Getenv("MIRROR_MAPPING"), "Path to the mirror mapping file")
68+
69+
if err := rootCmd.Execute(); err != nil {
70+
fmt.Println(err)
71+
os.Exit(1)
72+
}
73+
74+
}
75+
76+
func promptForInput(prompt string) string {
77+
var input string
78+
fmt.Printf("%s: ", prompt)
79+
fmt.Scanln(&input)
80+
return input
81+
}
82+
83+
func promptForMandatoryInput(defaultValue string, prompt string, errorMsg string, loggerMsg string, promptsEnabled bool, hideOutput bool) string {
84+
input := strings.TrimSpace(defaultValue)
85+
if input == "" {
86+
if promptsEnabled {
87+
input = strings.TrimSpace(promptForInput(prompt))
88+
if input == "" {
89+
log.Fatal(errorMsg)
90+
}
91+
if !hideOutput {
92+
utils.LogVerbose(loggerMsg + ": " + input)
93+
} else {
94+
utils.LogVerbose(loggerMsg)
95+
}
96+
} else {
97+
log.Fatalf("Prompting is disabled, %s", errorMsg)
98+
}
99+
}
100+
return input
101+
}

go.mod

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module github.com/boxboxjason/gitlab-sync
2+
3+
go 1.23.7
4+
5+
require (
6+
github.com/google/go-querystring v1.1.0 // indirect
7+
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
8+
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
9+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
10+
github.com/spf13/cobra v1.9.1 // indirect
11+
github.com/spf13/pflag v1.0.6 // indirect
12+
gitlab.com/gitlab-org/api/client-go v0.126.0 // indirect
13+
golang.org/x/oauth2 v0.25.0 // indirect
14+
golang.org/x/time v0.10.0 // indirect
15+
)

graphql/main.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package graphql
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"strings"
10+
)
11+
12+
type GraphQLClient struct {
13+
token string
14+
URL string
15+
}
16+
17+
type GraphQLRequest struct {
18+
Query string `json:"query"`
19+
Variables string `json:"variables,omitempty"`
20+
}
21+
22+
func NewGitlabGraphQLClient(token, gitlabUrl string) *GraphQLClient {
23+
return &GraphQLClient{
24+
token: token,
25+
URL: strings.TrimSuffix(gitlabUrl, "/") + "/api/graphql",
26+
}
27+
}
28+
29+
func (g *GraphQLClient) SendRequest(request *GraphQLRequest, method string) (string, error) {
30+
requestBody, err := json.Marshal(request)
31+
if err != nil {
32+
return "", err
33+
}
34+
req, err := http.NewRequestWithContext(context.Background(), method, g.URL, bytes.NewBuffer(requestBody))
35+
if err != nil {
36+
return "", err
37+
}
38+
39+
req.Header.Set("Content-Type", "application/json")
40+
req.Header.Set("Authorization", "Bearer "+g.token)
41+
42+
client := &http.Client{}
43+
resp, err := client.Do(req)
44+
if err != nil {
45+
return "", err
46+
}
47+
defer resp.Body.Close()
48+
if resp.StatusCode != http.StatusOK {
49+
return "", fmt.Errorf("GraphQL request failed with status: %s", resp.Status)
50+
}
51+
var responseBody bytes.Buffer
52+
if _, err := responseBody.ReadFrom(resp.Body); err != nil {
53+
return "", err
54+
}
55+
return responseBody.String(), nil
56+
}

mirroring/get.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package mirroring
2+
3+
import (
4+
"fmt"
5+
"path"
6+
"strings"
7+
8+
"github.com/boxboxjason/gitlab-sync/utils"
9+
)
10+
11+
func (g *GitlabInstance) fetchProjects(filters *utils.MirrorMapping) error {
12+
projects, _, err := g.Gitlab.Projects.ListProjects(nil)
13+
if err != nil {
14+
return err
15+
}
16+
17+
for _, project := range projects {
18+
// Check if the project matches the filters:
19+
// - either is in the projects map
20+
// - or path starts with any of the groups in the groups map
21+
if _, ok := filters.Projects[project.PathWithNamespace]; ok {
22+
g.addProject(project.PathWithNamespace, project)
23+
} else {
24+
for group := range filters.Groups {
25+
if strings.HasPrefix(project.PathWithNamespace, group) {
26+
g.addProject(project.PathWithNamespace, project)
27+
break
28+
}
29+
}
30+
}
31+
}
32+
33+
utils.LogVerbosef("Found %d projects to mirror in the source GitLab instance", len(g.Projects))
34+
35+
return nil
36+
}
37+
38+
func (g *GitlabInstance) fetchGroups(filters *utils.MirrorMapping) error {
39+
groups, _, err := g.Gitlab.Groups.ListGroups(nil)
40+
if err != nil {
41+
return err
42+
}
43+
44+
for _, group := range groups {
45+
// Check if the group matches the filters:
46+
// - either is in the groups map
47+
// - or path starts with any of the groups in the groups map
48+
// - or is a subgroup of any of the groups in the groups map
49+
if _, ok := filters.Groups[group.FullPath]; ok {
50+
g.addGroup(group.FullPath, group)
51+
} else {
52+
for groupPath := range filters.Groups {
53+
if strings.HasPrefix(group.FullPath, groupPath) {
54+
g.addGroup(group.FullPath, group)
55+
break
56+
}
57+
}
58+
}
59+
}
60+
61+
utils.LogVerbosef("Found %d groups to mirror in the source GitLab instance", len(g.Groups))
62+
63+
return nil
64+
}
65+
66+
func (g *GitlabInstance) getParentGroupID(projectOrGroupPath string) (int, error) {
67+
parentGroupID := -1
68+
parentPath := path.Dir(projectOrGroupPath)
69+
var err error = nil
70+
if parentPath != "." {
71+
// Check if parent path is already in the instance groups cache
72+
if parentGroup, ok := g.Groups[parentPath]; ok {
73+
parentGroupID = parentGroup.ID
74+
} else {
75+
err = fmt.Errorf("parent group not found for path: %s", parentPath)
76+
}
77+
}
78+
return parentGroupID, err
79+
}

mirroring/main.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package mirroring
2+
3+
import (
4+
"sync"
5+
6+
"github.com/boxboxjason/gitlab-sync/graphql"
7+
"github.com/boxboxjason/gitlab-sync/utils"
8+
gitlab "gitlab.com/gitlab-org/api/client-go"
9+
)
10+
11+
type GitlabInstance struct {
12+
Gitlab *gitlab.Client
13+
Projects map[string]*gitlab.Project
14+
muProjects sync.RWMutex
15+
Groups map[string]*gitlab.Group
16+
muGroups sync.RWMutex
17+
MirrorMapping *utils.MirrorMapping
18+
GraphQLClient *graphql.GraphQLClient
19+
}
20+
21+
func NewGitlabInstance(gitlabURL, gitlabToken string, mirrorMapping *utils.MirrorMapping) (*GitlabInstance, error) {
22+
gitlabClient, err := gitlab.NewClient(gitlabToken, gitlab.WithBaseURL(gitlabURL))
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
gitlabInstance := &GitlabInstance{
28+
Gitlab: gitlabClient,
29+
Projects: make(map[string]*gitlab.Project),
30+
Groups: make(map[string]*gitlab.Group),
31+
MirrorMapping: mirrorMapping,
32+
}
33+
34+
return gitlabInstance, nil
35+
}
36+
37+
func (g *GitlabInstance) addProject(projectPath string, project *gitlab.Project) {
38+
g.muProjects.Lock()
39+
defer g.muProjects.Unlock()
40+
g.Projects[projectPath] = project
41+
}
42+
43+
func (g *GitlabInstance) getProject(projectPath string) *gitlab.Project {
44+
g.muProjects.RLock()
45+
defer g.muProjects.RUnlock()
46+
var project *gitlab.Project
47+
project, exists := g.Projects[projectPath]
48+
if !exists {
49+
project = nil
50+
}
51+
return project
52+
}
53+
54+
func (g *GitlabInstance) addGroup(groupPath string, group *gitlab.Group) {
55+
g.muGroups.Lock()
56+
defer g.muGroups.Unlock()
57+
g.Groups[groupPath] = group
58+
}
59+
60+
func (g *GitlabInstance) getGroup(groupPath string) *gitlab.Group {
61+
g.muGroups.RLock()
62+
defer g.muGroups.RUnlock()
63+
var group *gitlab.Group
64+
group, exists := g.Groups[groupPath]
65+
if !exists {
66+
group = nil
67+
}
68+
return group
69+
}

0 commit comments

Comments
 (0)