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