1package jira
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/MichaelMure/git-bug/bridge/core"
13 "github.com/MichaelMure/git-bug/bug"
14 "github.com/MichaelMure/git-bug/cache"
15 "github.com/MichaelMure/git-bug/entity"
16 "github.com/MichaelMure/git-bug/util/text"
17)
18
19const (
20 keyJiraID = "jira-id"
21 keyJiraOperationID = "jira-derived-id"
22 keyJiraKey = "jira-key"
23 keyJiraUser = "jira-user"
24 keyJiraProject = "jira-project"
25 keyJiraExportTime = "jira-export-time"
26 defaultPageSize = 10
27)
28
29// jiraImporter implement the Importer interface
30type jiraImporter struct {
31 conf core.Configuration
32
33 // send only channel
34 out chan<- core.ImportResult
35}
36
37// Init .
38func (gi *jiraImporter) Init(conf core.Configuration) error {
39 gi.conf = conf
40 return nil
41}
42
43// ImportAll iterate over all the configured repository issues and ensure the
44// creation of the missing issues / timeline items / edits / label events ...
45func (self *jiraImporter) ImportAll(
46 ctx context.Context, repo *cache.RepoCache, since time.Time) (
47 <-chan core.ImportResult, error) {
48
49 sinceStr := since.Format("2006-01-02 15:04")
50 serverURL := self.conf[keyServer]
51 project := self.conf[keyProject]
52 // TODO(josh)[da52062]: Validate token and if it is expired then prompt for
53 // credentials and generate a new one
54 out := make(chan core.ImportResult)
55 self.out = out
56
57 go func() {
58 defer close(self.out)
59
60 client := NewClient(serverURL, ctx)
61 err := client.Login(self.conf)
62 if err != nil {
63 out <- core.NewImportError(err, "")
64 return
65 }
66
67 message, err := client.Search(
68 fmt.Sprintf("project=%s AND updatedDate>\"%s\"", project, sinceStr), 0, 0)
69 if err != nil {
70 out <- core.NewImportError(err, "")
71 return
72 }
73
74 fmt.Printf("So far so good. Have %d issues to import\n", message.Total)
75
76 jql := fmt.Sprintf("project=%s AND updatedDate>\"%s\"", project, sinceStr)
77 var searchIter *SearchIterator
78 for searchIter =
79 client.IterSearch(jql, defaultPageSize); searchIter.HasNext(); {
80 issue := searchIter.Next()
81 bug, err := self.ensureIssue(repo, *issue)
82 if err != nil {
83 err := fmt.Errorf("issue creation: %v", err)
84 out <- core.NewImportError(err, "")
85 return
86 }
87
88 var commentIter *CommentIterator
89 for commentIter =
90 client.IterComments(issue.ID, defaultPageSize); commentIter.HasNext(); {
91 comment := commentIter.Next()
92 err := self.ensureComment(repo, bug, *comment)
93 if err != nil {
94 out <- core.NewImportError(err, "")
95 }
96 }
97 if commentIter.HasError() {
98 out <- core.NewImportError(commentIter.Err, "")
99 }
100
101 snapshot := bug.Snapshot()
102 opIdx := 0
103
104 var changelogIter *ChangeLogIterator
105 for changelogIter =
106 client.IterChangeLog(issue.ID, defaultPageSize); changelogIter.HasNext(); {
107 changelogEntry := changelogIter.Next()
108
109 // Advance the operation iterator up to the first operation which has
110 // an export date not before the changelog entry date. If the changelog
111 // entry was created in response to an exported operation, then this
112 // will be that operation.
113 var exportTime time.Time
114 for ; opIdx < len(snapshot.Operations); opIdx++ {
115 exportTimeStr, hasTime := snapshot.Operations[opIdx].GetMetadata(
116 keyJiraExportTime)
117 if !hasTime {
118 continue
119 }
120 exportTime, err = http.ParseTime(exportTimeStr)
121 if err != nil {
122 continue
123 }
124 if !exportTime.Before(changelogEntry.Created.Time) {
125 break
126 }
127 }
128 if opIdx < len(snapshot.Operations) {
129 err = self.ensureChange(
130 repo, bug, *changelogEntry, snapshot.Operations[opIdx])
131 } else {
132 err = self.ensureChange(repo, bug, *changelogEntry, nil)
133 }
134 if err != nil {
135 out <- core.NewImportError(err, "")
136 }
137
138 }
139 if changelogIter.HasError() {
140 out <- core.NewImportError(changelogIter.Err, "")
141 }
142
143 if !bug.NeedCommit() {
144 out <- core.NewImportNothing(bug.Id(), "no imported operation")
145 } else if err := bug.Commit(); err != nil {
146 err = fmt.Errorf("bug commit: %v", err)
147 out <- core.NewImportError(err, "")
148 return
149 }
150 }
151 if searchIter.HasError() {
152 out <- core.NewImportError(searchIter.Err, "")
153 }
154 }()
155
156 return out, nil
157}
158
159// Create a bug.Person from a JIRA user
160func (self *jiraImporter) ensurePerson(
161 repo *cache.RepoCache, user User) (*cache.IdentityCache, error) {
162
163 // Look first in the cache
164 i, err := repo.ResolveIdentityImmutableMetadata(
165 keyJiraUser, string(user.Key))
166 if err == nil {
167 return i, nil
168 }
169 if _, ok := err.(entity.ErrMultipleMatch); ok {
170 return nil, err
171 }
172
173 i, err = repo.NewIdentityRaw(
174 user.DisplayName,
175 user.EmailAddress,
176 user.Key,
177 "",
178 map[string]string{
179 keyJiraUser: string(user.Key),
180 },
181 )
182
183 if err != nil {
184 return nil, err
185 }
186
187 self.out <- core.NewImportIdentity(i.Id())
188 return i, nil
189}
190
191// Create a bug.Bug based from a JIRA issue
192func (self *jiraImporter) ensureIssue(
193 repo *cache.RepoCache, issue Issue) (*cache.BugCache, error) {
194 author, err := self.ensurePerson(repo, issue.Fields.Creator)
195 if err != nil {
196 return nil, err
197 }
198
199 b, err := repo.ResolveBugCreateMetadata(keyJiraID, issue.ID)
200 if err != nil && err != bug.ErrBugNotExist {
201 return nil, err
202 }
203
204 if err == bug.ErrBugNotExist {
205 cleanText, err := text.Cleanup(string(issue.Fields.Description))
206 if err != nil {
207 return nil, err
208 }
209
210 // NOTE(josh): newlines in titles appears to be rare, but it has been seen
211 // in the wild. It does not appear to be allowed in the JIRA web interface.
212 title := strings.Replace(issue.Fields.Summary, "\n", "", -1)
213 b, _, err = repo.NewBugRaw(
214 author,
215 issue.Fields.Created.Unix(),
216 title,
217 cleanText,
218 nil,
219 map[string]string{
220 core.KeyOrigin: target,
221 keyJiraID: issue.ID,
222 keyJiraKey: issue.Key,
223 keyJiraProject: self.conf[keyProject],
224 })
225 if err != nil {
226 return nil, err
227 }
228
229 self.out <- core.NewImportBug(b.Id())
230 }
231
232 return b, nil
233}
234
235// Return a unique string derived from a unique jira id and a timestamp
236func getTimeDerivedID(jiraID string, timestamp MyTime) string {
237 return fmt.Sprintf("%s-%d", jiraID, timestamp.Unix())
238}
239
240// Create a bug.Comment from a JIRA comment
241func (self *jiraImporter) ensureComment(repo *cache.RepoCache,
242 b *cache.BugCache, item Comment) error {
243 // ensure person
244 author, err := self.ensurePerson(repo, item.Author)
245 if err != nil {
246 return err
247 }
248
249 targetOpID, err := b.ResolveOperationWithMetadata(
250 keyJiraID, item.ID)
251 if err != nil && err != cache.ErrNoMatchingOp {
252 return err
253 }
254
255 // If the comment is a new comment then create it
256 if targetOpID == "" && err == cache.ErrNoMatchingOp {
257 var cleanText string
258 if item.Updated != item.Created {
259 // We don't know the original text... we only have the updated text.
260 cleanText = ""
261 } else {
262 cleanText, err = text.Cleanup(string(item.Body))
263 if err != nil {
264 return err
265 }
266 }
267
268 // add comment operation
269 op, err := b.AddCommentRaw(
270 author,
271 item.Created.Unix(),
272 cleanText,
273 nil,
274 map[string]string{
275 keyJiraID: item.ID,
276 },
277 )
278 if err != nil {
279 return err
280 }
281
282 self.out <- core.NewImportComment(op.Id())
283 targetOpID = op.Id()
284 }
285
286 // If there are no updates to this comment, then we are done
287 if item.Updated == item.Created {
288 return nil
289 }
290
291 // If there has been an update to this comment, we try to find it in the
292 // database. We need a unique id so we'll concat the issue id with the update
293 // timestamp. Note that this must be consistent with the exporter during
294 // export of an EditCommentOperation
295 derivedID := getTimeDerivedID(item.ID, item.Updated)
296 _, err = b.ResolveOperationWithMetadata(
297 keyJiraID, derivedID)
298 if err == nil {
299 // Already imported this edition
300 return nil
301 }
302
303 if err != cache.ErrNoMatchingOp {
304 return err
305 }
306
307 // ensure editor identity
308 editor, err := self.ensurePerson(repo, item.UpdateAuthor)
309 if err != nil {
310 return err
311 }
312
313 // comment edition
314 cleanText, err := text.Cleanup(string(item.Body))
315 if err != nil {
316 return err
317 }
318 op, err := b.EditCommentRaw(
319 editor,
320 item.Updated.Unix(),
321 targetOpID,
322 cleanText,
323 map[string]string{
324 keyJiraID: derivedID,
325 },
326 )
327
328 if err != nil {
329 return err
330 }
331
332 self.out <- core.NewImportCommentEdition(op.Id())
333
334 return nil
335}
336
337// Return a unique string derived from a unique jira id and an index into the
338// data referred to by that jira id.
339func getIndexDerivedID(jiraID string, idx int) string {
340 return fmt.Sprintf("%s-%d", jiraID, idx)
341}
342
343func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool {
344 if len(jiraSet) != len(gitbugSet) {
345 return false
346 }
347
348 sort.Strings(jiraSet)
349 gitbugStrSet := make([]string, len(gitbugSet))
350 for idx, label := range gitbugSet {
351 gitbugStrSet[idx] = label.String()
352 }
353 sort.Strings(gitbugStrSet)
354
355 for idx, value := range jiraSet {
356 if value != gitbugStrSet[idx] {
357 return false
358 }
359 }
360
361 return true
362}
363
364// Create a bug.Operation (or a series of operations) from a JIRA changelog
365// entry
366func (self *jiraImporter) ensureChange(
367 repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry,
368 potentialOp bug.Operation) error {
369
370 // If we have an operation which is already mapped to the entire changelog
371 // entry then that means this changelog entry was induced by an export
372 // operation and we've already done the match, so we skip this one
373 _, err := b.ResolveOperationWithMetadata(keyJiraOperationID, entry.ID)
374 if err == nil {
375 return nil
376 } else if err != cache.ErrNoMatchingOp {
377 return err
378 }
379
380 // In general, multiple fields may be changed in changelog entry on
381 // JIRA. For example, when an issue is closed both its "status" and its
382 // "resolution" are updated within a single changelog entry.
383 // I don't thing git-bug has a single operation to modify an arbitrary
384 // number of fields in one go, so we break up the single JIRA changelog
385 // entry into individual field updates.
386 author, err := self.ensurePerson(repo, entry.Author)
387 if err != nil {
388 return err
389 }
390
391 if len(entry.Items) < 1 {
392 return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID)
393 }
394
395 statusMap, err := getStatusMapReverse(self.conf)
396 if err != nil {
397 return err
398 }
399
400 // NOTE(josh): first do an initial scan and see if any of the changed items
401 // matches the current potential operation. If it does, then we know that this
402 // entire changelog entry was created in response to that git-bug operation.
403 // So we associate the operation with the entire changelog, and not a specific
404 // entry.
405 for _, item := range entry.Items {
406 switch item.Field {
407 case "labels":
408 fromLabels := removeEmpty(strings.Split(item.FromString, " "))
409 toLabels := removeEmpty(strings.Split(item.ToString, " "))
410 removedLabels, addedLabels, _ := setSymmetricDifference(
411 fromLabels, toLabels)
412
413 opr, isRightType := potentialOp.(*bug.LabelChangeOperation)
414 if isRightType &&
415 labelSetsMatch(addedLabels, opr.Added) &&
416 labelSetsMatch(removedLabels, opr.Removed) {
417 _, err := b.SetMetadata(opr.Id(), map[string]string{
418 keyJiraOperationID: entry.ID,
419 })
420 if err != nil {
421 return err
422 }
423 return nil
424 }
425
426 case "status":
427 opr, isRightType := potentialOp.(*bug.SetStatusOperation)
428 if isRightType && statusMap[opr.Status.String()] == item.To {
429 _, err := b.SetMetadata(opr.Id(), map[string]string{
430 keyJiraOperationID: entry.ID,
431 })
432 if err != nil {
433 return err
434 }
435 return nil
436 }
437
438 case "summary":
439 // NOTE(josh): JIRA calls it "summary", which sounds more like the body
440 // text, but it's the title
441 opr, isRightType := potentialOp.(*bug.SetTitleOperation)
442 if isRightType && opr.Title == item.To {
443 _, err := b.SetMetadata(opr.Id(), map[string]string{
444 keyJiraOperationID: entry.ID,
445 })
446 if err != nil {
447 return err
448 }
449 return nil
450 }
451
452 case "description":
453 // NOTE(josh): JIRA calls it "description", which sounds more like the
454 // title but it's actually the body
455 opr, isRightType := potentialOp.(*bug.EditCommentOperation)
456 if isRightType &&
457 opr.Target == b.Snapshot().Operations[0].Id() &&
458 opr.Message == item.ToString {
459 _, err := b.SetMetadata(opr.Id(), map[string]string{
460 keyJiraOperationID: entry.ID,
461 })
462 if err != nil {
463 return err
464 }
465 return nil
466 }
467 }
468 }
469
470 // Since we didn't match the changelog entry to a known export operation,
471 // then this is a changelog entry that we should import. We import each
472 // changelog entry item as a separate git-bug operation.
473 for idx, item := range entry.Items {
474 derivedID := getIndexDerivedID(entry.ID, idx)
475 _, err := b.ResolveOperationWithMetadata(keyJiraOperationID, derivedID)
476 if err == nil {
477 continue
478 }
479 if err != cache.ErrNoMatchingOp {
480 return err
481 }
482
483 switch item.Field {
484 case "labels":
485 fromLabels := removeEmpty(strings.Split(item.FromString, " "))
486 toLabels := removeEmpty(strings.Split(item.ToString, " "))
487 removedLabels, addedLabels, _ := setSymmetricDifference(
488 fromLabels, toLabels)
489
490 op, err := b.ForceChangeLabelsRaw(
491 author,
492 entry.Created.Unix(),
493 addedLabels,
494 removedLabels,
495 map[string]string{
496 keyJiraID: entry.ID,
497 keyJiraOperationID: derivedID,
498 },
499 )
500 if err != nil {
501 return err
502 }
503
504 self.out <- core.NewImportLabelChange(op.Id())
505
506 case "status":
507 statusStr, hasMap := statusMap[item.To]
508 if hasMap {
509 switch statusStr {
510 case bug.OpenStatus.String():
511 op, err := b.OpenRaw(
512 author,
513 entry.Created.Unix(),
514 map[string]string{
515 keyJiraID: entry.ID,
516 keyJiraOperationID: derivedID,
517 },
518 )
519 if err != nil {
520 return err
521 }
522 self.out <- core.NewImportStatusChange(op.Id())
523
524 case bug.ClosedStatus.String():
525 op, err := b.CloseRaw(
526 author,
527 entry.Created.Unix(),
528 map[string]string{
529 keyJiraID: entry.ID,
530 keyJiraOperationID: derivedID,
531 },
532 )
533 if err != nil {
534 return err
535 }
536 self.out <- core.NewImportStatusChange(op.Id())
537 }
538 } else {
539 self.out <- core.NewImportError(
540 fmt.Errorf(
541 "No git-bug status mapped for jira status %s (%s)",
542 item.ToString, item.To), "")
543 }
544
545 case "summary":
546 // NOTE(josh): JIRA calls it "summary", which sounds more like the body
547 // text, but it's the title
548 op, err := b.SetTitleRaw(
549 author,
550 entry.Created.Unix(),
551 string(item.ToString),
552 map[string]string{
553 keyJiraID: entry.ID,
554 keyJiraOperationID: derivedID,
555 },
556 )
557 if err != nil {
558 return err
559 }
560
561 self.out <- core.NewImportTitleEdition(op.Id())
562
563 case "description":
564 // NOTE(josh): JIRA calls it "description", which sounds more like the
565 // title but it's actually the body
566 op, err := b.EditCreateCommentRaw(
567 author,
568 entry.Created.Unix(),
569 string(item.ToString),
570 map[string]string{
571 keyJiraID: entry.ID,
572 keyJiraOperationID: derivedID,
573 },
574 )
575 if err != nil {
576 return err
577 }
578
579 self.out <- core.NewImportCommentEdition(op.Id())
580
581 default:
582 self.out <- core.NewImportWarning(
583 fmt.Errorf(
584 "Unhandled changelog event %s", item.Field), "")
585 }
586
587 // Other Examples:
588 // "assignee" (jira)
589 // "Attachment" (jira)
590 // "Epic Link" (custom)
591 // "Rank" (custom)
592 // "resolution" (jira)
593 // "Sprint" (custom)
594 }
595 return nil
596}
597
598func getStatusMap(conf core.Configuration) (map[string]string, error) {
599 mapStr, hasConf := conf[keyIDMap]
600 if !hasConf {
601 return map[string]string{
602 bug.OpenStatus.String(): "1",
603 bug.ClosedStatus.String(): "6",
604 }, nil
605 }
606
607 statusMap := make(map[string]string)
608 err := json.Unmarshal([]byte(mapStr), &statusMap)
609 return statusMap, err
610}
611
612func getStatusMapReverse(conf core.Configuration) (map[string]string, error) {
613 fwdMap, err := getStatusMap(conf)
614 if err != nil {
615 return fwdMap, err
616 }
617
618 outMap := map[string]string{}
619 for key, val := range fwdMap {
620 outMap[val] = key
621 }
622
623 mapStr, hasConf := conf[keyIDRevMap]
624 if !hasConf {
625 return outMap, nil
626 }
627
628 revMap := make(map[string]string)
629 err = json.Unmarshal([]byte(mapStr), &revMap)
630 for key, val := range revMap {
631 outMap[key] = val
632 }
633
634 return outMap, err
635}
636
637func removeEmpty(values []string) []string {
638 output := make([]string, 0, len(values))
639 for _, value := range values {
640 value = strings.TrimSpace(value)
641 if value != "" {
642 output = append(output, value)
643 }
644 }
645 return output
646}