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