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		"",
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		// NOTE(josh): newlines in titles appears to be rare, but it has been seen
212		// in the wild. It does not appear to be allowed in the JIRA web interface.
213		title := strings.Replace(issue.Fields.Summary, "\n", "", -1)
214		b, _, err = repo.NewBugRaw(
215			author,
216			issue.Fields.Created.Unix(),
217			title,
218			cleanText,
219			nil,
220			map[string]string{
221				core.MetaKeyOrigin: target,
222				keyJiraID:          issue.ID,
223				keyJiraKey:         issue.Key,
224				keyJiraProject:     self.conf[keyProject],
225			})
226		if err != nil {
227			return nil, err
228		}
229
230		self.out <- core.NewImportBug(b.Id())
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(repo *cache.RepoCache,
243	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 && err != cache.ErrNoMatchingOp {
253		return err
254	}
255
256	// If the comment is a new comment then create it
257	if targetOpID == "" && err == cache.ErrNoMatchingOp {
258		var cleanText string
259		if item.Updated != item.Created {
260			// We don't know the original text... we only have the updated text.
261			cleanText = ""
262		} else {
263			cleanText, err = text.Cleanup(string(item.Body))
264			if err != nil {
265				return err
266			}
267		}
268
269		// add comment operation
270		op, err := b.AddCommentRaw(
271			author,
272			item.Created.Unix(),
273			cleanText,
274			nil,
275			map[string]string{
276				keyJiraID: item.ID,
277			},
278		)
279		if err != nil {
280			return err
281		}
282
283		self.out <- core.NewImportComment(op.Id())
284		targetOpID = op.Id()
285	}
286
287	// If there are no updates to this comment, then we are done
288	if item.Updated == item.Created {
289		return nil
290	}
291
292	// If there has been an update to this comment, we try to find it in the
293	// database. We need a unique id so we'll concat the issue id with the update
294	// timestamp. Note that this must be consistent with the exporter during
295	// export of an EditCommentOperation
296	derivedID := getTimeDerivedID(item.ID, item.Updated)
297	_, err = b.ResolveOperationWithMetadata(
298		keyJiraID, derivedID)
299	if err == nil {
300		// Already imported this edition
301		return nil
302	}
303
304	if err != cache.ErrNoMatchingOp {
305		return err
306	}
307
308	// ensure editor identity
309	editor, err := self.ensurePerson(repo, item.UpdateAuthor)
310	if err != nil {
311		return err
312	}
313
314	// comment edition
315	cleanText, err := text.Cleanup(string(item.Body))
316	if err != nil {
317		return err
318	}
319	op, err := b.EditCommentRaw(
320		editor,
321		item.Updated.Unix(),
322		targetOpID,
323		cleanText,
324		map[string]string{
325			keyJiraID: derivedID,
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		return nil
377	} else if err != cache.ErrNoMatchingOp {
378		return err
379	}
380
381	// In general, multiple fields may be changed in changelog entry  on
382	// JIRA. For example, when an issue is closed both its "status" and its
383	// "resolution" are updated within a single changelog entry.
384	// I don't thing git-bug has a single operation to modify an arbitrary
385	// number of fields in one go, so we break up the single JIRA changelog
386	// entry into individual field updates.
387	author, err := self.ensurePerson(repo, entry.Author)
388	if err != nil {
389		return err
390	}
391
392	if len(entry.Items) < 1 {
393		return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID)
394	}
395
396	statusMap, err := getStatusMapReverse(self.conf)
397	if err != nil {
398		return err
399	}
400
401	// NOTE(josh): first do an initial scan and see if any of the changed items
402	// matches the current potential operation. If it does, then we know that this
403	// entire changelog entry was created in response to that git-bug operation.
404	// So we associate the operation with the entire changelog, and not a specific
405	// entry.
406	for _, item := range entry.Items {
407		switch item.Field {
408		case "labels":
409			fromLabels := removeEmpty(strings.Split(item.FromString, " "))
410			toLabels := removeEmpty(strings.Split(item.ToString, " "))
411			removedLabels, addedLabels, _ := setSymmetricDifference(
412				fromLabels, toLabels)
413
414			opr, isRightType := potentialOp.(*bug.LabelChangeOperation)
415			if isRightType &&
416				labelSetsMatch(addedLabels, opr.Added) &&
417				labelSetsMatch(removedLabels, opr.Removed) {
418				_, err := b.SetMetadata(opr.Id(), map[string]string{
419					keyJiraOperationID: entry.ID,
420				})
421				if err != nil {
422					return err
423				}
424				return nil
425			}
426
427		case "status":
428			opr, isRightType := potentialOp.(*bug.SetStatusOperation)
429			if isRightType && statusMap[opr.Status.String()] == item.To {
430				_, err := b.SetMetadata(opr.Id(), map[string]string{
431					keyJiraOperationID: entry.ID,
432				})
433				if err != nil {
434					return err
435				}
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.To {
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		case "description":
454			// NOTE(josh): JIRA calls it "description", which sounds more like the
455			// title but it's actually the body
456			opr, isRightType := potentialOp.(*bug.EditCommentOperation)
457			if isRightType &&
458				opr.Target == b.Snapshot().Operations[0].Id() &&
459				opr.Message == item.ToString {
460				_, err := b.SetMetadata(opr.Id(), map[string]string{
461					keyJiraOperationID: entry.ID,
462				})
463				if err != nil {
464					return err
465				}
466				return nil
467			}
468		}
469	}
470
471	// Since we didn't match the changelog entry to a known export operation,
472	// then this is a changelog entry that we should import. We import each
473	// changelog entry item as a separate git-bug operation.
474	for idx, item := range entry.Items {
475		derivedID := getIndexDerivedID(entry.ID, idx)
476		_, err := b.ResolveOperationWithMetadata(keyJiraOperationID, derivedID)
477		if err == nil {
478			continue
479		}
480		if err != cache.ErrNoMatchingOp {
481			return err
482		}
483
484		switch item.Field {
485		case "labels":
486			fromLabels := removeEmpty(strings.Split(item.FromString, " "))
487			toLabels := removeEmpty(strings.Split(item.ToString, " "))
488			removedLabels, addedLabels, _ := setSymmetricDifference(
489				fromLabels, toLabels)
490
491			op, err := b.ForceChangeLabelsRaw(
492				author,
493				entry.Created.Unix(),
494				addedLabels,
495				removedLabels,
496				map[string]string{
497					keyJiraID:          entry.ID,
498					keyJiraOperationID: derivedID,
499				},
500			)
501			if err != nil {
502				return err
503			}
504
505			self.out <- core.NewImportLabelChange(op.Id())
506
507		case "status":
508			statusStr, hasMap := statusMap[item.To]
509			if hasMap {
510				switch statusStr {
511				case bug.OpenStatus.String():
512					op, err := b.OpenRaw(
513						author,
514						entry.Created.Unix(),
515						map[string]string{
516							keyJiraID:          entry.ID,
517							keyJiraOperationID: derivedID,
518						},
519					)
520					if err != nil {
521						return err
522					}
523					self.out <- core.NewImportStatusChange(op.Id())
524
525				case bug.ClosedStatus.String():
526					op, err := b.CloseRaw(
527						author,
528						entry.Created.Unix(),
529						map[string]string{
530							keyJiraID:          entry.ID,
531							keyJiraOperationID: derivedID,
532						},
533					)
534					if err != nil {
535						return err
536					}
537					self.out <- core.NewImportStatusChange(op.Id())
538				}
539			} else {
540				self.out <- core.NewImportError(
541					fmt.Errorf(
542						"No git-bug status mapped for jira status %s (%s)",
543						item.ToString, item.To), "")
544			}
545
546		case "summary":
547			// NOTE(josh): JIRA calls it "summary", which sounds more like the body
548			// text, but it's the title
549			op, err := b.SetTitleRaw(
550				author,
551				entry.Created.Unix(),
552				string(item.ToString),
553				map[string]string{
554					keyJiraID:          entry.ID,
555					keyJiraOperationID: derivedID,
556				},
557			)
558			if err != nil {
559				return err
560			}
561
562			self.out <- core.NewImportTitleEdition(op.Id())
563
564		case "description":
565			// NOTE(josh): JIRA calls it "description", which sounds more like the
566			// title but it's actually the body
567			op, err := b.EditCreateCommentRaw(
568				author,
569				entry.Created.Unix(),
570				string(item.ToString),
571				map[string]string{
572					keyJiraID:          entry.ID,
573					keyJiraOperationID: derivedID,
574				},
575			)
576			if err != nil {
577				return err
578			}
579
580			self.out <- core.NewImportCommentEdition(op.Id())
581
582		default:
583			self.out <- core.NewImportWarning(
584				fmt.Errorf(
585					"Unhandled changelog event %s", item.Field), "")
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, error) {
600	mapStr, hasConf := conf[keyIDMap]
601	if !hasConf {
602		return map[string]string{
603			bug.OpenStatus.String():   "1",
604			bug.ClosedStatus.String(): "6",
605		}, nil
606	}
607
608	statusMap := make(map[string]string)
609	err := json.Unmarshal([]byte(mapStr), &statusMap)
610	return statusMap, err
611}
612
613func getStatusMapReverse(conf core.Configuration) (map[string]string, error) {
614	fwdMap, err := getStatusMap(conf)
615	if err != nil {
616		return fwdMap, err
617	}
618
619	outMap := map[string]string{}
620	for key, val := range fwdMap {
621		outMap[val] = key
622	}
623
624	mapStr, hasConf := conf[keyIDRevMap]
625	if !hasConf {
626		return outMap, nil
627	}
628
629	revMap := make(map[string]string)
630	err = json.Unmarshal([]byte(mapStr), &revMap)
631	for key, val := range revMap {
632		outMap[key] = val
633	}
634
635	return outMap, err
636}
637
638func removeEmpty(values []string) []string {
639	output := make([]string, 0, len(values))
640	for _, value := range values {
641		value = strings.TrimSpace(value)
642		if value != "" {
643			output = append(output, value)
644		}
645	}
646	return output
647}