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