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