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(repo *cache.RepoCache,
 39	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		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.MetaKeyOrigin: 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}