1package gitlab
2
3import (
4 "fmt"
5 "strconv"
6 "time"
7
8 "github.com/pkg/errors"
9 "github.com/xanzy/go-gitlab"
10
11 "github.com/MichaelMure/git-bug/bridge/core"
12 "github.com/MichaelMure/git-bug/bug"
13 "github.com/MichaelMure/git-bug/cache"
14 "github.com/MichaelMure/git-bug/entity"
15)
16
17var (
18 ErrMissingIdentityToken = errors.New("missing identity token")
19)
20
21// gitlabExporter implement the Exporter interface
22type gitlabExporter struct {
23 conf core.Configuration
24
25 // cache identities clients
26 identityClient map[string]*gitlab.Client
27
28 // map identities with their tokens
29 identityToken map[string]string
30
31 // gitlab repository ID
32 repositoryID string
33
34 // cache identifiers used to speed up exporting operations
35 // cleared for each bug
36 cachedOperationIDs map[string]string
37
38 // cache labels used to speed up exporting labels events
39 cachedLabels map[string]string
40}
41
42// Init .
43func (ge *gitlabExporter) Init(conf core.Configuration) error {
44 ge.conf = conf
45 //TODO: initialize with multiple tokens
46 ge.identityToken = make(map[string]string)
47 ge.identityClient = make(map[string]*gitlab.Client)
48 ge.cachedOperationIDs = make(map[string]string)
49 ge.cachedLabels = make(map[string]string)
50
51 return nil
52}
53
54// getIdentityClient return a gitlab v4 API client configured with the access token of the given identity.
55// if no client were found it will initialize it from the known tokens map and cache it for next use
56func (ge *gitlabExporter) getIdentityClient(id entity.Id) (*gitlab.Client, error) {
57 client, ok := ge.identityClient[id.String()]
58 if ok {
59 return client, nil
60 }
61
62 // get token
63 token, ok := ge.identityToken[id.String()]
64 if !ok {
65 return nil, ErrMissingIdentityToken
66 }
67
68 // create client
69 client = buildClient(token)
70 // cache client
71 ge.identityClient[id.String()] = client
72
73 return client, nil
74}
75
76// ExportAll export all event made by the current user to Gitlab
77func (ge *gitlabExporter) ExportAll(repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) {
78 out := make(chan core.ExportResult)
79
80 user, err := repo.GetUserIdentity()
81 if err != nil {
82 return nil, err
83 }
84
85 ge.identityToken[user.Id().String()] = ge.conf[keyToken]
86
87 // get repository node id
88 ge.repositoryID = ge.conf[keyProjectID]
89
90 go func() {
91 defer close(out)
92
93 var allIdentitiesIds []entity.Id
94 for id := range ge.identityToken {
95 allIdentitiesIds = append(allIdentitiesIds, entity.Id(id))
96 }
97
98 allBugsIds := repo.AllBugsIds()
99
100 for _, id := range allBugsIds {
101 b, err := repo.ResolveBug(id)
102 if err != nil {
103 out <- core.NewExportError(err, id)
104 return
105 }
106
107 snapshot := b.Snapshot()
108
109 // ignore issues created before since date
110 // TODO: compare the Lamport time instead of using the unix time
111 if snapshot.CreatedAt.Before(since) {
112 out <- core.NewExportNothing(b.Id(), "bug created before the since date")
113 continue
114 }
115
116 if snapshot.HasAnyActor(allIdentitiesIds...) {
117 // try to export the bug and it associated events
118 ge.exportBug(b, since, out)
119 } else {
120 out <- core.NewExportNothing(id, "not an actor")
121 }
122 }
123 }()
124
125 return out, nil
126}
127
128// exportBug publish bugs and related events
129func (ge *gitlabExporter) exportBug(b *cache.BugCache, since time.Time, out chan<- core.ExportResult) {
130 snapshot := b.Snapshot()
131
132 var err error
133 var bugGitlabID int
134 var bugGitlabIDString string
135 var bugCreationId string
136
137 //labels := make([]string, 0)
138
139 // Special case:
140 // if a user try to export a bug that is not already exported to Gitlab (or imported
141 // from Gitlab) and we do not have the token of the bug author, there is nothing we can do.
142
143 // skip bug if origin is not allowed
144 origin, ok := snapshot.GetCreateMetadata(core.KeyOrigin)
145 if ok && origin != target {
146 out <- core.NewExportNothing(b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin))
147 return
148 }
149
150 // first operation is always createOp
151 createOp := snapshot.Operations[0].(*bug.CreateOperation)
152 author := snapshot.Author
153
154 // get gitlab bug ID
155 gitlabID, ok := snapshot.GetCreateMetadata(keyGitlabId)
156 if ok {
157 _, ok := snapshot.GetCreateMetadata(keyGitlabUrl)
158 if !ok {
159 // if we find gitlab ID, gitlab URL must be found too
160 err := fmt.Errorf("expected to find gitlab issue URL")
161 out <- core.NewExportError(err, b.Id())
162 return
163 }
164
165 out <- core.NewExportNothing(b.Id(), "bug already exported")
166
167 // will be used to mark operation related to a bug as exported
168 bugGitlabIDString = gitlabID
169 bugGitlabID, err = strconv.Atoi(bugGitlabIDString)
170 if err != nil {
171 panic("unexpected gitlab id format")
172 }
173
174 } else {
175 // check that we have a token for operation author
176 client, err := ge.getIdentityClient(author.Id())
177 if err != nil {
178 // if bug is still not exported and we do not have the author stop the execution
179 out <- core.NewExportNothing(b.Id(), fmt.Sprintf("missing author token"))
180 return
181 }
182
183 // create bug
184 id, url, err := createGitlabIssue(client, ge.repositoryID, createOp.Title, createOp.Message)
185 if err != nil {
186 err := errors.Wrap(err, "exporting gitlab issue")
187 out <- core.NewExportError(err, b.Id())
188 return
189 }
190
191 idString := strconv.Itoa(id)
192
193 out <- core.NewExportBug(b.Id())
194
195 // mark bug creation operation as exported
196 if err := markOperationAsExported(b, createOp.Id(), idString, url); err != nil {
197 err := errors.Wrap(err, "marking operation as exported")
198 out <- core.NewExportError(err, b.Id())
199 return
200 }
201
202 // commit operation to avoid creating multiple issues with multiple pushes
203 if err := b.CommitAsNeeded(); err != nil {
204 err := errors.Wrap(err, "bug commit")
205 out <- core.NewExportError(err, b.Id())
206 return
207 }
208
209 // cache bug gitlab ID and URL
210 bugGitlabID = id
211 bugGitlabIDString = idString
212 }
213
214 bugCreationId = createOp.Id().String()
215 // cache operation gitlab id
216 ge.cachedOperationIDs[bugCreationId] = bugGitlabIDString
217
218 for _, op := range snapshot.Operations[1:] {
219 // ignore SetMetadata operations
220 if _, ok := op.(*bug.SetMetadataOperation); ok {
221 continue
222 }
223
224 // ignore operations already existing in gitlab (due to import or export)
225 // cache the ID of already exported or imported issues and events from Gitlab
226 if id, ok := op.GetMetadata(keyGitlabId); ok {
227 ge.cachedOperationIDs[op.Id().String()] = id
228 out <- core.NewExportNothing(op.Id(), "already exported operation")
229 continue
230 }
231
232 opAuthor := op.GetAuthor()
233 client, err := ge.getIdentityClient(opAuthor.Id())
234 if err != nil {
235 out <- core.NewExportNothing(op.Id(), "missing operation author token")
236 continue
237 }
238
239 var id int
240 var idString, url string
241 switch op.(type) {
242 case *bug.AddCommentOperation:
243 opr := op.(*bug.AddCommentOperation)
244
245 // send operation to gitlab
246 id, err = addCommentGitlabIssue(client, ge.repositoryID, bugGitlabID, opr.Message)
247 if err != nil {
248 err := errors.Wrap(err, "adding comment")
249 out <- core.NewExportError(err, b.Id())
250 return
251 }
252
253 out <- core.NewExportComment(op.Id())
254
255 idString = strconv.Itoa(id)
256 // cache comment id
257 ge.cachedOperationIDs[op.Id().String()] = idString
258
259 case *bug.EditCommentOperation:
260 opr := op.(*bug.EditCommentOperation)
261 targetId := opr.Target.String()
262
263 // Since gitlab doesn't consider the issue body as a comment
264 if targetId == bugCreationId {
265
266 // case bug creation operation: we need to edit the Gitlab issue
267 if err := updateGitlabIssueBody(client, ge.repositoryID, bugGitlabID, opr.Message); err != nil {
268 err := errors.Wrap(err, "editing issue")
269 out <- core.NewExportError(err, b.Id())
270 return
271 }
272
273 out <- core.NewExportCommentEdition(op.Id())
274 id = bugGitlabID
275
276 } else {
277
278 // case comment edition operation: we need to edit the Gitlab comment
279 commentID, ok := ge.cachedOperationIDs[targetId]
280 if !ok {
281 panic("unexpected error: comment id not found")
282 }
283
284 commentIDint, err := strconv.Atoi(commentID)
285 if err != nil {
286 panic("unexpected comment id format")
287 }
288
289 if err := editCommentGitlabIssue(client, ge.repositoryID, commentIDint, id, opr.Message); err != nil {
290 err := errors.Wrap(err, "editing comment")
291 out <- core.NewExportError(err, b.Id())
292 return
293 }
294
295 out <- core.NewExportCommentEdition(op.Id())
296 id = commentIDint
297 }
298
299 case *bug.SetStatusOperation:
300 opr := op.(*bug.SetStatusOperation)
301 if err := updateGitlabIssueStatus(client, idString, id, opr.Status); err != nil {
302 err := errors.Wrap(err, "editing status")
303 out <- core.NewExportError(err, b.Id())
304 return
305 }
306
307 out <- core.NewExportStatusChange(op.Id())
308 id = bugGitlabID
309
310 case *bug.SetTitleOperation:
311 opr := op.(*bug.SetTitleOperation)
312 if err := updateGitlabIssueTitle(client, ge.repositoryID, id, opr.Title); err != nil {
313 err := errors.Wrap(err, "editing title")
314 out <- core.NewExportError(err, b.Id())
315 return
316 }
317
318 out <- core.NewExportTitleEdition(op.Id())
319 id = bugGitlabID
320
321 case *bug.LabelChangeOperation:
322 _ = op.(*bug.LabelChangeOperation)
323 if err := updateGitlabIssueLabels(client, ge.repositoryID, bugGitlabID, []string{}); err != nil {
324 err := errors.Wrap(err, "updating labels")
325 out <- core.NewExportError(err, b.Id())
326 return
327 }
328
329 out <- core.NewExportLabelChange(op.Id())
330 id = bugGitlabID
331 default:
332 panic("unhandled operation type case")
333 }
334
335 // mark operation as exported
336 if err := markOperationAsExported(b, op.Id(), idString, url); err != nil {
337 err := errors.Wrap(err, "marking operation as exported")
338 out <- core.NewExportError(err, b.Id())
339 return
340 }
341
342 // commit at each operation export to avoid exporting same events multiple times
343 if err := b.CommitAsNeeded(); err != nil {
344 err := errors.Wrap(err, "bug commit")
345 out <- core.NewExportError(err, b.Id())
346 return
347 }
348 }
349}
350
351func markOperationAsExported(b *cache.BugCache, target entity.Id, gitlabID, gitlabURL string) error {
352 _, err := b.SetMetadata(
353 target,
354 map[string]string{
355 keyGitlabId: gitlabID,
356 keyGitlabUrl: gitlabURL,
357 },
358 )
359
360 return err
361}
362
363func (ge *gitlabExporter) getGitlabLabelID(label string) (string, error) {
364 id, ok := ge.cachedLabels[label]
365 if !ok {
366 return "", fmt.Errorf("non cached label")
367 }
368
369 return id, nil
370}
371
372// get label from gitlab
373func (ge *gitlabExporter) loadLabelsFromGitlab(gc *gitlab.Client) error {
374 labels, _, err := gc.Labels.ListLabels(
375 ge.repositoryID,
376 &gitlab.ListLabelsOptions{
377 Page: 0,
378 },
379 )
380
381 if err != nil {
382 return err
383 }
384
385 for _, label := range labels {
386 ge.cachedLabels[label.Name] = strconv.Itoa(label.ID)
387 }
388
389 for page := 2; len(labels) != 0; page++ {
390
391 labels, _, err = gc.Labels.ListLabels(
392 ge.repositoryID,
393 &gitlab.ListLabelsOptions{
394 Page: page,
395 },
396 )
397
398 if err != nil {
399 return err
400 }
401
402 for _, label := range labels {
403 ge.cachedLabels[label.Name] = strconv.Itoa(label.ID)
404 }
405 }
406
407 return nil
408}
409
410func (ge *gitlabExporter) createGitlabLabel(gc *gitlab.Client, label bug.Label) error {
411 client := buildClient(ge.conf[keyToken])
412
413 // RGBA to hex color
414 rgba := label.RGBA()
415 hexColor := fmt.Sprintf("%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B)
416 name := label.String()
417
418 _, _, err := client.Labels.CreateLabel(ge.repositoryID, &gitlab.CreateLabelOptions{
419 Name: &name,
420 Color: &hexColor,
421 })
422
423 return err
424}
425
426// create a gitlab. issue and return it ID
427func createGitlabIssue(gc *gitlab.Client, repositoryID, title, body string) (int, string, error) {
428 issue, _, err := gc.Issues.CreateIssue(repositoryID, &gitlab.CreateIssueOptions{
429 Title: &title,
430 Description: &body,
431 })
432
433 if err != nil {
434 return 0, "", err
435 }
436
437 return issue.IID, issue.WebURL, nil
438}
439
440// add a comment to an issue and return it ID
441func addCommentGitlabIssue(gc *gitlab.Client, repositoryID string, issueID int, body string) (int, error) {
442 note, _, err := gc.Notes.CreateIssueNote(repositoryID, issueID, &gitlab.CreateIssueNoteOptions{
443 Body: &body,
444 })
445
446 if err != nil {
447 return 0, err
448 }
449
450 return note.ID, nil
451}
452
453func editCommentGitlabIssue(gc *gitlab.Client, repositoryID string, issueID, noteID int, body string) error {
454 _, _, err := gc.Notes.UpdateIssueNote(repositoryID, issueID, noteID, &gitlab.UpdateIssueNoteOptions{
455 Body: &body,
456 })
457
458 return err
459}
460
461func updateGitlabIssueStatus(gc *gitlab.Client, repositoryID string, issueID int, status bug.Status) error {
462 var state string
463
464 switch status {
465 case bug.OpenStatus:
466 state = "reopen"
467 case bug.ClosedStatus:
468 state = "close"
469 default:
470 panic("unknown bug state")
471 }
472
473 _, _, err := gc.Issues.UpdateIssue(repositoryID, issueID, &gitlab.UpdateIssueOptions{
474 StateEvent: &state,
475 })
476
477 return err
478}
479
480func updateGitlabIssueBody(gc *gitlab.Client, repositoryID string, issueID int, body string) error {
481 _, _, err := gc.Issues.UpdateIssue(repositoryID, issueID, &gitlab.UpdateIssueOptions{
482 Description: &body,
483 })
484
485 return err
486}
487
488func updateGitlabIssueTitle(gc *gitlab.Client, repositoryID string, issueID int, title string) error {
489 _, _, err := gc.Issues.UpdateIssue(repositoryID, issueID, &gitlab.UpdateIssueOptions{
490 Title: &title,
491 })
492
493 return err
494}
495
496// update gitlab. issue labels
497func updateGitlabIssueLabels(gc *gitlab.Client, repositoryID string, issueID int, labels []string) error {
498 _, _, err := gc.Issues.UpdateIssue(repositoryID, issueID, &gitlab.UpdateIssueOptions{
499 Labels: labels,
500 })
501
502 return err
503}