1package gitlab
2
3import (
4 "context"
5 "fmt"
6 "strconv"
7 "time"
8
9 "github.com/xanzy/go-gitlab"
10
11 "github.com/MichaelMure/git-bug/bridge/core"
12 "github.com/MichaelMure/git-bug/bridge/core/auth"
13 "github.com/MichaelMure/git-bug/bridge/gitlab/iterator"
14 "github.com/MichaelMure/git-bug/bug"
15 "github.com/MichaelMure/git-bug/cache"
16 "github.com/MichaelMure/git-bug/entity"
17 "github.com/MichaelMure/git-bug/util/text"
18)
19
20// gitlabImporter implement the Importer interface
21type gitlabImporter struct {
22 conf core.Configuration
23
24 // default client
25 client *gitlab.Client
26
27 // iterator
28 iterator *iterator.Iterator
29
30 // send only channel
31 out chan<- core.ImportResult
32}
33
34func (gi *gitlabImporter) Init(_ context.Context, repo *cache.RepoCache, conf core.Configuration) error {
35 gi.conf = conf
36
37 creds, err := auth.List(repo,
38 auth.WithTarget(target),
39 auth.WithKind(auth.KindToken),
40 auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyGitlabBaseUrl]),
41 auth.WithMeta(auth.MetaKeyLogin, conf[confKeyDefaultLogin]),
42 )
43 if err != nil {
44 return err
45 }
46
47 if len(creds) == 0 {
48 return ErrMissingIdentityToken
49 }
50
51 gi.client, err = buildClient(conf[confKeyGitlabBaseUrl], creds[0].(*auth.Token))
52 if err != nil {
53 return err
54 }
55
56 return nil
57}
58
59// ImportAll iterate over all the configured repository issues (notes) and ensure the creation
60// of the missing issues / comments / label events / title changes ...
61func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
62 gi.iterator = iterator.NewIterator(ctx, gi.client, 10, gi.conf[confKeyProjectID], since)
63 out := make(chan core.ImportResult)
64 gi.out = out
65
66 go func() {
67 defer close(gi.out)
68
69 // Loop over all matching issues
70 for gi.iterator.NextIssue() {
71 issue := gi.iterator.IssueValue()
72
73 // create issue
74 b, err := gi.ensureIssue(repo, issue)
75 if err != nil {
76 err := fmt.Errorf("issue creation: %v", err)
77 out <- core.NewImportError(err, "")
78 return
79 }
80
81 // Loop over all notes
82 for gi.iterator.NextNote() {
83 note := gi.iterator.NoteValue()
84 if err := gi.ensureNote(repo, b, note); err != nil {
85 err := fmt.Errorf("note creation: %v", err)
86 out <- core.NewImportError(err, entity.Id(strconv.Itoa(note.ID)))
87 return
88 }
89 }
90
91 // Loop over all label events
92 for gi.iterator.NextLabelEvent() {
93 labelEvent := gi.iterator.LabelEventValue()
94 if err := gi.ensureLabelEvent(repo, b, labelEvent); err != nil {
95 err := fmt.Errorf("label event creation: %v", err)
96 out <- core.NewImportError(err, entity.Id(strconv.Itoa(labelEvent.ID)))
97 return
98 }
99 }
100
101 if !b.NeedCommit() {
102 out <- core.NewImportNothing(b.Id(), "no imported operation")
103 } else if err := b.Commit(); err != nil {
104 // commit bug state
105 err := fmt.Errorf("bug commit: %v", err)
106 out <- core.NewImportError(err, "")
107 return
108 }
109 }
110
111 if err := gi.iterator.Error(); err != nil {
112 out <- core.NewImportError(err, "")
113 }
114 }()
115
116 return out, nil
117}
118
119func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue) (*cache.BugCache, error) {
120 // ensure issue author
121 author, err := gi.ensurePerson(repo, issue.Author.ID)
122 if err != nil {
123 return nil, err
124 }
125
126 // resolve bug
127 b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
128 return excerpt.CreateMetadata[core.MetaKeyOrigin] == target &&
129 excerpt.CreateMetadata[metaKeyGitlabId] == parseID(issue.IID) &&
130 excerpt.CreateMetadata[metaKeyGitlabBaseUrl] == gi.conf[confKeyProjectID] &&
131 excerpt.CreateMetadata[metaKeyGitlabProject] == gi.conf[confKeyGitlabBaseUrl]
132 })
133 if err == nil {
134 return b, nil
135 }
136 if err != bug.ErrBugNotExist {
137 return nil, err
138 }
139
140 // if bug was never imported
141 cleanText, err := text.Cleanup(issue.Description)
142 if err != nil {
143 return nil, err
144 }
145
146 // create bug
147 b, _, err = repo.NewBugRaw(
148 author,
149 issue.CreatedAt.Unix(),
150 issue.Title,
151 cleanText,
152 nil,
153 map[string]string{
154 core.MetaKeyOrigin: target,
155 metaKeyGitlabId: parseID(issue.IID),
156 metaKeyGitlabUrl: issue.WebURL,
157 metaKeyGitlabProject: gi.conf[confKeyProjectID],
158 metaKeyGitlabBaseUrl: gi.conf[confKeyGitlabBaseUrl],
159 },
160 )
161
162 if err != nil {
163 return nil, err
164 }
165
166 // importing a new bug
167 gi.out <- core.NewImportBug(b.Id())
168
169 return b, nil
170}
171
172func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, note *gitlab.Note) error {
173 gitlabID := parseID(note.ID)
174
175 id, errResolve := b.ResolveOperationWithMetadata(metaKeyGitlabId, gitlabID)
176 if errResolve != nil && errResolve != cache.ErrNoMatchingOp {
177 return errResolve
178 }
179
180 // ensure issue author
181 author, err := gi.ensurePerson(repo, note.Author.ID)
182 if err != nil {
183 return err
184 }
185
186 noteType, body := GetNoteType(note)
187 switch noteType {
188 case NOTE_CLOSED:
189 if errResolve == nil {
190 return nil
191 }
192
193 op, err := b.CloseRaw(
194 author,
195 note.CreatedAt.Unix(),
196 map[string]string{
197 metaKeyGitlabId: gitlabID,
198 },
199 )
200 if err != nil {
201 return err
202 }
203
204 gi.out <- core.NewImportStatusChange(op.Id())
205
206 case NOTE_REOPENED:
207 if errResolve == nil {
208 return nil
209 }
210
211 op, err := b.OpenRaw(
212 author,
213 note.CreatedAt.Unix(),
214 map[string]string{
215 metaKeyGitlabId: gitlabID,
216 },
217 )
218 if err != nil {
219 return err
220 }
221
222 gi.out <- core.NewImportStatusChange(op.Id())
223
224 case NOTE_DESCRIPTION_CHANGED:
225 issue := gi.iterator.IssueValue()
226
227 firstComment := b.Snapshot().Comments[0]
228 // since gitlab doesn't provide the issue history
229 // we should check for "changed the description" notes and compare issue texts
230 // TODO: Check only one time and ignore next 'description change' within one issue
231 if errResolve == cache.ErrNoMatchingOp && issue.Description != firstComment.Message {
232 // comment edition
233 op, err := b.EditCommentRaw(
234 author,
235 note.UpdatedAt.Unix(),
236 firstComment.Id(),
237 issue.Description,
238 map[string]string{
239 metaKeyGitlabId: gitlabID,
240 },
241 )
242 if err != nil {
243 return err
244 }
245
246 gi.out <- core.NewImportTitleEdition(op.Id())
247 }
248
249 case NOTE_COMMENT:
250 cleanText, err := text.Cleanup(body)
251 if err != nil {
252 return err
253 }
254
255 // if we didn't import the comment
256 if errResolve == cache.ErrNoMatchingOp {
257
258 // add comment operation
259 op, err := b.AddCommentRaw(
260 author,
261 note.CreatedAt.Unix(),
262 cleanText,
263 nil,
264 map[string]string{
265 metaKeyGitlabId: gitlabID,
266 },
267 )
268 if err != nil {
269 return err
270 }
271 gi.out <- core.NewImportComment(op.Id())
272 return nil
273 }
274
275 // if comment was already exported
276
277 // search for last comment update
278 comment, err := b.Snapshot().SearchComment(id)
279 if err != nil {
280 return err
281 }
282
283 // compare local bug comment with the new note body
284 if comment.Message != cleanText {
285 // comment edition
286 op, err := b.EditCommentRaw(
287 author,
288 note.UpdatedAt.Unix(),
289 comment.Id(),
290 cleanText,
291 nil,
292 )
293
294 if err != nil {
295 return err
296 }
297 gi.out <- core.NewImportCommentEdition(op.Id())
298 }
299
300 return nil
301
302 case NOTE_TITLE_CHANGED:
303 // title change events are given new notes
304 if errResolve == nil {
305 return nil
306 }
307
308 op, err := b.SetTitleRaw(
309 author,
310 note.CreatedAt.Unix(),
311 body,
312 map[string]string{
313 metaKeyGitlabId: gitlabID,
314 },
315 )
316 if err != nil {
317 return err
318 }
319
320 gi.out <- core.NewImportTitleEdition(op.Id())
321
322 case NOTE_UNKNOWN,
323 NOTE_ASSIGNED,
324 NOTE_UNASSIGNED,
325 NOTE_CHANGED_MILESTONE,
326 NOTE_REMOVED_MILESTONE,
327 NOTE_CHANGED_DUEDATE,
328 NOTE_REMOVED_DUEDATE,
329 NOTE_LOCKED,
330 NOTE_UNLOCKED,
331 NOTE_MENTIONED_IN_ISSUE,
332 NOTE_MENTIONED_IN_MERGE_REQUEST:
333
334 return nil
335
336 default:
337 panic("unhandled note type")
338 }
339
340 return nil
341}
342
343func (gi *gitlabImporter) ensureLabelEvent(repo *cache.RepoCache, b *cache.BugCache, labelEvent *gitlab.LabelEvent) error {
344 _, err := b.ResolveOperationWithMetadata(metaKeyGitlabId, parseID(labelEvent.ID))
345 if err != cache.ErrNoMatchingOp {
346 return err
347 }
348
349 // ensure issue author
350 author, err := gi.ensurePerson(repo, labelEvent.User.ID)
351 if err != nil {
352 return err
353 }
354
355 switch labelEvent.Action {
356 case "add":
357 _, err = b.ForceChangeLabelsRaw(
358 author,
359 labelEvent.CreatedAt.Unix(),
360 []string{labelEvent.Label.Name},
361 nil,
362 map[string]string{
363 metaKeyGitlabId: parseID(labelEvent.ID),
364 },
365 )
366
367 case "remove":
368 _, err = b.ForceChangeLabelsRaw(
369 author,
370 labelEvent.CreatedAt.Unix(),
371 nil,
372 []string{labelEvent.Label.Name},
373 map[string]string{
374 metaKeyGitlabId: parseID(labelEvent.ID),
375 },
376 )
377
378 default:
379 err = fmt.Errorf("unexpected label event action")
380 }
381
382 return err
383}
384
385func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.IdentityCache, error) {
386 // Look first in the cache
387 i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGitlabId, strconv.Itoa(id))
388 if err == nil {
389 return i, nil
390 }
391 if entity.IsErrMultipleMatch(err) {
392 return nil, err
393 }
394
395 user, _, err := gi.client.Users.GetUser(id)
396 if err != nil {
397 return nil, err
398 }
399
400 i, err = repo.NewIdentityRaw(
401 user.Name,
402 user.PublicEmail,
403 user.Username,
404 user.AvatarURL,
405 map[string]string{
406 // because Gitlab
407 metaKeyGitlabId: strconv.Itoa(id),
408 metaKeyGitlabLogin: user.Username,
409 },
410 )
411 if err != nil {
412 return nil, err
413 }
414
415 gi.out <- core.NewImportIdentity(i.Id())
416 return i, nil
417}
418
419func parseID(id int) string {
420 return fmt.Sprintf("%d", id)
421}