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