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