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