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