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