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