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/cache"
15 "github.com/MichaelMure/git-bug/entities/bug"
16 "github.com/MichaelMure/git-bug/entities/common"
17 "github.com/MichaelMure/git-bug/entity"
18 "github.com/MichaelMure/git-bug/entity/dag"
19 "github.com/MichaelMure/git-bug/util/text"
20)
21
22const (
23 defaultPageSize = 10
24)
25
26// jiraImporter implement the Importer interface
27type jiraImporter struct {
28 conf core.Configuration
29
30 client *Client
31
32 // send only channel
33 out chan<- core.ImportResult
34}
35
36// Init .
37func (ji *jiraImporter) Init(ctx context.Context, repo *cache.RepoCache, conf core.Configuration) error {
38 ji.conf = conf
39
40 var cred auth.Credential
41
42 // Prioritize LoginPassword credentials to avoid a prompt
43 creds, err := auth.List(repo,
44 auth.WithTarget(target),
45 auth.WithKind(auth.KindLoginPassword),
46 auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseUrl]),
47 auth.WithMeta(auth.MetaKeyLogin, conf[confKeyDefaultLogin]),
48 )
49 if err != nil {
50 return err
51 }
52 if len(creds) > 0 {
53 cred = creds[0]
54 goto end
55 }
56
57 creds, err = auth.List(repo,
58 auth.WithTarget(target),
59 auth.WithKind(auth.KindLogin),
60 auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseUrl]),
61 auth.WithMeta(auth.MetaKeyLogin, conf[confKeyDefaultLogin]),
62 )
63 if err != nil {
64 return err
65 }
66 if len(creds) > 0 {
67 cred = creds[0]
68 }
69
70end:
71 if cred == nil {
72 return fmt.Errorf("no credential for this bridge")
73 }
74
75 // TODO(josh)[da52062]: Validate token and if it is expired then prompt for
76 // credentials and generate a new one
77 ji.client, err = buildClient(ctx, conf[confKeyBaseUrl], conf[confKeyCredentialType], cred)
78 return err
79}
80
81// ImportAll iterate over all the configured repository issues and ensure the
82// creation of the missing issues / timeline items / edits / label events ...
83func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
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 b, _, err = repo.NewBugRaw(
238 author,
239 issue.Fields.Created.Unix(),
240 text.CleanupOneLine(issue.Fields.Summary),
241 text.Cleanup(issue.Fields.Description),
242 nil,
243 map[string]string{
244 core.MetaKeyOrigin: target,
245 metaKeyJiraId: issue.ID,
246 metaKeyJiraKey: issue.Key,
247 metaKeyJiraProject: ji.conf[confKeyProject],
248 metaKeyJiraBaseUrl: ji.conf[confKeyBaseUrl],
249 })
250 if err != nil {
251 return nil, err
252 }
253
254 ji.out <- core.NewImportBug(b.Id())
255 }
256
257 return b, nil
258}
259
260// Return a unique string derived from a unique jira id and a timestamp
261func getTimeDerivedID(jiraID string, timestamp Time) string {
262 return fmt.Sprintf("%s-%d", jiraID, timestamp.Unix())
263}
264
265// Create a bug.Comment from a JIRA comment
266func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, item Comment) error {
267 // ensure person
268 author, err := ji.ensurePerson(repo, item.Author)
269 if err != nil {
270 return err
271 }
272
273 targetOpID, err := b.ResolveOperationWithMetadata(
274 metaKeyJiraId, item.ID)
275 if err != nil && err != cache.ErrNoMatchingOp {
276 return err
277 }
278
279 // If the comment is a new comment then create it
280 if targetOpID == "" && err == cache.ErrNoMatchingOp {
281 var cleanText string
282 if item.Updated != item.Created {
283 // We don't know the original text... we only have the updated text.
284 cleanText = ""
285 } else {
286 cleanText = text.Cleanup(item.Body)
287 }
288
289 // add comment operation
290 op, err := b.AddCommentRaw(
291 author,
292 item.Created.Unix(),
293 cleanText,
294 nil,
295 map[string]string{
296 metaKeyJiraId: item.ID,
297 },
298 )
299 if err != nil {
300 return err
301 }
302
303 ji.out <- core.NewImportComment(op.Id())
304 targetOpID = op.Id()
305 }
306
307 // If there are no updates to this comment, then we are done
308 if item.Updated == item.Created {
309 return nil
310 }
311
312 // If there has been an update to this comment, we try to find it in the
313 // database. We need a unique id so we'll concat the issue id with the update
314 // timestamp. Note that this must be consistent with the exporter during
315 // export of an EditCommentOperation
316 derivedID := getTimeDerivedID(item.ID, item.Updated)
317 _, err = b.ResolveOperationWithMetadata(metaKeyJiraId, derivedID)
318 if err == nil {
319 // Already imported this edition
320 return nil
321 }
322
323 if err != cache.ErrNoMatchingOp {
324 return err
325 }
326
327 // ensure editor identity
328 editor, err := ji.ensurePerson(repo, item.UpdateAuthor)
329 if err != nil {
330 return err
331 }
332
333 // comment edition
334 op, err := b.EditCommentRaw(
335 editor,
336 item.Updated.Unix(),
337 targetOpID,
338 text.Cleanup(item.Body),
339 map[string]string{
340 metaKeyJiraId: derivedID,
341 },
342 )
343
344 if err != nil {
345 return err
346 }
347
348 ji.out <- core.NewImportCommentEdition(op.Id())
349
350 return nil
351}
352
353// Return a unique string derived from a unique jira id and an index into the
354// data referred to by that jira id.
355func getIndexDerivedID(jiraID string, idx int) string {
356 return fmt.Sprintf("%s-%d", jiraID, idx)
357}
358
359func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool {
360 if len(jiraSet) != len(gitbugSet) {
361 return false
362 }
363
364 sort.Strings(jiraSet)
365 gitbugStrSet := make([]string, len(gitbugSet))
366 for idx, label := range gitbugSet {
367 gitbugStrSet[idx] = label.String()
368 }
369 sort.Strings(gitbugStrSet)
370
371 for idx, value := range jiraSet {
372 if value != gitbugStrSet[idx] {
373 return false
374 }
375 }
376
377 return true
378}
379
380// Create a bug.Operation (or a series of operations) from a JIRA changelog
381// entry
382func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry, potentialOp dag.Operation) error {
383
384 // If we have an operation which is already mapped to the entire changelog
385 // entry then that means this changelog entry was induced by an export
386 // operation and we've already done the match, so we skip this one
387 _, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, entry.ID)
388 if err == nil {
389 return nil
390 } else if err != cache.ErrNoMatchingOp {
391 return err
392 }
393
394 // In general, multiple fields may be changed in changelog entry on
395 // JIRA. For example, when an issue is closed both its "status" and its
396 // "resolution" are updated within a single changelog entry.
397 // I don't thing git-bug has a single operation to modify an arbitrary
398 // number of fields in one go, so we break up the single JIRA changelog
399 // entry into individual field updates.
400 author, err := ji.ensurePerson(repo, entry.Author)
401 if err != nil {
402 return err
403 }
404
405 if len(entry.Items) < 1 {
406 return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID)
407 }
408
409 statusMap, err := getStatusMapReverse(ji.conf)
410 if err != nil {
411 return err
412 }
413
414 // NOTE(josh): first do an initial scan and see if any of the changed items
415 // matches the current potential operation. If it does, then we know that this
416 // entire changelog entry was created in response to that git-bug operation.
417 // So we associate the operation with the entire changelog, and not a specific
418 // entry.
419 for _, item := range entry.Items {
420 switch item.Field {
421 case "labels":
422 fromLabels := removeEmpty(strings.Split(item.FromString, " "))
423 toLabels := removeEmpty(strings.Split(item.ToString, " "))
424 removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels)
425
426 opr, isRightType := potentialOp.(*bug.LabelChangeOperation)
427 if isRightType && labelSetsMatch(addedLabels, opr.Added) && labelSetsMatch(removedLabels, opr.Removed) {
428 _, err := b.SetMetadata(opr.Id(), map[string]string{
429 metaKeyJiraDerivedId: entry.ID,
430 })
431 if err != nil {
432 return err
433 }
434 return nil
435 }
436
437 case "status":
438 opr, isRightType := potentialOp.(*bug.SetStatusOperation)
439 if isRightType && statusMap[opr.Status.String()] == item.To {
440 _, err := b.SetMetadata(opr.Id(), map[string]string{
441 metaKeyJiraDerivedId: entry.ID,
442 })
443 if err != nil {
444 return err
445 }
446 return nil
447 }
448
449 case "summary":
450 // NOTE(josh): JIRA calls it "summary", which sounds more like the body
451 // text, but it's the title
452 opr, isRightType := potentialOp.(*bug.SetTitleOperation)
453 if isRightType && opr.Title == item.To {
454 _, err := b.SetMetadata(opr.Id(), map[string]string{
455 metaKeyJiraDerivedId: entry.ID,
456 })
457 if err != nil {
458 return err
459 }
460 return nil
461 }
462
463 case "description":
464 // NOTE(josh): JIRA calls it "description", which sounds more like the
465 // title but it's actually the body
466 opr, isRightType := potentialOp.(*bug.EditCommentOperation)
467 if isRightType &&
468 opr.Target == b.Snapshot().Operations[0].Id() &&
469 opr.Message == item.ToString {
470 _, err := b.SetMetadata(opr.Id(), map[string]string{
471 metaKeyJiraDerivedId: entry.ID,
472 })
473 if err != nil {
474 return err
475 }
476 return nil
477 }
478 }
479 }
480
481 // Since we didn't match the changelog entry to a known export operation,
482 // then this is a changelog entry that we should import. We import each
483 // changelog entry item as a separate git-bug operation.
484 for idx, item := range entry.Items {
485 derivedID := getIndexDerivedID(entry.ID, idx)
486 _, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, derivedID)
487 if err == nil {
488 continue
489 }
490 if err != cache.ErrNoMatchingOp {
491 return err
492 }
493
494 switch item.Field {
495 case "labels":
496 fromLabels := removeEmpty(strings.Split(item.FromString, " "))
497 toLabels := removeEmpty(strings.Split(item.ToString, " "))
498 removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels)
499
500 op, err := b.ForceChangeLabelsRaw(
501 author,
502 entry.Created.Unix(),
503 text.CleanupOneLineArray(addedLabels),
504 text.CleanupOneLineArray(removedLabels),
505 map[string]string{
506 metaKeyJiraId: entry.ID,
507 metaKeyJiraDerivedId: derivedID,
508 },
509 )
510 if err != nil {
511 return err
512 }
513
514 ji.out <- core.NewImportLabelChange(op.Id())
515
516 case "status":
517 statusStr, hasMap := statusMap[item.To]
518 if hasMap {
519 switch statusStr {
520 case common.OpenStatus.String():
521 op, err := b.OpenRaw(
522 author,
523 entry.Created.Unix(),
524 map[string]string{
525 metaKeyJiraId: entry.ID,
526 metaKeyJiraDerivedId: derivedID,
527 },
528 )
529 if err != nil {
530 return err
531 }
532 ji.out <- core.NewImportStatusChange(op.Id())
533
534 case common.ClosedStatus.String():
535 op, err := b.CloseRaw(
536 author,
537 entry.Created.Unix(),
538 map[string]string{
539 metaKeyJiraId: entry.ID,
540 metaKeyJiraDerivedId: derivedID,
541 },
542 )
543 if err != nil {
544 return err
545 }
546 ji.out <- core.NewImportStatusChange(op.Id())
547 }
548 } else {
549 ji.out <- core.NewImportError(
550 fmt.Errorf(
551 "No git-bug status mapped for jira status %s (%s)",
552 item.ToString, item.To), "")
553 }
554
555 case "summary":
556 // NOTE(josh): JIRA calls it "summary", which sounds more like the body
557 // text, but it's the title
558 op, err := b.SetTitleRaw(
559 author,
560 entry.Created.Unix(),
561 text.CleanupOneLine(item.ToString),
562 map[string]string{
563 metaKeyJiraId: entry.ID,
564 metaKeyJiraDerivedId: derivedID,
565 },
566 )
567 if err != nil {
568 return err
569 }
570
571 ji.out <- core.NewImportTitleEdition(op.Id())
572
573 case "description":
574 // NOTE(josh): JIRA calls it "description", which sounds more like the
575 // title but it's actually the body
576 op, err := b.EditCreateCommentRaw(
577 author,
578 entry.Created.Unix(),
579 text.Cleanup(item.ToString),
580 map[string]string{
581 metaKeyJiraId: entry.ID,
582 metaKeyJiraDerivedId: derivedID,
583 },
584 )
585 if err != nil {
586 return err
587 }
588
589 ji.out <- core.NewImportCommentEdition(op.Id())
590
591 default:
592 ji.out <- core.NewImportWarning(
593 fmt.Errorf(
594 "Unhandled changelog event %s", item.Field), "")
595 }
596
597 // Other Examples:
598 // "assignee" (jira)
599 // "Attachment" (jira)
600 // "Epic Link" (custom)
601 // "Rank" (custom)
602 // "resolution" (jira)
603 // "Sprint" (custom)
604 }
605 return nil
606}
607
608func getStatusMap(conf core.Configuration) (map[string]string, error) {
609 mapStr, hasConf := conf[confKeyIDMap]
610 if !hasConf {
611 return map[string]string{
612 common.OpenStatus.String(): "1",
613 common.ClosedStatus.String(): "6",
614 }, nil
615 }
616
617 statusMap := make(map[string]string)
618 err := json.Unmarshal([]byte(mapStr), &statusMap)
619 return statusMap, err
620}
621
622func getStatusMapReverse(conf core.Configuration) (map[string]string, error) {
623 fwdMap, err := getStatusMap(conf)
624 if err != nil {
625 return fwdMap, err
626 }
627
628 outMap := map[string]string{}
629 for key, val := range fwdMap {
630 outMap[val] = key
631 }
632
633 mapStr, hasConf := conf[confKeyIDRevMap]
634 if !hasConf {
635 return outMap, nil
636 }
637
638 revMap := make(map[string]string)
639 err = json.Unmarshal([]byte(mapStr), &revMap)
640 for key, val := range revMap {
641 outMap[key] = val
642 }
643
644 return outMap, err
645}
646
647func removeEmpty(values []string) []string {
648 output := make([]string, 0, len(values))
649 for _, value := range values {
650 value = strings.TrimSpace(value)
651 if value != "" {
652 output = append(output, value)
653 }
654 }
655 return output
656}