diff options
Diffstat (limited to 'bridge/jira')
-rw-r--r-- | bridge/jira/client.go | 114 | ||||
-rw-r--r-- | bridge/jira/config.go | 166 | ||||
-rw-r--r-- | bridge/jira/export.go | 152 | ||||
-rw-r--r-- | bridge/jira/import.go | 133 | ||||
-rw-r--r-- | bridge/jira/jira.go | 59 |
5 files changed, 346 insertions, 278 deletions
diff --git a/bridge/jira/client.go b/bridge/jira/client.go index 15098a3c..5e1db26f 100644 --- a/bridge/jira/client.go +++ b/bridge/jira/client.go @@ -14,10 +14,9 @@ import ( "strings" "time" - "github.com/MichaelMure/git-bug/bridge/core" - "github.com/MichaelMure/git-bug/bug" - "github.com/MichaelMure/git-bug/input" "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/bug" ) var errDone = errors.New("Iteration Done") @@ -39,14 +38,14 @@ func ParseTime(timeStr string) (time.Time, error) { return out, err } -// MyTime is just a time.Time with a JSON serialization -type MyTime struct { +// Time is just a time.Time with a JSON serialization +type Time struct { time.Time } // UnmarshalJSON parses an RFC3339 date string into a time object // borrowed from: https://stackoverflow.com/a/39180230/141023 -func (self *MyTime) UnmarshalJSON(data []byte) (err error) { +func (t *Time) UnmarshalJSON(data []byte) (err error) { str := string(data) // Get rid of the quotes "" around the value. @@ -56,7 +55,7 @@ func (self *MyTime) UnmarshalJSON(data []byte) (err error) { str = str[1 : len(str)-1] timeObj, err := ParseTime(str) - self.Time = timeObj + t.Time = timeObj return } @@ -100,8 +99,8 @@ type Comment struct { Body string `json:"body"` Author User `json:"author"` UpdateAuthor User `json:"updateAuthor"` - Created MyTime `json:"created"` - Updated MyTime `json:"updated"` + Created Time `json:"created"` + Updated Time `json:"updated"` } // CommentPage the JSON object holding a single page of comments returned @@ -115,13 +114,13 @@ type CommentPage struct { } // NextStartAt return the index of the first item on the next page -func (self *CommentPage) NextStartAt() int { - return self.StartAt + len(self.Comments) +func (cp *CommentPage) NextStartAt() int { + return cp.StartAt + len(cp.Comments) } // IsLastPage return true if there are no more items beyond this page -func (self *CommentPage) IsLastPage() bool { - return self.NextStartAt() >= self.Total +func (cp *CommentPage) IsLastPage() bool { + return cp.NextStartAt() >= cp.Total } // IssueFields the JSON object returned as the "fields" member of an issue. @@ -130,7 +129,7 @@ func (self *CommentPage) IsLastPage() bool { // https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue type IssueFields struct { Creator User `json:"creator"` - Created MyTime `json:"created"` + Created Time `json:"created"` Description string `json:"description"` Summary string `json:"summary"` Comments CommentPage `json:"comment"` @@ -155,7 +154,7 @@ type ChangeLogItem struct { type ChangeLogEntry struct { ID string `json:"id"` Author User `json:"author"` - Created MyTime `json:"created"` + Created Time `json:"created"` Items []ChangeLogItem `json:"items"` } @@ -171,16 +170,16 @@ type ChangeLogPage struct { } // NextStartAt return the index of the first item on the next page -func (self *ChangeLogPage) NextStartAt() int { - return self.StartAt + len(self.Entries) +func (clp *ChangeLogPage) NextStartAt() int { + return clp.StartAt + len(clp.Entries) } // IsLastPage return true if there are no more items beyond this page -func (self *ChangeLogPage) IsLastPage() bool { +func (clp *ChangeLogPage) IsLastPage() bool { // NOTE(josh): The "isLast" field is returned on JIRA cloud, but not on // JIRA server. If we can distinguish which one we are working with, we can // possibly rely on that instead. - return self.NextStartAt() >= self.Total + return clp.NextStartAt() >= clp.Total } // Issue Top-level object for an issue @@ -202,13 +201,13 @@ type SearchResult struct { } // NextStartAt return the index of the first item on the next page -func (self *SearchResult) NextStartAt() int { - return self.StartAt + len(self.Issues) +func (sr *SearchResult) NextStartAt() int { + return sr.StartAt + len(sr.Issues) } // IsLastPage return true if there are no more items beyond this page -func (self *SearchResult) IsLastPage() bool { - return self.NextStartAt() >= self.Total +func (sr *SearchResult) IsLastPage() bool { + return sr.NextStartAt() >= sr.Total } // SearchRequest the JSON object POSTed to the /search endpoint @@ -296,8 +295,8 @@ type ServerInfo struct { Version string `json:"version"` VersionNumbers []int `json:"versionNumbers"` BuildNumber int `json:"buildNumber"` - BuildDate MyTime `json:"buildDate"` - ServerTime MyTime `json:"serverTime"` + BuildDate Time `json:"buildDate"` + ServerTime Time `json:"serverTime"` ScmInfo string `json:"scmInfo"` BuildPartnerName string `json:"buildPartnerName"` ServerTitle string `json:"serverTitle"` @@ -331,7 +330,7 @@ func (ct *ClientTransport) SetCredentials(username string, token string) { } // Client Thin wrapper around the http.Client providing jira-specific methods -// for APIendpoints +// for API endpoints type Client struct { *http.Client serverURL string @@ -340,7 +339,7 @@ type Client struct { // NewClient Construct a new client connected to the provided server and // utilizing the given context for asynchronous events -func NewClient(serverURL string, ctx context.Context) *Client { +func NewClient(ctx context.Context, serverURL string) *Client { cookiJar, _ := cookiejar.New(nil) client := &http.Client{ Transport: &ClientTransport{underlyingTransport: http.DefaultTransport}, @@ -350,57 +349,20 @@ func NewClient(serverURL string, ctx context.Context) *Client { return &Client{client, serverURL, ctx} } -// Login POST credentials to the /session endpoing and get a session cookie -func (client *Client) Login(conf core.Configuration) error { - credType := conf[keyCredentialsType] - - if conf[keyCredentialsFile] != "" { - content, err := ioutil.ReadFile(conf[keyCredentialsFile]) - if err != nil { - return err - } - - switch credType { - case "SESSION": - return client.RefreshSessionTokenRaw(content) - case "TOKEN": - var params SessionQuery - err := json.Unmarshal(content, ¶ms) - if err != nil { - return err - } - return client.SetTokenCredentials(params.Username, params.Password) - } - return fmt.Errorf("Unexpected credType: %s", credType) - } - - username := conf[keyUsername] - if username == "" { - return fmt.Errorf( - "Invalid configuration lacks both a username and credentials sidecar " + - "path. At least one is required.") - } - - password := conf[keyPassword] - if password == "" { - var err error - password, err = input.PromptPassword("Password", "password", input.Required) - if err != nil { - return err - } - } - +// Login POST credentials to the /session endpoint and get a session cookie +func (client *Client) Login(credType, login, password string) error { switch credType { case "SESSION": - return client.RefreshSessionToken(username, password) + return client.RefreshSessionToken(login, password) case "TOKEN": - return client.SetTokenCredentials(username, password) + return client.SetTokenCredentials(login, password) + default: + panic("unknown Jira cred type") } - return fmt.Errorf("Unexpected credType: %s", credType) } // RefreshSessionToken formulate the JSON request object from the user -// credentials and POST it to the /session endpoing and get a session cookie +// credentials and POST it to the /session endpoint and get a session cookie func (client *Client) RefreshSessionToken(username, password string) error { params := SessionQuery{ Username: username, @@ -415,7 +377,7 @@ func (client *Client) RefreshSessionToken(username, password string) error { return client.RefreshSessionTokenRaw(data) } -// SetTokenCredentials POST credentials to the /session endpoing and get a +// SetTokenCredentials POST credentials to the /session endpoint and get a // session cookie func (client *Client) SetTokenCredentials(username, password string) error { switch transport := client.Transport.(type) { @@ -427,7 +389,7 @@ func (client *Client) SetTokenCredentials(username, password string) error { return nil } -// RefreshSessionTokenRaw POST credentials to the /session endpoing and get a +// RefreshSessionTokenRaw POST credentials to the /session endpoint and get a // session cookie func (client *Client) RefreshSessionTokenRaw(credentialsJSON []byte) error { postURL := fmt.Sprintf("%s/rest/auth/1/session", client.serverURL) @@ -796,7 +758,7 @@ func (client *Client) IterComments(idOrKey string, pageSize int) *CommentIterato return iter } -// GetChangeLog fetchs one page of the changelog for an issue via the +// GetChangeLog fetch one page of the changelog for an issue via the // /issue/{IssueIdOrKey}/changelog endpoint (for JIRA cloud) or // /issue/{IssueIdOrKey} with (fields=*none&expand=changelog) // (for JIRA server) @@ -1489,8 +1451,8 @@ func (client *Client) GetServerInfo() (*ServerInfo, error) { } // GetServerTime returns the current time on the server -func (client *Client) GetServerTime() (MyTime, error) { - var result MyTime +func (client *Client) GetServerTime() (Time, error) { + var result Time info, err := client.GetServerInfo() if err != nil { return result, err diff --git a/bridge/jira/config.go b/bridge/jira/config.go index 077f258a..c4743448 100644 --- a/bridge/jira/config.go +++ b/bridge/jira/config.go @@ -1,15 +1,13 @@ package jira import ( - "encoding/json" "fmt" - "io/ioutil" - - "github.com/pkg/errors" "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/input" + "github.com/MichaelMure/git-bug/repository" ) const moreConfigText = ` @@ -28,32 +26,27 @@ after October 1st 2019 must use "TOKEN" authentication. You must create a user API token and the client will provide this along with your username with each request.` -// Configure sets up the bridge configuration -func (g *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { - conf := make(core.Configuration) - conf[core.ConfigKeyTarget] = target +func (*Jira) ValidParams() map[string]interface{} { + return map[string]interface{}{ + "BaseURL": nil, + "Login": nil, + "CredPrefix": nil, + "Project": nil, + } +} +// Configure sets up the bridge configuration +func (j *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { var err error - // if params.Token != "" || params.TokenStdin { - // return nil, fmt.Errorf( - // "JIRA session tokens are extremely short lived. We don't store them " + - // "in the configuration, so they are not valid for this bridge.") - // } - - if params.Owner != "" { - fmt.Println("warning: --owner is ineffective for a Jira bridge") - } - - serverURL := params.URL - if serverURL == "" { + baseURL := params.BaseURL + if baseURL == "" { // terminal prompt - serverURL, err = input.Prompt("JIRA server URL", "URL", input.Required) + baseURL, err = input.Prompt("JIRA server URL", "URL", input.Required, input.IsURL) if err != nil { return nil, err } } - conf[keyServer] = serverURL project := params.Project if project == "" { @@ -62,77 +55,56 @@ func (g *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core. return nil, err } } - conf[keyProject] = project fmt.Println(credTypeText) - credType, err := input.PromptChoice("Authentication mechanism", []string{"SESSION", "TOKEN"}) - if err != nil { - return nil, err - } - - switch credType { - case 1: - conf[keyCredentialsType] = "SESSION" - case 2: - conf[keyCredentialsType] = "TOKEN" - } - - fmt.Println("How would you like to store your JIRA login credentials?") - credTargetChoice, err := input.PromptChoice("Credential storage", []string{ - "sidecar JSON file: Your credentials will be stored in a JSON sidecar next" + - "to your git config. Note that it will contain your JIRA password in clear" + - "text.", - "git-config: Your credentials will be stored in the git config. Note that" + - "it will contain your JIRA password in clear text.", - "username in config, askpass: Your username will be stored in the git" + - "config. We will ask you for your password each time you execute the bridge.", - }) - if err != nil { - return nil, err - } - - username, err := input.Prompt("JIRA username", "username", input.Required) + credTypeInput, err := input.PromptChoice("Authentication mechanism", []string{"SESSION", "TOKEN"}) if err != nil { return nil, err } + credType := []string{"SESSION", "TOKEN"}[credTypeInput] - password, err := input.PromptPassword("Password", "password", input.Required) - if err != nil { - return nil, err - } + var login string + var cred auth.Credential - switch credTargetChoice { - case 1: - // TODO: a validator to see if the path is writable ? - credentialsFile, err := input.Prompt("Credentials file path", "path", input.Required) + switch { + case params.CredPrefix != "": + cred, err = auth.LoadWithPrefix(repo, params.CredPrefix) if err != nil { return nil, err } - conf[keyCredentialsFile] = credentialsFile - jsonData, err := json.Marshal(&SessionQuery{Username: username, Password: password}) - if err != nil { - return nil, err + l, ok := cred.GetMetadata(auth.MetaKeyLogin) + if !ok { + return nil, fmt.Errorf("credential doesn't have a login") + } + login = l + default: + login := params.Login + if login == "" { + // TODO: validate username + login, err = input.Prompt("JIRA login", "login", input.Required) + if err != nil { + return nil, err + } } - err = ioutil.WriteFile(credentialsFile, jsonData, 0644) + cred, err = promptCredOptions(repo, login, baseURL) if err != nil { - return nil, errors.Wrap( - err, fmt.Sprintf("Unable to write credentials to %s", credentialsFile)) + return nil, err } - case 2: - conf[keyUsername] = username - conf[keyPassword] = password - case 3: - conf[keyUsername] = username } - err = g.ValidateConfig(conf) + conf := make(core.Configuration) + conf[core.ConfigKeyTarget] = target + conf[confKeyBaseUrl] = baseURL + conf[confKeyProject] = project + conf[confKeyCredentialType] = credType + + err = j.ValidateConfig(conf) if err != nil { return nil, err } fmt.Printf("Attempting to login with credentials...\n") - client := NewClient(serverURL, nil) - err = client.Login(conf) + client, err := buildClient(nil, baseURL, credType, cred) if err != nil { return nil, err } @@ -144,7 +116,12 @@ func (g *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core. return nil, fmt.Errorf( "Project %s doesn't exist on %s, or authentication credentials for (%s)"+ " are invalid", - project, serverURL, username) + project, baseURL, login) + } + + err = core.FinishConfig(repo, metaKeyJiraLogin, login) + if err != nil { + return nil, err } fmt.Print(moreConfigText) @@ -159,9 +136,46 @@ func (*Jira) ValidateConfig(conf core.Configuration) error { return fmt.Errorf("unexpected target name: %v", v) } - if _, ok := conf[keyProject]; !ok { - return fmt.Errorf("missing %s key", keyProject) + if _, ok := conf[confKeyProject]; !ok { + return fmt.Errorf("missing %s key", confKeyProject) } return nil } + +func promptCredOptions(repo repository.RepoConfig, login, baseUrl string) (auth.Credential, error) { + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithKind(auth.KindToken), + auth.WithMeta(auth.MetaKeyLogin, login), + auth.WithMeta(auth.MetaKeyBaseURL, baseUrl), + ) + if err != nil { + return nil, err + } + + cred, index, err := input.PromptCredential(target, "password", creds, []string{ + "enter my password", + "ask my password each time", + }) + switch { + case err != nil: + return nil, err + case cred != nil: + return cred, nil + case index == 0: + password, err := input.PromptPassword("Password", "password", input.Required) + if err != nil { + return nil, err + } + lp := auth.NewLoginPassword(target, login, password) + lp.SetMetadata(auth.MetaKeyLogin, login) + return lp, nil + case index == 1: + l := auth.NewLogin(target, login) + l.SetMetadata(auth.MetaKeyLogin, login) + return l, nil + default: + panic("missed case") + } +} diff --git a/bridge/jira/export.go b/bridge/jira/export.go index f329e490..37066263 100644 --- a/bridge/jira/export.go +++ b/bridge/jira/export.go @@ -5,14 +5,17 @@ import ( "encoding/json" "fmt" "net/http" + "os" "time" "github.com/pkg/errors" "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/identity" ) var ( @@ -23,14 +26,12 @@ var ( type jiraExporter struct { conf core.Configuration - // the current user identity - // NOTE: this is only needed to mock the credentials database in - // getIdentityClient - userIdentity entity.Id - // cache identities clients identityClient map[entity.Id]*Client + // the mapping from git-bug "status" to JIRA "status" id + statusMap map[string]string + // cache identifiers used to speed up exporting operations // cleared for each bug cachedOperationIDs map[entity.Id]string @@ -43,62 +44,99 @@ type jiraExporter struct { } // Init . -func (je *jiraExporter) Init(repo *cache.RepoCache, conf core.Configuration) error { +func (je *jiraExporter) Init(ctx context.Context, repo *cache.RepoCache, conf core.Configuration) error { je.conf = conf je.identityClient = make(map[entity.Id]*Client) je.cachedOperationIDs = make(map[entity.Id]string) je.cachedLabels = make(map[string]string) - return nil -} -// getIdentityClient return an API client configured with the credentials -// of the given identity. If no client were found it will initialize it from -// the known credentials map and cache it for next use -func (je *jiraExporter) getIdentityClient(ctx context.Context, id entity.Id) (*Client, error) { - client, ok := je.identityClient[id] - if ok { - return client, nil + statusMap, err := getStatusMap(je.conf) + if err != nil { + return err + } + je.statusMap = statusMap + + // preload all clients + err = je.cacheAllClient(ctx, repo) + if err != nil { + return err } - client = NewClient(je.conf[keyServer], ctx) + if len(je.identityClient) == 0 { + return fmt.Errorf("no credentials for this bridge") + } + + var client *Client + for _, c := range je.identityClient { + client = c + break + } - // NOTE: as a future enhancement, the bridge would ideally be able to generate - // a separate session token for each user that we have stored credentials - // for. However we currently only support a single user. - if id != je.userIdentity { - return nil, ErrMissingCredentials + if client == nil { + panic("nil client") } - err := client.Login(je.conf) + + je.project, err = client.GetProject(je.conf[confKeyProject]) if err != nil { - return nil, err + return err } - je.identityClient[id] = client - return client, nil + return nil } -// ExportAll export all event made by the current user to Jira -func (je *jiraExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) { - out := make(chan core.ExportResult) - - user, err := repo.GetUserIdentity() +func (je *jiraExporter) cacheAllClient(ctx context.Context, repo *cache.RepoCache) error { + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithKind(auth.KindLoginPassword), auth.WithKind(auth.KindLogin), + auth.WithMeta(auth.MetaKeyBaseURL, je.conf[confKeyBaseUrl]), + ) if err != nil { - return nil, err + return err } - // NOTE: this is currently only need to mock the credentials database in - // getIdentityClient. - je.userIdentity = user.Id() - client, err := je.getIdentityClient(ctx, user.Id()) - if err != nil { - return nil, err + for _, cred := range creds { + login, ok := cred.GetMetadata(auth.MetaKeyLogin) + if !ok { + _, _ = fmt.Fprintf(os.Stderr, "credential %s is not tagged with a Jira login\n", cred.ID().Human()) + continue + } + + user, err := repo.ResolveIdentityImmutableMetadata(metaKeyJiraLogin, login) + if err == identity.ErrIdentityNotExist { + continue + } + if err != nil { + return nil + } + + if _, ok := je.identityClient[user.Id()]; !ok { + client, err := buildClient(ctx, je.conf[confKeyBaseUrl], je.conf[confKeyCredentialType], creds[0]) + if err != nil { + return err + } + je.identityClient[user.Id()] = client + } } - je.project, err = client.GetProject(je.conf[keyProject]) - if err != nil { - return nil, err + return nil +} + +// getClientForIdentity return an API client configured with the credentials +// of the given identity. If no client were found it will initialize it from +// the known credentials and cache it for next use. +func (je *jiraExporter) getClientForIdentity(userId entity.Id) (*Client, error) { + client, ok := je.identityClient[userId] + if ok { + return client, nil } + return nil, ErrMissingCredentials +} + +// ExportAll export all event made by the current user to Jira +func (je *jiraExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) { + out := make(chan core.ExportResult) + go func() { defer close(out) @@ -134,7 +172,7 @@ func (je *jiraExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, si if snapshot.HasAnyActor(allIdentitiesIds...) { // try to export the bug and it associated events - err := je.exportBug(ctx, b, since, out) + err := je.exportBug(ctx, b, out) if err != nil { out <- core.NewExportError(errors.Wrap(err, "can't export bug"), id) return @@ -150,7 +188,7 @@ func (je *jiraExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, si } // exportBug publish bugs and related events -func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, since time.Time, out chan<- core.ExportResult) error { +func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, out chan<- core.ExportResult) error { snapshot := b.Snapshot() var bugJiraID string @@ -174,7 +212,7 @@ func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, since // skip bug if it is a jira bug but is associated with another project // (one bridge per JIRA project) - project, ok := snapshot.GetCreateMetadata(keyJiraProject) + project, ok := snapshot.GetCreateMetadata(metaKeyJiraProject) if ok && !stringInSlice(project, []string{je.project.ID, je.project.Key}) { out <- core.NewExportNothing( b.Id(), fmt.Sprintf("issue tagged with project: %s", project)) @@ -182,18 +220,18 @@ func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, since } // get jira bug ID - jiraID, ok := snapshot.GetCreateMetadata(keyJiraID) + jiraID, ok := snapshot.GetCreateMetadata(metaKeyJiraId) if ok { // will be used to mark operation related to a bug as exported bugJiraID = jiraID } else { // check that we have credentials for operation author - client, err := je.getIdentityClient(ctx, author.Id()) + client, err := je.getClientForIdentity(author.Id()) if err != nil { // if bug is not yet exported and we do not have the author's credentials // then there is nothing we can do, so just skip this bug out <- core.NewExportNothing( - b.Id(), fmt.Sprintf("missing author token for user %.8s", + b.Id(), fmt.Sprintf("missing author credentials for user %.8s", author.Id().String())) return err } @@ -201,7 +239,7 @@ func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, since // Load any custom fields required to create an issue from the git // config file. fields := make(map[string]interface{}) - defaultFields, hasConf := je.conf[keyCreateDefaults] + defaultFields, hasConf := je.conf[confKeyCreateDefaults] if hasConf { err = json.Unmarshal([]byte(defaultFields), &fields) if err != nil { @@ -215,7 +253,7 @@ func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, since "id": "10001", } } - bugIDField, hasConf := je.conf[keyCreateGitBug] + bugIDField, hasConf := je.conf[confKeyCreateGitBug] if hasConf { // If the git configuration also indicates it, we can assign the git-bug // id to a custom field to assist in integrations @@ -257,12 +295,6 @@ func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, since // cache operation jira id je.cachedOperationIDs[createOp.Id()] = bugJiraID - // lookup the mapping from git-bug "status" to JIRA "status" id - statusMap, err := getStatusMap(je.conf) - if err != nil { - return err - } - for _, op := range snapshot.Operations[1:] { // ignore SetMetadata operations if _, ok := op.(*bug.SetMetadataOperation); ok { @@ -272,13 +304,13 @@ func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, since // ignore operations already existing in jira (due to import or export) // cache the ID of already exported or imported issues and events from // Jira - if id, ok := op.GetMetadata(keyJiraID); ok { + if id, ok := op.GetMetadata(metaKeyJiraId); ok { je.cachedOperationIDs[op.Id()] = id continue } opAuthor := op.GetAuthor() - client, err := je.getIdentityClient(ctx, opAuthor.Id()) + client, err := je.getClientForIdentity(opAuthor.Id()) if err != nil { out <- core.NewExportError( fmt.Errorf("missing operation author credentials for user %.8s", @@ -340,7 +372,7 @@ func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, since } case *bug.SetStatusOperation: - jiraStatus, hasStatus := statusMap[opr.Status.String()] + jiraStatus, hasStatus := je.statusMap[opr.Status.String()] if hasStatus { exportTime, err = UpdateIssueStatus(client, bugJiraID, jiraStatus) if err != nil { @@ -407,11 +439,11 @@ func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, since func markOperationAsExported(b *cache.BugCache, target entity.Id, jiraID, jiraProject string, exportTime time.Time) error { newMetadata := map[string]string{ - keyJiraID: jiraID, - keyJiraProject: jiraProject, + metaKeyJiraId: jiraID, + metaKeyJiraProject: jiraProject, } if !exportTime.IsZero() { - newMetadata[keyJiraExportTime] = exportTime.Format(http.TimeFormat) + newMetadata[metaKeyJiraExportTime] = exportTime.Format(http.TimeFormat) } _, err := b.SetMetadata(target, newMetadata) diff --git a/bridge/jira/import.go b/bridge/jira/import.go index 6a755a36..bfe83f4d 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -10,6 +10,7 @@ import ( "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" @@ -17,51 +18,75 @@ import ( ) const ( - keyJiraID = "jira-id" - keyJiraOperationID = "jira-derived-id" - keyJiraKey = "jira-key" - keyJiraUser = "jira-user" - keyJiraProject = "jira-project" - keyJiraExportTime = "jira-export-time" - defaultPageSize = 10 + 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(repo *cache.RepoCache, conf core.Configuration) error { +func (ji *jiraImporter) Init(ctx context.Context, repo *cache.RepoCache, conf core.Configuration) error { ji.conf = conf - return nil + + 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") - serverURL := ji.conf[keyServer] - project := ji.conf[keyProject] - // TODO(josh)[da52062]: Validate token and if it is expired then prompt for - // credentials and generate a new one + project := ji.conf[confKeyProject] + out := make(chan core.ImportResult) ji.out = out go func() { defer close(ji.out) - client := NewClient(serverURL, ctx) - err := client.Login(ji.conf) - if err != nil { - out <- core.NewImportError(err, "") - return - } - - message, err := client.Search( + message, err := ji.client.Search( fmt.Sprintf("project=%s AND updatedDate>\"%s\"", project, sinceStr), 0, 0) if err != nil { out <- core.NewImportError(err, "") @@ -73,7 +98,7 @@ func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, si jql := fmt.Sprintf("project=%s AND updatedDate>\"%s\"", project, sinceStr) var searchIter *SearchIterator for searchIter = - client.IterSearch(jql, defaultPageSize); searchIter.HasNext(); { + ji.client.IterSearch(jql, defaultPageSize); searchIter.HasNext(); { issue := searchIter.Next() b, err := ji.ensureIssue(repo, *issue) if err != nil { @@ -84,7 +109,7 @@ func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, si var commentIter *CommentIterator for commentIter = - client.IterComments(issue.ID, defaultPageSize); commentIter.HasNext(); { + ji.client.IterComments(issue.ID, defaultPageSize); commentIter.HasNext(); { comment := commentIter.Next() err := ji.ensureComment(repo, b, *comment) if err != nil { @@ -100,7 +125,7 @@ func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, si var changelogIter *ChangeLogIterator for changelogIter = - client.IterChangeLog(issue.ID, defaultPageSize); changelogIter.HasNext(); { + ji.client.IterChangeLog(issue.ID, defaultPageSize); changelogIter.HasNext(); { changelogEntry := changelogIter.Next() // Advance the operation iterator up to the first operation which has @@ -110,7 +135,7 @@ func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, si var exportTime time.Time for ; opIdx < len(snapshot.Operations); opIdx++ { exportTimeStr, hasTime := snapshot.Operations[opIdx].GetMetadata( - keyJiraExportTime) + metaKeyJiraExportTime) if !hasTime { continue } @@ -156,7 +181,7 @@ func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, si func (ji *jiraImporter) ensurePerson(repo *cache.RepoCache, user User) (*cache.IdentityCache, error) { // Look first in the cache i, err := repo.ResolveIdentityImmutableMetadata( - keyJiraUser, string(user.Key)) + metaKeyJiraUser, string(user.Key)) if err == nil { return i, nil } @@ -169,7 +194,7 @@ func (ji *jiraImporter) ensurePerson(repo *cache.RepoCache, user User) (*cache.I user.EmailAddress, user.Key, map[string]string{ - keyJiraUser: string(user.Key), + metaKeyJiraUser: string(user.Key), }, ) @@ -188,7 +213,7 @@ func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache. return nil, err } - b, err := repo.ResolveBugCreateMetadata(keyJiraID, issue.ID) + b, err := repo.ResolveBugCreateMetadata(metaKeyJiraId, issue.ID) if err != nil && err != bug.ErrBugNotExist { return nil, err } @@ -210,9 +235,9 @@ func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache. nil, map[string]string{ core.MetaKeyOrigin: target, - keyJiraID: issue.ID, - keyJiraKey: issue.Key, - keyJiraProject: ji.conf[keyProject], + metaKeyJiraId: issue.ID, + metaKeyJiraKey: issue.Key, + metaKeyJiraProject: ji.conf[confKeyProject], }) if err != nil { return nil, err @@ -225,7 +250,7 @@ func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache. } // Return a unique string derived from a unique jira id and a timestamp -func getTimeDerivedID(jiraID string, timestamp MyTime) string { +func getTimeDerivedID(jiraID string, timestamp Time) string { return fmt.Sprintf("%s-%d", jiraID, timestamp.Unix()) } @@ -238,7 +263,7 @@ func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, } targetOpID, err := b.ResolveOperationWithMetadata( - keyJiraID, item.ID) + metaKeyJiraId, item.ID) if err != nil && err != cache.ErrNoMatchingOp { return err } @@ -263,7 +288,7 @@ func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, cleanText, nil, map[string]string{ - keyJiraID: item.ID, + metaKeyJiraId: item.ID, }, ) if err != nil { @@ -284,7 +309,7 @@ func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, // timestamp. Note that this must be consistent with the exporter during // export of an EditCommentOperation derivedID := getTimeDerivedID(item.ID, item.Updated) - _, err = b.ResolveOperationWithMetadata(keyJiraID, derivedID) + _, err = b.ResolveOperationWithMetadata(metaKeyJiraId, derivedID) if err == nil { // Already imported this edition return nil @@ -311,7 +336,7 @@ func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, targetOpID, cleanText, map[string]string{ - keyJiraID: derivedID, + metaKeyJiraId: derivedID, }, ) @@ -358,7 +383,7 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e // 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(keyJiraOperationID, entry.ID) + _, err := b.ResolveOperationWithMetadata(metaKeyJiraOperationId, entry.ID) if err == nil { return nil } else if err != cache.ErrNoMatchingOp { @@ -400,7 +425,7 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e opr, isRightType := potentialOp.(*bug.LabelChangeOperation) if isRightType && labelSetsMatch(addedLabels, opr.Added) && labelSetsMatch(removedLabels, opr.Removed) { _, err := b.SetMetadata(opr.Id(), map[string]string{ - keyJiraOperationID: entry.ID, + metaKeyJiraOperationId: entry.ID, }) if err != nil { return err @@ -412,7 +437,7 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e opr, isRightType := potentialOp.(*bug.SetStatusOperation) if isRightType && statusMap[opr.Status.String()] == item.To { _, err := b.SetMetadata(opr.Id(), map[string]string{ - keyJiraOperationID: entry.ID, + metaKeyJiraOperationId: entry.ID, }) if err != nil { return err @@ -426,7 +451,7 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e opr, isRightType := potentialOp.(*bug.SetTitleOperation) if isRightType && opr.Title == item.To { _, err := b.SetMetadata(opr.Id(), map[string]string{ - keyJiraOperationID: entry.ID, + metaKeyJiraOperationId: entry.ID, }) if err != nil { return err @@ -442,7 +467,7 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e opr.Target == b.Snapshot().Operations[0].Id() && opr.Message == item.ToString { _, err := b.SetMetadata(opr.Id(), map[string]string{ - keyJiraOperationID: entry.ID, + metaKeyJiraOperationId: entry.ID, }) if err != nil { return err @@ -457,7 +482,7 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e // changelog entry item as a separate git-bug operation. for idx, item := range entry.Items { derivedID := getIndexDerivedID(entry.ID, idx) - _, err := b.ResolveOperationWithMetadata(keyJiraOperationID, derivedID) + _, err := b.ResolveOperationWithMetadata(metaKeyJiraOperationId, derivedID) if err == nil { continue } @@ -477,8 +502,8 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e addedLabels, removedLabels, map[string]string{ - keyJiraID: entry.ID, - keyJiraOperationID: derivedID, + metaKeyJiraId: entry.ID, + metaKeyJiraOperationId: derivedID, }, ) if err != nil { @@ -496,8 +521,8 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e author, entry.Created.Unix(), map[string]string{ - keyJiraID: entry.ID, - keyJiraOperationID: derivedID, + metaKeyJiraId: entry.ID, + metaKeyJiraOperationId: derivedID, }, ) if err != nil { @@ -510,8 +535,8 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e author, entry.Created.Unix(), map[string]string{ - keyJiraID: entry.ID, - keyJiraOperationID: derivedID, + metaKeyJiraId: entry.ID, + metaKeyJiraOperationId: derivedID, }, ) if err != nil { @@ -534,8 +559,8 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e entry.Created.Unix(), string(item.ToString), map[string]string{ - keyJiraID: entry.ID, - keyJiraOperationID: derivedID, + metaKeyJiraId: entry.ID, + metaKeyJiraOperationId: derivedID, }, ) if err != nil { @@ -552,8 +577,8 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e entry.Created.Unix(), string(item.ToString), map[string]string{ - keyJiraID: entry.ID, - keyJiraOperationID: derivedID, + metaKeyJiraId: entry.ID, + metaKeyJiraOperationId: derivedID, }, ) if err != nil { @@ -580,7 +605,7 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e } func getStatusMap(conf core.Configuration) (map[string]string, error) { - mapStr, hasConf := conf[keyIDMap] + mapStr, hasConf := conf[confKeyIDMap] if !hasConf { return map[string]string{ bug.OpenStatus.String(): "1", @@ -604,7 +629,7 @@ func getStatusMapReverse(conf core.Configuration) (map[string]string, error) { outMap[val] = key } - mapStr, hasConf := conf[keyIDRevMap] + mapStr, hasConf := conf[confKeyIDRevMap] if !hasConf { return outMap, nil } diff --git a/bridge/jira/jira.go b/bridge/jira/jira.go index 43a11c05..0ba27df3 100644 --- a/bridge/jira/jira.go +++ b/bridge/jira/jira.go @@ -2,27 +2,36 @@ package jira import ( + "context" + "fmt" "sort" "time" "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/input" ) const ( target = "jira" - metaKeyJiraLogin = "jira-login" - - keyServer = "server" - keyProject = "project" - keyCredentialsType = "credentials-type" - keyCredentialsFile = "credentials-file" - keyUsername = "username" - keyPassword = "password" - keyIDMap = "bug-id-map" - keyIDRevMap = "bug-id-revmap" - keyCreateDefaults = "create-issue-defaults" - keyCreateGitBug = "create-issue-gitbug-id" + metaKeyJiraId = "jira-id" + metaKeyJiraOperationId = "jira-derived-id" + metaKeyJiraKey = "jira-key" + metaKeyJiraUser = "jira-user" + metaKeyJiraProject = "jira-project" + metaKeyJiraExportTime = "jira-export-time" + metaKeyJiraLogin = "jira-login" + + confKeyBaseUrl = "base-url" + confKeyProject = "project" + confKeyCredentialType = "credentials-type" // "SESSION" or "TOKEN" + confKeyIDMap = "bug-id-map" + confKeyIDRevMap = "bug-id-revmap" + // the issue type when exporting a new bug. Default is Story (10001) + confKeyCreateDefaults = "create-issue-defaults" + // if set, the bridge fill this JIRA field with the `git-bug` id when exporting + confKeyCreateGitBug = "create-issue-gitbug-id" defaultTimeout = 60 * time.Second ) @@ -51,6 +60,32 @@ func (*Jira) NewExporter() core.Exporter { return &jiraExporter{} } +func buildClient(ctx context.Context, baseURL string, credType string, cred auth.Credential) (*Client, error) { + client := NewClient(ctx, baseURL) + + var login, password string + + switch cred := cred.(type) { + case *auth.LoginPassword: + login = cred.Login + password = cred.Password + case *auth.Login: + login = cred.Login + p, err := input.PromptPassword(fmt.Sprintf("Password for %s", login), "password", input.Required) + if err != nil { + return nil, err + } + password = p + } + + err := client.Login(credType, login, password) + if err != nil { + return nil, err + } + + return client, nil +} + // stringInSlice returns true if needle is found in haystack func stringInSlice(needle string, haystack []string) bool { for _, match := range haystack { |