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