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