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