import.go

  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 (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 !bug.NeedCommit() {
144				out <- core.NewImportNothing(bug.Id(), "no imported operation")
145			} else if err := bug.Commit(); err != nil {
146				err = fmt.Errorf("bug commit: %v", err)
147				out <- core.NewImportError(err, "")
148				return
149			}
150		}
151		if searchIter.HasError() {
152			out <- core.NewImportError(searchIter.Err, "")
153		}
154	}()
155
156	return out, nil
157}
158
159// Create a bug.Person from a JIRA user
160func (self *jiraImporter) ensurePerson(
161	repo *cache.RepoCache, user User) (*cache.IdentityCache, error) {
162
163	// Look first in the cache
164	i, err := repo.ResolveIdentityImmutableMetadata(
165		keyJiraUser, string(user.Key))
166	if err == nil {
167		return i, nil
168	}
169	if _, ok := err.(entity.ErrMultipleMatch); ok {
170		return nil, err
171	}
172
173	i, err = repo.NewIdentityRaw(
174		user.DisplayName,
175		user.EmailAddress,
176		user.Key,
177		"",
178		map[string]string{
179			keyJiraUser: string(user.Key),
180		},
181	)
182
183	if err != nil {
184		return nil, err
185	}
186
187	self.out <- core.NewImportIdentity(i.Id())
188	return i, nil
189}
190
191// Create a bug.Bug based from a JIRA issue
192func (self *jiraImporter) ensureIssue(
193	repo *cache.RepoCache, issue Issue) (*cache.BugCache, error) {
194	author, err := self.ensurePerson(repo, issue.Fields.Creator)
195	if err != nil {
196		return nil, err
197	}
198
199	b, err := repo.ResolveBugCreateMetadata(keyJiraID, issue.ID)
200	if err != nil && err != bug.ErrBugNotExist {
201		return nil, err
202	}
203
204	if err == bug.ErrBugNotExist {
205		cleanText, err := text.Cleanup(string(issue.Fields.Description))
206		if err != nil {
207			return nil, err
208		}
209
210		// NOTE(josh): newlines in titles appears to be rare, but it has been seen
211		// in the wild. It does not appear to be allowed in the JIRA web interface.
212		title := strings.Replace(issue.Fields.Summary, "\n", "", -1)
213		b, _, err = repo.NewBugRaw(
214			author,
215			issue.Fields.Created.Unix(),
216			title,
217			cleanText,
218			nil,
219			map[string]string{
220				core.KeyOrigin: target,
221				keyJiraID:      issue.ID,
222				keyJiraKey:     issue.Key,
223				keyJiraProject: self.conf[keyProject],
224			})
225		if err != nil {
226			return nil, err
227		}
228
229		self.out <- core.NewImportBug(b.Id())
230	}
231
232	return b, nil
233}
234
235// Return a unique string derived from a unique jira id and a timestamp
236func getTimeDerivedID(jiraID string, timestamp MyTime) string {
237	return fmt.Sprintf("%s-%d", jiraID, timestamp.Unix())
238}
239
240// Create a bug.Comment from a JIRA comment
241func (self *jiraImporter) ensureComment(repo *cache.RepoCache,
242	b *cache.BugCache, item Comment) error {
243	// ensure person
244	author, err := self.ensurePerson(repo, item.Author)
245	if err != nil {
246		return err
247	}
248
249	targetOpID, err := b.ResolveOperationWithMetadata(
250		keyJiraID, item.ID)
251	if err != nil && err != cache.ErrNoMatchingOp {
252		return err
253	}
254
255	// If the comment is a new comment then create it
256	if targetOpID == "" && err == cache.ErrNoMatchingOp {
257		var cleanText string
258		if item.Updated != item.Created {
259			// We don't know the original text... we only have the updated text.
260			cleanText = ""
261		} else {
262			cleanText, err = text.Cleanup(string(item.Body))
263			if err != nil {
264				return err
265			}
266		}
267
268		// add comment operation
269		op, err := b.AddCommentRaw(
270			author,
271			item.Created.Unix(),
272			cleanText,
273			nil,
274			map[string]string{
275				keyJiraID: item.ID,
276			},
277		)
278		if err != nil {
279			return err
280		}
281
282		self.out <- core.NewImportComment(op.Id())
283		targetOpID = op.Id()
284	}
285
286	// If there are no updates to this comment, then we are done
287	if item.Updated == item.Created {
288		return nil
289	}
290
291	// If there has been an update to this comment, we try to find it in the
292	// database. We need a unique id so we'll concat the issue id with the update
293	// timestamp. Note that this must be consistent with the exporter during
294	// export of an EditCommentOperation
295	derivedID := getTimeDerivedID(item.ID, item.Updated)
296	_, err = b.ResolveOperationWithMetadata(
297		keyJiraID, derivedID)
298	if err == nil {
299		// Already imported this edition
300		return nil
301	}
302
303	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		targetOpID,
322		cleanText,
323		map[string]string{
324			keyJiraID: derivedID,
325		},
326	)
327
328	if err != nil {
329		return err
330	}
331
332	self.out <- core.NewImportCommentEdition(op.Id())
333
334	return nil
335}
336
337// Return a unique string derived from a unique jira id and an index into the
338// data referred to by that jira id.
339func getIndexDerivedID(jiraID string, idx int) string {
340	return fmt.Sprintf("%s-%d", jiraID, idx)
341}
342
343func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool {
344	if len(jiraSet) != len(gitbugSet) {
345		return false
346	}
347
348	sort.Strings(jiraSet)
349	gitbugStrSet := make([]string, len(gitbugSet))
350	for idx, label := range gitbugSet {
351		gitbugStrSet[idx] = label.String()
352	}
353	sort.Strings(gitbugStrSet)
354
355	for idx, value := range jiraSet {
356		if value != gitbugStrSet[idx] {
357			return false
358		}
359	}
360
361	return true
362}
363
364// Create a bug.Operation (or a series of operations) from a JIRA changelog
365// entry
366func (self *jiraImporter) ensureChange(
367	repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry,
368	potentialOp bug.Operation) error {
369
370	// If we have an operation which is already mapped to the entire changelog
371	// entry then that means this changelog entry was induced by an export
372	// operation and we've already done the match, so we skip this one
373	_, err := b.ResolveOperationWithMetadata(keyJiraOperationID, entry.ID)
374	if err == nil {
375		return nil
376	} else if err != cache.ErrNoMatchingOp {
377		return err
378	}
379
380	// In general, multiple fields may be changed in changelog entry  on
381	// JIRA. For example, when an issue is closed both its "status" and its
382	// "resolution" are updated within a single changelog entry.
383	// I don't thing git-bug has a single operation to modify an arbitrary
384	// number of fields in one go, so we break up the single JIRA changelog
385	// entry into individual field updates.
386	author, err := self.ensurePerson(repo, entry.Author)
387	if err != nil {
388		return err
389	}
390
391	if len(entry.Items) < 1 {
392		return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID)
393	}
394
395	statusMap, err := getStatusMapReverse(self.conf)
396	if err != nil {
397		return err
398	}
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			fromLabels := removeEmpty(strings.Split(item.FromString, " "))
409			toLabels := removeEmpty(strings.Split(item.ToString, " "))
410			removedLabels, addedLabels, _ := setSymmetricDifference(
411				fromLabels, toLabels)
412
413			opr, isRightType := potentialOp.(*bug.LabelChangeOperation)
414			if isRightType &&
415				labelSetsMatch(addedLabels, opr.Added) &&
416				labelSetsMatch(removedLabels, opr.Removed) {
417				_, err := b.SetMetadata(opr.Id(), map[string]string{
418					keyJiraOperationID: entry.ID,
419				})
420				if err != nil {
421					return err
422				}
423				return nil
424			}
425
426		case "status":
427			opr, isRightType := potentialOp.(*bug.SetStatusOperation)
428			if isRightType && statusMap[opr.Status.String()] == item.To {
429				_, err := b.SetMetadata(opr.Id(), map[string]string{
430					keyJiraOperationID: entry.ID,
431				})
432				if err != nil {
433					return err
434				}
435				return nil
436			}
437
438		case "summary":
439			// NOTE(josh): JIRA calls it "summary", which sounds more like the body
440			// text, but it's the title
441			opr, isRightType := potentialOp.(*bug.SetTitleOperation)
442			if isRightType && opr.Title == item.To {
443				_, err := b.SetMetadata(opr.Id(), map[string]string{
444					keyJiraOperationID: entry.ID,
445				})
446				if err != nil {
447					return err
448				}
449				return nil
450			}
451
452		case "description":
453			// NOTE(josh): JIRA calls it "description", which sounds more like the
454			// title but it's actually the body
455			opr, isRightType := potentialOp.(*bug.EditCommentOperation)
456			if isRightType &&
457				opr.Target == b.Snapshot().Operations[0].Id() &&
458				opr.Message == item.ToString {
459				_, err := b.SetMetadata(opr.Id(), map[string]string{
460					keyJiraOperationID: entry.ID,
461				})
462				if err != nil {
463					return err
464				}
465				return nil
466			}
467		}
468	}
469
470	// Since we didn't match the changelog entry to a known export operation,
471	// then this is a changelog entry that we should import. We import each
472	// changelog entry item as a separate git-bug operation.
473	for idx, item := range entry.Items {
474		derivedID := getIndexDerivedID(entry.ID, idx)
475		_, err := b.ResolveOperationWithMetadata(keyJiraOperationID, derivedID)
476		if err == nil {
477			continue
478		}
479		if err != cache.ErrNoMatchingOp {
480			return err
481		}
482
483		switch item.Field {
484		case "labels":
485			fromLabels := removeEmpty(strings.Split(item.FromString, " "))
486			toLabels := removeEmpty(strings.Split(item.ToString, " "))
487			removedLabels, addedLabels, _ := setSymmetricDifference(
488				fromLabels, toLabels)
489
490			op, err := b.ForceChangeLabelsRaw(
491				author,
492				entry.Created.Unix(),
493				addedLabels,
494				removedLabels,
495				map[string]string{
496					keyJiraID:          entry.ID,
497					keyJiraOperationID: derivedID,
498				},
499			)
500			if err != nil {
501				return err
502			}
503
504			self.out <- core.NewImportLabelChange(op.Id())
505
506		case "status":
507			statusStr, hasMap := statusMap[item.To]
508			if hasMap {
509				switch statusStr {
510				case bug.OpenStatus.String():
511					op, err := b.OpenRaw(
512						author,
513						entry.Created.Unix(),
514						map[string]string{
515							keyJiraID:          entry.ID,
516							keyJiraOperationID: derivedID,
517						},
518					)
519					if err != nil {
520						return err
521					}
522					self.out <- core.NewImportStatusChange(op.Id())
523
524				case bug.ClosedStatus.String():
525					op, err := b.CloseRaw(
526						author,
527						entry.Created.Unix(),
528						map[string]string{
529							keyJiraID:          entry.ID,
530							keyJiraOperationID: derivedID,
531						},
532					)
533					if err != nil {
534						return err
535					}
536					self.out <- core.NewImportStatusChange(op.Id())
537				}
538			} else {
539				self.out <- core.NewImportError(
540					fmt.Errorf(
541						"No git-bug status mapped for jira status %s (%s)",
542						item.ToString, item.To), "")
543			}
544
545		case "summary":
546			// NOTE(josh): JIRA calls it "summary", which sounds more like the body
547			// text, but it's the title
548			op, err := b.SetTitleRaw(
549				author,
550				entry.Created.Unix(),
551				string(item.ToString),
552				map[string]string{
553					keyJiraID:          entry.ID,
554					keyJiraOperationID: derivedID,
555				},
556			)
557			if err != nil {
558				return err
559			}
560
561			self.out <- core.NewImportTitleEdition(op.Id())
562
563		case "description":
564			// NOTE(josh): JIRA calls it "description", which sounds more like the
565			// title but it's actually the body
566			op, err := b.EditCreateCommentRaw(
567				author,
568				entry.Created.Unix(),
569				string(item.ToString),
570				map[string]string{
571					keyJiraID:          entry.ID,
572					keyJiraOperationID: derivedID,
573				},
574			)
575			if err != nil {
576				return err
577			}
578
579			self.out <- core.NewImportCommentEdition(op.Id())
580
581		default:
582			self.out <- core.NewImportWarning(
583				fmt.Errorf(
584					"Unhandled changelog event %s", item.Field), "")
585		}
586
587		// Other Examples:
588		// "assignee" (jira)
589		// "Attachment" (jira)
590		// "Epic Link" (custom)
591		// "Rank" (custom)
592		// "resolution" (jira)
593		// "Sprint" (custom)
594	}
595	return nil
596}
597
598func getStatusMap(conf core.Configuration) (map[string]string, error) {
599	mapStr, hasConf := conf[keyIDMap]
600	if !hasConf {
601		return map[string]string{
602			bug.OpenStatus.String():   "1",
603			bug.ClosedStatus.String(): "6",
604		}, nil
605	}
606
607	statusMap := make(map[string]string)
608	err := json.Unmarshal([]byte(mapStr), &statusMap)
609	return statusMap, err
610}
611
612func getStatusMapReverse(conf core.Configuration) (map[string]string, error) {
613	fwdMap, err := getStatusMap(conf)
614	if err != nil {
615		return fwdMap, err
616	}
617
618	outMap := map[string]string{}
619	for key, val := range fwdMap {
620		outMap[val] = key
621	}
622
623	mapStr, hasConf := conf[keyIDRevMap]
624	if !hasConf {
625		return outMap, nil
626	}
627
628	revMap := make(map[string]string)
629	err = json.Unmarshal([]byte(mapStr), &revMap)
630	for key, val := range revMap {
631		outMap[key] = val
632	}
633
634	return outMap, err
635}
636
637func removeEmpty(values []string) []string {
638	output := make([]string, 0, len(values))
639	for _, value := range values {
640		value = strings.TrimSpace(value)
641		if value != "" {
642			output = append(output, value)
643		}
644	}
645	return output
646}