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