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 user.Key,
198 "",
199 nil,
200 map[string]string{
201 metaKeyJiraUser: user.Key,
202 },
203 )
204
205 if err != nil {
206 return nil, err
207 }
208
209 ji.out <- core.NewImportIdentity(i.Id())
210 return i, nil
211}
212
213// Create a bug.Bug based from a JIRA issue
214func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache.BugCache, error) {
215 author, err := ji.ensurePerson(repo, issue.Fields.Creator)
216 if err != nil {
217 return nil, err
218 }
219
220 b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
221 if _, ok := excerpt.CreateMetadata[metaKeyJiraBaseUrl]; ok &&
222 excerpt.CreateMetadata[metaKeyJiraBaseUrl] != ji.conf[confKeyBaseUrl] {
223 return false
224 }
225
226 return excerpt.CreateMetadata[core.MetaKeyOrigin] == target &&
227 excerpt.CreateMetadata[metaKeyJiraId] == issue.ID &&
228 excerpt.CreateMetadata[metaKeyJiraProject] == ji.conf[confKeyProject]
229 })
230 if err != nil && err != bug.ErrBugNotExist {
231 return nil, err
232 }
233
234 if err == bug.ErrBugNotExist {
235 b, _, err = repo.NewBugRaw(
236 author,
237 issue.Fields.Created.Unix(),
238 text.CleanupOneLine(issue.Fields.Summary),
239 text.Cleanup(issue.Fields.Description),
240 nil,
241 map[string]string{
242 core.MetaKeyOrigin: target,
243 metaKeyJiraId: issue.ID,
244 metaKeyJiraKey: issue.Key,
245 metaKeyJiraProject: ji.conf[confKeyProject],
246 metaKeyJiraBaseUrl: ji.conf[confKeyBaseUrl],
247 })
248 if err != nil {
249 return nil, err
250 }
251
252 ji.out <- core.NewImportBug(b.Id())
253 }
254
255 return b, nil
256}
257
258// Return a unique string derived from a unique jira id and a timestamp
259func getTimeDerivedID(jiraID string, timestamp Time) string {
260 return fmt.Sprintf("%s-%d", jiraID, timestamp.Unix())
261}
262
263// Create a bug.Comment from a JIRA comment
264func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, item Comment) error {
265 // ensure person
266 author, err := ji.ensurePerson(repo, item.Author)
267 if err != nil {
268 return err
269 }
270
271 targetOpID, err := b.ResolveOperationWithMetadata(
272 metaKeyJiraId, item.ID)
273 if err != nil && err != cache.ErrNoMatchingOp {
274 return err
275 }
276
277 // If the comment is a new comment then create it
278 if targetOpID == "" && err == cache.ErrNoMatchingOp {
279 var cleanText string
280 if item.Updated != item.Created {
281 // We don't know the original text... we only have the updated text.
282 cleanText = ""
283 } else {
284 cleanText = text.Cleanup(item.Body)
285 }
286
287 // add comment operation
288 op, err := b.AddCommentRaw(
289 author,
290 item.Created.Unix(),
291 cleanText,
292 nil,
293 map[string]string{
294 metaKeyJiraId: item.ID,
295 },
296 )
297 if err != nil {
298 return err
299 }
300
301 ji.out <- core.NewImportComment(op.Id())
302 targetOpID = op.Id()
303 }
304
305 // If there are no updates to this comment, then we are done
306 if item.Updated == item.Created {
307 return nil
308 }
309
310 // If there has been an update to this comment, we try to find it in the
311 // database. We need a unique id so we'll concat the issue id with the update
312 // timestamp. Note that this must be consistent with the exporter during
313 // export of an EditCommentOperation
314 derivedID := getTimeDerivedID(item.ID, item.Updated)
315 _, err = b.ResolveOperationWithMetadata(metaKeyJiraId, derivedID)
316 if err == nil {
317 // Already imported this edition
318 return nil
319 }
320
321 if err != cache.ErrNoMatchingOp {
322 return err
323 }
324
325 // ensure editor identity
326 editor, err := ji.ensurePerson(repo, item.UpdateAuthor)
327 if err != nil {
328 return err
329 }
330
331 // comment edition
332 op, err := b.EditCommentRaw(
333 editor,
334 item.Updated.Unix(),
335 targetOpID,
336 text.Cleanup(item.Body),
337 map[string]string{
338 metaKeyJiraId: derivedID,
339 },
340 )
341
342 if err != nil {
343 return err
344 }
345
346 ji.out <- core.NewImportCommentEdition(op.Id())
347
348 return nil
349}
350
351// Return a unique string derived from a unique jira id and an index into the
352// data referred to by that jira id.
353func getIndexDerivedID(jiraID string, idx int) string {
354 return fmt.Sprintf("%s-%d", jiraID, idx)
355}
356
357func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool {
358 if len(jiraSet) != len(gitbugSet) {
359 return false
360 }
361
362 sort.Strings(jiraSet)
363 gitbugStrSet := make([]string, len(gitbugSet))
364 for idx, label := range gitbugSet {
365 gitbugStrSet[idx] = label.String()
366 }
367 sort.Strings(gitbugStrSet)
368
369 for idx, value := range jiraSet {
370 if value != gitbugStrSet[idx] {
371 return false
372 }
373 }
374
375 return true
376}
377
378// Create a bug.Operation (or a series of operations) from a JIRA changelog
379// entry
380func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry, potentialOp bug.Operation) error {
381
382 // If we have an operation which is already mapped to the entire changelog
383 // entry then that means this changelog entry was induced by an export
384 // operation and we've already done the match, so we skip this one
385 _, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, entry.ID)
386 if err == nil {
387 return nil
388 } else if err != cache.ErrNoMatchingOp {
389 return err
390 }
391
392 // In general, multiple fields may be changed in changelog entry on
393 // JIRA. For example, when an issue is closed both its "status" and its
394 // "resolution" are updated within a single changelog entry.
395 // I don't thing git-bug has a single operation to modify an arbitrary
396 // number of fields in one go, so we break up the single JIRA changelog
397 // entry into individual field updates.
398 author, err := ji.ensurePerson(repo, entry.Author)
399 if err != nil {
400 return err
401 }
402
403 if len(entry.Items) < 1 {
404 return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID)
405 }
406
407 statusMap, err := getStatusMapReverse(ji.conf)
408 if err != nil {
409 return err
410 }
411
412 // NOTE(josh): first do an initial scan and see if any of the changed items
413 // matches the current potential operation. If it does, then we know that this
414 // entire changelog entry was created in response to that git-bug operation.
415 // So we associate the operation with the entire changelog, and not a specific
416 // entry.
417 for _, item := range entry.Items {
418 switch item.Field {
419 case "labels":
420 fromLabels := removeEmpty(strings.Split(item.FromString, " "))
421 toLabels := removeEmpty(strings.Split(item.ToString, " "))
422 removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels)
423
424 opr, isRightType := potentialOp.(*bug.LabelChangeOperation)
425 if isRightType && labelSetsMatch(addedLabels, opr.Added) && labelSetsMatch(removedLabels, opr.Removed) {
426 _, err := b.SetMetadata(opr.Id(), map[string]string{
427 metaKeyJiraDerivedId: entry.ID,
428 })
429 if err != nil {
430 return err
431 }
432 return nil
433 }
434
435 case "status":
436 opr, isRightType := potentialOp.(*bug.SetStatusOperation)
437 if isRightType && statusMap[opr.Status.String()] == item.To {
438 _, err := b.SetMetadata(opr.Id(), map[string]string{
439 metaKeyJiraDerivedId: entry.ID,
440 })
441 if err != nil {
442 return err
443 }
444 return nil
445 }
446
447 case "summary":
448 // NOTE(josh): JIRA calls it "summary", which sounds more like the body
449 // text, but it's the title
450 opr, isRightType := potentialOp.(*bug.SetTitleOperation)
451 if isRightType && opr.Title == item.To {
452 _, err := b.SetMetadata(opr.Id(), map[string]string{
453 metaKeyJiraDerivedId: entry.ID,
454 })
455 if err != nil {
456 return err
457 }
458 return nil
459 }
460
461 case "description":
462 // NOTE(josh): JIRA calls it "description", which sounds more like the
463 // title but it's actually the body
464 opr, isRightType := potentialOp.(*bug.EditCommentOperation)
465 if isRightType &&
466 opr.Target == b.Snapshot().Operations[0].Id() &&
467 opr.Message == item.ToString {
468 _, err := b.SetMetadata(opr.Id(), map[string]string{
469 metaKeyJiraDerivedId: entry.ID,
470 })
471 if err != nil {
472 return err
473 }
474 return nil
475 }
476 }
477 }
478
479 // Since we didn't match the changelog entry to a known export operation,
480 // then this is a changelog entry that we should import. We import each
481 // changelog entry item as a separate git-bug operation.
482 for idx, item := range entry.Items {
483 derivedID := getIndexDerivedID(entry.ID, idx)
484 _, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, derivedID)
485 if err == nil {
486 continue
487 }
488 if err != cache.ErrNoMatchingOp {
489 return err
490 }
491
492 switch item.Field {
493 case "labels":
494 fromLabels := removeEmpty(strings.Split(item.FromString, " "))
495 toLabels := removeEmpty(strings.Split(item.ToString, " "))
496 removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels)
497
498 op, err := b.ForceChangeLabelsRaw(
499 author,
500 entry.Created.Unix(),
501 text.CleanupOneLineArray(addedLabels),
502 text.CleanupOneLineArray(removedLabels),
503 map[string]string{
504 metaKeyJiraId: entry.ID,
505 metaKeyJiraDerivedId: derivedID,
506 },
507 )
508 if err != nil {
509 return err
510 }
511
512 ji.out <- core.NewImportLabelChange(op.Id())
513
514 case "status":
515 statusStr, hasMap := statusMap[item.To]
516 if hasMap {
517 switch statusStr {
518 case bug.OpenStatus.String():
519 op, err := b.OpenRaw(
520 author,
521 entry.Created.Unix(),
522 map[string]string{
523 metaKeyJiraId: entry.ID,
524 metaKeyJiraDerivedId: derivedID,
525 },
526 )
527 if err != nil {
528 return err
529 }
530 ji.out <- core.NewImportStatusChange(op.Id())
531
532 case bug.ClosedStatus.String():
533 op, err := b.CloseRaw(
534 author,
535 entry.Created.Unix(),
536 map[string]string{
537 metaKeyJiraId: entry.ID,
538 metaKeyJiraDerivedId: derivedID,
539 },
540 )
541 if err != nil {
542 return err
543 }
544 ji.out <- core.NewImportStatusChange(op.Id())
545 }
546 } else {
547 ji.out <- core.NewImportError(
548 fmt.Errorf(
549 "No git-bug status mapped for jira status %s (%s)",
550 item.ToString, item.To), "")
551 }
552
553 case "summary":
554 // NOTE(josh): JIRA calls it "summary", which sounds more like the body
555 // text, but it's the title
556 op, err := b.SetTitleRaw(
557 author,
558 entry.Created.Unix(),
559 text.CleanupOneLine(item.ToString),
560 map[string]string{
561 metaKeyJiraId: entry.ID,
562 metaKeyJiraDerivedId: derivedID,
563 },
564 )
565 if err != nil {
566 return err
567 }
568
569 ji.out <- core.NewImportTitleEdition(op.Id())
570
571 case "description":
572 // NOTE(josh): JIRA calls it "description", which sounds more like the
573 // title but it's actually the body
574 op, err := b.EditCreateCommentRaw(
575 author,
576 entry.Created.Unix(),
577 text.Cleanup(item.ToString),
578 map[string]string{
579 metaKeyJiraId: entry.ID,
580 metaKeyJiraDerivedId: derivedID,
581 },
582 )
583 if err != nil {
584 return err
585 }
586
587 ji.out <- core.NewImportCommentEdition(op.Id())
588
589 default:
590 ji.out <- core.NewImportWarning(
591 fmt.Errorf(
592 "Unhandled changelog event %s", item.Field), "")
593 }
594
595 // Other Examples:
596 // "assignee" (jira)
597 // "Attachment" (jira)
598 // "Epic Link" (custom)
599 // "Rank" (custom)
600 // "resolution" (jira)
601 // "Sprint" (custom)
602 }
603 return nil
604}
605
606func getStatusMap(conf core.Configuration) (map[string]string, error) {
607 mapStr, hasConf := conf[confKeyIDMap]
608 if !hasConf {
609 return map[string]string{
610 bug.OpenStatus.String(): "1",
611 bug.ClosedStatus.String(): "6",
612 }, nil
613 }
614
615 statusMap := make(map[string]string)
616 err := json.Unmarshal([]byte(mapStr), &statusMap)
617 return statusMap, err
618}
619
620func getStatusMapReverse(conf core.Configuration) (map[string]string, error) {
621 fwdMap, err := getStatusMap(conf)
622 if err != nil {
623 return fwdMap, err
624 }
625
626 outMap := map[string]string{}
627 for key, val := range fwdMap {
628 outMap[val] = key
629 }
630
631 mapStr, hasConf := conf[confKeyIDRevMap]
632 if !hasConf {
633 return outMap, nil
634 }
635
636 revMap := make(map[string]string)
637 err = json.Unmarshal([]byte(mapStr), &revMap)
638 for key, val := range revMap {
639 outMap[key] = val
640 }
641
642 return outMap, err
643}
644
645func removeEmpty(values []string) []string {
646 output := make([]string, 0, len(values))
647 for _, value := range values {
648 value = strings.TrimSpace(value)
649 if value != "" {
650 output = append(output, value)
651 }
652 }
653 return output
654}