Skip to content

Commit a2a7aed

Browse files
committed
feat: enable issues mirroring
Closes #15
1 parent 57da279 commit a2a7aed

File tree

11 files changed

+398
-26
lines changed

11 files changed

+398
-26
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Allowed options are:
9999
|--------|-------------|
100100
| `destination_path` | The path to the project / group on the destination GitLab instance. |
101101
| `ci_cd_catalog` | Whether to add the project to the CI/CD catalog. |
102-
| `issues` | Whether to copy issues from the source project to the destination project. |
102+
| `mirror_issues` | Whether to copy issues from the source project to the destination project. |
103103
| `visibility` | The visibility level of the project on the destination GitLab instance. Can be `public`, `internal`, or `private`. |
104104
| `mirror_trigger_builds` | Whether to trigger builds on the destination project when a push is made to the source project. |
105105
| `mirror_releases` | Whether to mirror releases from the source project to the destination project. |
@@ -114,7 +114,7 @@ Also, the destination namespace must exist on the destination GitLab instance. I
114114
"existingGroup1/project1" : {
115115
"destination_path": "existingGroup64/project1",
116116
"ci_cd_catalog": true,
117-
"issues": false,
117+
"mirror_issues": false,
118118
"visibility": "public",
119119
"mirror_trigger_builds": false,
120120
"mirror_releases": false
@@ -124,7 +124,7 @@ Also, the destination namespace must exist on the destination GitLab instance. I
124124
"existingGroup152" : {
125125
"destination_path": "existingGroup64/existingGroup152",
126126
"ci_cd_catalog": true,
127-
"issues": false,
127+
"mirror_issues": false,
128128
"visibility": "public",
129129
"mirror_trigger_builds": false,
130130
"mirror_releases": false

internal/mirroring/get_group.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func (g *GitlabInstance) storeGroup(group *gitlab.Group, parentGroupPath string,
5050
mirrorMapping.AddGroup(group.FullPath, &utils.MirroringOptions{
5151
DestinationPath: filepath.Join(groupCreationOptions.DestinationPath, relativePath),
5252
CI_CD_Catalog: groupCreationOptions.CI_CD_Catalog,
53-
Issues: groupCreationOptions.Issues,
53+
MirrorIssues: groupCreationOptions.MirrorIssues,
5454
MirrorTriggerBuilds: groupCreationOptions.MirrorTriggerBuilds,
5555
Visibility: groupCreationOptions.Visibility,
5656
MirrorReleases: groupCreationOptions.MirrorReleases,

internal/mirroring/get_project.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func (g *GitlabInstance) storeProject(project *gitlab.Project, parentGroupPath s
5050
mirrorMapping.AddProject(project.PathWithNamespace, &utils.MirroringOptions{
5151
DestinationPath: filepath.Join(groupCreationOptions.DestinationPath, relativePath),
5252
CI_CD_Catalog: groupCreationOptions.CI_CD_Catalog,
53-
Issues: groupCreationOptions.Issues,
53+
MirrorIssues: groupCreationOptions.MirrorIssues,
5454
MirrorTriggerBuilds: groupCreationOptions.MirrorTriggerBuilds,
5555
Visibility: groupCreationOptions.Visibility,
5656
MirrorReleases: groupCreationOptions.MirrorReleases,

internal/mirroring/helper_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,112 @@ var (
263263
"links": []
264264
}
265265
}`}
266+
267+
issue_string = "issue"
268+
269+
TEST_ISSUE = &gitlab.Issue{
270+
IID: 1,
271+
Title: "Test Issue",
272+
Description: "This is a test issue",
273+
Labels: []string{"bug", "urgent"},
274+
Confidential: false,
275+
Weight: 3,
276+
IssueType: &issue_string,
277+
}
278+
279+
// TEST_ISSUE_STRING is the string representation of TEST_ISSUE.
280+
TEST_ISSUE_STRING = `{
281+
"project_id" : 1,
282+
"milestone" : {
283+
"due_date" : null,
284+
"project_id" : 4,
285+
"state" : "closed",
286+
"description" : "Rerum est voluptatem provident consequuntur molestias similique ipsum dolor.",
287+
"iid" : 3,
288+
"id" : 11,
289+
"title" : "v3.0",
290+
"created_at" : "2016-01-04T15:31:39.788Z",
291+
"updated_at" : "2016-01-04T15:31:39.788Z"
292+
},
293+
"author" : {
294+
"state" : "active",
295+
"web_url" : "https://gitlab.example.com/root",
296+
"avatar_url" : null,
297+
"username" : "root",
298+
"id" : 1,
299+
"name" : "Administrator"
300+
},
301+
"description" : "Omnis vero earum sunt corporis dolor et placeat.",
302+
"state" : "closed",
303+
"iid" : 1,
304+
"assignees" : [{
305+
"avatar_url" : null,
306+
"web_url" : "https://gitlab.example.com/lennie",
307+
"state" : "active",
308+
"username" : "lennie",
309+
"id" : 9,
310+
"name" : "Dr. Luella Kovacek"
311+
}],
312+
"assignee" : {
313+
"avatar_url" : null,
314+
"web_url" : "https://gitlab.example.com/lennie",
315+
"state" : "active",
316+
"username" : "lennie",
317+
"id" : 9,
318+
"name" : "Dr. Luella Kovacek"
319+
},
320+
"type" : "ISSUE",
321+
"labels" : ["foo", "bar"],
322+
"upvotes": 4,
323+
"downvotes": 0,
324+
"merge_requests_count": 0,
325+
"id" : 41,
326+
"title" : "Ut commodi ullam eos dolores perferendis nihil sunt.",
327+
"updated_at" : "2016-01-04T15:31:46.176Z",
328+
"created_at" : "2016-01-04T15:31:46.176Z",
329+
"closed_at" : "2016-01-05T15:31:46.176Z",
330+
"closed_by" : {
331+
"state" : "active",
332+
"web_url" : "https://gitlab.example.com/root",
333+
"avatar_url" : null,
334+
"username" : "root",
335+
"id" : 1,
336+
"name" : "Administrator"
337+
},
338+
"user_notes_count": 1,
339+
"due_date": "2016-07-22",
340+
"imported": false,
341+
"imported_from": "none",
342+
"web_url": "http://gitlab.example.com/my-group/my-project/issues/1",
343+
"references": {
344+
"short": "#1",
345+
"relative": "#1",
346+
"full": "my-group/my-project#1"
347+
},
348+
"time_stats": {
349+
"time_estimate": 0,
350+
"total_time_spent": 0,
351+
"human_time_estimate": null,
352+
"human_total_time_spent": null
353+
},
354+
"has_tasks": true,
355+
"task_status": "10 of 15 tasks completed",
356+
"confidential": false,
357+
"discussion_locked": false,
358+
"issue_type": "issue",
359+
"severity": "UNKNOWN",
360+
"_links":{
361+
"self":"http://gitlab.example.com/api/v4/projects/4/issues/41",
362+
"notes":"http://gitlab.example.com/api/v4/projects/4/issues/41/notes",
363+
"award_emoji":"http://gitlab.example.com/api/v4/projects/4/issues/41/award_emoji",
364+
"project":"http://gitlab.example.com/api/v4/projects/4",
365+
"closed_as_duplicate_of": "http://gitlab.example.com/api/v4/projects/1/issues/75"
366+
},
367+
"task_completion_status":{
368+
"count":0,
369+
"completed_count":0
370+
}
371+
}`
266372
)
267373

268374
func setupEmptyTestServer(t *testing.T, role string, instanceSize string) (*http.ServeMux, *GitlabInstance) {
@@ -552,6 +658,39 @@ func setupTestProject(mux *http.ServeMux, project *gitlab.Project, stringRespons
552658
w.WriteHeader(http.StatusOK)
553659
fmt.Fprint(w, "{}")
554660
})
661+
// Setup the get project issues response from the project ID
662+
mux.HandleFunc(fmt.Sprintf("/api/v4/projects/%d/issues", project.ID), func(w http.ResponseWriter, r *http.Request) {
663+
switch r.Method {
664+
case http.MethodGet:
665+
w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT)
666+
w.WriteHeader(http.StatusOK)
667+
fmt.Fprintf(w, "[%s]", TEST_ISSUE_STRING)
668+
case http.MethodPost:
669+
w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT)
670+
w.WriteHeader(http.StatusCreated)
671+
fmt.Fprint(w, TEST_ISSUE_STRING)
672+
default:
673+
w.WriteHeader(http.StatusMethodNotAllowed)
674+
return
675+
}
676+
})
677+
// Setup the get project issue response from the project ID and issue IID
678+
mux.HandleFunc(fmt.Sprintf("/api/v4/projects/%d/issues/%d", project.ID, TEST_ISSUE.IID), func(w http.ResponseWriter, r *http.Request) {
679+
switch r.Method {
680+
case http.MethodGet:
681+
w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT)
682+
w.WriteHeader(http.StatusOK)
683+
fmt.Fprint(w, TEST_ISSUE_STRING)
684+
case http.MethodPut:
685+
w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT)
686+
w.WriteHeader(http.StatusOK)
687+
fmt.Fprint(w, TEST_ISSUE_STRING)
688+
default:
689+
w.WriteHeader(http.StatusMethodNotAllowed)
690+
return
691+
}
692+
})
693+
// Setup the get project issue notes response from the project ID and issue IID
555694
}
556695

557696
func TestReverseGroupMirrorMap(t *testing.T) {

internal/mirroring/issues.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package mirroring
2+
3+
import (
4+
"fmt"
5+
"gitlab-sync/pkg/helpers"
6+
"sync"
7+
8+
gitlab "gitlab.com/gitlab-org/api/client-go"
9+
"go.uber.org/zap"
10+
)
11+
12+
var (
13+
CLOSE_STATE_EVENT = "close"
14+
)
15+
16+
// ===========================================================================
17+
// ISSUES MIRRORING FUNCTIONS //
18+
// ===========================================================================
19+
20+
// ================
21+
// GET
22+
// ================
23+
24+
// FetchProjectIssues retrieves all issues for a project and processes them
25+
func (g *GitlabInstance) FetchProjectIssues(project *gitlab.Project) ([]*gitlab.Issue, error) {
26+
zap.L().Debug("Fetching issues for project", zap.String("project", project.PathWithNamespace))
27+
fetchOpts := &gitlab.ListProjectIssuesOptions{
28+
ListOptions: gitlab.ListOptions{
29+
PerPage: 100,
30+
Page: 1,
31+
},
32+
}
33+
34+
var issues = make([]*gitlab.Issue, 0)
35+
36+
for {
37+
fetchedIssues, resp, err := g.Gitlab.Issues.ListProjectIssues(project.ID, fetchOpts)
38+
if err != nil {
39+
return nil, err
40+
}
41+
issues = append(issues, fetchedIssues...)
42+
43+
if resp.CurrentPage >= resp.TotalPages {
44+
break
45+
}
46+
fetchOpts.Page = resp.NextPage
47+
}
48+
49+
return issues, nil
50+
}
51+
52+
// FetchProjectIssuesTitles retrieves all issue titles for a project and returns them as a map
53+
func (g *GitlabInstance) FetchProjectIssuesTitles(project *gitlab.Project) (map[string]struct{}, error) {
54+
// Fetch existing issues from the destination project
55+
issues, err := g.FetchProjectIssues(project)
56+
if err != nil {
57+
return nil, err
58+
}
59+
60+
// Create a map of existing issue titles for quick lookup
61+
issueTitles := make(map[string]struct{})
62+
for _, issue := range issues {
63+
if issue != nil {
64+
issueTitles[issue.Title] = struct{}{}
65+
}
66+
}
67+
return issueTitles, nil
68+
}
69+
70+
// ================
71+
// POST
72+
// ================
73+
74+
// MirrorIssue creates an issue in the destination project
75+
func (g *GitlabInstance) MirrorIssue(project *gitlab.Project, issue *gitlab.Issue) error {
76+
zap.L().Debug("Creating issue in destination project", zap.String("issue", issue.Title), zap.String(ROLE_DESTINATION, project.HTTPURLToRepo))
77+
78+
// Create the issue in the destination project
79+
_, _, err := g.Gitlab.Issues.CreateIssue(project.ID, &gitlab.CreateIssueOptions{
80+
Title: &issue.Title,
81+
Description: &issue.Description,
82+
Labels: (*gitlab.LabelOptions)(&issue.Labels),
83+
CreatedAt: issue.CreatedAt,
84+
Confidential: &issue.Confidential,
85+
DueDate: issue.DueDate,
86+
Weight: &issue.Weight,
87+
IssueType: issue.IssueType,
88+
})
89+
90+
if err == nil && issue.State == string(gitlab.ClosedEventType) {
91+
// If the issue is closed, close it in the destination project
92+
err = g.CloseIssue(project, issue)
93+
}
94+
95+
return err
96+
}
97+
98+
// CloseIssue closes an issue in the destination project
99+
func (g *GitlabInstance) CloseIssue(project *gitlab.Project, issue *gitlab.Issue) error {
100+
zap.L().Debug("Closing issue in destination project", zap.String("issue", issue.Title), zap.String(ROLE_DESTINATION, project.HTTPURLToRepo))
101+
_, _, err := g.Gitlab.Issues.UpdateIssue(project.ID, issue.IID, &gitlab.UpdateIssueOptions{
102+
StateEvent: &CLOSE_STATE_EVENT,
103+
})
104+
return err
105+
}
106+
107+
// ================
108+
// CONTROLLER
109+
// ================
110+
111+
// MirrorIssues mirrors issues from the source project to the destination project.
112+
// It fetches existing issues from the destination project and creates new issues for those that do not
113+
func (destinationGitlab *GitlabInstance) MirrorIssues(sourceGitlab *GitlabInstance, sourceProject *gitlab.Project, destinationProject *gitlab.Project) []error {
114+
zap.L().Info("Starting issues mirroring", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo))
115+
116+
// Fetch existing issues from the destination project
117+
existingIssuesTitles, err := destinationGitlab.FetchProjectIssuesTitles(destinationProject)
118+
if err != nil {
119+
return []error{fmt.Errorf("failed to fetch existing issues for destination project %s: %v", destinationProject.HTTPURLToRepo, err)}
120+
}
121+
122+
// Fetch issues from the source project
123+
sourceIssues, err := sourceGitlab.FetchProjectIssues(sourceProject)
124+
if err != nil {
125+
return []error{fmt.Errorf("failed to fetch issues for source project %s: %v", sourceProject.HTTPURLToRepo, err)}
126+
}
127+
128+
// Create a wait group and an error channel for handling API calls concurrently
129+
var wg sync.WaitGroup
130+
errorChan := make(chan error, len(sourceIssues))
131+
132+
// Iterate over each source issue
133+
for _, issue := range sourceIssues {
134+
// Check if the issue already exists in the destination project
135+
if _, exists := existingIssuesTitles[issue.Title]; exists {
136+
zap.L().Debug("Issue already exists", zap.String("issue", issue.Title), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo))
137+
continue
138+
}
139+
140+
// Increment the wait group counter
141+
wg.Add(1)
142+
143+
// Define the API call logic for creating an issue
144+
go func(project *gitlab.Project, issueToMirror *gitlab.Issue) {
145+
defer wg.Done()
146+
err := destinationGitlab.MirrorIssue(destinationProject, issueToMirror)
147+
if err != nil {
148+
errorChan <- fmt.Errorf("failed to create issue %s in project %s: %s", issueToMirror.Title, destinationProject.HTTPURLToRepo, err)
149+
}
150+
}(destinationProject, issue)
151+
}
152+
153+
// Wait for all goroutines to finish
154+
wg.Wait()
155+
close(errorChan)
156+
zap.L().Info("Issues mirroring completed", zap.String(ROLE_SOURCE, sourceProject.HTTPURLToRepo), zap.String(ROLE_DESTINATION, destinationProject.HTTPURLToRepo))
157+
return helpers.MergeErrors(errorChan)
158+
}

0 commit comments

Comments
 (0)