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