import.go

  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}