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/entities/common"
 17	"github.com/MichaelMure/git-bug/entity"
 18	"github.com/MichaelMure/git-bug/entity/dag"
 19	"github.com/MichaelMure/git-bug/util/text"
 20)
 21
 22const (
 23	defaultPageSize = 10
 24)
 25
 26// jiraImporter implement the Importer interface
 27type jiraImporter struct {
 28	conf core.Configuration
 29
 30	client *Client
 31
 32	// send only channel
 33	out chan<- core.ImportResult
 34}
 35
 36// Init .
 37func (ji *jiraImporter) Init(ctx context.Context, repo *cache.RepoCache, conf core.Configuration) error {
 38	ji.conf = conf
 39
 40	var cred auth.Credential
 41
 42	// Prioritize LoginPassword credentials to avoid a prompt
 43	creds, err := auth.List(repo,
 44		auth.WithTarget(target),
 45		auth.WithKind(auth.KindLoginPassword),
 46		auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseUrl]),
 47		auth.WithMeta(auth.MetaKeyLogin, conf[confKeyDefaultLogin]),
 48	)
 49	if err != nil {
 50		return err
 51	}
 52	if len(creds) > 0 {
 53		cred = creds[0]
 54		goto end
 55	}
 56
 57	creds, err = auth.List(repo,
 58		auth.WithTarget(target),
 59		auth.WithKind(auth.KindLogin),
 60		auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseUrl]),
 61		auth.WithMeta(auth.MetaKeyLogin, conf[confKeyDefaultLogin]),
 62	)
 63	if err != nil {
 64		return err
 65	}
 66	if len(creds) > 0 {
 67		cred = creds[0]
 68	}
 69
 70end:
 71	if cred == nil {
 72		return fmt.Errorf("no credential for this bridge")
 73	}
 74
 75	// TODO(josh)[da52062]: Validate token and if it is expired then prompt for
 76	// credentials and generate a new one
 77	ji.client, err = buildClient(ctx, conf[confKeyBaseUrl], conf[confKeyCredentialType], cred)
 78	return err
 79}
 80
 81// ImportAll iterate over all the configured repository issues and ensure the
 82// creation of the missing issues / timeline items / edits / label events ...
 83func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
 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		b, _, err = repo.NewBugRaw(
238			author,
239			issue.Fields.Created.Unix(),
240			text.CleanupOneLine(issue.Fields.Summary),
241			text.Cleanup(issue.Fields.Description),
242			nil,
243			map[string]string{
244				core.MetaKeyOrigin: target,
245				metaKeyJiraId:      issue.ID,
246				metaKeyJiraKey:     issue.Key,
247				metaKeyJiraProject: ji.conf[confKeyProject],
248				metaKeyJiraBaseUrl: ji.conf[confKeyBaseUrl],
249			})
250		if err != nil {
251			return nil, err
252		}
253
254		ji.out <- core.NewImportBug(b.Id())
255	}
256
257	return b, nil
258}
259
260// Return a unique string derived from a unique jira id and a timestamp
261func getTimeDerivedID(jiraID string, timestamp Time) string {
262	return fmt.Sprintf("%s-%d", jiraID, timestamp.Unix())
263}
264
265// Create a bug.Comment from a JIRA comment
266func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, item Comment) error {
267	// ensure person
268	author, err := ji.ensurePerson(repo, item.Author)
269	if err != nil {
270		return err
271	}
272
273	targetOpID, err := b.ResolveOperationWithMetadata(
274		metaKeyJiraId, item.ID)
275	if err != nil && err != cache.ErrNoMatchingOp {
276		return err
277	}
278
279	// If the comment is a new comment then create it
280	if targetOpID == "" && err == cache.ErrNoMatchingOp {
281		var cleanText string
282		if item.Updated != item.Created {
283			// We don't know the original text... we only have the updated text.
284			cleanText = ""
285		} else {
286			cleanText = text.Cleanup(item.Body)
287		}
288
289		// add comment operation
290		op, err := b.AddCommentRaw(
291			author,
292			item.Created.Unix(),
293			cleanText,
294			nil,
295			map[string]string{
296				metaKeyJiraId: item.ID,
297			},
298		)
299		if err != nil {
300			return err
301		}
302
303		ji.out <- core.NewImportComment(op.Id())
304		targetOpID = op.Id()
305	}
306
307	// If there are no updates to this comment, then we are done
308	if item.Updated == item.Created {
309		return nil
310	}
311
312	// If there has been an update to this comment, we try to find it in the
313	// database. We need a unique id so we'll concat the issue id with the update
314	// timestamp. Note that this must be consistent with the exporter during
315	// export of an EditCommentOperation
316	derivedID := getTimeDerivedID(item.ID, item.Updated)
317	_, err = b.ResolveOperationWithMetadata(metaKeyJiraId, derivedID)
318	if err == nil {
319		// Already imported this edition
320		return nil
321	}
322
323	if err != cache.ErrNoMatchingOp {
324		return err
325	}
326
327	// ensure editor identity
328	editor, err := ji.ensurePerson(repo, item.UpdateAuthor)
329	if err != nil {
330		return err
331	}
332
333	// comment edition
334	op, err := b.EditCommentRaw(
335		editor,
336		item.Updated.Unix(),
337		targetOpID,
338		text.Cleanup(item.Body),
339		map[string]string{
340			metaKeyJiraId: derivedID,
341		},
342	)
343
344	if err != nil {
345		return err
346	}
347
348	ji.out <- core.NewImportCommentEdition(op.Id())
349
350	return nil
351}
352
353// Return a unique string derived from a unique jira id and an index into the
354// data referred to by that jira id.
355func getIndexDerivedID(jiraID string, idx int) string {
356	return fmt.Sprintf("%s-%d", jiraID, idx)
357}
358
359func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool {
360	if len(jiraSet) != len(gitbugSet) {
361		return false
362	}
363
364	sort.Strings(jiraSet)
365	gitbugStrSet := make([]string, len(gitbugSet))
366	for idx, label := range gitbugSet {
367		gitbugStrSet[idx] = label.String()
368	}
369	sort.Strings(gitbugStrSet)
370
371	for idx, value := range jiraSet {
372		if value != gitbugStrSet[idx] {
373			return false
374		}
375	}
376
377	return true
378}
379
380// Create a bug.Operation (or a series of operations) from a JIRA changelog
381// entry
382func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry, potentialOp dag.Operation) error {
383
384	// If we have an operation which is already mapped to the entire changelog
385	// entry then that means this changelog entry was induced by an export
386	// operation and we've already done the match, so we skip this one
387	_, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, entry.ID)
388	if err == nil {
389		return nil
390	} else if err != cache.ErrNoMatchingOp {
391		return err
392	}
393
394	// In general, multiple fields may be changed in changelog entry  on
395	// JIRA. For example, when an issue is closed both its "status" and its
396	// "resolution" are updated within a single changelog entry.
397	// I don't thing git-bug has a single operation to modify an arbitrary
398	// number of fields in one go, so we break up the single JIRA changelog
399	// entry into individual field updates.
400	author, err := ji.ensurePerson(repo, entry.Author)
401	if err != nil {
402		return err
403	}
404
405	if len(entry.Items) < 1 {
406		return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID)
407	}
408
409	statusMap, err := getStatusMapReverse(ji.conf)
410	if err != nil {
411		return err
412	}
413
414	// NOTE(josh): first do an initial scan and see if any of the changed items
415	// matches the current potential operation. If it does, then we know that this
416	// entire changelog entry was created in response to that git-bug operation.
417	// So we associate the operation with the entire changelog, and not a specific
418	// entry.
419	for _, item := range entry.Items {
420		switch item.Field {
421		case "labels":
422			fromLabels := removeEmpty(strings.Split(item.FromString, " "))
423			toLabels := removeEmpty(strings.Split(item.ToString, " "))
424			removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels)
425
426			opr, isRightType := potentialOp.(*bug.LabelChangeOperation)
427			if isRightType && labelSetsMatch(addedLabels, opr.Added) && labelSetsMatch(removedLabels, opr.Removed) {
428				_, err := b.SetMetadata(opr.Id(), map[string]string{
429					metaKeyJiraDerivedId: entry.ID,
430				})
431				if err != nil {
432					return err
433				}
434				return nil
435			}
436
437		case "status":
438			opr, isRightType := potentialOp.(*bug.SetStatusOperation)
439			if isRightType && statusMap[opr.Status.String()] == item.To {
440				_, err := b.SetMetadata(opr.Id(), map[string]string{
441					metaKeyJiraDerivedId: entry.ID,
442				})
443				if err != nil {
444					return err
445				}
446				return nil
447			}
448
449		case "summary":
450			// NOTE(josh): JIRA calls it "summary", which sounds more like the body
451			// text, but it's the title
452			opr, isRightType := potentialOp.(*bug.SetTitleOperation)
453			if isRightType && opr.Title == item.To {
454				_, err := b.SetMetadata(opr.Id(), map[string]string{
455					metaKeyJiraDerivedId: entry.ID,
456				})
457				if err != nil {
458					return err
459				}
460				return nil
461			}
462
463		case "description":
464			// NOTE(josh): JIRA calls it "description", which sounds more like the
465			// title but it's actually the body
466			opr, isRightType := potentialOp.(*bug.EditCommentOperation)
467			if isRightType &&
468				opr.Target == b.Snapshot().Operations[0].Id() &&
469				opr.Message == item.ToString {
470				_, err := b.SetMetadata(opr.Id(), map[string]string{
471					metaKeyJiraDerivedId: entry.ID,
472				})
473				if err != nil {
474					return err
475				}
476				return nil
477			}
478		}
479	}
480
481	// Since we didn't match the changelog entry to a known export operation,
482	// then this is a changelog entry that we should import. We import each
483	// changelog entry item as a separate git-bug operation.
484	for idx, item := range entry.Items {
485		derivedID := getIndexDerivedID(entry.ID, idx)
486		_, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, derivedID)
487		if err == nil {
488			continue
489		}
490		if err != cache.ErrNoMatchingOp {
491			return err
492		}
493
494		switch item.Field {
495		case "labels":
496			fromLabels := removeEmpty(strings.Split(item.FromString, " "))
497			toLabels := removeEmpty(strings.Split(item.ToString, " "))
498			removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels)
499
500			op, err := b.ForceChangeLabelsRaw(
501				author,
502				entry.Created.Unix(),
503				text.CleanupOneLineArray(addedLabels),
504				text.CleanupOneLineArray(removedLabels),
505				map[string]string{
506					metaKeyJiraId:        entry.ID,
507					metaKeyJiraDerivedId: derivedID,
508				},
509			)
510			if err != nil {
511				return err
512			}
513
514			ji.out <- core.NewImportLabelChange(op.Id())
515
516		case "status":
517			statusStr, hasMap := statusMap[item.To]
518			if hasMap {
519				switch statusStr {
520				case common.OpenStatus.String():
521					op, err := b.OpenRaw(
522						author,
523						entry.Created.Unix(),
524						map[string]string{
525							metaKeyJiraId:        entry.ID,
526							metaKeyJiraDerivedId: derivedID,
527						},
528					)
529					if err != nil {
530						return err
531					}
532					ji.out <- core.NewImportStatusChange(op.Id())
533
534				case common.ClosedStatus.String():
535					op, err := b.CloseRaw(
536						author,
537						entry.Created.Unix(),
538						map[string]string{
539							metaKeyJiraId:        entry.ID,
540							metaKeyJiraDerivedId: derivedID,
541						},
542					)
543					if err != nil {
544						return err
545					}
546					ji.out <- core.NewImportStatusChange(op.Id())
547				}
548			} else {
549				ji.out <- core.NewImportError(
550					fmt.Errorf(
551						"No git-bug status mapped for jira status %s (%s)",
552						item.ToString, item.To), "")
553			}
554
555		case "summary":
556			// NOTE(josh): JIRA calls it "summary", which sounds more like the body
557			// text, but it's the title
558			op, err := b.SetTitleRaw(
559				author,
560				entry.Created.Unix(),
561				text.CleanupOneLine(item.ToString),
562				map[string]string{
563					metaKeyJiraId:        entry.ID,
564					metaKeyJiraDerivedId: derivedID,
565				},
566			)
567			if err != nil {
568				return err
569			}
570
571			ji.out <- core.NewImportTitleEdition(op.Id())
572
573		case "description":
574			// NOTE(josh): JIRA calls it "description", which sounds more like the
575			// title but it's actually the body
576			op, err := b.EditCreateCommentRaw(
577				author,
578				entry.Created.Unix(),
579				text.Cleanup(item.ToString),
580				map[string]string{
581					metaKeyJiraId:        entry.ID,
582					metaKeyJiraDerivedId: derivedID,
583				},
584			)
585			if err != nil {
586				return err
587			}
588
589			ji.out <- core.NewImportCommentEdition(op.Id())
590
591		default:
592			ji.out <- core.NewImportWarning(
593				fmt.Errorf(
594					"Unhandled changelog event %s", item.Field), "")
595		}
596
597		// Other Examples:
598		// "assignee" (jira)
599		// "Attachment" (jira)
600		// "Epic Link" (custom)
601		// "Rank" (custom)
602		// "resolution" (jira)
603		// "Sprint" (custom)
604	}
605	return nil
606}
607
608func getStatusMap(conf core.Configuration) (map[string]string, error) {
609	mapStr, hasConf := conf[confKeyIDMap]
610	if !hasConf {
611		return map[string]string{
612			common.OpenStatus.String():   "1",
613			common.ClosedStatus.String(): "6",
614		}, nil
615	}
616
617	statusMap := make(map[string]string)
618	err := json.Unmarshal([]byte(mapStr), &statusMap)
619	return statusMap, err
620}
621
622func getStatusMapReverse(conf core.Configuration) (map[string]string, error) {
623	fwdMap, err := getStatusMap(conf)
624	if err != nil {
625		return fwdMap, err
626	}
627
628	outMap := map[string]string{}
629	for key, val := range fwdMap {
630		outMap[val] = key
631	}
632
633	mapStr, hasConf := conf[confKeyIDRevMap]
634	if !hasConf {
635		return outMap, nil
636	}
637
638	revMap := make(map[string]string)
639	err = json.Unmarshal([]byte(mapStr), &revMap)
640	for key, val := range revMap {
641		outMap[key] = val
642	}
643
644	return outMap, err
645}
646
647func removeEmpty(values []string) []string {
648	output := make([]string, 0, len(values))
649	for _, value := range values {
650		value = strings.TrimSpace(value)
651		if value != "" {
652			output = append(output, value)
653		}
654	}
655	return output
656}