aboutsummaryrefslogblamecommitdiffstats
path: root/bridge/jira/import.go
blob: bfe83f4d92028263840f7d7b9222e9307d54db95 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12



                 
                       






                                                    
                                                         






                                                  
                            





                                                

                      




                                    
                                                                                                         
                      





































                                                                                                  



                                                                             
                                                                                                                                  
                                                    

                                          
                                           
                    

                   
                                   
 
                                                 










                                                                                                  
                                                                                           
                                                  
                                                              







                                                                            
                                                                                                           
                                                             
                                                                          


                                                                           




                                                                               
                                                



                                                            
                                                                                                              








                                                                                                         
                                                                      











                                                                                            
                                                                                                                   
                                        
                                                                                            


                                                                           






                                                                                 


                                                                                             













                                                                       
                                                                                                      

                                                        
                                                  










                                                       
                                  
                                                          






                               
                                                



                                           

                                                                                                  



                               
                                                                        









                                                                                


                                                                                           






                                                    
                                                           


                                                                            




                                       
                                                   





                                                                       
                                                             



                                                             
                                                                                                     
                        
                                                         




                                                          
                                       
                                                       






















                                                                                            
                                                       





                                  
                                                        
                                    











                                                                                     
                                                                         





                                                



                                 
                                                               











                                                         
                           

                                  
                                                 






                          
                                                       















                                                                             
                                                      















                                                                           
                                                                                                                                       



                                                                                  
                                                                                  
                       










                                                                               
                                                          







                                                                                          
                                                      


                          








                                                                                      

                                                                                      
                                                                                                     

                                                                                   
                                                                                                                                
                                                                                    
                                                                         
                                  


                                                  




                                                                                 
                                                                                     
                                                                                    
                                                                         
                                  
                                               
                                                  
                                 






                                                                                               
                                                                
                                                                                    
                                                                         
                                  
                                               
                                                  
                                 









                                                                                              
                                                                                    
                                                                         
                                  
                                               
                                                  
                                 









                                                                                 
                                                                                           
                               
                                

                                                 




                                   

                                                                                      
                                                                                                     






                                                          

                                                                          





                                          
                                                                    

                              







                                                                     

                                                                                          




                                                          
                                                                                     





                                                                     

                                                                                          




                                                          
                                                                                     
                                 
                                
                                                              
                                                   

                                                                                                   









                                                                                               

                                                                          





                                          
                                                                     



                                                                                              
                                                          



                                                      

                                                                          





                                          
                                                                       

                        
                                                        

                                                                                        












                                       
                                                                       
                                             
                     



                                                       
         



                                                         
 
 










                                                                              
                                                












                                                     









                                                      
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,
		user.Key,
		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(metaKeyJiraOperationId, 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{
					metaKeyJiraOperationId: 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{
					metaKeyJiraOperationId: 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{
					metaKeyJiraOperationId: 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{
					metaKeyJiraOperationId: 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(metaKeyJiraOperationId, 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,
					metaKeyJiraOperationId: 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,
							metaKeyJiraOperationId: 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,
							metaKeyJiraOperationId: 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,
					metaKeyJiraOperationId: 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,
					metaKeyJiraOperationId: 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
}