diff options
author | Michael Muré <batolettre@gmail.com> | 2020-02-19 19:07:44 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-02-19 19:07:44 +0100 |
commit | b3318335986618388637a9d35d68b39633e4548a (patch) | |
tree | 00caadd3bf8a96f1dc5bca092cdec7a73247f5f3 /bridge/jira/import.go | |
parent | 08122b3815dd9d684b50e5b901f34c879c3ae5b7 (diff) | |
parent | be763b4c4c7c5fd9e33bd53175364b3f9e781af7 (diff) | |
download | git-bug-b3318335986618388637a9d35d68b39633e4548a.tar.gz |
Merge pull request #250 from cheshirekow/cheshirekow-jira
Implement jira bridge
Diffstat (limited to 'bridge/jira/import.go')
-rw-r--r-- | bridge/jira/import.go | 655 |
1 files changed, 655 insertions, 0 deletions
diff --git a/bridge/jira/import.go b/bridge/jira/import.go new file mode 100644 index 00000000..f35f114f --- /dev/null +++ b/bridge/jira/import.go @@ -0,0 +1,655 @@ +package jira + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "time" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/bug" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/util/text" +) + +const ( + defaultPageSize = 10 +) + +// jiraImporter implement the Importer interface +type jiraImporter struct { + conf core.Configuration + + client *Client + + // send only channel + out chan<- core.ImportResult +} + +// Init . +func (ji *jiraImporter) Init(ctx context.Context, repo *cache.RepoCache, conf core.Configuration) error { + ji.conf = conf + + var cred auth.Credential + + // Prioritize LoginPassword credentials to avoid a prompt + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseUrl]), + auth.WithKind(auth.KindLoginPassword), + ) + if err != nil { + return err + } + if len(creds) > 0 { + cred = creds[0] + goto end + } + + creds, err = auth.List(repo, + auth.WithTarget(target), + auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseUrl]), + auth.WithKind(auth.KindLogin), + ) + if err != nil { + return err + } + if len(creds) > 0 { + cred = creds[0] + } + +end: + if cred == nil { + return fmt.Errorf("no credential for this bridge") + } + + // TODO(josh)[da52062]: Validate token and if it is expired then prompt for + // credentials and generate a new one + ji.client, err = buildClient(ctx, conf[confKeyBaseUrl], conf[confKeyCredentialType], cred) + return err +} + +// ImportAll iterate over all the configured repository issues and ensure the +// creation of the missing issues / timeline items / edits / label events ... +func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) { + sinceStr := since.Format("2006-01-02 15:04") + project := ji.conf[confKeyProject] + + out := make(chan core.ImportResult) + ji.out = out + + go func() { + defer close(ji.out) + + message, err := ji.client.Search( + fmt.Sprintf("project=%s AND updatedDate>\"%s\"", project, sinceStr), 0, 0) + if err != nil { + out <- core.NewImportError(err, "") + return + } + + fmt.Printf("So far so good. Have %d issues to import\n", message.Total) + + jql := fmt.Sprintf("project=%s AND updatedDate>\"%s\"", project, sinceStr) + var searchIter *SearchIterator + for searchIter = + ji.client.IterSearch(jql, defaultPageSize); searchIter.HasNext(); { + issue := searchIter.Next() + b, err := ji.ensureIssue(repo, *issue) + if err != nil { + err := fmt.Errorf("issue creation: %v", err) + out <- core.NewImportError(err, "") + return + } + + var commentIter *CommentIterator + for commentIter = + ji.client.IterComments(issue.ID, defaultPageSize); commentIter.HasNext(); { + comment := commentIter.Next() + err := ji.ensureComment(repo, b, *comment) + if err != nil { + out <- core.NewImportError(err, "") + } + } + if commentIter.HasError() { + out <- core.NewImportError(commentIter.Err, "") + } + + snapshot := b.Snapshot() + opIdx := 0 + + var changelogIter *ChangeLogIterator + for changelogIter = + ji.client.IterChangeLog(issue.ID, defaultPageSize); changelogIter.HasNext(); { + changelogEntry := changelogIter.Next() + + // Advance the operation iterator up to the first operation which has + // an export date not before the changelog entry date. If the changelog + // entry was created in response to an exported operation, then this + // will be that operation. + var exportTime time.Time + for ; opIdx < len(snapshot.Operations); opIdx++ { + exportTimeStr, hasTime := snapshot.Operations[opIdx].GetMetadata( + metaKeyJiraExportTime) + if !hasTime { + continue + } + exportTime, err = http.ParseTime(exportTimeStr) + if err != nil { + continue + } + if !exportTime.Before(changelogEntry.Created.Time) { + break + } + } + if opIdx < len(snapshot.Operations) { + err = ji.ensureChange(repo, b, *changelogEntry, snapshot.Operations[opIdx]) + } else { + err = ji.ensureChange(repo, b, *changelogEntry, nil) + } + if err != nil { + out <- core.NewImportError(err, "") + } + + } + if changelogIter.HasError() { + out <- core.NewImportError(changelogIter.Err, "") + } + + if !b.NeedCommit() { + out <- core.NewImportNothing(b.Id(), "no imported operation") + } else if err := b.Commit(); err != nil { + err = fmt.Errorf("bug commit: %v", err) + out <- core.NewImportError(err, "") + return + } + } + if searchIter.HasError() { + out <- core.NewImportError(searchIter.Err, "") + } + }() + + return out, nil +} + +// Create a bug.Person from a JIRA user +func (ji *jiraImporter) ensurePerson(repo *cache.RepoCache, user User) (*cache.IdentityCache, error) { + // Look first in the cache + i, err := repo.ResolveIdentityImmutableMetadata( + metaKeyJiraUser, string(user.Key)) + if err == nil { + return i, nil + } + if _, ok := err.(entity.ErrMultipleMatch); ok { + return nil, err + } + + i, err = repo.NewIdentityRaw( + user.DisplayName, + user.EmailAddress, + "", + map[string]string{ + metaKeyJiraUser: string(user.Key), + }, + ) + + if err != nil { + return nil, err + } + + ji.out <- core.NewImportIdentity(i.Id()) + return i, nil +} + +// Create a bug.Bug based from a JIRA issue +func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache.BugCache, error) { + author, err := ji.ensurePerson(repo, issue.Fields.Creator) + if err != nil { + return nil, err + } + + b, err := repo.ResolveBugCreateMetadata(metaKeyJiraId, issue.ID) + if err != nil && err != bug.ErrBugNotExist { + return nil, err + } + + if err == bug.ErrBugNotExist { + cleanText, err := text.Cleanup(string(issue.Fields.Description)) + if err != nil { + return nil, err + } + + // NOTE(josh): newlines in titles appears to be rare, but it has been seen + // in the wild. It does not appear to be allowed in the JIRA web interface. + title := strings.Replace(issue.Fields.Summary, "\n", "", -1) + b, _, err = repo.NewBugRaw( + author, + issue.Fields.Created.Unix(), + title, + cleanText, + nil, + map[string]string{ + core.MetaKeyOrigin: target, + metaKeyJiraId: issue.ID, + metaKeyJiraKey: issue.Key, + metaKeyJiraProject: ji.conf[confKeyProject], + }) + if err != nil { + return nil, err + } + + ji.out <- core.NewImportBug(b.Id()) + } + + return b, nil +} + +// Return a unique string derived from a unique jira id and a timestamp +func getTimeDerivedID(jiraID string, timestamp Time) string { + return fmt.Sprintf("%s-%d", jiraID, timestamp.Unix()) +} + +// Create a bug.Comment from a JIRA comment +func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, item Comment) error { + // ensure person + author, err := ji.ensurePerson(repo, item.Author) + if err != nil { + return err + } + + targetOpID, err := b.ResolveOperationWithMetadata( + metaKeyJiraId, item.ID) + if err != nil && err != cache.ErrNoMatchingOp { + return err + } + + // If the comment is a new comment then create it + if targetOpID == "" && err == cache.ErrNoMatchingOp { + var cleanText string + if item.Updated != item.Created { + // We don't know the original text... we only have the updated text. + cleanText = "" + } else { + cleanText, err = text.Cleanup(string(item.Body)) + if err != nil { + return err + } + } + + // add comment operation + op, err := b.AddCommentRaw( + author, + item.Created.Unix(), + cleanText, + nil, + map[string]string{ + metaKeyJiraId: item.ID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportComment(op.Id()) + targetOpID = op.Id() + } + + // If there are no updates to this comment, then we are done + if item.Updated == item.Created { + return nil + } + + // If there has been an update to this comment, we try to find it in the + // database. We need a unique id so we'll concat the issue id with the update + // timestamp. Note that this must be consistent with the exporter during + // export of an EditCommentOperation + derivedID := getTimeDerivedID(item.ID, item.Updated) + _, err = b.ResolveOperationWithMetadata(metaKeyJiraId, derivedID) + if err == nil { + // Already imported this edition + return nil + } + + if err != cache.ErrNoMatchingOp { + return err + } + + // ensure editor identity + editor, err := ji.ensurePerson(repo, item.UpdateAuthor) + if err != nil { + return err + } + + // comment edition + cleanText, err := text.Cleanup(string(item.Body)) + if err != nil { + return err + } + op, err := b.EditCommentRaw( + editor, + item.Updated.Unix(), + targetOpID, + cleanText, + map[string]string{ + metaKeyJiraId: derivedID, + }, + ) + + if err != nil { + return err + } + + ji.out <- core.NewImportCommentEdition(op.Id()) + + return nil +} + +// Return a unique string derived from a unique jira id and an index into the +// data referred to by that jira id. +func getIndexDerivedID(jiraID string, idx int) string { + return fmt.Sprintf("%s-%d", jiraID, idx) +} + +func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool { + if len(jiraSet) != len(gitbugSet) { + return false + } + + sort.Strings(jiraSet) + gitbugStrSet := make([]string, len(gitbugSet)) + for idx, label := range gitbugSet { + gitbugStrSet[idx] = label.String() + } + sort.Strings(gitbugStrSet) + + for idx, value := range jiraSet { + if value != gitbugStrSet[idx] { + return false + } + } + + return true +} + +// Create a bug.Operation (or a series of operations) from a JIRA changelog +// entry +func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry, potentialOp bug.Operation) error { + + // If we have an operation which is already mapped to the entire changelog + // entry then that means this changelog entry was induced by an export + // operation and we've already done the match, so we skip this one + _, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, entry.ID) + if err == nil { + return nil + } else if err != cache.ErrNoMatchingOp { + return err + } + + // In general, multiple fields may be changed in changelog entry on + // JIRA. For example, when an issue is closed both its "status" and its + // "resolution" are updated within a single changelog entry. + // I don't thing git-bug has a single operation to modify an arbitrary + // number of fields in one go, so we break up the single JIRA changelog + // entry into individual field updates. + author, err := ji.ensurePerson(repo, entry.Author) + if err != nil { + return err + } + + if len(entry.Items) < 1 { + return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID) + } + + statusMap, err := getStatusMapReverse(ji.conf) + if err != nil { + return err + } + + // NOTE(josh): first do an initial scan and see if any of the changed items + // matches the current potential operation. If it does, then we know that this + // entire changelog entry was created in response to that git-bug operation. + // So we associate the operation with the entire changelog, and not a specific + // entry. + for _, item := range entry.Items { + switch item.Field { + case "labels": + fromLabels := removeEmpty(strings.Split(item.FromString, " ")) + toLabels := removeEmpty(strings.Split(item.ToString, " ")) + removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels) + + opr, isRightType := potentialOp.(*bug.LabelChangeOperation) + if isRightType && labelSetsMatch(addedLabels, opr.Added) && labelSetsMatch(removedLabels, opr.Removed) { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + metaKeyJiraDerivedId: entry.ID, + }) + if err != nil { + return err + } + return nil + } + + case "status": + opr, isRightType := potentialOp.(*bug.SetStatusOperation) + if isRightType && statusMap[opr.Status.String()] == item.To { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + metaKeyJiraDerivedId: entry.ID, + }) + if err != nil { + return err + } + return nil + } + + case "summary": + // NOTE(josh): JIRA calls it "summary", which sounds more like the body + // text, but it's the title + opr, isRightType := potentialOp.(*bug.SetTitleOperation) + if isRightType && opr.Title == item.To { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + metaKeyJiraDerivedId: entry.ID, + }) + if err != nil { + return err + } + return nil + } + + case "description": + // NOTE(josh): JIRA calls it "description", which sounds more like the + // title but it's actually the body + opr, isRightType := potentialOp.(*bug.EditCommentOperation) + if isRightType && + opr.Target == b.Snapshot().Operations[0].Id() && + opr.Message == item.ToString { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + metaKeyJiraDerivedId: entry.ID, + }) + if err != nil { + return err + } + return nil + } + } + } + + // Since we didn't match the changelog entry to a known export operation, + // then this is a changelog entry that we should import. We import each + // changelog entry item as a separate git-bug operation. + for idx, item := range entry.Items { + derivedID := getIndexDerivedID(entry.ID, idx) + _, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, derivedID) + if err == nil { + continue + } + if err != cache.ErrNoMatchingOp { + return err + } + + switch item.Field { + case "labels": + fromLabels := removeEmpty(strings.Split(item.FromString, " ")) + toLabels := removeEmpty(strings.Split(item.ToString, " ")) + removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels) + + op, err := b.ForceChangeLabelsRaw( + author, + entry.Created.Unix(), + addedLabels, + removedLabels, + map[string]string{ + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportLabelChange(op.Id()) + + case "status": + statusStr, hasMap := statusMap[item.To] + if hasMap { + switch statusStr { + case bug.OpenStatus.String(): + op, err := b.OpenRaw( + author, + entry.Created.Unix(), + map[string]string{ + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, + }, + ) + if err != nil { + return err + } + ji.out <- core.NewImportStatusChange(op.Id()) + + case bug.ClosedStatus.String(): + op, err := b.CloseRaw( + author, + entry.Created.Unix(), + map[string]string{ + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, + }, + ) + if err != nil { + return err + } + ji.out <- core.NewImportStatusChange(op.Id()) + } + } else { + ji.out <- core.NewImportError( + fmt.Errorf( + "No git-bug status mapped for jira status %s (%s)", + item.ToString, item.To), "") + } + + case "summary": + // NOTE(josh): JIRA calls it "summary", which sounds more like the body + // text, but it's the title + op, err := b.SetTitleRaw( + author, + entry.Created.Unix(), + string(item.ToString), + map[string]string{ + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportTitleEdition(op.Id()) + + case "description": + // NOTE(josh): JIRA calls it "description", which sounds more like the + // title but it's actually the body + op, err := b.EditCreateCommentRaw( + author, + entry.Created.Unix(), + string(item.ToString), + map[string]string{ + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportCommentEdition(op.Id()) + + default: + ji.out <- core.NewImportWarning( + fmt.Errorf( + "Unhandled changelog event %s", item.Field), "") + } + + // Other Examples: + // "assignee" (jira) + // "Attachment" (jira) + // "Epic Link" (custom) + // "Rank" (custom) + // "resolution" (jira) + // "Sprint" (custom) + } + return nil +} + +func getStatusMap(conf core.Configuration) (map[string]string, error) { + mapStr, hasConf := conf[confKeyIDMap] + if !hasConf { + return map[string]string{ + bug.OpenStatus.String(): "1", + bug.ClosedStatus.String(): "6", + }, nil + } + + statusMap := make(map[string]string) + err := json.Unmarshal([]byte(mapStr), &statusMap) + return statusMap, err +} + +func getStatusMapReverse(conf core.Configuration) (map[string]string, error) { + fwdMap, err := getStatusMap(conf) + if err != nil { + return fwdMap, err + } + + outMap := map[string]string{} + for key, val := range fwdMap { + outMap[val] = key + } + + mapStr, hasConf := conf[confKeyIDRevMap] + if !hasConf { + return outMap, nil + } + + revMap := make(map[string]string) + err = json.Unmarshal([]byte(mapStr), &revMap) + for key, val := range revMap { + outMap[key] = val + } + + return outMap, err +} + +func removeEmpty(values []string) []string { + output := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + output = append(output, value) + } + } + return output +} |